//! El sandbox hermético de build (bubblewrap). Ver `docs/02-build-lab.md` §3 y §4. //! //! Capas: //! - Base read-only: rootfs Alpine (`.dev-fs/alpine`) montado como overlay con capa //! tmpfs encima, así el build puede escribir en /tmp/etc sin ensuciar el host. //! - Compilador inyectado: `zig` (`.dev-fs/tools/zig`) en `/opt/zig`. //! - Fuentes: bind RW en `/src` (deben venir ya copiadas a un dir host descartable). //! - Salida: bind RW en `/out` (DESTDIR). //! - Red: aislada (`--unshare-all` incluye `--unshare-net`). use std::collections::VecDeque; use std::io::{BufRead, BufReader, Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::{Arc, Mutex}; /// Cuántas líneas finales del log del sandbox conservamos para `BuildFailed.log_tail`. const TAIL_LINES: usize = 50; /// Fallo de una fase de build, con la cola del log del lab si la pudimos capturar. Viaja /// dentro de `takana_core::Error::Other(anyhow)`; el bus la recupera por `downcast_ref` para /// poblar `Event::BuildFailed.log_tail`. Ver `docs/07-agent-bus.md`. #[derive(Debug, Clone)] pub struct BuildFailure { pub reason: String, pub log_tail: Option, } impl std::fmt::Display for BuildFailure { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.reason)?; if let Some(tail) = &self.log_tail { write!(f, "\n--- log (últimas líneas) ---\n{tail}")?; } Ok(()) } } impl std::error::Error for BuildFailure {} impl BuildFailure { /// Recupera un `BuildFailure` de un error de takana-core, si lo lleva dentro. El bus lo /// usa para separar `reason` de `log_tail`. pub fn from_error(e: &takana_core::Error) -> Option<&BuildFailure> { match e { takana_core::Error::Other(a) => a.downcast_ref::(), _ => None, } } } /// Empuja `line` a un ring buffer acotado a `cap` líneas (descarta la más antigua). fn push_tail(buf: &mut VecDeque, line: String, cap: usize) { if buf.len() == cap { buf.pop_front(); } buf.push_back(line); } /// Resultado de correr un comando en el sandbox con captura (H1b). A diferencia de `run`, NO falla /// en exit != 0: el checker de evidencia compara `exit_code` y `blake3(stdout)` contra lo declarado. #[derive(Debug, Clone)] pub struct CmdOutcome { pub exit_code: i32, pub stdout: Vec, } #[derive(Debug, Clone)] pub struct Sandbox { pub rootfs: PathBuf, pub zig_dir: PathBuf, pub src_dir: PathBuf, pub out_dir: PathBuf, /// Si Some, se bindea RW como `/cache` y se setea `ZIG_GLOBAL_CACHE_DIR=/cache/zig`. /// Persistir esta caché entre builds evita que `zig cc` recompile musl cada vez (~30s). pub cache_dir: Option, /// Directorios sellados de las `deps.build` (artefactos ya construidos en el store). Cada uno se /// apila como capa overlay read-only BAJO el rootfs Alpine, así sus `usr/{include,lib,lib/pkgconfig}` /// aparecen en las rutas estándar de `/usr` — el compilador y pkgconf los ven sin plumbing de flags. /// Vacío ⇒ ninguna capa extra ⇒ el sandbox es idéntico al de una receta sin deps (baseline intacto). pub deps: Vec, /// Vars de entorno extra inyectadas además del set por defecto. pub env: Vec<(String, String)>, } impl Sandbox { /// Ejecuta `cmd` dentro del sandbox bajo `sh -c`. Hace *tee* de stdout/stderr al padre /// (para no perder el streaming en vivo) y a la vez retiene las últimas [`TAIL_LINES`] /// líneas; si el exit code es != 0, devuelve un [`BuildFailure`] con esa cola en /// `log_tail`, envuelto en `takana_core::Error::Other`. pub fn run(&self, cmd: &str) -> takana_core::Result<()> { // harkaq (SDD 16): sin HARKAQ=1 esto es `None` y todo lo de abajo es el camino de // siempre — mismos args de bwrap, mismo entorno, ningún proceso extra. Inerte por // requisito: hay 700+ artefactos sellados cuyo hash no puede moverse por encender un // diagnóstico. let hk = crate::harkaq::Harkaq::from_env(); let corrida = match &hk { Some(h) => Some(h.preparar(&self.deps, &self.contratos_harkaq(), "fase")?), None => None, }; let args = match (&hk, &corrida) { (Some(h), Some(c)) => self.bwrap_args_harkaq(cmd, h, c), _ => self.bwrap_args(cmd), }; tracing::debug!(?args, "bwrap"); let mut child = Command::new("bwrap") .args(&args) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("spawn bwrap: {e}")))?; // Ring buffer compartido por ambos pumps: la cola combinada de stdout+stderr. let tail = Arc::new(Mutex::new(VecDeque::::with_capacity(TAIL_LINES))); let out = child.stdout.take().expect("stdout piped"); let err = child.stderr.take().expect("stderr piped"); let h_out = spawn_pump(out, tail.clone()); let h_err = spawn_pump(err, tail.clone()); let status = child .wait() .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("wait bwrap: {e}")))?; // Aseguramos drenar ambos streams antes de leer la cola. let _ = h_out.join(); let _ = h_err.join(); // El veredicto se recoge SIEMPRE, haya ido bien o mal la fase: un build que falla es // justamente cuando más interesa saber qué tocó fuera de su clausura. if let (Some(h), Some(c)) = (&hk, corrida) { let policy = c.policy.display().to_string(); let verdict = h.recoger(c); tracing::info!(%verdict, %policy, "harkaq: verdict"); eprintln!("[harkaq] {}", verdict.trim()); eprintln!("[harkaq] política: {policy}"); } if !status.success() { let tail_str = { let g = tail.lock().expect("tail mutex"); if g.is_empty() { None } else { Some(g.iter().cloned().collect::>().join("\n")) } }; let reason = format!( "build phase falló (exit {}): {cmd}", status.code().map(|c| c.to_string()).unwrap_or_else(|| "?".into()) ); return Err(takana_core::Error::Other(anyhow::Error::new(BuildFailure { reason, log_tail: tail_str, }))); } Ok(()) } /// Como [`run`](Self::run), pero **captura el stdout completo** (no sólo la cola) y devuelve el /// exit code en vez de fallar en != 0 — el caller decide contra lo esperado. El stderr se teea al /// padre en vivo (logging), como en `run`. Es el motor de ejecución del checker de evidencia (H1b): /// correr un `cmd` en el sandbox reproducible y comparar (exit, blake3(stdout)) contra lo declarado. pub fn run_capture(&self, cmd: &str) -> takana_core::Result { let args = self.bwrap_args(cmd); tracing::debug!(?args, "bwrap (capture)"); let mut child = Command::new("bwrap") .args(&args) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("spawn bwrap: {e}")))?; let mut out = child.stdout.take().expect("stdout piped"); let err = child.stderr.take().expect("stderr piped"); // stdout: capturamos TODO en memoria (para hashearlo). stderr: al ring/tee del padre. let h_out = std::thread::spawn(move || { let mut buf = Vec::new(); let _ = out.read_to_end(&mut buf); buf }); let tail = Arc::new(Mutex::new(VecDeque::::new())); let h_err = spawn_pump(err, tail); let status = child .wait() .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("wait bwrap: {e}")))?; let stdout = h_out.join().unwrap_or_default(); let _ = h_err.join(); Ok(CmdOutcome { // -1 si el proceso murió por señal (sin exit code): un check nunca lo espera ⇒ falla. exit_code: status.code().unwrap_or(-1), stdout, }) } /// Funde todas las `deps` en UN solo directorio real por hardlinks (`cp -al`, last-wins) y lo /// devuelve para usarlo como capa overlay única. Ver [`Self::bwrap_args`]: bwrap canonicaliza los /// `--overlay-src`, así que la única forma de no desbordar el `lowerdir` con muchas deps es /// colapsarlas a una capa. El merge vive junto al store (mismo filesystem ⇒ hardlinks válidos) y /// es único-por-build (id derivado del `src_dir`). Cacheado con marcador `.done`: se hace UNA vez /// aunque `bwrap_args` se llame por cada fase (configure/compile/install). Devuelve `None` si /// falla (el caller cae al apilado directo). fn merge_deps_layer(&self) -> Option { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); self.src_dir.hash(&mut h); for d in &self.deps { d.hash(&mut h); } // DENTRO del store (el padre INMEDIATO de las deps), no en `/../`: los hardlinks NO // cruzan filesystems, y el store puede ser un mount aparte (en el worker es un VOLUMEN // bind-montado: `/opt/hammer/store` en /dev/sdb, `/opt/hammer` en /dev/sda1). Con // `.parent().parent()` el merge caía en la raíz ⇒ `cp -al` cross-device fallaba ⇒ fallback al // apilado directo ⇒ overflow del lowerdir en kio (5315B > 4096B) y build imposible. El padre // inmediato de un dep ES el store, siempre mismo fs que las deps. `.dmerge` (dot-prefix) no // matchea `-` ⇒ invisible para el store lookup y build-state, como `.times`. let base = self .deps .first() .and_then(|d| std::fs::canonicalize(d).ok()) .and_then(|d| d.parent().map(|p| p.to_path_buf())) .unwrap_or_else(|| PathBuf::from(".")); let merged = base.join(".dmerge").join(format!("{:x}", h.finish())); let done = merged.join(".done"); if done.exists() { return Some(merged); } let _ = std::fs::remove_dir_all(&merged); std::fs::create_dir_all(&merged).ok()?; // `cp -al /. /` hardlinkea el árbol recursivamente; `-f` fuerza el last-wins en // los pocos ficheros que coincidan (semántica de capa superior del overlay). Orden = orden de // deps (la última gana), igual que el apilado `--overlay-src`. for dep in &self.deps { let real = std::fs::canonicalize(dep).ok()?; let status = Command::new("cp") .arg("-alf") .arg(format!("{}/.", real.display())) .arg(&merged) .status() .ok()?; if !status.success() { return None; } } std::fs::write(&done, b"").ok()?; Some(merged) } /// Las superficies que define el SANDBOX (no la receta): las monta bwrap, no salen del rootfs /// Alpine, así que no son deuda de de-Alpinización (SDD 16 §4.2). Vive acá y no en `harkaq` /// porque es el sandbox quien sabe qué montó: `/cache` sólo existe si hay `cache_dir`, y una /// política que nombre un path inexistente aborta el build (a propósito). /// /// `list /` y `list /opt`: todo directorio concedido necesita listado en sus ancestros o el /// escaneo del padre es un falso positivo — lo encontró el propio banco de pruebas (§4.4). fn contratos_harkaq(&self) -> Vec { let mut v: Vec = [ "rw /src", "rw /out", "rw /tmp", "rw /dev/null", "ro /dev/urandom", "ro /opt/zig", // `--proc /proc`: bwrap monta un /proc FRESCO dentro del pidns del sandbox. No sale // del rootfs Alpine y no ve al host ⇒ contrato, no deuda. Lo destapó brotli, cuyo // configure lee /proc/cpuinfo y /proc/meminfo para dimensionar el paralelismo. "ro /proc", // LISTAR la estructura del árbol no es deuda: Landlock separa READ_DIR de READ_FILE, // así que esto deja hacer `ls /usr/lib` sin poder LEER un solo fichero no declarado. // La deuda de de-Alpinización es leer/ejecutar lo ajeno, no saber que existe. // // Sin esto, una receta SIN deps no genera ni un `list` (los `list` salían sólo de los // ancestros de la clausura) ⇒ todo escaneo del árbol caía como denegación. El barrido // en la granja marcaba a bash como deuda IRREDUCIBLE por `/usr/lib`, que es un gap de // política, no una dependencia de Alpine — un falso positivo de los que matan el // proyecto (§7). "list /", "list /opt", "list /usr", "list /usr/bin", "list /usr/lib", "list /usr/include", "list /usr/share", "list /usr/libexec", "list /usr/local", "list /lib", "list /bin", "list /sbin", "list /usr/sbin", "list /etc", "list /var", // `--tmp-overlay /` hace la raíz escribible con una capa tmpfs descartable: escribir // en /var/tmp no toca el rootfs Alpine ni sobrevive al build. Es scratch, como /tmp. "rw /var/tmp", ] .iter() .map(|s| s.to_string()) .collect(); if self.cache_dir.is_some() { // `ZIG_GLOBAL_CACHE_DIR=/cache/zig` ⇒ zig CREA directorios ahí. Sin esto la primera // fase muere con `fs.make_dir /cache/zig/tmp` — lo cazó la primera corrida real. v.push("rw /cache".into()); } v } /// Como [`bwrap_args`](Self::bwrap_args), pero mete a harkaq como último eslabón: bindea /// `harkaq-exec`, la política y el canario, y sustituye el `sh -c ` final por /// `harkaq-exec --policy ... -- sh -c "; "`. /// /// Todo lo anterior (overlays, ns, binds, entorno) queda intacto: harkaq NO reemplaza al /// sandbox de bwrap, se compone encima (§0, no-tesis). fn bwrap_args_harkaq( &self, cmd: &str, h: &crate::harkaq::Harkaq, c: &crate::harkaq::Corrida, ) -> Vec { // El probe del canario va DENTRO del `sh -c`, o sea DESPUÉS del execve: es el único sitio // donde valida lo que tiene que validar (que el logging post-exec funciona). Usa SÓLO // builtins — con `cat` dependería de /bin/cat y moriría si no está en la clausura, sin // probar nada (SDD §4.2). let con_canario = format!("read _ < {} || true; {cmd}", c.canary); let mut args = self.bwrap_args(&con_canario); // El `sh -c ` que bwrap_args deja al final se reemplaza por la cadena de harkaq. // Los binds van ANTES del comando; por eso se corta la cola y se rearma. let n = args.len(); args.truncate(n - 3); // quita: /bin/sh, -c, for (src, dst) in [ (c.canary_src.display().to_string(), c.canary.clone()), (h.exec_bin().display().to_string(), "/harkaq-exec".to_string()), (c.policy.display().to_string(), "/harkaq.policy".to_string()), ] { args.push("--ro-bind".into()); args.push(src); args.push(dst); } // En la raíz y no en /tmp: `--tmp-overlay /` la hace escribible, así que bwrap puede // crear ahí los puntos de montaje, y la política no alcanza la raíz ⇒ el canario deniega // por construcción. En /tmp caería dentro de `rw /tmp` y no denunciaría nada. args.push("/harkaq-exec".into()); args.push("--policy".into()); args.push("/harkaq.policy".into()); args.push("--".into()); args.push("/bin/sh".into()); args.push("-c".into()); args.push(con_canario); args } fn bwrap_args(&self, cmd: &str) -> Vec { let mut args: Vec = vec![ "--overlay-src".into(), self.rootfs.display().to_string(), ]; // Capas overlay de las deps de build, apiladas bajo el rootfs (mismo `--tmp-overlay /`). bwrap // admite múltiples `--overlay-src` para un overlay; los `usr/{include,lib}` de cada dep se // funden con los del rootfs en `/usr`. Sólo se emiten si hay deps (compat hacia atrás). // // LÍMITE DE PÁGINA (overlayfs): el kernel concatena todas las lowerdir en un string de // opciones de mount acotado a ~1 página (4096B). Con muchas deps (KIO ~45, Plasma más), la // suma de rutas largas `/<64hex>-` DESBORDA y el mount falla (bwrap: "Can't make // overlay mount ... No such file or directory" por truncado). Mitigación: cuando la suma de // longitudes se acerca al tope, symlinkeamos cada dep a un nombre corto (`/hdl-/`) // por-build y pasamos ESAS rutas → lowerdir compacto que escala a cientos de deps. Con pocas // deps (caso común) usamos la ruta directa: cero I/O y sandbox idéntico al previo. // bwrap CANONICALIZA cada `--overlay-src` a su ruta real para armar el `lowerdir=`, así que // acortar con symlinks no sirve: el string sigue siendo la suma de rutas reales del store. // La única forma de escalar a muchas deps es FUNDIRLAS en UN solo directorio real (hardlinks, // `cp -al`, last-wins = misma semántica que el apilado overlay) y pasar esa ÚNICA capa ⇒ // lowerdir = rootfs + 1 merge, constante sin importar cuántas deps haya. // // MEDIDO (SDD 25 T19, `docs/evidencia/tasas-kernel-2026-08-29/overlay.txt`), y conviene // leerlo antes de tocar este umbral: // · El muro es de UNA PÁGINA EXACTA: `mount(2)` acepta 41 capas del store (4 059 B de // opciones) y falla en 42 (4 158 B) con `ENOENT` — el kernel no dice «demasiado largo». // (El `mount(8)` de util-linux se rinde mucho antes; bisecarlo desde la shell miente.) // · Este umbral dispara a las ~27 deps ⇒ factor 1,5 de margen, y es estructural: el muro // va como ~4002/(L+1) y el umbral como 3000/(L+16), cociente ≥1,33 para cualquier L. // No hay que recalibrarlo si el store se muda. // · Fundir NO es una optimización de velocidad. El overlay cobra por PRIMER toque de cada // ruta (+1 925 ns por capa en un lookup que falla; ~660 ns planos si la ruta ya se // tocó), y el montaje dura toda la fase: un compilado toca 381 rutas distintas que // fallan ⇒ 2,9 ms por fase a 24 capas. Fundir 24 deps reales cuesta 264 ms, o sea que // sólo se pagaría sola a partir de ~35 200 primeros fallos en una misma fase. // ⇒ NO bajar el umbral «para que vaya más rápido»: no gana nada y engorda `.dmerge`, que // retiene por hardlink los artefactos que borres del store. Y por lo mismo, PODAR // `.dmerge` es gratis en rendimiento. let total_len: usize = self.deps.iter().map(|d| d.as_os_str().len() + 16).sum(); if total_len > 3000 && self.deps.len() > 1 { if let Some(merged) = self.merge_deps_layer() { args.push("--overlay-src".into()); args.push(merged.display().to_string()); } else { // Fallback: si la fusión falló, volvemos al apilado directo (puede desbordar, pero es // mejor que abortar sin intentar). for dep in &self.deps { args.push("--overlay-src".into()); args.push(dep.display().to_string()); } } } else { for dep in &self.deps { args.push("--overlay-src".into()); args.push(dep.display().to_string()); } } args.extend([ "--tmp-overlay".into(), "/".into(), "--proc".into(), "/proc".into(), "--dev".into(), "/dev".into(), "--tmpfs".into(), "/tmp".into(), "--ro-bind".into(), self.zig_dir.display().to_string(), "/opt/zig".into(), "--bind".into(), self.src_dir.display().to_string(), "/src".into(), "--bind".into(), self.out_dir.display().to_string(), "/out".into(), "--chdir".into(), "/src".into(), "--unshare-all".into(), "--die-with-parent".into(), ]); if let Some(cache) = &self.cache_dir { args.push("--bind".into()); args.push(cache.display().to_string()); args.push("/cache".into()); } // Entorno mínimo y predecible. let mut defaults: Vec<(&str, &str)> = vec![ ("PATH", "/opt/zig:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"), ("HOME", "/tmp"), ("DESTDIR", "/out"), ("PREFIX", "/usr"), // `-mcpu=baseline`: codegen al baseline x86-64 genérico, NO al CPU del builder. Sin esto, // `zig cc` default a `-mcpu=native` y hornea la ISA de quien compila (SHA-NI/AVX2 del // host) ⇒ el binario diverge entre CPUs distintos (host vs la VM `-cpu Broadwell`) y // rompe la reproducibilidad — un no-determinismo que Stage 2 cazó (runbook §8c, SDD 09 // §2). De paso cierra el SIGILL de AVX en qemu64 (runbook §7). Las rutas SIMD que sí // necesita el runtime (blake3/sha2) son asm con dispatch en runtime: siguen presentes. // Driver vía wrapper (ensure_layout lo materializa en /usr/local/bin): idéntico a // `zig cc -mcpu=baseline` salvo que quita `-static` en links `-shared` (extensiones // Python y demás `.so` dlopen-ables). Ver write_cc_wrappers. ("CC", CC_WRAPPER), ("CXX", CXX_WRAPPER), ("AR", "zig ar"), ("LC_ALL", "C"), ("LANG", "C"), // Reproducibilidad (pre-Stage 2): fija los timestamps que muchas herramientas embeben // (tar, gzip, __DATE__/__TIME__ vía SOURCE_DATE_EPOCH). Valor fijo y arbitrario // ⚠ NO cubre `ar`/`ranlib`: binutils NO lee SOURCE_DATE_EPOCH — la determinismo de // archivos se decide en `configure` (`--enable-deterministic-archives`) o por opción // `-D` en cada invocación. Este comentario decía que sí lo cubría y era FALSO; lo // desmintió comparar dos builds de zlib (ver AR_WRAPPER/RANLIB_WRAPPER abajo). // (1970-01-01 00:00:01). Las rutas ya son deterministas: el source se bindea en `/src` // (constante entre rebuilds, sea cual sea el work dir del host). Ver SDD 11 / runbook. ("SOURCE_DATE_EPOCH", "1"), ("TZ", "UTC"), // Reproducibilidad de builds Cargo: fuerza `codegen-units = 1` para TODAS las crates // del build (el var de cargo gana sobre el `[profile.release]` del repo fuente). Con el // default (16) el codegen paralelo de rustc reparte el crate en N unidades y la salida // varía build-a-build incluso en la MISMA máquina y con entradas idénticas — Stage 2 lo // cazó: `arje-zero` (cuyo workspace tawasuyu no fija perfil) divergía ~10 KB repartidos // por .text/.rodata/.eh_frame, mientras `hammerd` (workspace takana que SÍ pinea // codegen-units=1) reproducía bit-a-bit. El lab lo impone en vez de confiar en que cada // repo upstream lo declare (SDD 09 §2). Inerte para builds no-Cargo (musl/busybox lo // ignoran). Ver runbook §8c / SDD 11. ("CARGO_PROFILE_RELEASE_CODEGEN_UNITS", "1"), // Reproducibilidad de builds Cargo (parte 2): `codegen-units=1` NO basta. El backend // paralelo de rustc/LLVM (ThinLTO + codegen) lanza un pool de hilos dimensionado por la // paralelismo disponible, y sus decisiones (orden de import/inline) varían build-a-build // a >1 hilo ⇒ `arje-zero` divergía ~62 KB entre dos builds host idénticos, mientras un // build serial (la VM tiene 1 vCPU, o `taskset -c 0`) reproducía bit-a-bit. `JOBS=1` // limita el jobserver a 1 token ⇒ rustc serializa el backend ⇒ salida estable e // independiente del nº de CPUs del que reconstruye. Lo cazó Stage 2 variante (b): host // (paralelo) daba `0039b2b9…` no-reproducible vs VM (serial) `9adefb82…`. Ver SDD 09 §2. ("CARGO_BUILD_JOBS", "1"), ]; if self.cache_dir.is_some() { defaults.push(("ZIG_GLOBAL_CACHE_DIR", "/cache/zig")); defaults.push(("ZIG_LOCAL_CACHE_DIR", "/cache/zig")); } for (k, v) in defaults { args.push("--setenv".into()); args.push(k.into()); args.push(v.into()); } for (k, v) in &self.env { args.push("--setenv".into()); args.push(k.clone()); args.push(v.clone()); } // Comando final. args.push("/bin/sh".into()); args.push("-c".into()); args.push(cmd.into()); args } } /// Lanza un thread que lee `reader` línea a línea, la reenvía a stderr del padre y la /// acumula en el ring `tail`. Devuelve el handle para join. /// /// El tee va SIEMPRE a stderr (tanto el stdout como el stderr del build): stdout del proceso /// queda reservado para el resultado legible-por-máquina (el content-hash que imprime la CLI, /// p. ej. `bootstrap stage1`). Antes el stdout del build se teeaba al stdout del padre y se /// colaba en capturas tipo `PRIME=$(takana … bootstrap stage1)`; con builds reales (no cacheados, /// p. ej. el rebuild in-VM) eso son miles de líneas de `configure`/`make` ⇒ el argumento crece y /// la fase de verificación siguiente (`--rootfs "$PRIME"`) revienta el exec con E2BIG /// (Argument list too long). Va a stderr, junto a los logs de tracing. Ver runbook §8c. fn spawn_pump( reader: R, tail: Arc>>, ) -> std::thread::JoinHandle<()> { std::thread::spawn(move || { let buf = BufReader::new(reader); for line in buf.lines() { let line = match line { Ok(l) => l, Err(_) => break, }; // Tee en vivo al padre por stderr (sin bufferizar de más: el build puede tardar). let _ = writeln!(std::io::stderr(), "{line}"); if let Ok(mut g) = tail.lock() { push_tail(&mut g, line, TAIL_LINES); } } }) } /// Errores tempranos: comprueba que las rutas base existen antes de invocar el build. pub fn ensure_layout(rootfs: &Path, zig_dir: &Path) -> takana_core::Result<()> { // bin/sh en Alpine es un symlink absoluto a /bin/busybox, que desde el host no resuelve. // Comprobamos el archivo regular real para no engañarnos. if !rootfs.join("bin/busybox").is_file() { return Err(takana_core::Error::Other(anyhow::anyhow!( "rootfs inválido: no encuentro {}/bin/busybox — ¿corriste el bootstrap de Alpine?", rootfs.display() ))); } if !zig_dir.join("zig").exists() { return Err(takana_core::Error::Other(anyhow::anyhow!( "zig no encontrado en {}/zig — ¿instalaste zig en .dev-fs/tools/zig?", zig_dir.display() ))); } write_cc_wrappers(rootfs)?; Ok(()) } /// Nombres (en `/usr/local/bin`, que está en el PATH del sandbox) del driver de compilador que /// usan CC/CXX por defecto. Ver [`write_cc_wrappers`]. pub const CC_WRAPPER: &str = "hammer-zig-cc"; pub const CXX_WRAPPER: &str = "hammer-zig-cxx"; /// Herramientas de archivo que se interponen en `/usr/local/bin` para forzar **modo determinista**. /// /// ── POR QUÉ (medido 2026-08-28) ───────────────────────────────────────────────────────────── /// Construir `zlib` en dos máquinas dio el MISMO `ArtifactHash` y **bytes distintos**: /// `usr/lib/libz.a` divergía sólo en la cabecera `ar` del miembro `/` (la tabla de símbolos), /// con la hora de pared del build. `takana why-differs` lo nombró exacto. /// /// La causa NO es `zig ar` (llvm-ar sella mtime 0) ni el binutils del rootfs Alpine (que Alpine /// compila con `--enable-deterministic-archives`): es **nuestro propio `recipes/binutils.toml`**, /// que no pasa ese flag. Toda receta que lo materialice como build-dep y llame a `ar`/`ranlib` /// —directamente o vía el `$(RANLIB)` de su Makefile— hereda la hora de pared. /// /// ── POR QUÉ AQUÍ Y NO EN LA RECETA ────────────────────────────────────────────────────────── /// Arreglar `recipes/binutils.toml` es la cura de raíz, pero su radio son **421 recetas /// transitivas y 365 sellados a deuda** (medido con `yupana radio binutils`) — desproporcionado /// frente a los 9 artefactos realmente contaminados. El sandbox es donde este proyecto ya /// arregla esta CLASE de bug sin re-hashear: `-mcpu=baseline`, `CARGO_PROFILE_RELEASE_CODEGEN_UNITS` /// y `CARGO_BUILD_JOBS` son exactamente el mismo patrón. La cura de raíz queda pendiente para /// cuando toque un rebuild del corpus por otro motivo (SDD 23), no como campaña propia. /// /// ⚠ CONSECUENCIA QUE HAY QUE EJECUTAR, NO SÓLO DECLARAR: esto cambia los BYTES sin cambiar la /// DIRECCIÓN de los 9 artefactos ya contaminados. Un artefacto viejo y uno nuevo son distintos en /// el mismo hash — el peligro exacto que la huella del lab existe para eliminar. Por eso la /// adopción incluye **re-sellar esos 9 a propósito**, no esperar a que caduquen solos. pub const AR_WRAPPER: &str = "ar"; pub const RANLIB_WRAPPER: &str = "ranlib"; /// Materializa en el rootfs dos wrappers de linker-driver (`takana-zig-cc`/`-cxx`) que envuelven a /// `zig cc`/`zig c++`. Su ÚNICO efecto es QUITAR `-static` cuando el link REQUIERE enlace dinámico. /// /// El harness inyecta `LDFLAGS=-static` para las recetas `link=static` (binario monolítico que pide /// la hidratación primaria). Pero meson/autotools/giscanner aplican ese LDFLAGS a TODOS los links por /// igual, incluidos algunos que son INTRÍNSECAMENTE dinámicos y con los que `-static` es /// contradictorio (`error: using shared libraries requires dynamic linking`). El caso que lo destapó /// es gobject-introspection, dinámico por naturaleza en TRES puntos: /// 1. `_giscanner.cpython-*.so`: extensión Python (`shared_module`) que el intérprete `dlopen`ea. /// Con `-static` sale ET_EXEC ⇒ `dlopen` da `Exec format error`. /// 2. las tools (`g-ir-compiler`/`-generate`/`-inspect`) linkean la `libgirepository-1.0.so`. /// 3. el "dumper" del scanner usa `-Wl,--export-dynamic` (necesita la symtab dinámica para /// introspectar GTypes en runtime) — incompatible con `-static`. /// /// Regla principista: **`-static` cede ante cualquier link que requiera dinámico** — `-shared`, /// `--export-dynamic`/`-rdynamic`, o un objeto compartido (`.so`/`.so.N`) en la línea. En todos esos /// casos `-static` no puede sostenerse; quitarlo es correcto por construcción. Para el link normal /// (ejecutable estático / `.a`) el wrapper es TRANSPARENTE ⇒ byte-idéntico a `zig cc`, y el resto del /// árbol sigue estático. El ArtifactHash se deriva de inputs (receta+deps), no de los bytes ⇒ este /// cambio NO re-hashea nada sellado; sólo destraba builds nuevos con enlace dinámico irreducible. fn write_cc_wrappers(rootfs: &Path) -> takana_core::Result<()> { use std::os::unix::fs::PermissionsExt; let bindir = rootfs.join("usr/local/bin"); std::fs::create_dir_all(&bindir).map_err(|e| { takana_core::Error::Other(anyhow::anyhow!("no pude crear {}: {e}", bindir.display())) })?; // `case " $* "` detecta como palabra los marcadores de "este link es dinámico": -shared, // -rdynamic, --export-dynamic, o un shared object en la línea (`…​.so ` final o `…​.so.N` versionado). // El bucle rota los positional params (pop del frente, push al fondo salvo `-static`) ⇒ filtra // preservando orden y seguro con espacios. let body = |driver: &str| { format!( "#!/bin/sh\n\ case \" $* \" in\n\ \x20 *\" -shared \"* | *\" -rdynamic \"* | *\"--export-dynamic\"* | *\".so \"* | *\".so.\"*)\n\ \x20 n=$#\n\ \x20 while [ \"$n\" -gt 0 ]; do\n\ \x20 arg=$1; shift\n\ \x20 [ \"$arg\" = \"-static\" ] || set -- \"$@\" \"$arg\"\n\ \x20 n=$((n - 1))\n\ \x20 done\n\ \x20 ;;\n\ esac\n\ \x20# `-mcpu=baseline` NO se inyecta en modo PREPROCESADOR (`-E`): ahí zig lo traduce a\n\ \x20# un `-mcpu=` VACÍO y lo rechaza («unsupported option '-mcpu=' for target»). Es\n\ \x20# inofensivo omitirlo —preprocesar no genera código, así que no hay CPU al que\n\ \x20# apuntar— y sin esto NO se puede esquivar desde la receta: util-linux ya filtra\n\ \x20# el flag en sus generadores `all_errnos`/`all_syscalls`, pero filtra `\"$@\"` y el\n\ \x20# wrapper lo vuelve a poner DESPUÉS ⇒ el apaño de la receta es inerte y `errnos.h`\n\ \x20# no se genera (2026-08-12; util-linux es del perfil base).\n\ case \" $* \" in\n\ \x20 *\" -E \"*) exec {driver} \"$@\" ;;\n\ esac\n\ exec {driver} -mcpu=baseline \"$@\"\n" ) }; for (name, driver) in [(CC_WRAPPER, "zig cc"), (CXX_WRAPPER, "zig c++")] { let path = bindir.join(name); std::fs::write(&path, body(driver)).map_err(|e| { takana_core::Error::Other(anyhow::anyhow!("no pude escribir {}: {e}", path.display())) })?; std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).map_err(|e| { takana_core::Error::Other(anyhow::anyhow!("no pude chmod {}: {e}", path.display())) })?; } // Archivadores en modo determinista. Ver la doc de AR_WRAPPER. // // `-D` ANTEPUESTO y no como modificador de la cadena de operación (`Drc`): las dos formas // funcionan en binutils (verificado), pero anteponerlo es lo único que compone con una línea // de comando ajena — el llamador pone su propia operación (`rc`, `t`, `x`…) en "$@" y no la // podemos reescribir. `-D` es inocuo en las operaciones de lectura. // // Ruta ABSOLUTA `/usr/bin/` a propósito: el wrapper vive en `/usr/local/bin`, que va // ANTES en el PATH, así que un `exec ar` se llamaría a sí mismo para siempre. for tool in [AR_WRAPPER, RANLIB_WRAPPER] { let path = bindir.join(tool); let body = format!( "#!/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" ); std::fs::write(&path, body).map_err(|e| { takana_core::Error::Other(anyhow::anyhow!("no pude escribir {}: {e}", path.display())) })?; std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).map_err(|e| { takana_core::Error::Other(anyhow::anyhow!("no pude chmod {}: {e}", path.display())) })?; } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn push_tail_keeps_last_n() { let mut buf = VecDeque::new(); for i in 0..10 { push_tail(&mut buf, format!("line {i}"), 3); } let got: Vec<_> = buf.iter().cloned().collect(); assert_eq!(got, vec!["line 7", "line 8", "line 9"]); } #[test] fn build_failure_roundtrips_through_core_error() { // El contrato que usa el bus: envolver en Error::Other y recuperar por downcast. let bf = BuildFailure { reason: "exit 2".into(), log_tail: Some("undefined reference to `foo'".into()), }; let err = takana_core::Error::Other(anyhow::Error::new(bf)); let recovered = BuildFailure::from_error(&err).expect("debe recuperarse"); assert_eq!(recovered.reason, "exit 2"); assert_eq!( recovered.log_tail.as_deref(), Some("undefined reference to `foo'") ); } #[test] fn from_error_none_for_plain_errors() { let err = takana_core::Error::Recipe("nope".into()); assert!(BuildFailure::from_error(&err).is_none()); } fn fixture(deps: Vec) -> Sandbox { Sandbox { rootfs: PathBuf::from("/rootfs"), zig_dir: PathBuf::from("/zig"), src_dir: PathBuf::from("/src-host"), out_dir: PathBuf::from("/out-host"), cache_dir: None, deps, env: vec![], } } #[test] fn no_deps_emits_single_overlay_src() { // Compat hacia atrás: sin deps, una sola capa overlay (el rootfs) — sandbox idéntico al previo. let args = fixture(vec![]).bwrap_args("true"); let n = args.iter().filter(|a| *a == "--overlay-src").count(); assert_eq!(n, 1, "sin deps ⇒ un único --overlay-src"); // El --tmp-overlay viene inmediatamente tras el rootfs. let i = args.iter().position(|a| a == "--overlay-src").unwrap(); assert_eq!(args[i + 1], "/rootfs"); assert_eq!(args[i + 2], "--tmp-overlay"); } #[test] fn deps_stack_as_overlay_layers_under_rootfs() { let args = fixture(vec![PathBuf::from("/dep/libcap"), PathBuf::from("/dep/zlib")]) .bwrap_args("true"); // rootfs + 2 deps = 3 capas overlay, todas antes del --tmp-overlay. let srcs: Vec<&String> = args .iter() .enumerate() .filter(|(_, a)| *a == "--overlay-src") .map(|(i, _)| &args[i + 1]) .collect(); assert_eq!(srcs, vec!["/rootfs", "/dep/libcap", "/dep/zlib"]); let to = args.iter().position(|a| a == "--tmp-overlay").unwrap(); let last_src = args.iter().rposition(|a| a == "--overlay-src").unwrap(); assert!(last_src < to, "todas las capas overlay van antes del --tmp-overlay"); } }