From 9676fff0dda889e484db65a38a5f0b7a0b14807c Mon Sep 17 00:00:00 2001 From: Sergio Date: Thu, 11 Jun 2026 11:48:15 +0000 Subject: [PATCH] =?UTF-8?q?core:=20ArtifactHash::of=5Ftree=20=E2=80=94=20c?= =?UTF-8?q?ontent-hash=20determinista=20de=20un=20=C3=A1rbol=20(pre-Stage?= =?UTF-8?q?=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 verifica bit-reproducibilidad comparando hash(stage1) vs hash(stage1'). Pero el ArtifactHash del store es INPUT-addressed (Merkle de inputs de receta: fuente+flags+deps), así que esa comparación sería trivialmente true. Stage 2 necesita un hash de la SALIDA real (los bytes). of_tree(root) hashea el contenido: rutas relativas ordenadas + tipo + bit de ejecución + contenido / target de symlink, BLAKE3 length-prefijado. Determinista e independiente de la ruta raíz y del orden del filesystem; no sigue symlinks. +2 tests (determinismo entre dos árboles idénticos; detección de cambios de contenido/exec/symlink-target). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/hammer-core/src/hash.rs | 112 +++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/crates/hammer-core/src/hash.rs b/crates/hammer-core/src/hash.rs index f912751d..65f2b730 100644 --- a/crates/hammer-core/src/hash.rs +++ b/crates/hammer-core/src/hash.rs @@ -3,6 +3,10 @@ //! El `ArtifactHash` identifica un artefacto por TODO lo que influye en su salida: //! commit fuente + parches + flags/compilador/target + hashes de dependencias. +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + use serde::{Deserialize, Serialize}; /// Hash BLAKE3 de un artefacto, con prefijo legible `b3:`. @@ -36,6 +40,66 @@ impl ArtifactHash { pub fn as_str(&self) -> &str { &self.0 } + + /// Hash de **contenido** de un árbol de archivos: BLAKE3 determinista sobre los bytes reales + /// (rutas relativas ordenadas + tipo + bit de ejecución + contenido / target de symlink). + /// + /// A diferencia de [`of_inputs`](Self::of_inputs) (input-addressed: identifica por *qué* + /// produjo el artefacto — fuente, flags, deps), esto identifica por *qué bytes son*. Es lo que + /// **Stage 2** ([SDD 11](../../docs/11-bootstrap.md)) necesita para verificar + /// bit-reproducibilidad: `of_tree(stage1) == of_tree(stage1')` ⇒ el sistema se reconstruye + /// idéntico. No sigue symlinks (hashea su target literal); el orden del filesystem no afecta + /// (se ordena por ruta). El modo se reduce al bit de ejecución (lo único semánticamente + /// relevante; el resto lo fija `seal` de forma consistente). + pub fn of_tree(root: &Path) -> std::io::Result { + let mut rels: Vec = Vec::new(); + collect_rel(root, Path::new(""), &mut rels)?; + rels.sort(); + + let mut hasher = blake3::Hasher::new(); + hasher.update(b"hammer-tree-v1"); + for rel in &rels { + let abs = root.join(rel); + let meta = std::fs::symlink_metadata(&abs)?; + let relb = rel.as_os_str().as_bytes(); + hasher.update(&(relb.len() as u64).to_le_bytes()); + hasher.update(relb); + + let ft = meta.file_type(); + if ft.is_symlink() { + let tgt = std::fs::read_link(&abs)?; + let t = tgt.as_os_str().as_bytes(); + hasher.update(b"L"); + hasher.update(&(t.len() as u64).to_le_bytes()); + hasher.update(t); + } else if ft.is_dir() { + hasher.update(b"D"); + } else { + // Archivo regular: bit de ejecución + contenido. + let exec = meta.permissions().mode() & 0o111 != 0; + hasher.update(if exec { b"Fx" } else { b"F0" }); + let bytes = std::fs::read(&abs)?; + hasher.update(&(bytes.len() as u64).to_le_bytes()); + hasher.update(&bytes); + } + } + Ok(ArtifactHash(format!("b3:{}", hasher.finalize().to_hex()))) + } +} + +/// Recorre `root` recursivamente acumulando rutas **relativas a `root`** en `out`. No sigue +/// symlinks (los registra como entrada, sin descender). +fn collect_rel(root: &Path, rel: &Path, out: &mut Vec) -> std::io::Result<()> { + for entry in std::fs::read_dir(root.join(rel))? { + let entry = entry?; + let child = rel.join(entry.file_name()); + let is_dir = entry.file_type()?.is_dir(); + out.push(child.clone()); + if is_dir { + collect_rel(root, &child, out)?; + } + } + Ok(()) } impl std::fmt::Display for ArtifactHash { @@ -63,4 +127,52 @@ mod tests { let h = ArtifactHash::from_hex("deadbeef"); assert_eq!(h.store_dir_name("grep"), "deadbeef-grep"); } + + // --- of_tree: content-hash determinista (pre-Stage 2) --- + + fn populate(d: &std::path::Path) { + use std::os::unix::fs::{symlink, PermissionsExt}; + std::fs::create_dir_all(d.join("usr/bin")).unwrap(); + std::fs::write(d.join("usr/bin/hello"), b"#!/bin/sh\necho hi\n").unwrap(); + std::fs::set_permissions(d.join("usr/bin/hello"), std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write(d.join("README"), b"docs").unwrap(); + symlink("usr/bin/hello", d.join("link")).unwrap(); + } + + #[test] + fn of_tree_is_deterministic_across_two_identical_trees() { + let a = tempfile::tempdir().unwrap(); + let b = tempfile::tempdir().unwrap(); + populate(a.path()); + populate(b.path()); + // Mismo contenido en dos dirs distintos ⇒ mismo hash (independiente de la ruta raíz). + assert_eq!(ArtifactHash::of_tree(a.path()).unwrap(), ArtifactHash::of_tree(b.path()).unwrap()); + assert!(ArtifactHash::of_tree(a.path()).unwrap().as_str().starts_with("b3:")); + } + + #[test] + fn of_tree_detects_content_exec_and_symlink_changes() { + let base = tempfile::tempdir().unwrap(); + populate(base.path()); + let h0 = ArtifactHash::of_tree(base.path()).unwrap(); + + // (1) contenido distinto. + let c = tempfile::tempdir().unwrap(); + populate(c.path()); + std::fs::write(c.path().join("README"), b"otra cosa").unwrap(); + assert_ne!(h0, ArtifactHash::of_tree(c.path()).unwrap(), "el contenido entra al hash"); + + // (2) bit de ejecución distinto. + let e = tempfile::tempdir().unwrap(); + populate(e.path()); + std::fs::set_permissions(e.path().join("usr/bin/hello"), std::fs::Permissions::from_mode(0o644)).unwrap(); + assert_ne!(h0, ArtifactHash::of_tree(e.path()).unwrap(), "el bit de ejecución entra al hash"); + + // (3) target de symlink distinto. + let s = tempfile::tempdir().unwrap(); + populate(s.path()); + std::fs::remove_file(s.path().join("link")).unwrap(); + std::os::unix::fs::symlink("README", s.path().join("link")).unwrap(); + assert_ne!(h0, ArtifactHash::of_tree(s.path()).unwrap(), "el target del symlink entra al hash"); + } }