> 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…
bootstrap: seed como toolchain del sandbox (Lote 3) + decisiones ADR 0008
SeedSpec::toolchain_dir resuelve el dir del compilador dentro de la semilla
sellada, listo para que el sandbox lo bindee como /opt/zig; seed_build_config
deriva una BuildConfig que cross-compila con el seed pinned, no con el zig del
host. Stage 0 sigue siendo ingesta pura: el layout se resuelve al usarlo
(zig en la raíz o en el hijo versionado), con error claro si falta o es ambiguo.
Decisiones del Lote 4 registradas en ADR 0008 / SDD 11:
- coreutils+shell = busybox (balsa desechable; GPLv2 no contamina el final)
- init arje diferido (Stage 1 con init mínimo provisional; CRASHED real luego)
- layout de semilla: ingesta pura + resolución al usar
+5 tests (layouts: raíz / hijo versionado / no-sellada / ambiguo / build_config).
Workspace verde.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
diff --git a/crates/hammer-bootstrap/src/lib.rs b/crates/hammer-bootstrap/src/lib.rs
index a2064b68..d3f8b994 100644
--- a/crates/hammer-bootstrap/src/lib.rs
+++ b/crates/hammer-bootstrap/src/lib.rs
@@ -14,7 +14,7 @@
pub mod manifest;
-use std::path::Path;
+use std::path::{Path, PathBuf};
use std::process::Command;
use hammer_build::download;
@@ -83,6 +83,70 @@ impl SeedSpec {
pub fn store_name(&self) -> String {
format!("seed-{}", self.kind.as_str())
}
+
+ /// Directorio del toolchain **dentro de la semilla sellada**, listo para que el sandbox lo
+ /// bindee como `/opt/zig`. Stage 0 sella el árbol del tarball tal cual (ingesta pura, sin
+ /// aplanar): este método lo resuelve al usarlo, localizando el dir que contiene el ejecutable
+ /// `zig` —en la raíz si el tarball lo trae plano, o en el único hijo si trae el típico
+ /// top-level versionado `zig-linux-x86_64-<ver>/`—. Falla si la semilla no está sellada o si
+ /// el ejecutable no aparece (o es ambiguo).
+ pub fn toolchain_dir(&self, store: &Store) -> Result<PathBuf> {
+ let base = store.path_of(&self.seed_hash(), &self.store_name());
+ if !base.is_dir() {
+ return Err(Error::Other(format!(
+ "semilla '{}' no sellada en {}; corre `hammer bootstrap stage0` primero",
+ self.kind.as_str(),
+ base.display()
+ )));
+ }
+ match self.kind {
+ SeedKind::Zig => locate_tool_dir(&base, "zig"),
+ SeedKind::MuslCrossMake => Err(Error::Other(
+ "toolchain_dir para musl-cross-make aún no implementado (escotilla diferida)".into(),
+ )),
+ }
+ }
+}
+
+/// Localiza, dentro de `base`, el directorio que contiene **directamente** el ejecutable `tool`.
+/// Mira la raíz primero y luego los hijos inmediatos; exige exactamente una coincidencia para no
+/// elegir a ciegas entre layouts ambiguos.
+fn locate_tool_dir(base: &Path, tool: &str) -> Result<PathBuf> {
+ if base.join(tool).is_file() {
+ return Ok(base.to_path_buf());
+ }
+ let mut hits = Vec::new();
+ for entry in std::fs::read_dir(base)? {
+ let path = entry?.path();
+ if path.is_dir() && path.join(tool).is_file() {
+ hits.push(path);
+ }
+ }
+ hits.sort();
+ match hits.len() {
+ 1 => Ok(hits.pop().unwrap()),
+ 0 => Err(Error::Other(format!(
+ "no encontré el ejecutable '{tool}' en la raíz ni en los hijos inmediatos de {}",
+ base.display()
+ ))),
+ _ => Err(Error::Other(format!(
+ "ambiguo: '{tool}' aparece en varios directorios de {}: {hits:?}",
+ base.display()
+ ))),
+ }
+}
+
+/// Deriva una [`hammer_build::BuildConfig`] desde `base` cuyo `zig_dir` apunta a la **semilla
+/// sellada** en vez del toolchain del host: así las recetas del Stage 1 cross-compilan con el
+/// toolchain fijado por hash (`seed_hash`), no con el `.dev-fs/tools/zig` de desarrollo. El resto
+/// de la config (rootfs, work, cache) se hereda intacto.
+pub fn seed_build_config(
+ base: &hammer_build::BuildConfig,
+ seed: &SeedSpec,
+ store: &Store,
+) -> Result<hammer_build::BuildConfig> {
+ let zig_dir = seed.toolchain_dir(store)?;
+ Ok(hammer_build::BuildConfig { zig_dir, ..base.clone() })
}
/// **Stage 0** — ingiere la semilla al store y devuelve su `ArtifactHash`.
@@ -309,4 +373,82 @@ mod tests {
"el staging debe quedar limpio tras el fallo"
);
}
+
+ /// Sella una semilla cuyo tarball coloca un `zig` ejecutable en `zig_rel` (relativo a la raíz
+ /// del árbol) y devuelve `(store, seed)` listos para resolver el toolchain.
+ fn seal_seed_with_zig_at(dir: &Path, zig_rel: &str) -> (Store, SeedSpec) {
+ let src = dir.join("src");
+ let zig = src.join(zig_rel);
+ std::fs::create_dir_all(zig.parent().unwrap()).unwrap();
+ std::fs::write(&zig, b"#!/bin/sh\necho fake zig\n").unwrap();
+ let tarball = dir.join("seed.tar.gz");
+ let st = Command::new("tar")
+ .args(["-czf"])
+ .arg(&tarball)
+ .arg("-C")
+ .arg(&src)
+ .arg(".")
+ .status()
+ .unwrap();
+ assert!(st.success());
+ let sha = sha256_hex(&std::fs::read(&tarball).unwrap());
+ let store = Store::open(dir.join("store")).unwrap();
+ let seed = spec(&format!("file://{}", tarball.display()), &sha);
+ stage0(&seed, &store).unwrap();
+ (store, seed)
+ }
+
+ #[test]
+ fn toolchain_dir_finds_zig_in_versioned_subdir() {
+ let tmp = tempfile::tempdir().unwrap();
+ let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig-linux-x86_64-0.13.0/zig");
+ let dir = seed.toolchain_dir(&store).unwrap();
+ assert!(dir.join("zig").is_file(), "el dir resuelto debe contener zig: {}", dir.display());
+ assert!(dir.ends_with("zig-linux-x86_64-0.13.0"), "debe elegir el hijo versionado: {}", dir.display());
+ }
+
+ #[test]
+ fn toolchain_dir_finds_zig_at_root() {
+ let tmp = tempfile::tempdir().unwrap();
+ let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig");
+ let dir = seed.toolchain_dir(&store).unwrap();
+ let base = store.path_of(&seed.seed_hash(), &seed.store_name());
+ assert_eq!(dir, base, "zig en la raíz ⇒ el dir es la raíz de la semilla");
+ }
+
+ #[test]
+ fn toolchain_dir_errors_when_unsealed() {
+ let tmp = tempfile::tempdir().unwrap();
+ let store = Store::open(tmp.path().join("store")).unwrap();
+ let seed = spec("file:///nope.tar", &"0".repeat(64)); // nunca sellada
+ let err = seed.toolchain_dir(&store).unwrap_err().to_string();
+ assert!(err.contains("no sellada"), "esperaba 'no sellada': {err}");
+ }
+
+ #[test]
+ fn toolchain_dir_errors_when_ambiguous() {
+ let tmp = tempfile::tempdir().unwrap();
+ // Dos hijos, cada uno con su `zig`: el resolutor no debe elegir a ciegas.
+ let (store, seed) = seal_seed_with_zig_at(tmp.path(), "a/zig");
+ let base = store.path_of(&seed.seed_hash(), &seed.store_name());
+ // El store sella read-only; relajamos permisos para inyectar el segundo `zig` del test.
+ let b = base.join("b");
+ let _ = std::process::Command::new("chmod").arg("-R").arg("u+w").arg(&base).status();
+ std::fs::create_dir_all(&b).unwrap();
+ std::fs::write(b.join("zig"), b"x").unwrap();
+ let err = seed.toolchain_dir(&store).unwrap_err().to_string();
+ assert!(err.contains("ambiguo"), "esperaba 'ambiguo': {err}");
+ }
+
+ #[test]
+ fn seed_build_config_points_zig_dir_at_seed() {
+ let tmp = tempfile::tempdir().unwrap();
+ let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig-linux-x86_64-0.13.0/zig");
+ let base = hammer_build::BuildConfig::defaults_for_store(store.root());
+ let cfg = seed_build_config(&base, &seed, &store).unwrap();
+ assert_eq!(cfg.zig_dir, seed.toolchain_dir(&store).unwrap());
+ // El resto de la config se hereda sin cambios.
+ assert_eq!(cfg.rootfs, base.rootfs);
+ assert_eq!(cfg.work_root, base.work_root);
+ }
}
diff --git a/docs/11-bootstrap.md b/docs/11-bootstrap.md
index 15f7c641..9272077e 100644
--- a/docs/11-bootstrap.md
+++ b/docs/11-bootstrap.md
@@ -137,14 +137,20 @@ hammer bootstrap stage2 --verify
hammer bootstrap --all # las tres + reporte de reproducibilidad # ☐
```
-## 6. Decisiones abiertas (fijadas en [ADR 0008](adr/0008-bootstrap-stages.md))
-
-- **Semilla primaria:** `zig` vs `musl-cross-make`. Inclinación: `zig` por hermeticidad
- (ADR 0003), con `musl-cross-make` como escotilla por receta.
-- **busybox vs toybox** para el userland mínimo (licencia vs cobertura de comandos).
-- **Particionado/montaje** de `/store` y `/var/lib/hammer` en la imagen destino (track posterior
- del roadmap).
-- **Kernel:** importado pinned ahora; from-source después.
+## 6. Decisiones (fijadas en [ADR 0008](adr/0008-bootstrap-stages.md))
+
+- **Semilla primaria:** `zig` por hermeticidad (ADR 0003), con `musl-cross-make` como escotilla
+ por receta. ✅
+- **coreutils+shell = `busybox`** (no toybox): el Stage 1 es una balsa desechable hacia el
+ userland nativo en Rust/Zig; se prioriza compatibilidad de flags y velocidad, y su GPLv2 no
+ contamina el sistema final. ✅
+- **init `arje` diferido:** Stage 1 con init mínimo provisional; `arje` (y el `CRASHED` real) entran
+ como receta git pinned en un lote posterior. ✅
+- **Layout de la semilla:** ingesta pura en Stage 0 + resolución del toolchain al usarlo
+ (`SeedSpec::toolchain_dir`, localiza `zig` en raíz o hijo versionado). ✅
+- **Particionado/montaje** de `/store` y `/var/lib/hammer` en la imagen destino: track posterior
+ del roadmap. ☐
+- **Kernel:** importado pinned ahora; from-source después. ☐
## 7. Hecho cuando
diff --git a/docs/adr/0008-bootstrap-stages.md b/docs/adr/0008-bootstrap-stages.md
index a3a4e15e..180e3312 100644
--- a/docs/adr/0008-bootstrap-stages.md
+++ b/docs/adr/0008-bootstrap-stages.md
@@ -19,8 +19,20 @@ mecanismo de build nuevo):
como una *fuente fijada por sha256*, y se sella en el store. El `ArtifactHash` se deriva de
la **identidad pinned** (clase + versión + sha256), no de rutas del host. **`zig` es la
semilla primaria**; `musl-cross-make` (gcc+musl) es escotilla detrás de la misma interfaz.
-1. **Stage 1 — userland mínimo:** cross-compilado con Stage 0 (musl, busybox/toybox, el init
- `arje` [ADR 0007](0007-arje-como-init-propio.md), `hammerd`), ensamblado en un rootfs sellado.
+1. **Stage 1 — userland mínimo:** cross-compilado con Stage 0 (musl, **busybox**, init, `hammerd`),
+ ensamblado en un rootfs sellado. Decisiones de implementación fijadas al abrir el Lote 4:
+ - **coreutils+shell = `busybox`** (no toybox): el Stage 1 es una *balsa desechable* —se sustituye
+ por el userland multi-call nativo en Rust/Zig más adelante—, así que se optimiza la velocidad
+ y la compatibilidad de flags POSIX en el pipeline (mismo comportamiento que el rootfs Alpine de
+ desarrollo). Su GPLv2 queda *atrapada* en una etapa temprana del manifiesto; no contamina el
+ sistema final mutable.
+ - **init `arje` diferido:** Stage 1 arranca con musl+busybox+`hammerd` y un init mínimo
+ provisional. `arje` ([ADR 0007](0007-arje-como-init-propio.md)) —y con él el `CRASHED` real
+ de la Fase 5— se integra como receta git pinned en un lote posterior, sin retrabajo.
+ - **layout de la semilla:** Stage 0 sella el árbol del tarball **tal cual** (ingesta pura, no
+ aplana); el directorio del toolchain se resuelve **al usarlo** localizando el ejecutable `zig`
+ en la raíz o en su único hijo versionado (`SeedSpec::toolchain_dir`). Stage 0 no adivina
+ layouts y el sandbox recibe un `zig_dir` correcto sea cual sea la forma del tarball.
2. **Stage 2 — rebuild nativo:** dentro del rootfs Stage 1, recompilar con las herramientas de
Stage 1 y comparar hashes (`stage1` vs `stage1'`). Iguales ⇒ auto-alojado y reproducible.