//! 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", // ── librerías del rootfs que los artefactos ENLAZAN (añadidas 2026-08-12) ────────────────── // Normalmente una receta toma sus libs de `[deps]` (el corpus), no del rootfs, y por eso esta // lista era sólo el toolchain. `python3` es la excepción forzada: CPython construye sus módulos // opcionales como `.so` y las `.a` del corpus no son PIC, así que sus headers TIENEN que venir // del lab. En cuanto una lib del rootfs entra en el CONTENIDO de un artefacto, su versión es // parte de la identidad del build — dejarla fuera reabriría, en pequeño, el mismo agujero que // este módulo existe para cerrar: mismo hash, distintos bytes. // Comprobado en la misma pasada: instalar los `-dev` subió ncurses 6.5→6.6 y readline // 8.3.1→8.3.3 sin tocar gcc/rust/musl. Si no estuvieran aquí, ese salto habría cambiado el // python3 producido SIN mover su dirección. "bzip2", "expat", "ncurses", "readline", "sqlite", "xz", ]; /// 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 { 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á TAKANA_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:` … `V:` separados por /// líneas en blanco. Se reconstruye `-`, 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 = 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}" ); } }