hash: el toolchain del lab entra en hash_inputs — el corpus entero se re-hashea

Decision del usuario tras la medicion de los 4 kernels: dos labs con distinto
rustc producian bytes distintos en la MISMA direccion, y el store no tenia
como notarlo. Ahora el toolchain es una entrada del ArtifactHash.

QUE ENTRA: 29 paquetes del rootfs cuya VERSION puede cambiar los bytes —
compiladores/enlazadores (gcc, clang, llvm, binutils, rust, cargo), las libs
de codegen de gcc (gmp, mpfr4, mpc1, isl), el runtime que se enlaza (musl,
libgcc, libstdc++, libatomic, libgomp) y los headers que se compilan dentro
(linux-headers, fortify-headers). NO entra el rootfs entero: cada paquete de
mas invalida el corpus en cada bump, y con edge rodante curl se actualiza sin
que cambie una sola instruccion emitida.

Quedan fuera a proposito, y no es una afirmacion de que no influyan: los
autotools y las shells pueden cambiar ficheros generados. Es una decision de
coste. Si algun dia se ve una divergencia que rastree ahi, se anaden — y ese
dia el corpus se re-hashea otra vez.

DE DONDE SALE: del apk db del rootfs REAL (cfg.rootfs, que respeta
HAMMER_LAB/HAMMER_ROOTFS), no de docs/state/lab-toolchain.lock. El lock sigue
siendo el registro legible que viaja por git; hashearlo permitiria sellar con
un lab distinto del declarado. Derivar la ruta del padre del store se
descarto: esa suposicion ya rompio al worker cuando su store se anclo a un
volumen (ver defaults_for_store_with_lab).

SIN CAMINO SILENCIOSO: el parametro es obligatorio, no Option. Sin rootfs
falla y dice que hacer. Un default aqui reintroduciria la divergencia que
esto cierra.

Trae test de regresion de un fallo MUDO: la primera lista de prefijos llevaba
el guion de version (`gcc-`) y en el apk db el campo P: es solo el nombre
(`gcc`) ⇒ no casaba ninguno y la huella salia la del conjunto vacio. Un hash
valido, constante e inutil, que mirando el hash no se nota.

