//! Hashing, scratch directories, and running other programs. use std::ffi::OsStr; use std::fs::{self, File}; use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{bail, Context, Result}; use sha2::{Digest, Sha256}; /// Lower-case hex, written out rather than taken from a crate. /// /// Every hash this tool handles is compared against one produced by `sha256sum` or by `jq` in CI, /// both of which emit lower-case hex — so the formatting is part of the contract, not a display /// choice. pub fn hex(bytes: &[u8]) -> String { let mut out = String::with_capacity(bytes.len() * 2); for b in bytes { out.push_str(&format!("{b:02x}")); } out } /// Hashes a buffer. /// /// The run hashes files and streams almost everywhere, since they are large. The exception is the /// patch tier, which already holds each `.patch` in memory to parse it and would otherwise re-read /// from disk purely to hash a few kilobytes it is looking at. pub fn sha256_bytes(bytes: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(bytes); hex(&hasher.finalize()) } /// Streams a file through SHA256 rather than reading it whole: the overlay tarball and ServUO's /// `Scripts.dll` are both large enough that slurping them is a waste, and this same function runs /// once per deployed file. pub fn sha256_file(path: &Path) -> Result { let mut file = File::open(path).with_context(|| format!("cannot read {} to hash it", path.display()))?; let mut hasher = Sha256::new(); let mut buf = vec![0u8; 64 * 1024]; loop { let n = file .read(&mut buf) .with_context(|| format!("cannot read {}", path.display()))?; if n == 0 { break; } hasher.update(&buf[..n]); } Ok(hex(&hasher.finalize())) } /// The git object name of a buffer treated as a blob: `sha1("blob " + len + "\0" + content)`. /// /// This is what `git hash-object` prints and what a patch's `index ..` line records, so /// reproducing it is how the patch tier answers rung 1 — "is this whole file still the one the /// patch was written against?" (PLAN.md §2.2.1). Computed here rather than by shelling out, because /// the entire reason the plugin ships as a release tarball is that a shard host has no git on it /// (§1). /// /// The bytes are hashed exactly as they sit on disk. That matters: the three files this tier edits /// are CRLF, and the recorded hashes were taken from those CRLF bytes, so any normalization here /// would make every rung-1 check miss. pub fn git_blob_hash(content: &[u8]) -> String { use sha1::{Digest as _, Sha1}; let mut hasher = Sha1::new(); hasher.update(format!("blob {}\0", content.len()).as_bytes()); hasher.update(content); hex(&hasher.finalize()) } /// A [`Write`] that hashes everything passing through it. /// /// Downloads are verified *while* being written rather than by re-reading the finished file: it /// halves the I/O and, more importantly, means the bytes that were hashed are provably the bytes /// that were written. pub struct HashingWriter { inner: W, hasher: Sha256, } impl HashingWriter { pub fn new(inner: W) -> Self { Self { inner, hasher: Sha256::new(), } } pub fn finish(self) -> String { hex(&self.hasher.finalize()) } } impl Write for HashingWriter { fn write(&mut self, buf: &[u8]) -> io::Result { let n = self.inner.write(buf)?; self.hasher.update(&buf[..n]); Ok(n) } fn flush(&mut self) -> io::Result<()> { self.inner.flush() } } /// A scratch directory that deletes itself. /// /// Downloads and the extracted overlay land here. Hand-rolled rather than pulled from a crate /// because the requirement is one directory with a unique name and a `Drop` — and because a failed /// cleanup must never fail the run: by the time it matters the install has already succeeded or /// failed on its own merits. pub struct TempDir { path: PathBuf, } impl TempDir { pub fn new(prefix: &str) -> Result { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); let path = std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id())); fs::create_dir_all(&path) .with_context(|| format!("cannot create scratch directory {}", path.display()))?; Ok(Self { path }) } pub fn path(&self) -> &Path { &self.path } } impl Drop for TempDir { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.path); } } /// Writes a file by writing a sibling `.tmp` and renaming over the target. /// /// `install.json` is the record every later command reasons from; a half-written one after a /// crash or a full disk would be worse than none at all, because `doctor` and `update` would /// believe it. pub fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("cannot create {}", parent.display()))?; } let tmp = path.with_extension("tmp"); { let mut file = File::create(&tmp).with_context(|| format!("cannot create {}", tmp.display()))?; file.write_all(contents) .with_context(|| format!("cannot write {}", tmp.display()))?; file.sync_all() .with_context(|| format!("cannot flush {}", tmp.display()))?; } // Windows will not rename onto an existing file, so the old one goes first. The window this // opens is the reason for the .tmp file existing at all: its content is already durable. if path.exists() { fs::remove_file(path).with_context(|| format!("cannot replace {}", path.display()))?; } fs::rename(&tmp, path).with_context(|| format!("cannot move {} into place", tmp.display()))?; Ok(()) } /// How a command is written back to the operator when it fails. /// /// Reproducible by hand is the whole point: every external command this tool runs — `systemctl`, /// `useradd`, `sc.exe` — is one an operator can run themselves, and a failure they can retype is a /// failure they can diagnose. pub fn command_line>(program: &str, args: &[S]) -> String { let mut line = String::from(program); for arg in args { let text = arg.as_ref().to_string_lossy().into_owned(); line.push(' '); if text.contains(' ') && !text.starts_with('"') { line.push('"'); line.push_str(&text); line.push('"'); } else { line.push_str(&text); } } line } /// Runs a program to completion, capturing its output. A non-zero exit is **not** an error here — /// several callers ask questions whose answer *is* the exit code (`id -u`, `sc query`). pub fn run>(program: &str, args: &[S]) -> Result { Command::new(program).args(args).output().with_context(|| { format!( "cannot run `{}` — is it installed and on PATH?", command_line(program, args) ) }) } /// Runs a program and treats a non-zero exit as a failure, quoting what it printed. /// /// **Never call this on anything that emits a secret.** The sidecar's `--print-config` writes the /// auth token to stdout, so it is run through [`run`] and handled where the token can be kept out /// of the error path (PLAN.md §6). pub fn run_ok>(program: &str, args: &[S]) -> Result { let output = run(program, args)?; if !output.status.success() { bail!(failure_message( &command_line(program, args), output.status.code(), &output.stderr, &output.stdout, )); } Ok(output) } /// The message a failed command produces. Split out from [`run_ok`] because it is the part worth /// testing — spawning a process that fails identically on Linux and Windows is not. fn failure_message(line: &str, code: Option, stderr: &[u8], stdout: &[u8]) -> String { let detail = first_useful_line(stderr) .or_else(|| first_useful_line(stdout)) .unwrap_or_else(|| "(no output)".to_string()); let status = match code { Some(code) => format!("exit code {code}"), None => "no exit code (killed by a signal)".to_string(), }; format!("`{line}` failed with {status}: {detail}") } /// The first non-blank line of a captured stream, for a one-line error message. fn first_useful_line(bytes: &[u8]) -> Option { String::from_utf8_lossy(bytes) .lines() .map(str::trim) .find(|l| !l.is_empty()) .map(str::to_string) } #[cfg(test)] mod tests { use super::*; // The canonical empty-input SHA256. If this ever changes, everything else in the trust chain // is meaningless, so it is worth one line. const EMPTY: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; #[test] fn hashing_matches_sha256sum() { assert_eq!(sha256_bytes(b""), EMPTY); assert_eq!( sha256_bytes(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" ); } #[test] fn file_and_byte_hashing_agree() { let dir = TempDir::new("rg-test-hash").unwrap(); let path = dir.path().join("f.bin"); // Larger than the 64 KiB read buffer, so the streaming path is actually exercised. let blob: Vec = (0..200_000u32).map(|i| (i % 251) as u8).collect(); fs::write(&path, &blob).unwrap(); assert_eq!(sha256_file(&path).unwrap(), sha256_bytes(&blob)); } #[test] fn blob_hashing_matches_git_hash_object() { // These are the values `git hash-object` prints, and the same ones a patch's `index` line // carries. If this drifts, rung 1 silently stops recognising a stock file and every patch // falls through to the region match — which still works, and would hide the bug for a long // time. assert_eq!( git_blob_hash(b""), "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391" ); assert_eq!( git_blob_hash(b"hello\n"), "ce013625030ba8dba906f756967f9e9ca394464a" ); // CRLF is hashed as it sits on disk — the ServUO files this is used on are all CRLF, and // normalizing here would make every rung-1 check miss. assert_ne!(git_blob_hash(b"a\r\n"), git_blob_hash(b"a\n")); } #[test] fn the_hashing_writer_sees_what_was_written() { let mut w = HashingWriter::new(Vec::new()); w.write_all(b"abc").unwrap(); assert_eq!( w.finish(), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" ); } #[test] fn a_temp_dir_removes_itself() { let path = { let dir = TempDir::new("rg-test-drop").unwrap(); fs::write(dir.path().join("x"), b"x").unwrap(); dir.path().to_path_buf() }; assert!(!path.exists()); } #[test] fn a_failed_command_is_reported_with_what_it_printed() { // Both halves matter: the command to retype, and the reason it failed. stderr wins over // stdout because that is where systemctl and sc.exe put the reason. let message = failure_message( "systemctl enable --now runicgateway-link.service", Some(1), b"Failed to enable unit: Unit file does not exist.\n", b"noise\n", ); assert!(message.contains("systemctl enable"), "{message}"); assert!(message.contains("exit code 1"), "{message}"); assert!(message.contains("Unit file does not exist."), "{message}"); // A command that fails silently must still say something usable. let quiet = failure_message("sc.exe start RunicGatewayLink", Some(1053), b"", b""); assert!( quiet.contains("1053") && quiet.contains("(no output)"), "{quiet}" ); } #[test] fn a_missing_program_says_so_rather_than_panicking() { let err = run("rg-no-such-program-exists", &["x"]) .unwrap_err() .to_string(); assert!(err.contains("rg-no-such-program-exists"), "{err}"); } #[test] fn command_lines_quote_arguments_containing_spaces() { // These strings are printed for an operator to paste back; an unquoted Windows path with // spaces in it would be a command that does not work when they do. let line = command_line( "sc.exe", &[ "create", "RunicGatewayLink", "binPath=", "C:\\Program Files\\x.exe", ], ); assert!(line.contains("\"C:\\Program Files\\x.exe\""), "{line}"); } #[test] fn atomic_write_replaces_an_existing_file() { let dir = TempDir::new("rg-test-atomic").unwrap(); let path = dir.path().join("nested").join("install.json"); write_atomic(&path, b"first").unwrap(); write_atomic(&path, b"second").unwrap(); assert_eq!(fs::read(&path).unwrap(), b"second"); assert!(!path.with_extension("tmp").exists()); } }