//! Direccionamiento por contenido (CAS). Ver `docs/02-build-lab.md` §2. //! //! 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:`. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct ArtifactHash(String); impl ArtifactHash { /// Construye desde bytes ya hasheados (representación hex). pub fn from_hex(hex: impl Into) -> Self { ArtifactHash(format!("b3:{}", hex.into())) } /// Hashea un conjunto ordenado de entradas. El llamador es responsable de pasar las /// entradas en orden canónico y estable (ver `docs/02-build-lab.md` §2). pub fn of_inputs(inputs: &[&[u8]]) -> Self { let mut hasher = blake3::Hasher::new(); for chunk in inputs { // length-prefijado para evitar colisiones por concatenación ambigua. hasher.update(&(chunk.len() as u64).to_le_bytes()); hasher.update(chunk); } ArtifactHash(format!("b3:{}", hasher.finalize().to_hex())) } /// Forma corta para directorios del store: `-`. pub fn store_dir_name(&self, name: &str) -> String { let bare = self.0.strip_prefix("b3:").unwrap_or(&self.0); format!("{bare}-{name}") } 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()))) } /// BLAKE3 **crudo** del contenido de un fichero (sin framing): exactamente `blake3(bytes)`. Es lo /// que computa el `blake3_of` de `arje-cas` y el `expected_hash` de un `.swm`, así que sirve para /// la atestación de integridad al arranque (el gate que arje aplica antes de incarnar). A diferencia /// de [`of_inputs`](Self::of_inputs) (length-prefijado) y [`of_tree`](Self::of_tree) (árbol con /// rutas/modo), aquí el hash es del binario tal cual ⇒ casa con quien recompute `blake3` del fichero. pub fn of_file(path: &Path) -> std::io::Result { let mut f = std::fs::File::open(path)?; let mut hasher = blake3::Hasher::new(); std::io::copy(&mut f, &mut hasher)?; Ok(ArtifactHash(format!("b3:{}", hasher.finalize().to_hex()))) } /// BLAKE3 crudo de un buffer en memoria — exactamente `blake3(bytes)`, la misma convención que /// [`of_file`](Self::of_file). Lo usa el checker de evidencia (H1b) para anclar el stdout de un /// check contra su `expected_output`: quien recompute `blake3` de la salida obtiene el mismo `b3:…`. /// /// **Ésta, y no [`of_inputs`](Self::of_inputs), es la que direcciona un blob por su contenido** /// (ADR 0014): con una sola entrada el length-prefijado no desambigua nada y cuesta que el /// nombre deje de ser verificable por un tercero con `b3sum` en la mano. Vale también fuera del /// repo: churay (tawasuyu) direcciona sus blobs con esta misma función bajo el mismo prefijo /// `b3:`, así que un CAS compartido sólo cierra si los dos lados usan ésta — con `of_inputs` el /// mismo archivo tendría dos hex distintos y la discrepancia no fallaría ruidosamente. pub fn of_bytes(bytes: &[u8]) -> ArtifactHash { ArtifactHash(format!("b3:{}", blake3::hash(bytes).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 { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) } } #[cfg(test)] mod tests { use super::*; #[test] fn deterministic_and_order_sensitive() { let a = ArtifactHash::of_inputs(&[b"grep", b"abc123", b"--static"]); let b = ArtifactHash::of_inputs(&[b"grep", b"abc123", b"--static"]); assert_eq!(a, b, "misma entrada ⇒ mismo hash"); let c = ArtifactHash::of_inputs(&[b"abc123", b"grep", b"--static"]); assert_ne!(a, c, "orden distinto ⇒ hash distinto"); } #[test] fn store_dir_name_strips_prefix() { 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"); } }