sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
⎇ 1940a94a118f7040e26f2c2872764c06e601bd1d
crates/hammer-core/src/lab.rs
Historia
Quién tocó qué
crudo
lab: los -dev al rootfs — es el unico sitio donde _curses se puede resolver
1//! La huella del **lab**: qué toolchain construyó un artefacto.
2//!
3//! ── POR QUÉ EXISTE ──────────────────────────────────────────────────────────────────────────────
4//! Medido el 2026-08-10 reconstruyendo los cuatro kernels en un segundo hub: el `.config` que la
5//! receta instala salió distinto del que produjo el build anterior, en **4 líneas y ninguna del
6//! cambio de receta**:
7//!
8//! ```text
9//! CONFIG_RUSTC_VERSION=109600 → 109700
10//! CONFIG_RUSTC_LLVM_VERSION=220103 → 220108
11//! ```
12//!
13//! Rust 1.96 en una máquina, 1.97 en la otra — las dos resueltas del mismo `apk add` contra Alpine
14//! edge, que es rodante. Y **Rust ni siquiera estaba activado en esos kernels**: Kconfig sondea el
15//! `rustc` del entorno y graba su versión igual. Una herramienta que el artefacto no usa le cambia
16//! los bytes.
17//!
18//! Antes de esto el toolchain del rootfs NO era una entrada del `ArtifactHash` ⇒ **dos labs sellaban
19//! bytes distintos en la MISMA dirección**, y el store no tenía forma de notarlo: para él un
20//! artefacto *es* su dirección. Un `mirror pull` entre dos hubs se llevaba cualquiera de los dos.
21//!
22//! ── QUÉ ENTRA Y QUÉ NO ──────────────────────────────────────────────────────────────────────────
23//! NO entra el rootfs entero. El criterio es: **¿la versión de este paquete puede cambiar los BYTES
24//! del artefacto?** Si no puede, no entra, porque cada paquete de más es una invalidación del corpus
25//! entero cada vez que Alpine lo bumpee — y con edge rodante `curl` se actualiza seguido sin que
26//! nada de lo que emite el compilador cambie.
27//!
28//! Entran, y por qué:
29//! · compiladores y enlazadores: gcc, g++, clang*, llvm*, binutils, rust, cargo
30//! · las libs con las que gcc hace CODEGEN: gmp, mpfr4, mpc1, isl* — un bump cambia lo que emite
31//! · runtime que se ENLAZA dentro: musl*, libgcc*, libstdc++*, libatomic, libgomp
32//! · headers que se COMPILAN dentro: linux-headers, fortify-headers
33//!
34//! Quedan fuera a propósito, aun sabiendo que no es una línea perfecta: los autotools
35//! (`m4`/`autoconf`/`automake`/`libtool`/`make`/`pkgconf`) y las shells (`bash`/`busybox`/
36//! `coreutils`). Pueden cambiar ficheros generados y por tanto el output, pero casi todas las
37//! recetas traen su `configure` ya generado en el tarball y `autoreconf` es raro. **Es una decisión
38//! de coste, no una afirmación de que no influyen**: si algún día se ve una divergencia que
39//! rastree hasta ahí, se añaden — y ese día el corpus se re-hashea otra vez.
40//!
41//! ── LA VERDAD ESTÁ EN EL ROOTFS, NO EN UN FICHERO APARTE ────────────────────────────────────────
42//! La huella se computa del `apk db` del rootfs REAL que se va a usar, no de
43//! `docs/state/lab-toolchain.lock`. El lock es el registro legible y versionado (viaja por git, que
44//! es como dos hubs se comparan) y `bootstrap-devfs.sh` avisa cuando divergen; pero hashear el lock
45//! permitiría sellar con un lab distinto del declarado y quedarse tan tranquilo. Se hashea lo que
46//! de verdad va a compilar.
47
48use std::path::Path;
49
50/// Prefijos de paquete cuya VERSIÓN puede cambiar los bytes de un artefacto. Ver el módulo.
51///
52/// Son prefijos y no nombres exactos porque Alpine versiona en el nombre (`clang22`, `llvm22`,
53/// `isl26`) y sub-paqueta (`clang22-libs`, `musl-dev`, `libstdc++-dev`). Un prefijo captura la
54/// familia entera sin tener que listar cada variante ni actualizar esto en cada bump mayor.
55///
56/// ⚠ Se comparan contra el **NOMBRE** del paquete (`P:` del apk db), sin versión: son `gcc`, no
57/// `gcc-`. El guion sólo existe en el formato `nombre-versión` que imprime `apk info -v` y guarda
58/// el lock; escribirlo aquí hace que NADA case y la huella salga la del conjunto vacío — que es un
59/// hash perfectamente válido y constante, o sea un fallo mudo. Lo cazó un test, no una revisión.
60pub const TOOLCHAIN_PREFIXES: &[&str] = &[
61 "binutils",
62 "cargo",
63 "clang",
64 "fortify-headers",
65 "g++",
66 "gcc",
67 "gmp",
68 "isl",
69 "libatomic",
70 "libgcc",
71 "libgomp",
72 "libstdc++",
73 "linux-headers",
74 "llvm",
75 "mpc1",
76 "mpfr4",
77 "musl",
78 "rust",
79 // ── librerías del rootfs que los artefactos ENLAZAN (añadidas 2026-08-12) ──────────────────
80 // Normalmente una receta toma sus libs de `[deps]` (el corpus), no del rootfs, y por eso esta
81 // lista era sólo el toolchain. `python3` es la excepción forzada: CPython construye sus módulos
82 // opcionales como `.so` y las `.a` del corpus no son PIC, así que sus headers TIENEN que venir
83 // del lab. En cuanto una lib del rootfs entra en el CONTENIDO de un artefacto, su versión es
84 // parte de la identidad del build — dejarla fuera reabriría, en pequeño, el mismo agujero que
85 // este módulo existe para cerrar: mismo hash, distintos bytes.
86 // Comprobado en la misma pasada: instalar los `-dev` subió ncurses 6.5→6.6 y readline
87 // 8.3.1→8.3.3 sin tocar gcc/rust/musl. Si no estuvieran aquí, ese salto habría cambiado el
88 // python3 producido SIN mover su dirección.
89 "bzip2",
90 "expat",
91 "ncurses",
92 "readline",
93 "sqlite",
94 "xz",
95];
96
97/// Huella del toolchain del lab. Opaca a propósito: sólo se compara y se mete al hash.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct LabFingerprint(String);
100
101impl LabFingerprint {
102 /// Huella fija para tests y para árboles herméticos sin rootfs.
103 ///
104 /// Existe para que los tests no necesiten un lab de verdad **sin abrir un camino silencioso**:
105 /// no hay `Option` ni default que se aplique solo. Quien no tiene lab lo dice explícitamente.
106 pub fn for_tests() -> Self {
107 Self("test".into())
108 }
109
110 /// Lee el `apk db` de un rootfs y resume los paquetes de [`TOOLCHAIN_PREFIXES`].
111 ///
112 /// Falla si el db no existe. **No cae a un default**: un hash calculado sin lab y uno calculado
113 /// con lab son direcciones distintas para el mismo contenido, así que adivinar aquí produciría
114 /// exactamente la divergencia silenciosa que este módulo existe para cerrar.
115 pub fn from_rootfs(rootfs: &Path) -> crate::Result<Self> {
116 let db = rootfs.join("lib/apk/db/installed");
117 let texto = std::fs::read_to_string(&db).map_err(|e| {
118 crate::Error::Other(anyhow::anyhow!(
119 "no pude leer el apk db del lab en {}: {e}. \
120 El toolchain entra en el ArtifactHash, así que sin rootfs no se puede calcular \
121 un hash comparable (corré scripts/bootstrap-devfs.sh, o apuntá HAMMER_ROOTFS)",
122 db.display()
123 ))
124 })?;
125 Ok(Self::from_apk_db(&texto))
126 }
127
128 /// Igual que [`Self::from_rootfs`] pero sobre el texto del db ya leído (testeable sin ficheros).
129 ///
130 /// El formato de `lib/apk/db/installed` son bloques `P:<nombre>` … `V:<versión>` separados por
131 /// líneas en blanco. Se reconstruye `<nombre>-<versión>`, que es la misma forma que imprime
132 /// `apk info -v` y la que guarda `docs/state/lab-toolchain.lock` ⇒ los dos se leen igual.
133 pub fn from_apk_db(texto: &str) -> Self {
134 let mut nombre: Option<&str> = None;
135 let mut paquetes: Vec<String> = Vec::new();
136 for linea in texto.lines() {
137 if let Some(n) = linea.strip_prefix("P:") {
138 nombre = Some(n);
139 } else if let Some(v) = linea.strip_prefix("V:") {
140 if let Some(n) = nombre.take() {
141 if TOOLCHAIN_PREFIXES.iter().any(|p| n.starts_with(p)) {
142 paquetes.push(format!("{n}-{v}"));
143 }
144 }
145 }
146 }
147 // Ordenar: el orden del db depende del orden de instalación, que no es una propiedad del
148 // toolchain. Sin esto, dos rootfs idénticos instalados en distinto orden darían huellas
149 // distintas y re-hashearían el corpus por nada.
150 paquetes.sort();
151 paquetes.dedup();
152 let mut h = blake3::Hasher::new();
153 for p in &paquetes {
154 h.update(p.as_bytes());
155 h.update(b"\n");
156 }
157 Self(h.finalize().to_hex().to_string())
158 }
159
160 pub fn as_str(&self) -> &str {
161 &self.0
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 const DB: &str = "\
170P:gcc
171V:15.2.0-r8
172
173P:curl
174V:8.21.0-r0
175
176P:rust
177V:1.97.0-r0
178";
179
180 #[test]
181 fn la_huella_no_es_la_del_conjunto_vacio() {
182 // REGRESIÓN: la primera versión comparaba prefijos con guion (`gcc-`) contra el NOMBRE
183 // (`gcc`) ⇒ no casaba ninguno y la huella era la del conjunto vacío: constante, válida y
184 // completamente inútil. Un filtro que no selecciona nada no se nota mirando el hash.
185 let vacia = LabFingerprint::from_apk_db("");
186 assert_ne!(
187 LabFingerprint::from_apk_db(DB),
188 vacia,
189 "si esto falla, el filtro no está seleccionando NADA"
190 );
191 }
192
193 #[test]
194 fn filtra_solo_el_toolchain() {
195 let a = LabFingerprint::from_apk_db(DB);
196 // Cambiar curl NO mueve la huella: no puede cambiar los bytes de un artefacto, y si contara
197 // el corpus entero se invalidaría en cada bump de Alpine edge.
198 let b = LabFingerprint::from_apk_db(&DB.replace("8.21.0-r0", "8.22.0-r0"));
199 assert_eq!(a, b, "curl no es toolchain");
200 }
201
202 #[test]
203 fn el_compilador_si_mueve_la_huella() {
204 let a = LabFingerprint::from_apk_db(DB);
205 // El caso REAL medido el 2026-08-10: rust 1.96 vs 1.97 entre dos hubs.
206 let b = LabFingerprint::from_apk_db(&DB.replace("1.97.0-r0", "1.96.0-r0"));
207 assert_ne!(a, b, "un rustc distinto es un lab distinto");
208 let c = LabFingerprint::from_apk_db(&DB.replace("15.2.0-r8", "15.3.0-r0"));
209 assert_ne!(a, c, "un gcc distinto es un lab distinto");
210 }
211
212 #[test]
213 fn el_orden_de_instalacion_no_cuenta() {
214 let al_reves = "P:rust\nV:1.97.0-r0\n\nP:gcc\nV:15.2.0-r8\n";
215 assert_eq!(
216 LabFingerprint::from_apk_db(DB).as_str(),
217 LabFingerprint::from_apk_db(al_reves).as_str(),
218 "el orden del db es del instalador, no del toolchain"
219 );
220 }
221
222 #[test]
223 fn rootfs_ausente_falla_en_vez_de_adivinar() {
224 let err = LabFingerprint::from_rootfs(Path::new("/no/existe")).unwrap_err();
225 assert!(
226 err.to_string().contains("apk db del lab"),
227 "el error debe decir qué falta: {err}"
228 );
229 }
230}