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 1472Shell 176Rust 106Markdown 953592 Definiciones

⎇ main crates/takana-build/src/sandbox.rs Rust · 746 líneas · 38.4 KB Historia Quién tocó qué crudo

takana etapa 5d: comentarios de crates, CLAUDE.md y el skill de granja b818f5249 · Sergio ·

1//! El sandbox hermético de build (bubblewrap). Ver `docs/02-build-lab.md` §3 y §4.
2//!
3//! Capas:
4//!   - Base read-only: rootfs Alpine (`.dev-fs/alpine`) montado como overlay con capa
5//!     tmpfs encima, así el build puede escribir en /tmp/etc sin ensuciar el host.
6//!   - Compilador inyectado: `zig` (`.dev-fs/tools/zig`) en `/opt/zig`.
7//!   - Fuentes: bind RW en `/src` (deben venir ya copiadas a un dir host descartable).
8//!   - Salida: bind RW en `/out` (DESTDIR).
9//!   - Red: aislada (`--unshare-all` incluye `--unshare-net`).
10
11use std::collections::VecDeque;
12use std::io::{BufRead, BufReader, Read, Write};
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15use std::sync::{Arc, Mutex};
16
17/// Cuántas líneas finales del log del sandbox conservamos para `BuildFailed.log_tail`.
18const TAIL_LINES: usize = 50;
19
20/// Fallo de una fase de build, con la cola del log del lab si la pudimos capturar. Viaja
21/// dentro de `takana_core::Error::Other(anyhow)`; el bus la recupera por `downcast_ref` para
22/// poblar `Event::BuildFailed.log_tail`. Ver `docs/07-agent-bus.md`.
23#[derive(Debug, Clone)]
24pub struct BuildFailure {
25    pub reason: String,
26    pub log_tail: Option<String>,
27}
28
29impl std::fmt::Display for BuildFailure {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        write!(f, "{}", self.reason)?;
32        if let Some(tail) = &self.log_tail {
33            write!(f, "\n--- log (últimas líneas) ---\n{tail}")?;
34        }
35        Ok(())
36    }
37}
38
39impl std::error::Error for BuildFailure {}
40
41impl BuildFailure {
42    /// Recupera un `BuildFailure` de un error de takana-core, si lo lleva dentro. El bus lo
43    /// usa para separar `reason` de `log_tail`.
44    pub fn from_error(e: &takana_core::Error) -> Option<&BuildFailure> {
45        match e {
46            takana_core::Error::Other(a) => a.downcast_ref::<BuildFailure>(),
47            _ => None,
48        }
49    }
50}
51
52/// Empuja `line` a un ring buffer acotado a `cap` líneas (descarta la más antigua).
53fn push_tail(buf: &mut VecDeque<String>, line: String, cap: usize) {
54    if buf.len() == cap {
55        buf.pop_front();
56    }
57    buf.push_back(line);
58}
59
60/// Resultado de correr un comando en el sandbox con captura (H1b). A diferencia de `run`, NO falla
61/// en exit != 0: el checker de evidencia compara `exit_code` y `blake3(stdout)` contra lo declarado.
62#[derive(Debug, Clone)]
63pub struct CmdOutcome {
64    pub exit_code: i32,
65    pub stdout: Vec<u8>,
66}
67
68#[derive(Debug, Clone)]
69pub struct Sandbox {
70    pub rootfs: PathBuf,
71    pub zig_dir: PathBuf,
72    pub src_dir: PathBuf,
73    pub out_dir: PathBuf,
74    /// Si Some, se bindea RW como `/cache` y se setea `ZIG_GLOBAL_CACHE_DIR=/cache/zig`.
75    /// Persistir esta caché entre builds evita que `zig cc` recompile musl cada vez (~30s).
76    pub cache_dir: Option<PathBuf>,
77    /// Directorios sellados de las `deps.build` (artefactos ya construidos en el store). Cada uno se
78    /// apila como capa overlay read-only BAJO el rootfs Alpine, así sus `usr/{include,lib,lib/pkgconfig}`
79    /// aparecen en las rutas estándar de `/usr` — el compilador y pkgconf los ven sin plumbing de flags.
80    /// Vacío ⇒ ninguna capa extra ⇒ el sandbox es idéntico al de una receta sin deps (baseline intacto).
81    pub deps: Vec<PathBuf>,
82    /// Vars de entorno extra inyectadas además del set por defecto.
83    pub env: Vec<(String, String)>,
84}
85
86impl Sandbox {
87    /// Ejecuta `cmd` dentro del sandbox bajo `sh -c`. Hace *tee* de stdout/stderr al padre
88    /// (para no perder el streaming en vivo) y a la vez retiene las últimas [`TAIL_LINES`]
89    /// líneas; si el exit code es != 0, devuelve un [`BuildFailure`] con esa cola en
90    /// `log_tail`, envuelto en `takana_core::Error::Other`.
91    pub fn run(&self, cmd: &str) -> takana_core::Result<()> {
92        // harkaq (SDD 16): sin HARKAQ=1 esto es `None` y todo lo de abajo es el camino de
93        // siempre — mismos args de bwrap, mismo entorno, ningún proceso extra. Inerte por
94        // requisito: hay 700+ artefactos sellados cuyo hash no puede moverse por encender un
95        // diagnóstico.
96        let hk = crate::harkaq::Harkaq::from_env();
97        let corrida = match &hk {
98            Some(h) => Some(h.preparar(&self.deps, &self.contratos_harkaq(), "fase")?),
99            None => None,
100        };
101        let args = match (&hk, &corrida) {
102            (Some(h), Some(c)) => self.bwrap_args_harkaq(cmd, h, c),
103            _ => self.bwrap_args(cmd),
104        };
105        tracing::debug!(?args, "bwrap");
106        let mut child = Command::new("bwrap")
107            .args(&args)
108            .stdin(Stdio::null())
109            .stdout(Stdio::piped())
110            .stderr(Stdio::piped())
111            .spawn()
112            .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("spawn bwrap: {e}")))?;
113
114        // Ring buffer compartido por ambos pumps: la cola combinada de stdout+stderr.
115        let tail = Arc::new(Mutex::new(VecDeque::<String>::with_capacity(TAIL_LINES)));
116        let out = child.stdout.take().expect("stdout piped");
117        let err = child.stderr.take().expect("stderr piped");
118        let h_out = spawn_pump(out, tail.clone());
119        let h_err = spawn_pump(err, tail.clone());
120
121        let status = child
122            .wait()
123            .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("wait bwrap: {e}")))?;
124        // Aseguramos drenar ambos streams antes de leer la cola.
125        let _ = h_out.join();
126        let _ = h_err.join();
127
128        // El veredicto se recoge SIEMPRE, haya ido bien o mal la fase: un build que falla es
129        // justamente cuando más interesa saber qué tocó fuera de su clausura.
130        if let (Some(h), Some(c)) = (&hk, corrida) {
131            let policy = c.policy.display().to_string();
132            let verdict = h.recoger(c);
133            tracing::info!(%verdict, %policy, "harkaq: verdict");
134            eprintln!("[harkaq] {}", verdict.trim());
135            eprintln!("[harkaq] política: {policy}");
136        }
137
138        if !status.success() {
139            let tail_str = {
140                let g = tail.lock().expect("tail mutex");
141                if g.is_empty() {
142                    None
143                } else {
144                    Some(g.iter().cloned().collect::<Vec<_>>().join("\n"))
145                }
146            };
147            let reason = format!(
148                "build phase falló (exit {}): {cmd}",
149                status.code().map(|c| c.to_string()).unwrap_or_else(|| "?".into())
150            );
151            return Err(takana_core::Error::Other(anyhow::Error::new(BuildFailure {
152                reason,
153                log_tail: tail_str,
154            })));
155        }
156        Ok(())
157    }
158
159    /// Como [`run`](Self::run), pero **captura el stdout completo** (no sólo la cola) y devuelve el
160    /// exit code en vez de fallar en != 0 — el caller decide contra lo esperado. El stderr se teea al
161    /// padre en vivo (logging), como en `run`. Es el motor de ejecución del checker de evidencia (H1b):
162    /// correr un `cmd` en el sandbox reproducible y comparar (exit, blake3(stdout)) contra lo declarado.
163    pub fn run_capture(&self, cmd: &str) -> takana_core::Result<CmdOutcome> {
164        let args = self.bwrap_args(cmd);
165        tracing::debug!(?args, "bwrap (capture)");
166        let mut child = Command::new("bwrap")
167            .args(&args)
168            .stdin(Stdio::null())
169            .stdout(Stdio::piped())
170            .stderr(Stdio::piped())
171            .spawn()
172            .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("spawn bwrap: {e}")))?;
173
174        let mut out = child.stdout.take().expect("stdout piped");
175        let err = child.stderr.take().expect("stderr piped");
176        // stdout: capturamos TODO en memoria (para hashearlo). stderr: al ring/tee del padre.
177        let h_out = std::thread::spawn(move || {
178            let mut buf = Vec::new();
179            let _ = out.read_to_end(&mut buf);
180            buf
181        });
182        let tail = Arc::new(Mutex::new(VecDeque::<String>::new()));
183        let h_err = spawn_pump(err, tail);
184
185        let status = child
186            .wait()
187            .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("wait bwrap: {e}")))?;
188        let stdout = h_out.join().unwrap_or_default();
189        let _ = h_err.join();
190
191        Ok(CmdOutcome {
192            // -1 si el proceso murió por señal (sin exit code): un check nunca lo espera ⇒ falla.
193            exit_code: status.code().unwrap_or(-1),
194            stdout,
195        })
196    }
197
198    /// Funde todas las `deps` en UN solo directorio real por hardlinks (`cp -al`, last-wins) y lo
199    /// devuelve para usarlo como capa overlay única. Ver [`Self::bwrap_args`]: bwrap canonicaliza los
200    /// `--overlay-src`, así que la única forma de no desbordar el `lowerdir` con muchas deps es
201    /// colapsarlas a una capa. El merge vive junto al store (mismo filesystem ⇒ hardlinks válidos) y
202    /// es único-por-build (id derivado del `src_dir`). Cacheado con marcador `.done`: se hace UNA vez
203    /// aunque `bwrap_args` se llame por cada fase (configure/compile/install). Devuelve `None` si
204    /// falla (el caller cae al apilado directo).
205    fn merge_deps_layer(&self) -> Option<PathBuf> {
206        use std::hash::{Hash, Hasher};
207        let mut h = std::collections::hash_map::DefaultHasher::new();
208        self.src_dir.hash(&mut h);
209        for d in &self.deps {
210            d.hash(&mut h);
211        }
212        // DENTRO del store (el padre INMEDIATO de las deps), no en `<store>/../`: los hardlinks NO
213        // cruzan filesystems, y el store puede ser un mount aparte (en el worker es un VOLUMEN
214        // bind-montado: `/opt/hammer/store` en /dev/sdb, `/opt/hammer` en /dev/sda1). Con
215        // `.parent().parent()` el merge caía en la raíz ⇒ `cp -al` cross-device fallaba ⇒ fallback al
216        // apilado directo ⇒ overflow del lowerdir en kio (5315B > 4096B) y build imposible. El padre
217        // inmediato de un dep ES el store, siempre mismo fs que las deps. `.dmerge` (dot-prefix) no
218        // matchea `<hash>-<name>` ⇒ invisible para el store lookup y build-state, como `.times`.
219        let base = self
220            .deps
221            .first()
222            .and_then(|d| std::fs::canonicalize(d).ok())
223            .and_then(|d| d.parent().map(|p| p.to_path_buf()))
224            .unwrap_or_else(|| PathBuf::from("."));
225        let merged = base.join(".dmerge").join(format!("{:x}", h.finish()));
226        let done = merged.join(".done");
227        if done.exists() {
228            return Some(merged);
229        }
230        let _ = std::fs::remove_dir_all(&merged);
231        std::fs::create_dir_all(&merged).ok()?;
232        // `cp -al <dep>/. <merged>/` hardlinkea el árbol recursivamente; `-f` fuerza el last-wins en
233        // los pocos ficheros que coincidan (semántica de capa superior del overlay). Orden = orden de
234        // deps (la última gana), igual que el apilado `--overlay-src`.
235        for dep in &self.deps {
236            let real = std::fs::canonicalize(dep).ok()?;
237            let status = Command::new("cp")
238                .arg("-alf")
239                .arg(format!("{}/.", real.display()))
240                .arg(&merged)
241                .status()
242                .ok()?;
243            if !status.success() {
244                return None;
245            }
246        }
247        std::fs::write(&done, b"").ok()?;
248        Some(merged)
249    }
250
251    /// Las superficies que define el SANDBOX (no la receta): las monta bwrap, no salen del rootfs
252    /// Alpine, así que no son deuda de de-Alpinización (SDD 16 §4.2). Vive acá y no en `harkaq`
253    /// porque es el sandbox quien sabe qué montó: `/cache` sólo existe si hay `cache_dir`, y una
254    /// política que nombre un path inexistente aborta el build (a propósito).
255    ///
256    /// `list /` y `list /opt`: todo directorio concedido necesita listado en sus ancestros o el
257    /// escaneo del padre es un falso positivo — lo encontró el propio banco de pruebas (§4.4).
258    fn contratos_harkaq(&self) -> Vec<String> {
259        let mut v: Vec<String> = [
260            "rw /src", "rw /out", "rw /tmp", "rw /dev/null", "ro /dev/urandom", "ro /opt/zig",
261            // `--proc /proc`: bwrap monta un /proc FRESCO dentro del pidns del sandbox. No sale
262            // del rootfs Alpine y no ve al host ⇒ contrato, no deuda. Lo destapó brotli, cuyo
263            // configure lee /proc/cpuinfo y /proc/meminfo para dimensionar el paralelismo.
264            "ro /proc",
265            // LISTAR la estructura del árbol no es deuda: Landlock separa READ_DIR de READ_FILE,
266            // así que esto deja hacer `ls /usr/lib` sin poder LEER un solo fichero no declarado.
267            // La deuda de de-Alpinización es leer/ejecutar lo ajeno, no saber que existe.
268            //
269            // Sin esto, una receta SIN deps no genera ni un `list` (los `list` salían sólo de los
270            // ancestros de la clausura) ⇒ todo escaneo del árbol caía como denegación. El barrido
271            // en la granja marcaba a bash como deuda IRREDUCIBLE por `/usr/lib`, que es un gap de
272            // política, no una dependencia de Alpine — un falso positivo de los que matan el
273            // proyecto (§7).
274            "list /", "list /opt", "list /usr", "list /usr/bin", "list /usr/lib",
275            "list /usr/include", "list /usr/share", "list /usr/libexec", "list /usr/local",
276            "list /lib", "list /bin", "list /sbin", "list /usr/sbin", "list /etc", "list /var",
277            // `--tmp-overlay /` hace la raíz escribible con una capa tmpfs descartable: escribir
278            // en /var/tmp no toca el rootfs Alpine ni sobrevive al build. Es scratch, como /tmp.
279            "rw /var/tmp",
280        ]
281        .iter()
282        .map(|s| s.to_string())
283        .collect();
284        if self.cache_dir.is_some() {
285            // `ZIG_GLOBAL_CACHE_DIR=/cache/zig` ⇒ zig CREA directorios ahí. Sin esto la primera
286            // fase muere con `fs.make_dir /cache/zig/tmp` — lo cazó la primera corrida real.
287            v.push("rw /cache".into());
288        }
289        v
290    }
291
292    /// Como [`bwrap_args`](Self::bwrap_args), pero mete a harkaq como último eslabón: bindea
293    /// `harkaq-exec`, la política y el canario, y sustituye el `sh -c <cmd>` final por
294    /// `harkaq-exec --policy ... -- sh -c "<probe del canario>; <cmd>"`.
295    ///
296    /// Todo lo anterior (overlays, ns, binds, entorno) queda intacto: harkaq NO reemplaza al
297    /// sandbox de bwrap, se compone encima (§0, no-tesis).
298    fn bwrap_args_harkaq(
299        &self,
300        cmd: &str,
301        h: &crate::harkaq::Harkaq,
302        c: &crate::harkaq::Corrida,
303    ) -> Vec<String> {
304        // El probe del canario va DENTRO del `sh -c`, o sea DESPUÉS del execve: es el único sitio
305        // donde valida lo que tiene que validar (que el logging post-exec funciona). Usa SÓLO
306        // builtins — con `cat` dependería de /bin/cat y moriría si no está en la clausura, sin
307        // probar nada (SDD §4.2).
308        let con_canario = format!("read _ < {} || true; {cmd}", c.canary);
309        let mut args = self.bwrap_args(&con_canario);
310
311        // El `sh -c <cmd>` que bwrap_args deja al final se reemplaza por la cadena de harkaq.
312        // Los binds van ANTES del comando; por eso se corta la cola y se rearma.
313        let n = args.len();
314        args.truncate(n - 3); // quita: /bin/sh, -c, <cmd>
315
316        for (src, dst) in [
317            (c.canary_src.display().to_string(), c.canary.clone()),
318            (h.exec_bin().display().to_string(), "/harkaq-exec".to_string()),
319            (c.policy.display().to_string(), "/harkaq.policy".to_string()),
320        ] {
321            args.push("--ro-bind".into());
322            args.push(src);
323            args.push(dst);
324        }
325        // En la raíz y no en /tmp: `--tmp-overlay /` la hace escribible, así que bwrap puede
326        // crear ahí los puntos de montaje, y la política no alcanza la raíz ⇒ el canario deniega
327        // por construcción. En /tmp caería dentro de `rw /tmp` y no denunciaría nada.
328        args.push("/harkaq-exec".into());
329        args.push("--policy".into());
330        args.push("/harkaq.policy".into());
331        args.push("--".into());
332        args.push("/bin/sh".into());
333        args.push("-c".into());
334        args.push(con_canario);
335        args
336    }
337
338    fn bwrap_args(&self, cmd: &str) -> Vec<String> {
339        let mut args: Vec<String> = vec![
340            "--overlay-src".into(),
341            self.rootfs.display().to_string(),
342        ];
343        // Capas overlay de las deps de build, apiladas bajo el rootfs (mismo `--tmp-overlay /`). bwrap
344        // admite múltiples `--overlay-src` para un overlay; los `usr/{include,lib}` de cada dep se
345        // funden con los del rootfs en `/usr`. Sólo se emiten si hay deps (compat hacia atrás).
346        //
347        // LÍMITE DE PÁGINA (overlayfs): el kernel concatena todas las lowerdir en un string de
348        // opciones de mount acotado a ~1 página (4096B). Con muchas deps (KIO ~45, Plasma más), la
349        // suma de rutas largas `<store>/<64hex>-<name>` DESBORDA y el mount falla (bwrap: "Can't make
350        // overlay mount ... No such file or directory" por truncado). Mitigación: cuando la suma de
351        // longitudes se acerca al tope, symlinkeamos cada dep a un nombre corto (`<tmp>/hdl-<id>/<N>`)
352        // por-build y pasamos ESAS rutas → lowerdir compacto que escala a cientos de deps. Con pocas
353        // deps (caso común) usamos la ruta directa: cero I/O y sandbox idéntico al previo.
354        // bwrap CANONICALIZA cada `--overlay-src` a su ruta real para armar el `lowerdir=`, así que
355        // acortar con symlinks no sirve: el string sigue siendo la suma de rutas reales del store.
356        // La única forma de escalar a muchas deps es FUNDIRLAS en UN solo directorio real (hardlinks,
357        // `cp -al`, last-wins = misma semántica que el apilado overlay) y pasar esa ÚNICA capa ⇒
358        // lowerdir = rootfs + 1 merge, constante sin importar cuántas deps haya.
359        //
360        // MEDIDO (SDD 25 T19, `docs/evidencia/tasas-kernel-2026-08-29/overlay.txt`), y conviene
361        // leerlo antes de tocar este umbral:
362        //   · El muro es de UNA PÁGINA EXACTA: `mount(2)` acepta 41 capas del store (4 059 B de
363        //     opciones) y falla en 42 (4 158 B) con `ENOENT` — el kernel no dice «demasiado largo».
364        //     (El `mount(8)` de util-linux se rinde mucho antes; bisecarlo desde la shell miente.)
365        //   · Este umbral dispara a las ~27 deps ⇒ factor 1,5 de margen, y es estructural: el muro
366        //     va como ~4002/(L+1) y el umbral como 3000/(L+16), cociente ≥1,33 para cualquier L.
367        //     No hay que recalibrarlo si el store se muda.
368        //   · Fundir NO es una optimización de velocidad. El overlay cobra por PRIMER toque de cada
369        //     ruta (+1 925 ns por capa en un lookup que falla; ~660 ns planos si la ruta ya se
370        //     tocó), y el montaje dura toda la fase: un compilado toca 381 rutas distintas que
371        //     fallan ⇒ 2,9 ms por fase a 24 capas. Fundir 24 deps reales cuesta 264 ms, o sea que
372        //     sólo se pagaría sola a partir de ~35 200 primeros fallos en una misma fase.
373        //   ⇒ NO bajar el umbral «para que vaya más rápido»: no gana nada y engorda `.dmerge`, que
374        //     retiene por hardlink los artefactos que borres del store. Y por lo mismo, PODAR
375        //     `.dmerge` es gratis en rendimiento.
376        let total_len: usize = self.deps.iter().map(|d| d.as_os_str().len() + 16).sum();
377        if total_len > 3000 && self.deps.len() > 1 {
378            if let Some(merged) = self.merge_deps_layer() {
379                args.push("--overlay-src".into());
380                args.push(merged.display().to_string());
381            } else {
382                // Fallback: si la fusión falló, volvemos al apilado directo (puede desbordar, pero es
383                // mejor que abortar sin intentar).
384                for dep in &self.deps {
385                    args.push("--overlay-src".into());
386                    args.push(dep.display().to_string());
387                }
388            }
389        } else {
390            for dep in &self.deps {
391                args.push("--overlay-src".into());
392                args.push(dep.display().to_string());
393            }
394        }
395        args.extend([
396            "--tmp-overlay".into(),
397            "/".into(),
398            "--proc".into(),
399            "/proc".into(),
400            "--dev".into(),
401            "/dev".into(),
402            "--tmpfs".into(),
403            "/tmp".into(),
404            "--ro-bind".into(),
405            self.zig_dir.display().to_string(),
406            "/opt/zig".into(),
407            "--bind".into(),
408            self.src_dir.display().to_string(),
409            "/src".into(),
410            "--bind".into(),
411            self.out_dir.display().to_string(),
412            "/out".into(),
413            "--chdir".into(),
414            "/src".into(),
415            "--unshare-all".into(),
416            "--die-with-parent".into(),
417        ]);
418
419        if let Some(cache) = &self.cache_dir {
420            args.push("--bind".into());
421            args.push(cache.display().to_string());
422            args.push("/cache".into());
423        }
424
425        // Entorno mínimo y predecible.
426        let mut defaults: Vec<(&str, &str)> = vec![
427            ("PATH", "/opt/zig:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"),
428            ("HOME", "/tmp"),
429            ("DESTDIR", "/out"),
430            ("PREFIX", "/usr"),
431            // `-mcpu=baseline`: codegen al baseline x86-64 genérico, NO al CPU del builder. Sin esto,
432            // `zig cc` default a `-mcpu=native` y hornea la ISA de quien compila (SHA-NI/AVX2 del
433            // host) ⇒ el binario diverge entre CPUs distintos (host vs la VM `-cpu Broadwell`) y
434            // rompe la reproducibilidad — un no-determinismo que Stage 2 cazó (runbook §8c, SDD 09
435            // §2). De paso cierra el SIGILL de AVX en qemu64 (runbook §7). Las rutas SIMD que sí
436            // necesita el runtime (blake3/sha2) son asm con dispatch en runtime: siguen presentes.
437            // Driver vía wrapper (ensure_layout lo materializa en /usr/local/bin): idéntico a
438            // `zig cc -mcpu=baseline` salvo que quita `-static` en links `-shared` (extensiones
439            // Python y demás `.so` dlopen-ables). Ver write_cc_wrappers.
440            ("CC", CC_WRAPPER),
441            ("CXX", CXX_WRAPPER),
442            ("AR", "zig ar"),
443            ("LC_ALL", "C"),
444            ("LANG", "C"),
445            // Reproducibilidad (pre-Stage 2): fija los timestamps que muchas herramientas embeben
446            // (tar, gzip, __DATE__/__TIME__ vía SOURCE_DATE_EPOCH). Valor fijo y arbitrario
447            // ⚠ NO cubre `ar`/`ranlib`: binutils NO lee SOURCE_DATE_EPOCH — la determinismo de
448            // archivos se decide en `configure` (`--enable-deterministic-archives`) o por opción
449            // `-D` en cada invocación. Este comentario decía que sí lo cubría y era FALSO; lo
450            // desmintió comparar dos builds de zlib (ver AR_WRAPPER/RANLIB_WRAPPER abajo).
451            // (1970-01-01 00:00:01). Las rutas ya son deterministas: el source se bindea en `/src`
452            // (constante entre rebuilds, sea cual sea el work dir del host). Ver SDD 11 / runbook.
453            ("SOURCE_DATE_EPOCH", "1"),
454            ("TZ", "UTC"),
455            // Reproducibilidad de builds Cargo: fuerza `codegen-units = 1` para TODAS las crates
456            // del build (el var de cargo gana sobre el `[profile.release]` del repo fuente). Con el
457            // default (16) el codegen paralelo de rustc reparte el crate en N unidades y la salida
458            // varía build-a-build incluso en la MISMA máquina y con entradas idénticas — Stage 2 lo
459            // cazó: `arje-zero` (cuyo workspace tawasuyu no fija perfil) divergía ~10 KB repartidos
460            // por .text/.rodata/.eh_frame, mientras `hammerd` (workspace takana que SÍ pinea
461            // codegen-units=1) reproducía bit-a-bit. El lab lo impone en vez de confiar en que cada
462            // repo upstream lo declare (SDD 09 §2). Inerte para builds no-Cargo (musl/busybox lo
463            // ignoran). Ver runbook §8c / SDD 11.
464            ("CARGO_PROFILE_RELEASE_CODEGEN_UNITS", "1"),
465            // Reproducibilidad de builds Cargo (parte 2): `codegen-units=1` NO basta. El backend
466            // paralelo de rustc/LLVM (ThinLTO + codegen) lanza un pool de hilos dimensionado por la
467            // paralelismo disponible, y sus decisiones (orden de import/inline) varían build-a-build
468            // a >1 hilo ⇒ `arje-zero` divergía ~62 KB entre dos builds host idénticos, mientras un
469            // build serial (la VM tiene 1 vCPU, o `taskset -c 0`) reproducía bit-a-bit. `JOBS=1`
470            // limita el jobserver a 1 token ⇒ rustc serializa el backend ⇒ salida estable e
471            // independiente del nº de CPUs del que reconstruye. Lo cazó Stage 2 variante (b): host
472            // (paralelo) daba `0039b2b9…` no-reproducible vs VM (serial) `9adefb82…`. Ver SDD 09 §2.
473            ("CARGO_BUILD_JOBS", "1"),
474        ];
475        if self.cache_dir.is_some() {
476            defaults.push(("ZIG_GLOBAL_CACHE_DIR", "/cache/zig"));
477            defaults.push(("ZIG_LOCAL_CACHE_DIR", "/cache/zig"));
478        }
479        for (k, v) in defaults {
480            args.push("--setenv".into());
481            args.push(k.into());
482            args.push(v.into());
483        }
484        for (k, v) in &self.env {
485            args.push("--setenv".into());
486            args.push(k.clone());
487            args.push(v.clone());
488        }
489
490        // Comando final.
491        args.push("/bin/sh".into());
492        args.push("-c".into());
493        args.push(cmd.into());
494        args
495    }
496}
497
498/// Lanza un thread que lee `reader` línea a línea, la reenvía a stderr del padre y la
499/// acumula en el ring `tail`. Devuelve el handle para join.
500///
501/// El tee va SIEMPRE a stderr (tanto el stdout como el stderr del build): stdout del proceso
502/// queda reservado para el resultado legible-por-máquina (el content-hash que imprime la CLI,
503/// p. ej. `bootstrap stage1`). Antes el stdout del build se teeaba al stdout del padre y se
504/// colaba en capturas tipo `PRIME=$(takana … bootstrap stage1)`; con builds reales (no cacheados,
505/// p. ej. el rebuild in-VM) eso son miles de líneas de `configure`/`make` ⇒ el argumento crece y
506/// la fase de verificación siguiente (`--rootfs "$PRIME"`) revienta el exec con E2BIG
507/// (Argument list too long). Va a stderr, junto a los logs de tracing. Ver runbook §8c.
508fn spawn_pump<R: std::io::Read + Send + 'static>(
509    reader: R,
510    tail: Arc<Mutex<VecDeque<String>>>,
511) -> std::thread::JoinHandle<()> {
512    std::thread::spawn(move || {
513        let buf = BufReader::new(reader);
514        for line in buf.lines() {
515            let line = match line {
516                Ok(l) => l,
517                Err(_) => break,
518            };
519            // Tee en vivo al padre por stderr (sin bufferizar de más: el build puede tardar).
520            let _ = writeln!(std::io::stderr(), "{line}");
521            if let Ok(mut g) = tail.lock() {
522                push_tail(&mut g, line, TAIL_LINES);
523            }
524        }
525    })
526}
527
528/// Errores tempranos: comprueba que las rutas base existen antes de invocar el build.
529pub fn ensure_layout(rootfs: &Path, zig_dir: &Path) -> takana_core::Result<()> {
530    // bin/sh en Alpine es un symlink absoluto a /bin/busybox, que desde el host no resuelve.
531    // Comprobamos el archivo regular real para no engañarnos.
532    if !rootfs.join("bin/busybox").is_file() {
533        return Err(takana_core::Error::Other(anyhow::anyhow!(
534            "rootfs inválido: no encuentro {}/bin/busybox — ¿corriste el bootstrap de Alpine?",
535            rootfs.display()
536        )));
537    }
538    if !zig_dir.join("zig").exists() {
539        return Err(takana_core::Error::Other(anyhow::anyhow!(
540            "zig no encontrado en {}/zig — ¿instalaste zig en .dev-fs/tools/zig?",
541            zig_dir.display()
542        )));
543    }
544    write_cc_wrappers(rootfs)?;
545    Ok(())
546}
547
548/// Nombres (en `/usr/local/bin`, que está en el PATH del sandbox) del driver de compilador que
549/// usan CC/CXX por defecto. Ver [`write_cc_wrappers`].
550pub const CC_WRAPPER: &str = "hammer-zig-cc";
551pub const CXX_WRAPPER: &str = "hammer-zig-cxx";
552
553/// Herramientas de archivo que se interponen en `/usr/local/bin` para forzar **modo determinista**.
554///
555/// ── POR QUÉ (medido 2026-08-28) ─────────────────────────────────────────────────────────────
556/// Construir `zlib` en dos máquinas dio el MISMO `ArtifactHash` y **bytes distintos**:
557/// `usr/lib/libz.a` divergía sólo en la cabecera `ar` del miembro `/` (la tabla de símbolos),
558/// con la hora de pared del build. `takana why-differs` lo nombró exacto.
559///
560/// La causa NO es `zig ar` (llvm-ar sella mtime 0) ni el binutils del rootfs Alpine (que Alpine
561/// compila con `--enable-deterministic-archives`): es **nuestro propio `recipes/binutils.toml`**,
562/// que no pasa ese flag. Toda receta que lo materialice como build-dep y llame a `ar`/`ranlib`
563/// —directamente o vía el `$(RANLIB)` de su Makefile— hereda la hora de pared.
564///
565/// ── POR QUÉ AQUÍ Y NO EN LA RECETA ──────────────────────────────────────────────────────────
566/// Arreglar `recipes/binutils.toml` es la cura de raíz, pero su radio son **421 recetas
567/// transitivas y 365 sellados a deuda** (medido con `yupana radio binutils`) — desproporcionado
568/// frente a los 9 artefactos realmente contaminados. El sandbox es donde este proyecto ya
569/// arregla esta CLASE de bug sin re-hashear: `-mcpu=baseline`, `CARGO_PROFILE_RELEASE_CODEGEN_UNITS`
570/// y `CARGO_BUILD_JOBS` son exactamente el mismo patrón. La cura de raíz queda pendiente para
571/// cuando toque un rebuild del corpus por otro motivo (SDD 23), no como campaña propia.
572///
573/// ⚠ CONSECUENCIA QUE HAY QUE EJECUTAR, NO SÓLO DECLARAR: esto cambia los BYTES sin cambiar la
574/// DIRECCIÓN de los 9 artefactos ya contaminados. Un artefacto viejo y uno nuevo son distintos en
575/// el mismo hash — el peligro exacto que la huella del lab existe para eliminar. Por eso la
576/// adopción incluye **re-sellar esos 9 a propósito**, no esperar a que caduquen solos.
577pub const AR_WRAPPER: &str = "ar";
578pub const RANLIB_WRAPPER: &str = "ranlib";
579
580/// Materializa en el rootfs dos wrappers de linker-driver (`takana-zig-cc`/`-cxx`) que envuelven a
581/// `zig cc`/`zig c++`. Su ÚNICO efecto es QUITAR `-static` cuando el link REQUIERE enlace dinámico.
582///
583/// El harness inyecta `LDFLAGS=-static` para las recetas `link=static` (binario monolítico que pide
584/// la hidratación primaria). Pero meson/autotools/giscanner aplican ese LDFLAGS a TODOS los links por
585/// igual, incluidos algunos que son INTRÍNSECAMENTE dinámicos y con los que `-static` es
586/// contradictorio (`error: using shared libraries requires dynamic linking`). El caso que lo destapó
587/// es gobject-introspection, dinámico por naturaleza en TRES puntos:
588///   1. `_giscanner.cpython-*.so`: extensión Python (`shared_module`) que el intérprete `dlopen`ea.
589///      Con `-static` sale ET_EXEC ⇒ `dlopen` da `Exec format error`.
590///   2. las tools (`g-ir-compiler`/`-generate`/`-inspect`) linkean la `libgirepository-1.0.so`.
591///   3. el "dumper" del scanner usa `-Wl,--export-dynamic` (necesita la symtab dinámica para
592///      introspectar GTypes en runtime) — incompatible con `-static`.
593///
594/// Regla principista: **`-static` cede ante cualquier link que requiera dinámico** — `-shared`,
595/// `--export-dynamic`/`-rdynamic`, o un objeto compartido (`.so`/`.so.N`) en la línea. En todos esos
596/// casos `-static` no puede sostenerse; quitarlo es correcto por construcción. Para el link normal
597/// (ejecutable estático / `.a`) el wrapper es TRANSPARENTE ⇒ byte-idéntico a `zig cc`, y el resto del
598/// árbol sigue estático. El ArtifactHash se deriva de inputs (receta+deps), no de los bytes ⇒ este
599/// cambio NO re-hashea nada sellado; sólo destraba builds nuevos con enlace dinámico irreducible.
600fn write_cc_wrappers(rootfs: &Path) -> takana_core::Result<()> {
601    use std::os::unix::fs::PermissionsExt;
602    let bindir = rootfs.join("usr/local/bin");
603    std::fs::create_dir_all(&bindir).map_err(|e| {
604        takana_core::Error::Other(anyhow::anyhow!("no pude crear {}: {e}", bindir.display()))
605    })?;
606    // `case " $* "` detecta como palabra los marcadores de "este link es dinámico": -shared,
607    // -rdynamic, --export-dynamic, o un shared object en la línea (`…​.so ` final o `…​.so.N` versionado).
608    // El bucle rota los positional params (pop del frente, push al fondo salvo `-static`) ⇒ filtra
609    // preservando orden y seguro con espacios.
610    let body = |driver: &str| {
611        format!(
612            "#!/bin/sh\n\
613             case \" $* \" in\n\
614             \x20 *\" -shared \"* | *\" -rdynamic \"* | *\"--export-dynamic\"* | *\".so \"* | *\".so.\"*)\n\
615             \x20   n=$#\n\
616             \x20   while [ \"$n\" -gt 0 ]; do\n\
617             \x20     arg=$1; shift\n\
618             \x20     [ \"$arg\" = \"-static\" ] || set -- \"$@\" \"$arg\"\n\
619             \x20     n=$((n - 1))\n\
620             \x20   done\n\
621             \x20   ;;\n\
622             esac\n\
623             \x20# `-mcpu=baseline` NO se inyecta en modo PREPROCESADOR (`-E`): ahí zig lo traduce a\n\
624             \x20# un `-mcpu=` VACÍO y lo rechaza («unsupported option '-mcpu=' for target»). Es\n\
625             \x20# inofensivo omitirlo —preprocesar no genera código, así que no hay CPU al que\n\
626             \x20# apuntar— y sin esto NO se puede esquivar desde la receta: util-linux ya filtra\n\
627             \x20# el flag en sus generadores `all_errnos`/`all_syscalls`, pero filtra `\"$@\"` y el\n\
628             \x20# wrapper lo vuelve a poner DESPUÉS ⇒ el apaño de la receta es inerte y `errnos.h`\n\
629             \x20# no se genera (2026-08-12; util-linux es del perfil base).\n\
630             case \" $* \" in\n\
631             \x20 *\" -E \"*) exec {driver} \"$@\" ;;\n\
632             esac\n\
633             exec {driver} -mcpu=baseline \"$@\"\n"
634        )
635    };
636    for (name, driver) in [(CC_WRAPPER, "zig cc"), (CXX_WRAPPER, "zig c++")] {
637        let path = bindir.join(name);
638        std::fs::write(&path, body(driver)).map_err(|e| {
639            takana_core::Error::Other(anyhow::anyhow!("no pude escribir {}: {e}", path.display()))
640        })?;
641        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).map_err(|e| {
642            takana_core::Error::Other(anyhow::anyhow!("no pude chmod {}: {e}", path.display()))
643        })?;
644    }
645
646    // Archivadores en modo determinista. Ver la doc de AR_WRAPPER.
647    //
648    // `-D` ANTEPUESTO y no como modificador de la cadena de operación (`Drc`): las dos formas
649    // funcionan en binutils (verificado), pero anteponerlo es lo único que compone con una línea
650    // de comando ajena — el llamador pone su propia operación (`rc`, `t`, `x`…) en "$@" y no la
651    // podemos reescribir. `-D` es inocuo en las operaciones de lectura.
652    //
653    // Ruta ABSOLUTA `/usr/bin/<tool>` a propósito: el wrapper vive en `/usr/local/bin`, que va
654    // ANTES en el PATH, así que un `exec ar` se llamaría a sí mismo para siempre.
655    for tool in [AR_WRAPPER, RANLIB_WRAPPER] {
656        let path = bindir.join(tool);
657        let body = format!(
658            "#!/bin/sh\n             # Interpuesto por hammer: fuerza archivos deterministas (sin hora de pared en la\n             # cabecera `ar`). Sin esto dos builds idénticos sellan bytes distintos. Ver\n             # sandbox.rs::AR_WRAPPER.\n             [ -x /usr/bin/{tool} ] || exit 127\n             exec /usr/bin/{tool} -D \"$@\"\n"
659        );
660        std::fs::write(&path, body).map_err(|e| {
661            takana_core::Error::Other(anyhow::anyhow!("no pude escribir {}: {e}", path.display()))
662        })?;
663        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).map_err(|e| {
664            takana_core::Error::Other(anyhow::anyhow!("no pude chmod {}: {e}", path.display()))
665        })?;
666    }
667    Ok(())
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673
674    #[test]
675    fn push_tail_keeps_last_n() {
676        let mut buf = VecDeque::new();
677        for i in 0..10 {
678            push_tail(&mut buf, format!("line {i}"), 3);
679        }
680        let got: Vec<_> = buf.iter().cloned().collect();
681        assert_eq!(got, vec!["line 7", "line 8", "line 9"]);
682    }
683
684    #[test]
685    fn build_failure_roundtrips_through_core_error() {
686        // El contrato que usa el bus: envolver en Error::Other y recuperar por downcast.
687        let bf = BuildFailure {
688            reason: "exit 2".into(),
689            log_tail: Some("undefined reference to `foo'".into()),
690        };
691        let err = takana_core::Error::Other(anyhow::Error::new(bf));
692        let recovered = BuildFailure::from_error(&err).expect("debe recuperarse");
693        assert_eq!(recovered.reason, "exit 2");
694        assert_eq!(
695            recovered.log_tail.as_deref(),
696            Some("undefined reference to `foo'")
697        );
698    }
699
700    #[test]
701    fn from_error_none_for_plain_errors() {
702        let err = takana_core::Error::Recipe("nope".into());
703        assert!(BuildFailure::from_error(&err).is_none());
704    }
705
706    fn fixture(deps: Vec<PathBuf>) -> Sandbox {
707        Sandbox {
708            rootfs: PathBuf::from("/rootfs"),
709            zig_dir: PathBuf::from("/zig"),
710            src_dir: PathBuf::from("/src-host"),
711            out_dir: PathBuf::from("/out-host"),
712            cache_dir: None,
713            deps,
714            env: vec![],
715        }
716    }
717
718    #[test]
719    fn no_deps_emits_single_overlay_src() {
720        // Compat hacia atrás: sin deps, una sola capa overlay (el rootfs) — sandbox idéntico al previo.
721        let args = fixture(vec![]).bwrap_args("true");
722        let n = args.iter().filter(|a| *a == "--overlay-src").count();
723        assert_eq!(n, 1, "sin deps ⇒ un único --overlay-src");
724        // El --tmp-overlay viene inmediatamente tras el rootfs.
725        let i = args.iter().position(|a| a == "--overlay-src").unwrap();
726        assert_eq!(args[i + 1], "/rootfs");
727        assert_eq!(args[i + 2], "--tmp-overlay");
728    }
729
730    #[test]
731    fn deps_stack_as_overlay_layers_under_rootfs() {
732        let args = fixture(vec![PathBuf::from("/dep/libcap"), PathBuf::from("/dep/zlib")])
733            .bwrap_args("true");
734        // rootfs + 2 deps = 3 capas overlay, todas antes del --tmp-overlay.
735        let srcs: Vec<&String> = args
736            .iter()
737            .enumerate()
738            .filter(|(_, a)| *a == "--overlay-src")
739            .map(|(i, _)| &args[i + 1])
740            .collect();
741        assert_eq!(srcs, vec!["/rootfs", "/dep/libcap", "/dep/zlib"]);
742        let to = args.iter().position(|a| a == "--tmp-overlay").unwrap();
743        let last_src = args.iter().rposition(|a| a == "--overlay-src").unwrap();
744        assert!(last_src < to, "todas las capas overlay van antes del --tmp-overlay");
745    }
746}