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…
⎇ 13233ee4e9334e0aa3785f0dc35e97ef50148476
crates/hammer-build/src/sandbox.rs
Historia
Quién tocó qué
crudo
harkaq: primer barrido en la granja — la deuda irreducible de la muestra es perl (§4.10)
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 `hammer_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 hammer-core, si lo lleva dentro. El bus lo
43 /// usa para separar `reason` de `log_tail`.
44 pub fn from_error(e: &hammer_core::Error) -> Option<&BuildFailure> {
45 match e {
46 hammer_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 `hammer_core::Error::Other`.
91 pub fn run(&self, cmd: &str) -> hammer_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| hammer_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| hammer_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(hammer_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) -> hammer_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| hammer_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| hammer_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 // Junto al store para compartir filesystem (hardlinks no cruzan fs). El store suele estar en
213 // `<base>/store`; ubicamos el merge en `<base>/.dmerge/<id>`. Derivamos <base> del primer dep.
214 let base = self
215 .deps
216 .first()
217 .and_then(|d| std::fs::canonicalize(d).ok())
218 .and_then(|d| d.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf()))
219 .unwrap_or_else(|| PathBuf::from("."));
220 let merged = base.join(".dmerge").join(format!("{:x}", h.finish()));
221 let done = merged.join(".done");
222 if done.exists() {
223 return Some(merged);
224 }
225 let _ = std::fs::remove_dir_all(&merged);
226 std::fs::create_dir_all(&merged).ok()?;
227 // `cp -al <dep>/. <merged>/` hardlinkea el árbol recursivamente; `-f` fuerza el last-wins en
228 // los pocos ficheros que coincidan (semántica de capa superior del overlay). Orden = orden de
229 // deps (la última gana), igual que el apilado `--overlay-src`.
230 for dep in &self.deps {
231 let real = std::fs::canonicalize(dep).ok()?;
232 let status = Command::new("cp")
233 .arg("-alf")
234 .arg(format!("{}/.", real.display()))
235 .arg(&merged)
236 .status()
237 .ok()?;
238 if !status.success() {
239 return None;
240 }
241 }
242 std::fs::write(&done, b"").ok()?;
243 Some(merged)
244 }
245
246 /// Las superficies que define el SANDBOX (no la receta): las monta bwrap, no salen del rootfs
247 /// Alpine, así que no son deuda de de-Alpinización (SDD 16 §4.2). Vive acá y no en `harkaq`
248 /// porque es el sandbox quien sabe qué montó: `/cache` sólo existe si hay `cache_dir`, y una
249 /// política que nombre un path inexistente aborta el build (a propósito).
250 ///
251 /// `list /` y `list /opt`: todo directorio concedido necesita listado en sus ancestros o el
252 /// escaneo del padre es un falso positivo — lo encontró el propio banco de pruebas (§4.4).
253 fn contratos_harkaq(&self) -> Vec<String> {
254 let mut v: Vec<String> = [
255 "rw /src", "rw /out", "rw /tmp", "rw /dev/null", "ro /dev/urandom", "ro /opt/zig",
256 // `--proc /proc`: bwrap monta un /proc FRESCO dentro del pidns del sandbox. No sale
257 // del rootfs Alpine y no ve al host ⇒ contrato, no deuda. Lo destapó brotli, cuyo
258 // configure lee /proc/cpuinfo y /proc/meminfo para dimensionar el paralelismo.
259 "ro /proc",
260 // LISTAR la estructura del árbol no es deuda: Landlock separa READ_DIR de READ_FILE,
261 // así que esto deja hacer `ls /usr/lib` sin poder LEER un solo fichero no declarado.
262 // La deuda de de-Alpinización es leer/ejecutar lo ajeno, no saber que existe.
263 //
264 // Sin esto, una receta SIN deps no genera ni un `list` (los `list` salían sólo de los
265 // ancestros de la clausura) ⇒ todo escaneo del árbol caía como denegación. El barrido
266 // en la granja marcaba a bash como deuda IRREDUCIBLE por `/usr/lib`, que es un gap de
267 // política, no una dependencia de Alpine — un falso positivo de los que matan el
268 // proyecto (§7).
269 "list /", "list /opt", "list /usr", "list /usr/bin", "list /usr/lib",
270 "list /usr/include", "list /usr/share", "list /usr/libexec", "list /usr/local",
271 "list /lib", "list /bin", "list /sbin", "list /usr/sbin", "list /etc", "list /var",
272 // `--tmp-overlay /` hace la raíz escribible con una capa tmpfs descartable: escribir
273 // en /var/tmp no toca el rootfs Alpine ni sobrevive al build. Es scratch, como /tmp.
274 "rw /var/tmp",
275 ]
276 .iter()
277 .map(|s| s.to_string())
278 .collect();
279 if self.cache_dir.is_some() {
280 // `ZIG_GLOBAL_CACHE_DIR=/cache/zig` ⇒ zig CREA directorios ahí. Sin esto la primera
281 // fase muere con `fs.make_dir /cache/zig/tmp` — lo cazó la primera corrida real.
282 v.push("rw /cache".into());
283 }
284 v
285 }
286
287 /// Como [`bwrap_args`](Self::bwrap_args), pero mete a harkaq como último eslabón: bindea
288 /// `harkaq-exec`, la política y el canario, y sustituye el `sh -c <cmd>` final por
289 /// `harkaq-exec --policy ... -- sh -c "<probe del canario>; <cmd>"`.
290 ///
291 /// Todo lo anterior (overlays, ns, binds, entorno) queda intacto: harkaq NO reemplaza al
292 /// sandbox de bwrap, se compone encima (§0, no-tesis).
293 fn bwrap_args_harkaq(
294 &self,
295 cmd: &str,
296 h: &crate::harkaq::Harkaq,
297 c: &crate::harkaq::Corrida,
298 ) -> Vec<String> {
299 // El probe del canario va DENTRO del `sh -c`, o sea DESPUÉS del execve: es el único sitio
300 // donde valida lo que tiene que validar (que el logging post-exec funciona). Usa SÓLO
301 // builtins — con `cat` dependería de /bin/cat y moriría si no está en la clausura, sin
302 // probar nada (SDD §4.2).
303 let con_canario = format!("read _ < {} || true; {cmd}", c.canary);
304 let mut args = self.bwrap_args(&con_canario);
305
306 // El `sh -c <cmd>` que bwrap_args deja al final se reemplaza por la cadena de harkaq.
307 // Los binds van ANTES del comando; por eso se corta la cola y se rearma.
308 let n = args.len();
309 args.truncate(n - 3); // quita: /bin/sh, -c, <cmd>
310
311 for (src, dst) in [
312 (c.canary_src.display().to_string(), c.canary.clone()),
313 (h.exec_bin().display().to_string(), "/harkaq-exec".to_string()),
314 (c.policy.display().to_string(), "/harkaq.policy".to_string()),
315 ] {
316 args.push("--ro-bind".into());
317 args.push(src);
318 args.push(dst);
319 }
320 // En la raíz y no en /tmp: `--tmp-overlay /` la hace escribible, así que bwrap puede
321 // crear ahí los puntos de montaje, y la política no alcanza la raíz ⇒ el canario deniega
322 // por construcción. En /tmp caería dentro de `rw /tmp` y no denunciaría nada.
323 args.push("/harkaq-exec".into());
324 args.push("--policy".into());
325 args.push("/harkaq.policy".into());
326 args.push("--".into());
327 args.push("/bin/sh".into());
328 args.push("-c".into());
329 args.push(con_canario);
330 args
331 }
332
333 fn bwrap_args(&self, cmd: &str) -> Vec<String> {
334 let mut args: Vec<String> = vec![
335 "--overlay-src".into(),
336 self.rootfs.display().to_string(),
337 ];
338 // Capas overlay de las deps de build, apiladas bajo el rootfs (mismo `--tmp-overlay /`). bwrap
339 // admite múltiples `--overlay-src` para un overlay; los `usr/{include,lib}` de cada dep se
340 // funden con los del rootfs en `/usr`. Sólo se emiten si hay deps (compat hacia atrás).
341 //
342 // LÍMITE DE PÁGINA (overlayfs): el kernel concatena todas las lowerdir en un string de
343 // opciones de mount acotado a ~1 página (4096B). Con muchas deps (KIO ~45, Plasma más), la
344 // suma de rutas largas `<store>/<64hex>-<name>` DESBORDA y el mount falla (bwrap: "Can't make
345 // overlay mount ... No such file or directory" por truncado). Mitigación: cuando la suma de
346 // longitudes se acerca al tope, symlinkeamos cada dep a un nombre corto (`<tmp>/hdl-<id>/<N>`)
347 // por-build y pasamos ESAS rutas → lowerdir compacto que escala a cientos de deps. Con pocas
348 // deps (caso común) usamos la ruta directa: cero I/O y sandbox idéntico al previo.
349 // bwrap CANONICALIZA cada `--overlay-src` a su ruta real para armar el `lowerdir=`, así que
350 // acortar con symlinks no sirve: el string sigue siendo la suma de rutas reales del store.
351 // La única forma de escalar a muchas deps es FUNDIRLAS en UN solo directorio real (hardlinks,
352 // `cp -al`, last-wins = misma semántica que el apilado overlay) y pasar esa ÚNICA capa ⇒
353 // lowerdir = rootfs + 1 merge, constante sin importar cuántas deps haya.
354 let total_len: usize = self.deps.iter().map(|d| d.as_os_str().len() + 16).sum();
355 if total_len > 3000 && self.deps.len() > 1 {
356 if let Some(merged) = self.merge_deps_layer() {
357 args.push("--overlay-src".into());
358 args.push(merged.display().to_string());
359 } else {
360 // Fallback: si la fusión falló, volvemos al apilado directo (puede desbordar, pero es
361 // mejor que abortar sin intentar).
362 for dep in &self.deps {
363 args.push("--overlay-src".into());
364 args.push(dep.display().to_string());
365 }
366 }
367 } else {
368 for dep in &self.deps {
369 args.push("--overlay-src".into());
370 args.push(dep.display().to_string());
371 }
372 }
373 args.extend([
374 "--tmp-overlay".into(),
375 "/".into(),
376 "--proc".into(),
377 "/proc".into(),
378 "--dev".into(),
379 "/dev".into(),
380 "--tmpfs".into(),
381 "/tmp".into(),
382 "--ro-bind".into(),
383 self.zig_dir.display().to_string(),
384 "/opt/zig".into(),
385 "--bind".into(),
386 self.src_dir.display().to_string(),
387 "/src".into(),
388 "--bind".into(),
389 self.out_dir.display().to_string(),
390 "/out".into(),
391 "--chdir".into(),
392 "/src".into(),
393 "--unshare-all".into(),
394 "--die-with-parent".into(),
395 ]);
396
397 if let Some(cache) = &self.cache_dir {
398 args.push("--bind".into());
399 args.push(cache.display().to_string());
400 args.push("/cache".into());
401 }
402
403 // Entorno mínimo y predecible.
404 let mut defaults: Vec<(&str, &str)> = vec![
405 ("PATH", "/opt/zig:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"),
406 ("HOME", "/tmp"),
407 ("DESTDIR", "/out"),
408 ("PREFIX", "/usr"),
409 // `-mcpu=baseline`: codegen al baseline x86-64 genérico, NO al CPU del builder. Sin esto,
410 // `zig cc` default a `-mcpu=native` y hornea la ISA de quien compila (SHA-NI/AVX2 del
411 // host) ⇒ el binario diverge entre CPUs distintos (host vs la VM `-cpu Broadwell`) y
412 // rompe la reproducibilidad — un no-determinismo que Stage 2 cazó (runbook §8c, SDD 09
413 // §2). De paso cierra el SIGILL de AVX en qemu64 (runbook §7). Las rutas SIMD que sí
414 // necesita el runtime (blake3/sha2) son asm con dispatch en runtime: siguen presentes.
415 ("CC", "zig cc -mcpu=baseline"),
416 ("CXX", "zig c++ -mcpu=baseline"),
417 ("AR", "zig ar"),
418 ("LC_ALL", "C"),
419 ("LANG", "C"),
420 // Reproducibilidad (pre-Stage 2): fija los timestamps que muchas herramientas embeben
421 // (tar, ar, gzip, __DATE__/__TIME__ vía SOURCE_DATE_EPOCH). Valor fijo y arbitrario
422 // (1970-01-01 00:00:01). Las rutas ya son deterministas: el source se bindea en `/src`
423 // (constante entre rebuilds, sea cual sea el work dir del host). Ver SDD 11 / runbook.
424 ("SOURCE_DATE_EPOCH", "1"),
425 ("TZ", "UTC"),
426 // Reproducibilidad de builds Cargo: fuerza `codegen-units = 1` para TODAS las crates
427 // del build (el var de cargo gana sobre el `[profile.release]` del repo fuente). Con el
428 // default (16) el codegen paralelo de rustc reparte el crate en N unidades y la salida
429 // varía build-a-build incluso en la MISMA máquina y con entradas idénticas — Stage 2 lo
430 // cazó: `arje-zero` (cuyo workspace tawasuyu no fija perfil) divergía ~10 KB repartidos
431 // por .text/.rodata/.eh_frame, mientras `hammerd` (workspace hammer que SÍ pinea
432 // codegen-units=1) reproducía bit-a-bit. El lab lo impone en vez de confiar en que cada
433 // repo upstream lo declare (SDD 09 §2). Inerte para builds no-Cargo (musl/busybox lo
434 // ignoran). Ver runbook §8c / SDD 11.
435 ("CARGO_PROFILE_RELEASE_CODEGEN_UNITS", "1"),
436 // Reproducibilidad de builds Cargo (parte 2): `codegen-units=1` NO basta. El backend
437 // paralelo de rustc/LLVM (ThinLTO + codegen) lanza un pool de hilos dimensionado por la
438 // paralelismo disponible, y sus decisiones (orden de import/inline) varían build-a-build
439 // a >1 hilo ⇒ `arje-zero` divergía ~62 KB entre dos builds host idénticos, mientras un
440 // build serial (la VM tiene 1 vCPU, o `taskset -c 0`) reproducía bit-a-bit. `JOBS=1`
441 // limita el jobserver a 1 token ⇒ rustc serializa el backend ⇒ salida estable e
442 // independiente del nº de CPUs del que reconstruye. Lo cazó Stage 2 variante (b): host
443 // (paralelo) daba `0039b2b9…` no-reproducible vs VM (serial) `9adefb82…`. Ver SDD 09 §2.
444 ("CARGO_BUILD_JOBS", "1"),
445 ];
446 if self.cache_dir.is_some() {
447 defaults.push(("ZIG_GLOBAL_CACHE_DIR", "/cache/zig"));
448 defaults.push(("ZIG_LOCAL_CACHE_DIR", "/cache/zig"));
449 }
450 for (k, v) in defaults {
451 args.push("--setenv".into());
452 args.push(k.into());
453 args.push(v.into());
454 }
455 for (k, v) in &self.env {
456 args.push("--setenv".into());
457 args.push(k.clone());
458 args.push(v.clone());
459 }
460
461 // Comando final.
462 args.push("/bin/sh".into());
463 args.push("-c".into());
464 args.push(cmd.into());
465 args
466 }
467}
468
469/// Lanza un thread que lee `reader` línea a línea, la reenvía a stderr del padre y la
470/// acumula en el ring `tail`. Devuelve el handle para join.
471///
472/// El tee va SIEMPRE a stderr (tanto el stdout como el stderr del build): stdout del proceso
473/// queda reservado para el resultado legible-por-máquina (el content-hash que imprime la CLI,
474/// p. ej. `bootstrap stage1`). Antes el stdout del build se teeaba al stdout del padre y se
475/// colaba en capturas tipo `PRIME=$(hammer … bootstrap stage1)`; con builds reales (no cacheados,
476/// p. ej. el rebuild in-VM) eso son miles de líneas de `configure`/`make` ⇒ el argumento crece y
477/// la fase de verificación siguiente (`--rootfs "$PRIME"`) revienta el exec con E2BIG
478/// (Argument list too long). Va a stderr, junto a los logs de tracing. Ver runbook §8c.
479fn spawn_pump<R: std::io::Read + Send + 'static>(
480 reader: R,
481 tail: Arc<Mutex<VecDeque<String>>>,
482) -> std::thread::JoinHandle<()> {
483 std::thread::spawn(move || {
484 let buf = BufReader::new(reader);
485 for line in buf.lines() {
486 let line = match line {
487 Ok(l) => l,
488 Err(_) => break,
489 };
490 // Tee en vivo al padre por stderr (sin bufferizar de más: el build puede tardar).
491 let _ = writeln!(std::io::stderr(), "{line}");
492 if let Ok(mut g) = tail.lock() {
493 push_tail(&mut g, line, TAIL_LINES);
494 }
495 }
496 })
497}
498
499/// Errores tempranos: comprueba que las rutas base existen antes de invocar el build.
500pub fn ensure_layout(rootfs: &Path, zig_dir: &Path) -> hammer_core::Result<()> {
501 // bin/sh en Alpine es un symlink absoluto a /bin/busybox, que desde el host no resuelve.
502 // Comprobamos el archivo regular real para no engañarnos.
503 if !rootfs.join("bin/busybox").is_file() {
504 return Err(hammer_core::Error::Other(anyhow::anyhow!(
505 "rootfs inválido: no encuentro {}/bin/busybox — ¿corriste el bootstrap de Alpine?",
506 rootfs.display()
507 )));
508 }
509 if !zig_dir.join("zig").exists() {
510 return Err(hammer_core::Error::Other(anyhow::anyhow!(
511 "zig no encontrado en {}/zig — ¿instalaste zig en .dev-fs/tools/zig?",
512 zig_dir.display()
513 )));
514 }
515 Ok(())
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 #[test]
523 fn push_tail_keeps_last_n() {
524 let mut buf = VecDeque::new();
525 for i in 0..10 {
526 push_tail(&mut buf, format!("line {i}"), 3);
527 }
528 let got: Vec<_> = buf.iter().cloned().collect();
529 assert_eq!(got, vec!["line 7", "line 8", "line 9"]);
530 }
531
532 #[test]
533 fn build_failure_roundtrips_through_core_error() {
534 // El contrato que usa el bus: envolver en Error::Other y recuperar por downcast.
535 let bf = BuildFailure {
536 reason: "exit 2".into(),
537 log_tail: Some("undefined reference to `foo'".into()),
538 };
539 let err = hammer_core::Error::Other(anyhow::Error::new(bf));
540 let recovered = BuildFailure::from_error(&err).expect("debe recuperarse");
541 assert_eq!(recovered.reason, "exit 2");
542 assert_eq!(
543 recovered.log_tail.as_deref(),
544 Some("undefined reference to `foo'")
545 );
546 }
547
548 #[test]
549 fn from_error_none_for_plain_errors() {
550 let err = hammer_core::Error::Recipe("nope".into());
551 assert!(BuildFailure::from_error(&err).is_none());
552 }
553
554 fn fixture(deps: Vec<PathBuf>) -> Sandbox {
555 Sandbox {
556 rootfs: PathBuf::from("/rootfs"),
557 zig_dir: PathBuf::from("/zig"),
558 src_dir: PathBuf::from("/src-host"),
559 out_dir: PathBuf::from("/out-host"),
560 cache_dir: None,
561 deps,
562 env: vec![],
563 }
564 }
565
566 #[test]
567 fn no_deps_emits_single_overlay_src() {
568 // Compat hacia atrás: sin deps, una sola capa overlay (el rootfs) — sandbox idéntico al previo.
569 let args = fixture(vec![]).bwrap_args("true");
570 let n = args.iter().filter(|a| *a == "--overlay-src").count();
571 assert_eq!(n, 1, "sin deps ⇒ un único --overlay-src");
572 // El --tmp-overlay viene inmediatamente tras el rootfs.
573 let i = args.iter().position(|a| a == "--overlay-src").unwrap();
574 assert_eq!(args[i + 1], "/rootfs");
575 assert_eq!(args[i + 2], "--tmp-overlay");
576 }
577
578 #[test]
579 fn deps_stack_as_overlay_layers_under_rootfs() {
580 let args = fixture(vec![PathBuf::from("/dep/libcap"), PathBuf::from("/dep/zlib")])
581 .bwrap_args("true");
582 // rootfs + 2 deps = 3 capas overlay, todas antes del --tmp-overlay.
583 let srcs: Vec<&String> = args
584 .iter()
585 .enumerate()
586 .filter(|(_, a)| *a == "--overlay-src")
587 .map(|(i, _)| &args[i + 1])
588 .collect();
589 assert_eq!(srcs, vec!["/rootfs", "/dep/libcap", "/dep/zlib"]);
590 let to = args.iter().position(|a| a == "--tmp-overlay").unwrap();
591 let last_src = args.iter().rposition(|a| a == "--overlay-src").unwrap();
592 assert!(last_src < to, "todas las capas overlay van antes del --tmp-overlay");
593 }
594}