Lo destapó escribir la contraparte del ADR 0014 para churay: churay direcciona sus blobs con blake3(bytes) pelado y hammer iba a sellar el digest con of_inputs(&[bytes]), los dos bajo el prefijo `b3:`. El mismo archivo con dos hex distintos, y un CAS compartido que no falla ruidosamente: cada lado busca un nombre distinto para los mismos bytes. Con una sola entrada el length-prefijado no desambigua ninguna concatenación — sólo hace que el nombre deje de ser verificable por un tercero con b3sum en la mano. `ArtifactHash::of_bytes` YA EXISTÍA y ya era blake3 pelado: es la convención de of_file y la del expected_hash de un .swm, así que of_inputs era además la pieza fuera de sitio dentro del propio repo. of_inputs se queda para lo que fue escrito: hashear una LISTA. Coste: una línea, porque ningún índice publicado lleva todavía el campo. Es el argumento del ADR aplicado a sí mismo — decidir antes de que haya usuarios. La corrección queda en el ADR, no reescrita en silencio. Guardián: digest_es_blake3_pelado_y_no_length_prefijado, con vector fijo de b3sum y un assert_ne contra of_inputs que nombra la consecuencia. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CK6HpSoHcN9M4GBpqRSusR
205 lines
9.1 KiB
Rust
205 lines
9.1 KiB
Rust
//! 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<String>) -> 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: `<hash-sin-prefijo>-<name>`.
|
|
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<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())))
|
|
}
|
|
|
|
/// 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<ArtifactHash> {
|
|
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<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 {
|
|
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");
|
|
}
|
|
}
|