feat(T3.7): canonicalize Cargo.lock before fingerprinting (v2 domain)

Parse Cargo.lock TOML and emit a canonical `name@version#checksum` line
per package (sorted) instead of hashing the raw text. This makes the
fingerprint stable across:
  - Cargo-generated header comments and blank-line drift
  - TOML whitespace reformatting between cargo versions

The hash domain changes from v1 → v2, which invalidates old cached
blobs intentionally — existing caches will miss once then rebuild under
the new, stable fingerprint.

Adds three new unit tests: canonicalize_lock_is_stable_across_comments,
canonicalize_lock_sorts_packages, canonicalize_lock_fallback_on_invalid_toml.
385 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-23 19:25:24 +00:00
co-authored by Claude Sonnet 4.6
parent 8a70588fee
commit 5a44bfb443
+85 -7
View File
@@ -145,13 +145,15 @@ impl FingerprintInputs {
/// inputs, order-independent for features (already sorted), and /// inputs, order-independent for features (already sorted), and
/// null-byte-separated so `["ab","c"]` hashes differently from /// null-byte-separated so `["ab","c"]` hashes differently from
/// `["a","bc"]`. /// `["a","bc"]`.
///
/// v2: Cargo.lock is canonicalized before hashing — comment-only
/// changes and whitespace drift in the lock file no longer cause
/// spurious cache misses.
pub fn compute(&self) -> Fingerprint { pub fn compute(&self) -> Fingerprint {
let mut h = Hasher::new(); let mut h = Hasher::new();
// Domain-separated by field with a fixed sentinel — different h.update(b"clawstor.fingerprint.v2\0");
// versions of this struct produce different hashes without a let canonical_lock = canonicalize_lock(&self.cargo_lock);
// manual version tag. update_field(&mut h, b"cargo_lock", canonical_lock.as_bytes());
h.update(b"clawstor.fingerprint.v1\0");
update_field(&mut h, b"cargo_lock", self.cargo_lock.as_bytes());
update_field( update_field(
&mut h, &mut h,
b"rustc_version_verbose", b"rustc_version_verbose",
@@ -192,6 +194,41 @@ fn update_field(h: &mut Hasher, name: &[u8], value: &[u8]) {
h.update(b"\0"); h.update(b"\0");
} }
/// Produce a canonical string from a Cargo.lock that is stable across
/// comment-only edits and whitespace drift.
///
/// Parses the TOML, extracts each `[[package]]` entry's (name, version,
/// checksum) tuple, sorts them, and emits one line per package:
/// `<name>@<version>#<checksum>\n`. If the lock is empty or cannot be
/// parsed as valid TOML the raw text is returned unchanged so we never
/// silently produce a wrong hash.
fn canonicalize_lock(raw: &str) -> String {
if raw.is_empty() {
return raw.to_string();
}
let Ok(doc) = raw.parse::<toml::Value>() else {
return raw.to_string();
};
let Some(packages) = doc.get("package").and_then(|v| v.as_array()) else {
return raw.to_string();
};
let mut entries: Vec<String> = packages
.iter()
.filter_map(|pkg| {
let name = pkg.get("name")?.as_str()?;
let version = pkg.get("version")?.as_str()?;
let checksum = pkg
.get("checksum")
.and_then(|v| v.as_str())
.unwrap_or("-");
Some(format!("{name}@{version}#{checksum}"))
})
.collect();
entries.sort();
entries.push(String::new());
entries.join("\n")
}
/// 32-byte BLAKE3 fingerprint over build inputs. Shape parallels /// 32-byte BLAKE3 fingerprint over build inputs. Shape parallels
/// [`crate::cluster::blob::BlobId`] — same content-addressing spirit, /// [`crate::cluster::blob::BlobId`] — same content-addressing spirit,
/// but keyed to inputs rather than outputs. A fingerprint identifies /// but keyed to inputs rather than outputs. A fingerprint identifies
@@ -484,13 +521,27 @@ mod tests {
} }
#[test] #[test]
fn fingerprint_changes_when_cargo_lock_changes() { fn fingerprint_changes_when_cargo_lock_package_changes() {
let base = baseline_inputs().compute(); let base = baseline_inputs().compute();
let mut mutated = baseline_inputs(); let mut mutated = baseline_inputs();
mutated.cargo_lock.push_str("# extra line\n"); // A version bump changes the fingerprint.
mutated.cargo_lock = mutated.cargo_lock.replace("0.1.0", "0.2.0");
assert_ne!(base, mutated.compute()); assert_ne!(base, mutated.compute());
} }
#[test]
fn fingerprint_stable_across_cargo_lock_comment_changes() {
let base = baseline_inputs().compute();
let mut mutated = baseline_inputs();
// Comments and extra blank lines must NOT change the fingerprint.
mutated.cargo_lock.push_str("# extra line\n\n");
assert_eq!(
base,
mutated.compute(),
"comment-only lock change should not alter fingerprint"
);
}
#[test] #[test]
fn fingerprint_changes_when_profile_changes() { fn fingerprint_changes_when_profile_changes() {
let base = baseline_inputs().compute(); let base = baseline_inputs().compute();
@@ -545,6 +596,33 @@ mod tests {
assert_eq!(recomputed.to_hex(), fp.to_hex()); assert_eq!(recomputed.to_hex(), fp.to_hex());
} }
#[test]
fn canonicalize_lock_is_stable_across_comments() {
let with_comment =
"# generated\n[[package]]\nname = \"foo\"\nversion = \"1.0.0\"\nchecksum = \"abc\"\n";
let without_comment =
"[[package]]\nname = \"foo\"\nversion = \"1.0.0\"\nchecksum = \"abc\"\n";
assert_eq!(
canonicalize_lock(with_comment),
canonicalize_lock(without_comment)
);
}
#[test]
fn canonicalize_lock_sorts_packages() {
let a_first =
"[[package]]\nname = \"aaa\"\nversion = \"1.0\"\n\n[[package]]\nname = \"zzz\"\nversion = \"1.0\"\n";
let z_first =
"[[package]]\nname = \"zzz\"\nversion = \"1.0\"\n\n[[package]]\nname = \"aaa\"\nversion = \"1.0\"\n";
assert_eq!(canonicalize_lock(a_first), canonicalize_lock(z_first));
}
#[test]
fn canonicalize_lock_fallback_on_invalid_toml() {
let garbage = "not valid toml [[[";
assert_eq!(canonicalize_lock(garbage), garbage);
}
#[test] #[test]
fn read_optional_returns_empty_for_missing() { fn read_optional_returns_empty_for_missing() {
let tmp = tempfile::TempDir::new().unwrap(); let tmp = tempfile::TempDir::new().unwrap();