feat(installer): implement Phase 3 — the patch tier
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 46s
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 46s
Two features need edits to stock ServUO sources, because the events they depend on do not exist. This adds the rung ladder of PLAN.md §2.2.1, the unsupported-version path of §2.2.2, and the record and cache Phase 4 will read. Three decisions were not settled by the plan: * The engine is fully native, with no `git`. §2.2.1 wrote rung 1 as "apply verbatim with git apply", but §1 chose the release tarball specifically so there would be no git on the shard host, and rung 2 needs a native applier regardless. Rung 1 keeps its distinct, stronger verdict — the whole file reproduced the diff's `index` pre-image, computed as a git blob SHA1 in process — while the write goes through the same code path as rung 2. On the real trees here that is not academic: the shipped .patch files are CRLF in a Windows checkout and two of their three targets are LF, so `git apply` refuses patches this applies correctly. * Per-patch metadata is declared by the release, with a built-in fallback. Which patches form one all-or-nothing unit, which companion .cs follows which, whether a CORE rebuild is needed and what declining costs are not derivable from a diff. servuo-plugins now declares them; overlay v0.1.1 is in the current bundle and declares nothing, so a built-in copy stands in for it. A checked-in fixture of the release workflow's own jq output asserts the two descriptions are identical, so the repos cannot drift quietly. * Pre-images are cached in the state directory. The tier edits files the operator owns, and `/etc/runicgateway/patches/originals/` is what turns "here are the hunks we added" into a revert anyone can verify — kept out of the ServUO tree, which uninstall has promised never to clean up. Everything else follows §2.2.1: exact matching with only line-ending and trailing-whitespace normalization, exactly one occurrence or it fails, all-or-nothing per patch file and again per feature, and a byte-preserving splice so nothing outside a hunk can be reformatted. Verified against the ServUO 57.4 tree on this machine across four scratch roots: a hand-patched tree (rung 0), a reverse-applied stock one (rung 1 on the real EventSink.cs, its blob matching the patch's declared pre-image), a mixed-rung feature, a tree with edits inside two patched regions (rung 3 — nothing written, nothing held back applied, no companions copied), and a non-57.4 tree both with and without the extra consent flag. Three consecutive runs left install.json byte-identical and the cached pre-image still pre-patch. Three reporting defects the live runs caught are fixed with tests: a dry run and a held-back patch both claimed to be "applied", the core-rebuild warning fired when nothing had been written and named a Scripts file as core, and a declined tier announced the loss of features install.json showed as applied. Refused patches are now cached too, since the refusal message names that path. Refs: docs/installer/PLAN.md §2.2, §5 Phase 3 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
46
src/util.rs
46
src/util.rs
@@ -23,9 +23,11 @@ pub fn hex(bytes: &[u8]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Hashes a buffer. Used by the tests to prove the streaming paths below agree with a
|
||||
/// straight-line hash of the same bytes; the run itself only ever hashes files and streams.
|
||||
#[cfg(test)]
|
||||
/// 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);
|
||||
@@ -52,6 +54,25 @@ pub fn sha256_file(path: &Path) -> Result<String> {
|
||||
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 <old>..<new>` 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
|
||||
@@ -247,6 +268,25 @@ mod tests {
|
||||
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());
|
||||
|
||||
Reference in New Issue
Block a user