Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions tools/tailor/crates/tailor-core/src/deps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,11 @@ fn walk_dir(

/// A per-file dependency hash: XXH3 over the file's relative path (rename sensitivity + machine
/// portability) domain-separated from its content hash + size. The content hash comes from the shared
/// cache, so an unchanged file is not re-read.
fn hash_one(
/// cache, so an unchanged file is not re-read. Unlike [`hash_dependencies`]'s directory-walk dispatch,
/// this follows symlinks (via `fs::metadata`/`File::open` in [`hashcache::hash_file_cached`]) rather
/// than skipping them -- safe here because callers that know in advance they have exactly one
/// declared file (not a directory to walk) have no symlink-cycle risk to guard against.
pub(crate) fn hash_one(
path: &Path,
base_dir: &Path,
cache_dir: Option<&Path>,
Expand All @@ -126,14 +129,36 @@ fn hash_one(
source,
})
})?;
Ok(compose_hash(path, base_dir, &file))
}

/// Like [`hash_one`], but never consults the `(size, mtime)` cache -- always reads and hashes the
/// file fresh. Required for a symlinked declared path (e.g. a signing key/cert): the cache key is
/// scoped to the symlink's own path plus its *resolved target's* size/mtime, so rotating the symlink
/// to a different target file with coincidentally equal size and mtime would otherwise return the
/// previous target's stale cached hash.
pub(crate) fn hash_one_uncached(
path: &Path,
base_dir: &Path,
) -> Result<[u8; DEP_HASH_BYTES], CoreError> {
let file = hashcache::hash_file(path).map_err(|source| {
CoreError::Exec(ExecError::Io {
context: format!("failed to read dependency file `{}`", path.display()),
source,
})
})?;
Ok(compose_hash(path, base_dir, &file))
}

fn compose_hash(path: &Path, base_dir: &Path, file: &hashcache::FileHash) -> [u8; DEP_HASH_BYTES] {
let rel = path.strip_prefix(base_dir).unwrap_or(path);
let rel_bytes = rel.to_string_lossy();
let mut hasher = Xxh3::new();
hasher.update(&(rel_bytes.len() as u64).to_le_bytes());
hasher.update(rel_bytes.as_bytes());
hasher.update(&file.hash);
hasher.update(&file.size.to_le_bytes());
Ok(hasher.digest128().to_le_bytes())
hasher.digest128().to_le_bytes()
}

#[cfg(test)]
Expand Down
132 changes: 132 additions & 0 deletions tools/tailor/crates/tailor-core/src/fingerprint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,32 @@ pub struct FingerprintInputs<'a> {
pub compression: Option<Compression>,
/// COSI compression level (`--cosi-compression-level`); a change rebuilds the COSI.
pub cosi_compression_level: Option<u8>,
/// Effective signing profile's id (`meta/docs/2026-06-29-signing.md` §4); a change -- including
/// unsigned-to-signed or a profile swap -- rebuilds the cell. The reference fields below are
/// hashed separately (each under its own domain-separated tag, not concatenated into this or any
/// other single string) so that, say, a `:`-containing `certificate` value can never be
/// misattributed to `publish_ca_cert` or vice versa.
pub signing_profile_id: Option<&'a str>,
/// Effective signing profile's `backend`; a change (e.g. `keypair` -> `azure-key-vault` under the
/// same id) rebuilds the cell.
pub signing_backend: Option<&'a str>,
/// Effective signing profile's `vault` (azure-key-vault backend); a change rebuilds the cell.
pub signing_vault: Option<&'a str>,
/// Effective signing profile's `certificate` (azure-key-vault backend); a change rebuilds the
/// cell.
pub signing_certificate: Option<&'a str>,
/// Effective signing profile's `publishCaCert` path (local-test-ca backend); a change -- e.g.
/// redirecting where the enrollable CA certificate is written -- rebuilds the cell.
pub signing_publish_ca_cert: Option<&'a str>,
/// Content hash of the profile's local key file (XXH3-128, `deps.rs`, `keypair` backend only); a
/// change to the file *contents* alone (same declared path) still busts the fingerprint, unlike
/// the id or path strings above which only change when the config text itself changes. Kept
/// separate from `signing_cert_hash` (rather than merged into one sorted set) so swapping the
/// key and cert files changes the fingerprint instead of silently reusing a stale artifact.
pub signing_key_hash: Option<[u8; 16]>,
/// Content hash of the profile's local certificate file (XXH3-128, `deps.rs`, `keypair` backend
/// only); see `signing_key_hash`.
pub signing_cert_hash: Option<[u8; 16]>,
}

