core: ArtifactHash::of_tree — content-hash determinista de un árbol (pre-Stage 2)
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5a6d188869
commit
9676fff0dd
@@ -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<ArtifactHash> {
|
||||
let mut rels: Vec<PathBuf> = 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<PathBuf>) -> 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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user