COSTE, medido y no estimado: sealed 768 -> 0, debt 777. Los 1745 artefactos
del respaldo quedan SUPERADOS, no perdidos. Ninguna imagen queda lista.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-08-10 18:30:21 +00:00
co-authored by Claude Opus 5
parent f961b3863f
commit 58d31618b7
8 changed files with 4130 additions and 2198 deletions
+3 -3
View File
@@ -2360,7 +2360,7 @@ open(os.path.join(os.path.dirname(out),"..","..","bins-seen.txt"),"w").write("\n
let path = recipes_dir.join(format!("{name}.toml")); let path = recipes_dir.join(format!("{name}.toml"));
let r = Recipe::load_from_path(&path).unwrap_or_else(|e| panic!("parse {name}: {e}")); let r = Recipe::load_from_path(&path).unwrap_or_else(|e| panic!("parse {name}: {e}"));
assert_eq!(&r.name, name); assert_eq!(&r.name, name);
let h = hammer_build::artifact_hash(&r, &store).expect("hash"); let h = hammer_build::artifact_hash(&r, &store, &hammer_core::lab::LabFingerprint::for_tests()).expect("hash");
assert!(h.as_str().starts_with("b3:")); assert!(h.as_str().starts_with("b3:"));
} }
} }
@@ -2375,7 +2375,7 @@ open(os.path.join(os.path.dirname(out),"..","..","bins-seen.txt"),"w").write("\n
let path = recipes_dir.join(format!("{name}.toml")); let path = recipes_dir.join(format!("{name}.toml"));
let r = Recipe::load_from_path(&path).unwrap_or_else(|e| panic!("parse {name}: {e}")); let r = Recipe::load_from_path(&path).unwrap_or_else(|e| panic!("parse {name}: {e}"));
assert_eq!(&r.name, name); assert_eq!(&r.name, name);
let h = hammer_build::artifact_hash(&r, &store).expect("hash"); let h = hammer_build::artifact_hash(&r, &store, &hammer_core::lab::LabFingerprint::for_tests()).expect("hash");
assert!(h.as_str().starts_with("b3:"), "hash con prefijo b3:"); assert!(h.as_str().starts_with("b3:"), "hash con prefijo b3:");
} }
} }
@@ -2696,7 +2696,7 @@ open(os.path.join(os.path.dirname(out),"..","..","bins-seen.txt"),"w").write("\n
} }
let r = Recipe::load_from_path(&path) let r = Recipe::load_from_path(&path)
.unwrap_or_else(|e| panic!("parse {}: {e}", path.display())); .unwrap_or_else(|e| panic!("parse {}: {e}", path.display()));
let h = hammer_build::artifact_hash(&r, &store) let h = hammer_build::artifact_hash(&r, &store, &hammer_core::lab::LabFingerprint::for_tests())
.unwrap_or_else(|e| panic!("hash {}: {e}", path.display())); .unwrap_or_else(|e| panic!("hash {}: {e}", path.display()));
assert!(h.as_str().starts_with("b3:"), "{}: hash con prefijo b3:", path.display()); assert!(h.as_str().starts_with("b3:"), "{}: hash con prefijo b3:", path.display());
count += 1; count += 1;
+27 -15
View File
@@ -30,7 +30,11 @@ pub use swm_bridge::build_source_patch;
/// Errores: una receta con `deps.build` pero sin `base_dir` (parseada de TOML crudo, no cargada /// Errores: una receta con `deps.build` pero sin `base_dir` (parseada de TOML crudo, no cargada
/// de disco) no puede resolver su catálogo; un `<dep>.toml` ausente, o un ciclo de deps, también /// de disco) no puede resolver su catálogo; un `<dep>.toml` ausente, o un ciclo de deps, también
/// fallan con un mensaje que nombra la cadena. /// fallan con un mensaje que nombra la cadena.
pub fn artifact_hash(recipe: &Recipe, _store: &Store) -> hammer_core::Result<ArtifactHash> { pub fn artifact_hash(
recipe: &Recipe,
_store: &Store,
lab: &hammer_core::lab::LabFingerprint,
) -> hammer_core::Result<ArtifactHash> {
let mut stack: Vec<String> = Vec::new(); let mut stack: Vec<String> = Vec::new();
// Memo por path canónico de receta: el grafo de build es un DAG-diamante (p.ej. TODO KF6/Qt // Memo por path canónico de receta: el grafo de build es un DAG-diamante (p.ej. TODO KF6/Qt
// converge en qtbase/kcoreaddons, alcanzables por decenas de rutas). Sin memoizar, el walk // converge en qtbase/kcoreaddons, alcanzables por decenas de rutas). Sin memoizar, el walk
@@ -39,13 +43,14 @@ pub fn artifact_hash(recipe: &Recipe, _store: &Store) -> hammer_core::Result<Art
// Clave = path resuelto (no `name`): dos variantes homónimas (incoming-kde/libxml2 vs canónica) // Clave = path resuelto (no `name`): dos variantes homónimas (incoming-kde/libxml2 vs canónica)
// no deben colisionar. Ver granja-promote-colisiones. // no deben colisionar. Ver granja-promote-colisiones.
let mut memo: HashMap<PathBuf, ArtifactHash> = HashMap::new(); let mut memo: HashMap<PathBuf, ArtifactHash> = HashMap::new();
artifact_hash_rec(recipe, &mut stack, &mut memo) artifact_hash_rec(recipe, &mut stack, &mut memo, lab)
} }
fn artifact_hash_rec( fn artifact_hash_rec(
recipe: &Recipe, recipe: &Recipe,
stack: &mut Vec<String>, stack: &mut Vec<String>,
memo: &mut HashMap<PathBuf, ArtifactHash>, memo: &mut HashMap<PathBuf, ArtifactHash>,
lab: &hammer_core::lab::LabFingerprint,
) -> hammer_core::Result<ArtifactHash> { ) -> hammer_core::Result<ArtifactHash> {
if stack.iter().any(|n| n == &recipe.name) { if stack.iter().any(|n| n == &recipe.name) {
stack.push(recipe.name.clone()); stack.push(recipe.name.clone());
@@ -55,8 +60,8 @@ fn artifact_hash_rec(
))); )));
} }
let dep_hashes = resolve_build_dep_hashes(recipe, stack, memo)?; let dep_hashes = resolve_build_dep_hashes(recipe, stack, memo, lab)?;
let inputs = recipe.hash_inputs(&dep_hashes)?; let inputs = recipe.hash_inputs(&dep_hashes, lab)?;
let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect(); let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect();
Ok(ArtifactHash::of_inputs(&refs)) Ok(ArtifactHash::of_inputs(&refs))
} }
@@ -87,6 +92,7 @@ fn resolve_build_dep_hashes(
recipe: &Recipe, recipe: &Recipe,
stack: &mut Vec<String>, stack: &mut Vec<String>,
memo: &mut HashMap<PathBuf, ArtifactHash>, memo: &mut HashMap<PathBuf, ArtifactHash>,
lab: &hammer_core::lab::LabFingerprint,
) -> hammer_core::Result<Vec<ArtifactHash>> { ) -> hammer_core::Result<Vec<ArtifactHash>> {
if recipe.deps.build.is_empty() { if recipe.deps.build.is_empty() {
return Ok(Vec::new()); return Ok(Vec::new());
@@ -116,7 +122,7 @@ fn resolve_build_dep_hashes(
dep_path.display() dep_path.display()
)) ))
})?; })?;
let h = artifact_hash_rec(&dep_recipe, stack, memo)?; let h = artifact_hash_rec(&dep_recipe, stack, memo, lab)?;
memo.insert(key, h.clone()); memo.insert(key, h.clone());
hashes.push(h); hashes.push(h);
} }
@@ -187,7 +193,13 @@ pub fn build(
cfg: &BuildConfig, cfg: &BuildConfig,
store: &Store, store: &Store,
) -> hammer_core::Result<ArtifactHash> { ) -> hammer_core::Result<ArtifactHash> {
let h = artifact_hash(recipe, store)?; // La huella sale del rootfs de ESTE cfg, que es el lab que de verdad va a compilar — no de
// `docs/state/lab-toolchain.lock` ni de una ruta derivada del store. Derivarla del padre del
// store ya mordió una vez (ver `BuildConfig::defaults_for_store_with_lab`): al anclar el store
// de un worker a un volumen, el lab se buscaba donde no estaba. `cfg.rootfs` respeta
// `HAMMER_LAB`/`HAMMER_ROOTFS` y es la única fuente que no puede desincronizarse del build.
let lab = hammer_core::lab::LabFingerprint::from_rootfs(&cfg.rootfs)?;
let h = artifact_hash(recipe, store, &lab)?;
if store.has(&h, &recipe.name) { if store.has(&h, &recipe.name) {
tracing::info!(hash = %h, name = %recipe.name, "caché: artefacto ya en el store"); tracing::info!(hash = %h, name = %recipe.name, "caché: artefacto ya en el store");
return Ok(h); return Ok(h);
@@ -1405,8 +1417,8 @@ mod dep_hash_tests {
write_recipe(d.path(), "app", "a0", &["base"]); write_recipe(d.path(), "app", "a0", &["base"]);
write_recipe(d.path(), "app_nodep", "a0", &[]); // misma fuente, sin la dep write_recipe(d.path(), "app_nodep", "a0", &[]); // misma fuente, sin la dep
let with_dep = artifact_hash(&load(d.path(), "app"), &store).unwrap(); let with_dep = artifact_hash(&load(d.path(), "app"), &store, &hammer_core::lab::LabFingerprint::for_tests()).unwrap();
let no_dep = artifact_hash(&load(d.path(), "app_nodep"), &store).unwrap(); let no_dep = artifact_hash(&load(d.path(), "app_nodep"), &store, &hammer_core::lab::LabFingerprint::for_tests()).unwrap();
// `app_nodep` se llama distinto, pero el nombre no entra al hash: la única diferencia // `app_nodep` se llama distinto, pero el nombre no entra al hash: la única diferencia
// de entrada es la presencia de la dep. (Renombramos sólo para tener dos .toml.) // de entrada es la presencia de la dep. (Renombramos sólo para tener dos .toml.)
assert_ne!(with_dep, no_dep, "la dep de build debe entrar al hash"); assert_ne!(with_dep, no_dep, "la dep de build debe entrar al hash");
@@ -1419,11 +1431,11 @@ mod dep_hash_tests {
write_recipe(d.path(), "base", "b0", &[]); write_recipe(d.path(), "base", "b0", &[]);
write_recipe(d.path(), "lib", "l0", &["base"]); write_recipe(d.path(), "lib", "l0", &["base"]);
write_recipe(d.path(), "app", "a0", &["lib"]); write_recipe(d.path(), "app", "a0", &["lib"]);
let before = artifact_hash(&load(d.path(), "app"), &store).unwrap(); let before = artifact_hash(&load(d.path(), "app"), &store, &hammer_core::lab::LabFingerprint::for_tests()).unwrap();
// Cambiar la fuente de la dep transitiva `base` debe re-hashear `app`. // Cambiar la fuente de la dep transitiva `base` debe re-hashear `app`.
write_recipe(d.path(), "base", "b1", &[]); write_recipe(d.path(), "base", "b1", &[]);
let after = artifact_hash(&load(d.path(), "app"), &store).unwrap(); let after = artifact_hash(&load(d.path(), "app"), &store, &hammer_core::lab::LabFingerprint::for_tests()).unwrap();
assert_ne!(before, after, "un cambio transitivo debe propagarse"); assert_ne!(before, after, "un cambio transitivo debe propagarse");
} }
@@ -1432,7 +1444,7 @@ mod dep_hash_tests {
let d = tempfile::tempdir().unwrap(); let d = tempfile::tempdir().unwrap();
let store = store_in(d.path()); let store = store_in(d.path());
write_recipe(d.path(), "app", "a0", &["pcre2"]); // sin pcre2.toml write_recipe(d.path(), "app", "a0", &["pcre2"]); // sin pcre2.toml
let err = artifact_hash(&load(d.path(), "app"), &store).unwrap_err().to_string(); let err = artifact_hash(&load(d.path(), "app"), &store, &hammer_core::lab::LabFingerprint::for_tests()).unwrap_err().to_string();
assert!(err.contains("pcre2"), "el error debe nombrar la dep: {err}"); assert!(err.contains("pcre2"), "el error debe nombrar la dep: {err}");
assert!(err.contains("app"), "y la receta que la pide: {err}"); assert!(err.contains("app"), "y la receta que la pide: {err}");
} }
@@ -1443,7 +1455,7 @@ mod dep_hash_tests {
let store = store_in(d.path()); let store = store_in(d.path());
write_recipe(d.path(), "a", "a0", &["b"]); write_recipe(d.path(), "a", "a0", &["b"]);
write_recipe(d.path(), "b", "b0", &["a"]); write_recipe(d.path(), "b", "b0", &["a"]);
let err = artifact_hash(&load(d.path(), "a"), &store).unwrap_err().to_string(); let err = artifact_hash(&load(d.path(), "a"), &store, &hammer_core::lab::LabFingerprint::for_tests()).unwrap_err().to_string();
assert!(err.contains("ciclo"), "esperaba ciclo: {err}"); assert!(err.contains("ciclo"), "esperaba ciclo: {err}");
assert!(err.contains("a -> b -> a"), "debe mostrar la cadena: {err}"); assert!(err.contains("a -> b -> a"), "debe mostrar la cadena: {err}");
} }
@@ -1456,7 +1468,7 @@ mod dep_hash_tests {
"name=\"x\"\nversion=\"1\"\n[source]\nrepo=\"git://x\"\ncommit=\"c0\"\n[build]\n[deps]\nbuild=[\"y\"]\n", "name=\"x\"\nversion=\"1\"\n[source]\nrepo=\"git://x\"\ncommit=\"c0\"\n[build]\n[deps]\nbuild=[\"y\"]\n",
) )
.unwrap(); .unwrap();
let err = artifact_hash(&r, &store).unwrap_err().to_string(); let err = artifact_hash(&r, &store, &hammer_core::lab::LabFingerprint::for_tests()).unwrap_err().to_string();
assert!(err.contains("base_dir"), "debe explicar la falta de base_dir: {err}"); assert!(err.contains("base_dir"), "debe explicar la falta de base_dir: {err}");
} }
@@ -1465,8 +1477,8 @@ mod dep_hash_tests {
let d = tempfile::tempdir().unwrap(); let d = tempfile::tempdir().unwrap();
let store = store_in(d.path()); let store = store_in(d.path());
write_recipe(d.path(), "solo", "s0", &[]); write_recipe(d.path(), "solo", "s0", &[]);
let a = artifact_hash(&load(d.path(), "solo"), &store).unwrap(); let a = artifact_hash(&load(d.path(), "solo"), &store, &hammer_core::lab::LabFingerprint::for_tests()).unwrap();
let b = artifact_hash(&load(d.path(), "solo"), &store).unwrap(); let b = artifact_hash(&load(d.path(), "solo"), &store, &hammer_core::lab::LabFingerprint::for_tests()).unwrap();
assert_eq!(a, b, "sin deps el hash es estable"); assert_eq!(a, b, "sin deps el hash es estable");
} }
} }
+6 -1
View File
@@ -945,7 +945,12 @@ fn plan(
let derived = hammer_core::kernel::plan::derive_recipe(&base, &p); let derived = hammer_core::kernel::plan::derive_recipe(&base, &p);
match hammer_core::Store::open(Path::new(store)) match hammer_core::Store::open(Path::new(store))
.map_err(anyhow::Error::from) .map_err(anyhow::Error::from)
.and_then(|s| hammer_build::artifact_hash(&derived, &s).map_err(anyhow::Error::from)) .and_then(|s| {
let cfg = hammer_build::BuildConfig::from_env_or_defaults(s.root());
let lab = hammer_core::lab::LabFingerprint::from_rootfs(&cfg.rootfs)
.map_err(anyhow::Error::from)?;
hammer_build::artifact_hash(&derived, &s, &lab).map_err(anyhow::Error::from)
})
{ {
Ok(h) => p.artifact_hash = Some(h.to_string()), Ok(h) => p.artifact_hash = Some(h.to_string()),
Err(e) => eprintln!("aviso: no pude calcular el ArtifactHash ({e})"), Err(e) => eprintln!("aviso: no pude calcular el ArtifactHash ({e})"),
+5 -1
View File
@@ -792,7 +792,11 @@ fn main() -> anyhow::Result<()> {
Cmd::Hash { recipe, check } => { Cmd::Hash { recipe, check } => {
let store = hammer_core::Store::open(&cli.store)?; let store = hammer_core::Store::open(&cli.store)?;
let recipe = hammer_core::Recipe::load_from_path(&recipe)?; let recipe = hammer_core::Recipe::load_from_path(&recipe)?;
let hash = hammer_build::artifact_hash(&recipe, &store)?; // Misma resolución de lab que `build`, o `hash` mentiría: el toolchain entra en el
// ArtifactHash, así que un `hash` que no lo mire daría una dirección que `build` no usa.
let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
let lab = hammer_core::lab::LabFingerprint::from_rootfs(&cfg.rootfs)?;
let hash = hammer_build::artifact_hash(&recipe, &store, &lab)?;
if check { if check {
// ¿el artefacto VIGENTE (el que corresponde a la receta de HOY) ya está sellado? // ¿el artefacto VIGENTE (el que corresponde a la receta de HOY) ya está sellado?
// Es lo que static-audit.sh no podía saber: `ls -dt store/*-<n>` da el más RECIENTE, // Es lo que static-audit.sh no podía saber: `ls -dt store/*-<n>` da el más RECIENTE,
+214
View File
@@ -0,0 +1,214 @@
//! La huella del **lab**: qué toolchain construyó un artefacto.
//!
//! ── POR QUÉ EXISTE ──────────────────────────────────────────────────────────────────────────────
//! Medido el 2026-08-10 reconstruyendo los cuatro kernels en un segundo hub: el `.config` que la
//! receta instala salió distinto del que produjo el build anterior, en **4 líneas y ninguna del
//! cambio de receta**:
//!
//! ```text
//! CONFIG_RUSTC_VERSION=109600 → 109700
//! CONFIG_RUSTC_LLVM_VERSION=220103 → 220108
//! ```
//!
//! Rust 1.96 en una máquina, 1.97 en la otra — las dos resueltas del mismo `apk add` contra Alpine
//! edge, que es rodante. Y **Rust ni siquiera estaba activado en esos kernels**: Kconfig sondea el
//! `rustc` del entorno y graba su versión igual. Una herramienta que el artefacto no usa le cambia
//! los bytes.
//!
//! Antes de esto el toolchain del rootfs NO era una entrada del `ArtifactHash` ⇒ **dos labs sellaban
//! bytes distintos en la MISMA dirección**, y el store no tenía forma de notarlo: para él un
//! artefacto *es* su dirección. Un `mirror pull` entre dos hubs se llevaba cualquiera de los dos.
//!
//! ── QUÉ ENTRA Y QUÉ NO ──────────────────────────────────────────────────────────────────────────
//! NO entra el rootfs entero. El criterio es: **¿la versión de este paquete puede cambiar los BYTES
//! del artefacto?** Si no puede, no entra, porque cada paquete de más es una invalidación del corpus
//! entero cada vez que Alpine lo bumpee — y con edge rodante `curl` se actualiza seguido sin que
//! nada de lo que emite el compilador cambie.
//!
//! Entran, y por qué:
//! · compiladores y enlazadores: gcc, g++, clang*, llvm*, binutils, rust, cargo
//! · las libs con las que gcc hace CODEGEN: gmp, mpfr4, mpc1, isl* — un bump cambia lo que emite
//! · runtime que se ENLAZA dentro: musl*, libgcc*, libstdc++*, libatomic, libgomp
//! · headers que se COMPILAN dentro: linux-headers, fortify-headers
//!
//! Quedan fuera a propósito, aun sabiendo que no es una línea perfecta: los autotools
//! (`m4`/`autoconf`/`automake`/`libtool`/`make`/`pkgconf`) y las shells (`bash`/`busybox`/
//! `coreutils`). Pueden cambiar ficheros generados y por tanto el output, pero casi todas las
//! recetas traen su `configure` ya generado en el tarball y `autoreconf` es raro. **Es una decisión
//! de coste, no una afirmación de que no influyen**: si algún día se ve una divergencia que
//! rastree hasta ahí, se añaden — y ese día el corpus se re-hashea otra vez.
//!
//! ── LA VERDAD ESTÁ EN EL ROOTFS, NO EN UN FICHERO APARTE ────────────────────────────────────────
//! La huella se computa del `apk db` del rootfs REAL que se va a usar, no de
//! `docs/state/lab-toolchain.lock`. El lock es el registro legible y versionado (viaja por git, que
//! es como dos hubs se comparan) y `bootstrap-devfs.sh` avisa cuando divergen; pero hashear el lock
//! permitiría sellar con un lab distinto del declarado y quedarse tan tranquilo. Se hashea lo que
//! de verdad va a compilar.
use std::path::Path;
/// Prefijos de paquete cuya VERSIÓN puede cambiar los bytes de un artefacto. Ver el módulo.
///
/// Son prefijos y no nombres exactos porque Alpine versiona en el nombre (`clang22`, `llvm22`,
/// `isl26`) y sub-paqueta (`clang22-libs`, `musl-dev`, `libstdc++-dev`). Un prefijo captura la
/// familia entera sin tener que listar cada variante ni actualizar esto en cada bump mayor.
///
/// ⚠ Se comparan contra el **NOMBRE** del paquete (`P:` del apk db), sin versión: son `gcc`, no
/// `gcc-`. El guion sólo existe en el formato `nombre-versión` que imprime `apk info -v` y guarda
/// el lock; escribirlo aquí hace que NADA case y la huella salga la del conjunto vacío — que es un
/// hash perfectamente válido y constante, o sea un fallo mudo. Lo cazó un test, no una revisión.
pub const TOOLCHAIN_PREFIXES: &[&str] = &[
"binutils",
"cargo",
"clang",
"fortify-headers",
"g++",
"gcc",
"gmp",
"isl",
"libatomic",
"libgcc",
"libgomp",
"libstdc++",
"linux-headers",
"llvm",
"mpc1",
"mpfr4",
"musl",
"rust",
];
/// Huella del toolchain del lab. Opaca a propósito: sólo se compara y se mete al hash.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LabFingerprint(String);
impl LabFingerprint {
/// Huella fija para tests y para árboles herméticos sin rootfs.
///
/// Existe para que los tests no necesiten un lab de verdad **sin abrir un camino silencioso**:
/// no hay `Option` ni default que se aplique solo. Quien no tiene lab lo dice explícitamente.
pub fn for_tests() -> Self {
Self("test".into())
}
/// Lee el `apk db` de un rootfs y resume los paquetes de [`TOOLCHAIN_PREFIXES`].
///
/// Falla si el db no existe. **No cae a un default**: un hash calculado sin lab y uno calculado
/// con lab son direcciones distintas para el mismo contenido, así que adivinar aquí produciría
/// exactamente la divergencia silenciosa que este módulo existe para cerrar.
pub fn from_rootfs(rootfs: &Path) -> crate::Result<Self> {
let db = rootfs.join("lib/apk/db/installed");
let texto = std::fs::read_to_string(&db).map_err(|e| {
crate::Error::Other(anyhow::anyhow!(
"no pude leer el apk db del lab en {}: {e}. \
El toolchain entra en el ArtifactHash, así que sin rootfs no se puede calcular \
un hash comparable (corré scripts/bootstrap-devfs.sh, o apuntá HAMMER_ROOTFS)",
db.display()
))
})?;
Ok(Self::from_apk_db(&texto))
}
/// Igual que [`Self::from_rootfs`] pero sobre el texto del db ya leído (testeable sin ficheros).
///
/// El formato de `lib/apk/db/installed` son bloques `P:<nombre>` … `V:<versión>` separados por
/// líneas en blanco. Se reconstruye `<nombre>-<versión>`, que es la misma forma que imprime
/// `apk info -v` y la que guarda `docs/state/lab-toolchain.lock` ⇒ los dos se leen igual.
pub fn from_apk_db(texto: &str) -> Self {
let mut nombre: Option<&str> = None;
let mut paquetes: Vec<String> = Vec::new();
for linea in texto.lines() {
if let Some(n) = linea.strip_prefix("P:") {
nombre = Some(n);
} else if let Some(v) = linea.strip_prefix("V:") {
if let Some(n) = nombre.take() {
if TOOLCHAIN_PREFIXES.iter().any(|p| n.starts_with(p)) {
paquetes.push(format!("{n}-{v}"));
}
}
}
}
// Ordenar: el orden del db depende del orden de instalación, que no es una propiedad del
// toolchain. Sin esto, dos rootfs idénticos instalados en distinto orden darían huellas
// distintas y re-hashearían el corpus por nada.
paquetes.sort();
paquetes.dedup();
let mut h = blake3::Hasher::new();
for p in &paquetes {
h.update(p.as_bytes());
h.update(b"\n");
}
Self(h.finalize().to_hex().to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
const DB: &str = "\
P:gcc
V:15.2.0-r8
P:curl
V:8.21.0-r0
P:rust
V:1.97.0-r0
";
#[test]
fn la_huella_no_es_la_del_conjunto_vacio() {
// REGRESIÓN: la primera versión comparaba prefijos con guion (`gcc-`) contra el NOMBRE
// (`gcc`) ⇒ no casaba ninguno y la huella era la del conjunto vacío: constante, válida y
// completamente inútil. Un filtro que no selecciona nada no se nota mirando el hash.
let vacia = LabFingerprint::from_apk_db("");
assert_ne!(
LabFingerprint::from_apk_db(DB),
vacia,
"si esto falla, el filtro no está seleccionando NADA"
);
}
#[test]
fn filtra_solo_el_toolchain() {
let a = LabFingerprint::from_apk_db(DB);
// Cambiar curl NO mueve la huella: no puede cambiar los bytes de un artefacto, y si contara
// el corpus entero se invalidaría en cada bump de Alpine edge.
let b = LabFingerprint::from_apk_db(&DB.replace("8.21.0-r0", "8.22.0-r0"));
assert_eq!(a, b, "curl no es toolchain");
}
#[test]
fn el_compilador_si_mueve_la_huella() {
let a = LabFingerprint::from_apk_db(DB);
// El caso REAL medido el 2026-08-10: rust 1.96 vs 1.97 entre dos hubs.
let b = LabFingerprint::from_apk_db(&DB.replace("1.97.0-r0", "1.96.0-r0"));
assert_ne!(a, b, "un rustc distinto es un lab distinto");
let c = LabFingerprint::from_apk_db(&DB.replace("15.2.0-r8", "15.3.0-r0"));
assert_ne!(a, c, "un gcc distinto es un lab distinto");
}
#[test]
fn el_orden_de_instalacion_no_cuenta() {
let al_reves = "P:rust\nV:1.97.0-r0\n\nP:gcc\nV:15.2.0-r8\n";
assert_eq!(
LabFingerprint::from_apk_db(DB).as_str(),
LabFingerprint::from_apk_db(al_reves).as_str(),
"el orden del db es del instalador, no del toolchain"
);
}
#[test]
fn rootfs_ausente_falla_en_vez_de_adivinar() {
let err = LabFingerprint::from_rootfs(Path::new("/no/existe")).unwrap_err();
assert!(
err.to_string().contains("apk db del lab"),
"el error debe decir qué falta: {err}"
);
}
}
+1
View File
@@ -11,6 +11,7 @@ pub mod differs;
pub mod hash; pub mod hash;
pub mod installed; pub mod installed;
pub mod kernel; pub mod kernel;
pub mod lab;
pub mod proto; pub mod proto;
pub mod query; pub mod query;
pub mod recipe; pub mod recipe;
+25 -17
View File
@@ -482,9 +482,16 @@ impl Recipe {
/// Las entradas canónicas que alimentan el `ArtifactHash` de esta receta. Incluye el /// Las entradas canónicas que alimentan el `ArtifactHash` de esta receta. Incluye el
/// **contenido** de cada patch (no su ruta), de modo que renombrar un archivo no cambia el /// **contenido** de cada patch (no su ruta), de modo que renombrar un archivo no cambia el
/// hash y editarlo sí lo hace. Ver `docs/02-build-lab.md` §2. /// hash y editarlo sí lo hace. Ver `docs/02-build-lab.md` §2.
/// `lab` es la huella del toolchain que va a construir esto (ver [`crate::lab`]). Entra SIEMPRE
/// y es un parámetro obligatorio, no un `Option`: el 2026-08-10 se midió que dos labs con
/// distinto `rustc` producen bytes distintos, así que un hash calculado sin lab y otro con lab
/// son direcciones distintas para el mismo contenido. Un default silencioso aquí reintroduciría
/// exactamente la divergencia que esto cierra — quien no tiene lab usa
/// [`crate::lab::LabFingerprint::for_tests`] y lo dice.
pub fn hash_inputs( pub fn hash_inputs(
&self, &self,
dep_hashes: &[ArtifactHash], dep_hashes: &[ArtifactHash],
lab: &crate::lab::LabFingerprint,
) -> crate::Result<Vec<Vec<u8>>> { ) -> crate::Result<Vec<Vec<u8>>> {
// El identificador inmutable del contenido fuente: el commit en modo git, el // El identificador inmutable del contenido fuente: el commit en modo git, el
// sha256 del archivo en modo tarball. Cualquiera de los dos es un puntero al // sha256 del archivo en modo tarball. Cualquiera de los dos es un puntero al
@@ -498,6 +505,7 @@ impl Recipe {
self.build.compiler.as_str().as_bytes().to_vec(), self.build.compiler.as_str().as_bytes().to_vec(),
self.build.target.as_bytes().to_vec(), self.build.target.as_bytes().to_vec(),
self.build.link.as_str().as_bytes().to_vec(), self.build.link.as_str().as_bytes().to_vec(),
format!("lab:{}", lab.as_str()).into_bytes(),
]; ];
// Sólo entra al hash si está fijado: una receta sin `zig_version` mantiene su hash de antes // Sólo entra al hash si está fijado: una receta sin `zig_version` mantiene su hash de antes
// de existir el campo (compatibilidad hacia atrás; el baseline 9adefb82 no se mueve). // de existir el campo (compatibilidad hacia atrás; el baseline 9adefb82 no se mueve).
@@ -651,7 +659,7 @@ sha256 = "1f31014953e71c3cddcedb97692ad7620cb9d6d04fbdc19e0d8dd836f87622bb"
flags = ["--enable-perl-regexp"] flags = ["--enable-perl-regexp"]
"#; "#;
let tar = Recipe::from_toml(tar_toml).unwrap(); let tar = Recipe::from_toml(tar_toml).unwrap();
assert_ne!(git.hash_inputs(&[]).unwrap(), tar.hash_inputs(&[]).unwrap()); assert_ne!(git.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(), tar.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap());
} }
#[test] #[test]
@@ -674,8 +682,8 @@ commit = "deadbeef"
#[test] #[test]
fn hash_no_patches_is_deterministic() { fn hash_no_patches_is_deterministic() {
let r = Recipe::from_toml(SAMPLE).unwrap(); let r = Recipe::from_toml(SAMPLE).unwrap();
let a = r.hash_inputs(&[]).unwrap(); let a = r.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap();
let b = r.hash_inputs(&[]).unwrap(); let b = r.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap();
assert_eq!(a, b); assert_eq!(a, b);
} }
@@ -695,7 +703,7 @@ commit = "deadbeef"
r2.base_dir = dir2.path().to_path_buf(); r2.base_dir = dir2.path().to_path_buf();
// Mismo CONTENIDO ⇒ misma entrada al hash, aunque las rutas sean distintas. // Mismo CONTENIDO ⇒ misma entrada al hash, aunque las rutas sean distintas.
assert_eq!(r1.hash_inputs(&[]).unwrap(), r2.hash_inputs(&[]).unwrap()); assert_eq!(r1.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(), r2.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap());
} }
#[test] #[test]
@@ -707,10 +715,10 @@ commit = "deadbeef"
let mut r = Recipe::from_toml(SAMPLE).unwrap(); let mut r = Recipe::from_toml(SAMPLE).unwrap();
r.source.patches = vec!["p.patch".into()]; r.source.patches = vec!["p.patch".into()];
r.base_dir = dir.path().to_path_buf(); r.base_dir = dir.path().to_path_buf();
let h_a = r.hash_inputs(&[]).unwrap(); let h_a = r.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap();
std::fs::write(&patch, b"version B").unwrap(); std::fs::write(&patch, b"version B").unwrap();
let h_b = r.hash_inputs(&[]).unwrap(); let h_b = r.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap();
assert_ne!(h_a, h_b); assert_ne!(h_a, h_b);
} }
@@ -719,7 +727,7 @@ commit = "deadbeef"
let mut r = Recipe::from_toml(SAMPLE).unwrap(); let mut r = Recipe::from_toml(SAMPLE).unwrap();
r.source.patches = vec!["no-existe.patch".into()]; r.source.patches = vec!["no-existe.patch".into()];
r.base_dir = PathBuf::from("/tmp/seguro-que-no-existe-hammer"); r.base_dir = PathBuf::from("/tmp/seguro-que-no-existe-hammer");
let err = r.hash_inputs(&[]).unwrap_err().to_string(); let err = r.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap_err().to_string();
assert!(err.contains("no-existe.patch"), "msg = {err}"); assert!(err.contains("no-existe.patch"), "msg = {err}");
} }
@@ -788,8 +796,8 @@ expected_output = "b3:deadbeef"
sin.evidence = Evidence::default(); sin.evidence = Evidence::default();
assert!(con.evidence.checks.len() == 3 && sin.evidence.is_empty()); assert!(con.evidence.checks.len() == 3 && sin.evidence.is_empty());
assert_eq!( assert_eq!(
sin.hash_inputs(&[]).unwrap(), sin.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(),
con.hash_inputs(&[]).unwrap(), con.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(),
"la evidencia NO debe entrar en hash_inputs" "la evidencia NO debe entrar en hash_inputs"
); );
} }
@@ -804,8 +812,8 @@ expected_output = "b3:deadbeef"
let mut con = sin.clone(); let mut con = sin.clone();
con.source.cargo_vendor = Some(true); con.source.cargo_vendor = Some(true);
assert_eq!( assert_eq!(
sin.hash_inputs(&[]).unwrap(), sin.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(),
con.hash_inputs(&[]).unwrap(), con.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(),
"cargo_vendor NO debe entrar en hash_inputs" "cargo_vendor NO debe entrar en hash_inputs"
); );
// Y roundtripea por TOML: si se perdiera al serializar, el forzado se apagaría solo en // Y roundtripea por TOML: si se perdiera al serializar, el forzado se apagaría solo en
@@ -843,8 +851,8 @@ expected_output = "b3:deadbeef"
sin.slots = Slots::default(); sin.slots = Slots::default();
assert!(!con.slots.is_empty() && sin.slots.is_empty()); assert!(!con.slots.is_empty() && sin.slots.is_empty());
assert_eq!( assert_eq!(
sin.hash_inputs(&[]).unwrap(), sin.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(),
con.hash_inputs(&[]).unwrap(), con.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(),
"los slots NO deben entrar en hash_inputs" "los slots NO deben entrar en hash_inputs"
); );
} }
@@ -878,8 +886,8 @@ expected_output = "b3:deadbeef"
// Y el hash es el MISMO con y sin licencia. // Y el hash es el MISMO con y sin licencia.
assert_eq!( assert_eq!(
sin.hash_inputs(&[]).unwrap(), sin.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(),
con.hash_inputs(&[]).unwrap(), con.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(),
"la licencia NO debe entrar en hash_inputs: si entrara, declararla re-hashearía las \ "la licencia NO debe entrar en hash_inputs: si entrara, declararla re-hashearía las \
1141 recetas ya selladas y tiraría el baseline de reproducibilidad" 1141 recetas ya selladas y tiraría el baseline de reproducibilidad"
); );
@@ -903,8 +911,8 @@ expected_output = "b3:deadbeef"
let mut con = Recipe::from_toml(SAMPLE).unwrap(); let mut con = Recipe::from_toml(SAMPLE).unwrap();
con.build.strip_debug = Some(true); con.build.strip_debug = Some(true);
assert_ne!( assert_ne!(
sin.hash_inputs(&[]).unwrap(), sin.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(),
con.hash_inputs(&[]).unwrap(), con.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(),
"strip_debug DEBE entrar en hash_inputs: cambia el contenido del artefacto" "strip_debug DEBE entrar en hash_inputs: cambia el contenido del artefacto"
); );
+3849 -2161
View File
File diff suppressed because it is too large Load Diff