tawasuyu

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…

TOML 1572Shell 203Rust 109Markdown 993728 Definitions

⎇ main crates/takana-core/src/lab.rs Rust · 230 lines · 10.7 KB History Who touched what raw

takana: las variables de entorno leen las dos formas, gana la nueva 1c3e18516 · Sergio ·

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á TAKANA_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}