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:
co-authored by
Claude Sonnet 4.6
parent
8a70588fee
commit
5a44bfb443
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user