/// Compute the canonical fingerprint. Each field is domain-separated and length-prefixed so distinct
Expand Down Expand Up @@ -100,6 +126,35 @@ pub fn fingerprint(inputs: &FingerprintInputs<'_>) -> Fingerprint {
if let Some(level) = inputs.cosi_compression_level {
field(&mut hasher, b"cosi-compression-level", &[level]);
}
if let Some(id) = inputs.signing_profile_id {
field(&mut hasher, b"signing.profile.id", id.as_bytes());
}
if let Some(backend) = inputs.signing_backend {
field(&mut hasher, b"signing.profile.backend", backend.as_bytes());
}
if let Some(vault) = inputs.signing_vault {
field(&mut hasher, b"signing.profile.vault", vault.as_bytes());
}
if let Some(certificate) = inputs.signing_certificate {
field(
&mut hasher,
b"signing.profile.certificate",
certificate.as_bytes(),
);
}
if let Some(publish_ca_cert) = inputs.signing_publish_ca_cert {
field(
&mut hasher,
b"signing.profile.publish-ca-cert",
publish_ca_cert.as_bytes(),
);
}
if let Some(hash) = inputs.signing_key_hash {
field(&mut hasher, b"signing.key", &hash);
}
if let Some(hash) = inputs.signing_cert_hash {
field(&mut hasher, b"signing.cert", &hash);
}

Fingerprint(hasher.finalize().into())
}
Expand Down Expand Up @@ -154,6 +209,13 @@ mod tests {
extra_params: &[],
compression: None,
cosi_compression_level: None,
signing_profile_id: None,
signing_backend: None,
signing_vault: None,
signing_certificate: None,
signing_publish_ca_cert: None,
signing_key_hash: None,
signing_cert_hash: None,
}
}

Expand All @@ -166,6 +228,76 @@ mod tests {
assert_eq!(a, b);
}

#[test]
fn fingerprint_changes_when_signing_profile_changes() {
let base = base();
let cfg: Value = serde_yaml_ng::from_str("os:\n hostname: a\n").unwrap();
let mut unsigned = inputs("cell", &cfg, &base);
let mut signed = inputs("cell", &cfg, &base);
signed.signing_profile_id = Some("release");
assert_ne!(fingerprint(&unsigned), fingerprint(&signed));

unsigned.signing_profile_id = Some("release");
let mut other_profile = inputs("cell", &cfg, &base);
other_profile.signing_profile_id = Some("dev");
assert_ne!(fingerprint(&unsigned), fingerprint(&other_profile));
}

#[test]
fn fingerprint_changes_when_signing_profile_fields_are_reassigned_across_a_delimiter() {
// Regression: concatenating profile fields with a plain `:` was not collision-free --
// `certificate: "ignored"` + `publish_ca_cert: "a:b"` produced the same joined string as
// `certificate: "ignored:a"` + `publish_ca_cert: "b"`. Each field must be hashed under its
// own domain-separated tag instead.
let base = base();
let cfg: Value = serde_yaml_ng::from_str("os:\n hostname: a\n").unwrap();
let mut a = inputs("cell", &cfg, &base);
a.signing_certificate = Some("ignored");
a.signing_publish_ca_cert = Some("a:b");
let mut b = inputs("cell", &cfg, &base);
b.signing_certificate = Some("ignored:a");
b.signing_publish_ca_cert = Some("b");
assert_ne!(
fingerprint(&a),
fingerprint(&b),
"reassigning bytes across the certificate/publish_ca_cert boundary must change the fingerprint"
);
}

#[test]
fn fingerprint_changes_when_signing_key_hash_changes() {
// A credential edit under the *same* profile id (e.g. rotating the keypair's PEM files in
// place) must still bust the fingerprint -- the id string alone doesn't change.
let base = base();
let cfg: Value = serde_yaml_ng::from_str("os:\n hostname: a\n").unwrap();
let mut a = inputs("cell", &cfg, &base);
a.signing_profile_id = Some("release:keypair::");
a.signing_key_hash = Some([1; 16]);
a.signing_cert_hash = Some([2; 16]);
let mut b = inputs("cell", &cfg, &base);
b.signing_profile_id = Some("release:keypair::");
b.signing_key_hash = Some([1; 16]);
b.signing_cert_hash = Some([9; 16]);
assert_ne!(fingerprint(&a), fingerprint(&b));
}

#[test]
fn fingerprint_changes_when_key_and_cert_hashes_are_swapped() {
// Swapping which file is the key and which is the cert must not be a no-op: merging both
// into one sorted hash set would make this pair indistinguishable from an unswapped one.
let base = base();
let cfg: Value = serde_yaml_ng::from_str("os:\n hostname: a\n").unwrap();
let mut a = inputs("cell", &cfg, &base);
a.signing_profile_id = Some("release:keypair::");
a.signing_key_hash = Some([1; 16]);
a.signing_cert_hash = Some([2; 16]);
let mut swapped = inputs("cell", &cfg, &base);
swapped.signing_profile_id = Some("release:keypair::");
swapped.signing_key_hash = Some([2; 16]);
swapped.signing_cert_hash = Some([1; 16]);
assert_ne!(fingerprint(&a), fingerprint(&swapped));
}

#[test]
fn config_change_changes_fingerprint() {
let base = base();
Expand Down
2 changes: 1 addition & 1 deletion tools/tailor/crates/tailor-core/src/hashcache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ pub fn hash_file_cached(path: &Path, cache_dir: Option<&Path>) -> io::Result<Fil
}

/// Hash `path`'s bytes with XXH3-128, unconditionally reading the file (no cache).
fn hash_file(path: &Path) -> io::Result<FileHash> {
pub(crate) fn hash_file(path: &Path) -> io::Result<FileHash> {
let mut file = File::open(path)?;
let mut hasher = Xxh3::new();
let mut size = 0_u64;
Expand Down
Loading
Loading