takana etapa 4: los 10 crates de librería y el CLI pasan a takana-*

hammer-{core,build,bootstrap,overlay,journal,mirror,upgrade,agent,recover,cli}
→ takana-*, con sus deps de workspace, sus identificadores en el fuente y las
referencias -p de los scripts.

VERIFICADO que no mueve nada del corpus: `takana hash recipes/zlib.toml`
devuelve b3:dc363f26… , idéntico a antes del renombre. Los nombres de crate no
entran en hash_inputs, pero eso se comprueba, no se supone. 600 tests en verde.

DOS BINARIOS SE CONGELAN, y no por prolijidad:

- `hammerd` — paquete Y binario. Es componente de Stage 1 de la distro (musl,
  busybox, hammerd, arje-zero), lo supervisa arje-zero en el sistema arrancado,
  `PRESEED=hammerd` lo nombra en selfhost-verify y sus bytes anclan el baseline
  of_tree. El nombre del crate va en los símbolos ⇒ renombrarlo mueve los bytes.

- `hammer-recover` — el PAQUETE se renombra a takana-recover, el BINARIO no.
  hammer-live-install.sh lo copia a /usr/sbin/hammer-recover en sistemas ya
  instalados y hornea un hook de arranque que lo invoca por ese nombre:
  renombrarlo rompe máquinas instaladas, no el repo.

Consecuencia que hay que anotar igual: al renombrar hammer-core, los bytes de
hammerd cambian de todos modos porque linkea contra un crate con otro nombre.
El baseline of_tree del selfhost hay que rehacerlo — es efecto de la etapa 4,
no de un cambio de hammerd.

Las referencias en comentarios de recetas y docs (rutas hammer-core/src/…)
quedan para la etapa 5: son texto, no mueven hash.
This commit is contained in:
Sergio
2026-09-09 18:46:41 +00:00
parent d47cafa05d
commit 24bcf1783c
105 changed files with 943 additions and 940 deletions
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "takana-build"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
description = "El laboratorio de hammer: sandbox de build (bubblewrap + zig cc) e hidratación."
[dependencies]
takana-core.workspace = true
anyhow.workspace = true
thiserror.workspace = true
tracing.workspace = true
sha2.workspace = true
hex.workspace = true
serde_json.workspace = true
# harkaq (SDD 16) necesita SIGTERM para despertar al lector: Child::kill() manda SIGKILL y el
# lector moriría mudo, sin emitir el Verdict — justo lo que no queremos de un componente cuyo
# producto ES el veredicto.
libc = "0.2"
[dev-dependencies]
tempfile.workspace = true
tracing-subscriber = { workspace = true }
+181
View File
@@ -0,0 +1,181 @@
//! Configuración del lab: paths del rootfs base, del compilador inyectado y del work dir.
//!
//! La idea es que `BuildConfig` se construye una vez (en la CLI desde env vars + defaults) y
//! se pasa explícitamente a `build`, de modo que los tests pueden montar configuraciones
//! aisladas sin tocar variables de entorno globales.
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct BuildConfig {
/// Rootfs base read-only del sandbox (Alpine para la fase actual del roadmap).
pub rootfs: PathBuf,
/// Directorio con el binario `zig` y su `lib/`.
pub zig_dir: PathBuf,
/// Raíz para directorios de trabajo (repos clonados, copias del source para
/// patch+compile, y `out` por build). Debe estar en el mismo filesystem que el store
/// para que el `seal` final sea un rename atómico.
pub work_root: PathBuf,
/// Directorio host persistente que se bindea como `/cache` en el sandbox; alberga la
/// caché global de `zig` (objetos de musl, libc, etc.) entre builds. `None` desactiva.
pub cache_root: Option<PathBuf>,
}
/// Nombre del directorio del lab, buscado hacia arriba como hace git con `.git`.
const LAB_DIR: &str = ".dev-fs";
/// Busca el lab (`.dev-fs`) desde `start` hacia arriba. Pura y testeable: no mira env ni CWD.
fn find_lab_upward(start: &Path) -> Option<PathBuf> {
start.ancestors().map(|d| d.join(LAB_DIR)).find(|c| c.is_dir())
}
/// Resuelve dónde vive el lab SIN atarlo a dónde vive el store.
///
/// Orden, de más explícito a más adivinado:
/// 1. `HAMMER_LAB` — el operador manda.
/// 2. Hermano del store (`<store>/../.dev-fs`) **si existe**. Preserva exactamente el
/// comportamiento histórico en la topología normal (lab y store en la raíz del proyecto).
/// 3. Hacia arriba desde el CWD. Ésta es la que desacopla: cubre el caso en que el store fue
/// movido a otro filesystem (volumen persistente) y el lab se quedó en el proyecto.
/// 4. `None` ⇒ el llamador cae al default histórico, para que los mensajes de error sigan
/// apuntando a un lugar previsible en vez de a la nada.
fn resolve_lab_dir(store_root: &Path) -> Option<PathBuf> {
if let Some(v) = std::env::var_os("HAMMER_LAB") {
return Some(PathBuf::from(v));
}
if let Some(parent) = store_root.parent() {
let sibling = parent.join(LAB_DIR);
if sibling.is_dir() {
return Some(sibling);
}
}
find_lab_upward(&std::env::current_dir().ok()?)
}
impl BuildConfig {
/// Defaults relativos al store: `.dev-fs` y `work/` como hermanos del store.
/// `store_root = /…/store` → rootfs = `/…/.dev-fs/alpine`, zig = `/…/.dev-fs/tools/zig`,
/// work = `/…/work`, cache = `/…/.dev-fs/cache`.
///
/// PURA a propósito (no mira env ni CWD): los tests montan configs herméticas con esto. Quien
/// quiera la resolución real del entorno usa [`Self::from_env_or_defaults`].
pub fn defaults_for_store(store_root: &Path) -> Self {
let project_root = store_root
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
Self::defaults_for_store_with_lab(store_root, &project_root.join(LAB_DIR))
}
/// Igual que [`Self::defaults_for_store`] pero con el lab (`.dev-fs`) puesto a mano.
///
/// POR QUÉ EXISTE (2026-07-22): atar el lab al PADRE DEL STORE resultó ser una suposición
/// oculta y cara. Al anclar el store de un worker a un volumen persistente (symlink a
/// `/mnt/cosecha/store`), el lab pasó a buscarse en `/mnt/cosecha/.dev-fs` — que no existe — y
/// TODA receta con `zig_version` murió con "no encuentro el ejecutable zig en …", un mensaje
/// que apunta al lugar equivocado (el zig estaba, y estaba bien). Dos campañas leyeron eso como
/// deuda de la cascada GUI. Son dos decisiones independientes: dónde se GUARDA lo sellado
/// (almacenamiento) y dónde vive el toolchain de desarrollo (entorno).
///
/// `work_root` SÍ sigue al store, y es deliberado: el `seal` final es un rename, que sólo es
/// atómico dentro del mismo filesystem. Ése es un acoplamiento real, no una suposición.
pub fn defaults_for_store_with_lab(store_root: &Path, lab: &Path) -> Self {
let store_parent = store_root
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
Self {
rootfs: lab.join("alpine"),
zig_dir: lab.join("tools/zig"),
work_root: store_parent.join("work"),
cache_root: Some(lab.join("cache")),
}
}
/// Lee overrides desde entorno (`HAMMER_LAB`, `HAMMER_ROOTFS`, `HAMMER_ZIG`, `HAMMER_WORK`,
/// `HAMMER_CACHE`). `HAMMER_CACHE=""` (vacío) desactiva la caché.
///
/// Es el constructor que HUELE EL ENTORNO (env + CWD); `defaults_for_store` se mantiene pura.
/// `HAMMER_LAB` mueve el lab entero de una; los `HAMMER_ROOTFS`/`HAMMER_ZIG`/`HAMMER_CACHE`
/// siguen pisando pieza por pieza y ganan sobre él.
pub fn from_env_or_defaults(store_root: &Path) -> Self {
let mut cfg = match resolve_lab_dir(store_root) {
Some(lab) => Self::defaults_for_store_with_lab(store_root, &lab),
None => Self::defaults_for_store(store_root),
};
if let Ok(v) = std::env::var("HAMMER_ROOTFS") {
cfg.rootfs = PathBuf::from(v);
}
if let Ok(v) = std::env::var("HAMMER_ZIG") {
cfg.zig_dir = PathBuf::from(v);
}
if let Ok(v) = std::env::var("HAMMER_WORK") {
cfg.work_root = PathBuf::from(v);
}
if let Ok(v) = std::env::var("HAMMER_CACHE") {
cfg.cache_root = if v.is_empty() { None } else { Some(PathBuf::from(v)) };
}
cfg
}
}
#[cfg(test)]
mod tests {
use super::*;
/// La topología normal (lab y store hermanos en la raíz) no se mueve.
#[test]
fn lab_hermano_del_store_sigue_igual() {
let cfg = BuildConfig::defaults_for_store(Path::new("/proj/store"));
assert_eq!(cfg.rootfs, Path::new("/proj/.dev-fs/alpine"));
assert_eq!(cfg.zig_dir, Path::new("/proj/.dev-fs/tools/zig"));
assert_eq!(cfg.cache_root.unwrap(), Path::new("/proj/.dev-fs/cache"));
assert_eq!(cfg.work_root, Path::new("/proj/work"));
}
/// LA REGRESIÓN: store mudado a un volumen, lab quieto en el proyecto. El lab debe seguir al
/// proyecto y NO al store; `work` debe seguir al STORE (mismo filesystem ⇒ seal atómico).
#[test]
fn lab_y_store_pueden_vivir_en_filesystems_distintos() {
let cfg = BuildConfig::defaults_for_store_with_lab(
Path::new("/mnt/cosecha/store"),
Path::new("/opt/hammer/.dev-fs"),
);
assert_eq!(cfg.zig_dir, Path::new("/opt/hammer/.dev-fs/tools/zig"));
assert_eq!(cfg.rootfs, Path::new("/opt/hammer/.dev-fs/alpine"));
assert_eq!(cfg.cache_root.unwrap(), Path::new("/opt/hammer/.dev-fs/cache"));
assert_eq!(
cfg.work_root,
Path::new("/mnt/cosecha/work"),
"work DEBE seguir al store: el seal es un rename y sólo es atómico en el mismo FS"
);
}
#[test]
fn find_lab_upward_encuentra_desde_un_subdirectorio() {
let d = tempfile::tempdir().unwrap();
let raiz = d.path().join("proj");
std::fs::create_dir_all(raiz.join(LAB_DIR)).unwrap();
let hondo = raiz.join("a/b/c");
std::fs::create_dir_all(&hondo).unwrap();
assert_eq!(find_lab_upward(&hondo), Some(raiz.join(LAB_DIR)));
}
#[test]
fn find_lab_upward_no_inventa_lo_que_no_existe() {
let d = tempfile::tempdir().unwrap();
let hondo = d.path().join("sin/lab/alguno");
std::fs::create_dir_all(&hondo).unwrap();
assert_eq!(find_lab_upward(&hondo), None);
}
/// Un fichero llamado `.dev-fs` no es un lab: la búsqueda exige directorio.
#[test]
fn find_lab_upward_ignora_un_fichero_homonimo() {
let d = tempfile::tempdir().unwrap();
let raiz = d.path().join("proj");
std::fs::create_dir_all(&raiz).unwrap();
std::fs::write(raiz.join(LAB_DIR), b"no soy un lab").unwrap();
assert_eq!(find_lab_upward(&raiz), None);
}
}
+96
View File
@@ -0,0 +1,96 @@
//! Descarga remota para `.swm`: `patch_url` (source_patch) y `content_url` (file_drop).
//! Ver `docs/06-swm-format.md` §2. La red sólo se toca aquí y en `fetch` — nunca dentro del
//! sandbox de build.
//!
//! Usa `curl` (ya presente en el rootfs del lab). Acepta cualquier esquema que curl entienda;
//! en tests usamos `file://` para no depender de la red. La integridad la garantiza el
//! llamador: el `file_drop` verifica BLAKE3 (`content_hash`) tras descargar; el `patch_url`
//! se cubre indirectamente por el build reproducible + `expected_hash` del artefacto.
use std::path::Path;
use std::process::Command;
/// Descarga `url` y devuelve su contenido en memoria. Falla con el stderr de curl si el
/// código de salida no es 0 (incluye errores HTTP gracias a `-f`).
pub fn fetch_url_bytes(url: &str) -> takana_core::Result<Vec<u8>> {
let out = Command::new("curl")
.args([
"-fsSL",
"--retry",
"2",
"--connect-timeout",
"30",
"--max-time",
"300",
url,
])
.output()
.map_err(|e| takana_core::Error::Other(anyhow::anyhow!("spawn curl: {e}")))?;
if !out.status.success() {
return Err(takana_core::Error::Other(anyhow::anyhow!(
"curl {url} falló (exit {}): {}",
out.status.code().map(|c| c.to_string()).unwrap_or_else(|| "?".into()),
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(out.stdout)
}
/// Descarga `url` y lo escribe en `dst` (creando los directorios padre).
pub fn fetch_url_to_file(url: &str, dst: &Path) -> takana_core::Result<()> {
let bytes = fetch_url_bytes(url)?;
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(dst, &bytes)?;
Ok(())
}
/// Verifica que el sha256 de `path` coincide con `expected` (hex, case-insensitive). Lo usan
/// los consumidores que ingieren una fuente fijada (p. ej. el toolchain semilla del bootstrap,
/// [SDD 11](../../docs/11-bootstrap.md)) y necesitan comprobar integridad **antes** de sellar.
pub fn verify_sha256(path: &Path, expected: &str) -> takana_core::Result<()> {
use sha2::{Digest, Sha256};
let mut f = std::fs::File::open(path)?;
let mut hasher = Sha256::new();
std::io::copy(&mut f, &mut hasher)?;
let got = hex::encode(hasher.finalize());
if !got.eq_ignore_ascii_case(expected) {
return Err(takana_core::Error::Other(anyhow::anyhow!(
"sha256 mismatch en {}: esperado {expected}, obtenido {got}",
path.display()
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fetch_file_url_roundtrip() {
let d = tempfile::tempdir().unwrap();
let src = d.path().join("payload.txt");
std::fs::write(&src, b"contenido remoto\n").unwrap();
let url = format!("file://{}", src.display());
let got = fetch_url_bytes(&url).unwrap();
assert_eq!(got, b"contenido remoto\n");
}
#[test]
fn fetch_to_file_writes_and_creates_parents() {
let d = tempfile::tempdir().unwrap();
let src = d.path().join("p.txt");
std::fs::write(&src, b"abc").unwrap();
let dst = d.path().join("nested/dir/out.txt");
fetch_url_to_file(&format!("file://{}", src.display()), &dst).unwrap();
assert_eq!(std::fs::read(&dst).unwrap(), b"abc");
}
#[test]
fn missing_url_is_error() {
let err = fetch_url_bytes("file:///no/existe/aqui.bin").unwrap_err().to_string();
assert!(err.contains("curl"), "{err}");
}
}
File diff suppressed because it is too large Load Diff
+304
View File
@@ -0,0 +1,304 @@
//! harkaq — la jaula del build (SDD 16). Integración con el sandbox de bwrap.
//!
//! Cablea las tres piezas de `scripts/harkaq/` al `Sandbox` real: deriva la política de la
//! clausura declarada (D1), lanza el lector de evidencia, mete `harkaq-exec` como último eslabón
//! antes del `sh -c` del build, y recoge el `Verdict`.
//!
//! **Inerte por defecto.** Sin `HARKAQ=1` no se emite un solo argumento distinto de bwrap, no se
//! toca el entorno y no se lanza ningún proceso: el sandbox es byte-por-byte el de siempre. Es un
//! requisito duro, no una cortesía — hay 700+ artefactos sellados cuyo hash no puede moverse por
//! encender un diagnóstico (ver el patrón de `cargo-recipe-ar-wrapper`: nada que re-hashee).
//!
//! Variables:
//! HARKAQ=1 enciende
//! HARKAQ_BIN=DIR dir con harkaq-exec y harkaq-audit (default ~/.cache/harkaq)
//! HARKAQ_BASE=F fichero con el runtime base, generado por `harkaq-base-closure.py`
//! (§4.3: el runtime base es POR ROOTFS y se deriva; acá se consume ya hecho)
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
/// Config de harkaq para un build. `from_env` devuelve `None` salvo que `HARKAQ=1`.
#[derive(Debug, Clone)]
pub struct Harkaq {
pub bin: PathBuf,
pub base: Option<PathBuf>,
}
/// Lo que viaja de vuelta tras una fase enjaulada: el fichero del veredicto crudo y el canario
/// con el que se lo interpreta.
#[derive(Debug)]
pub struct Corrida {
pub policy: PathBuf,
pub canary: String,
pub canary_src: PathBuf,
verdict: PathBuf,
reader: Child,
}
impl Harkaq {
pub fn from_env() -> Option<Self> {
if std::env::var("HARKAQ").ok().as_deref() != Some("1") {
return None;
}
let bin = std::env::var("HARKAQ_BIN")
.map(PathBuf::from)
.unwrap_or_else(|_| {
PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/root".into()))
.join(".cache/harkaq")
});
Some(Harkaq {
bin,
base: std::env::var("HARKAQ_BASE").ok().map(PathBuf::from),
})
}
pub fn exec_bin(&self) -> PathBuf {
self.bin.join("harkaq-exec")
}
/// Deriva la política de la clausura declarada (D1: no se escribe, se deriva).
///
/// Grano POR FICHERO, no por directorio: el sandbox funde las deps y el rootfs Alpine en el
/// mismo `/usr` (`--overlay-src` × N), así que `/usr/include/zlib.h` (dep declarada) y
/// `/usr/include/stdio.h` (Alpine) son el mismo directorio y una regla sobre `/usr` concedería
/// las dos — la evidencia dejaría de valer (SDD §4.1). En el host sí sabemos qué aporta cada
/// dep, así que la clausura se enumera fichero a fichero.
fn derivar_politica(
&self,
deps: &[PathBuf],
contratos: &[String],
dst: &Path,
) -> takana_core::Result<()> {
let mut ro: Vec<String> = Vec::new();
let mut dirs: std::collections::BTreeSet<String> = Default::default();
for dep in deps {
let real = std::fs::canonicalize(dep).map_err(|e| {
takana_core::Error::Other(anyhow::anyhow!("canonicalize {}: {e}", dep.display()))
})?;
recorrer(&real, &real, &mut ro, &mut dirs)?;
}
let mut f = std::fs::File::create(dst).map_err(|e| {
takana_core::Error::Other(anyhow::anyhow!("crear política {}: {e}", dst.display()))
})?;
writeln!(f, "# harkaq: clausura derivada de {} dep(s)", deps.len()).ok();
// Superficies de CONTRATO del sandbox, no de la receta: las monta bwrap, no salen de
// Alpine, así que no son deuda (SDD §4.2). Las decide el `Sandbox` —que es quien sabe
// cuáles montó— y no harkaq: `/cache` sólo existe si hay `cache_dir`, y `harkaq-exec`
// falla duro ante un path inexistente (a propósito: una entrada que no existe es un bug
// de la política, y tragárselo la haría más chica que la clausura ⇒ falsos positivos).
writeln!(f, "# superficies de contrato del sandbox").ok();
for l in contratos {
writeln!(f, "{l}").ok();
}
if let Some(base) = &self.base {
let txt = std::fs::read_to_string(base).map_err(|e| {
takana_core::Error::Other(anyhow::anyhow!("leer HARKAQ_BASE {}: {e}", base.display()))
})?;
writeln!(f, "# runtime base ({})", base.display()).ok();
f.write_all(txt.as_bytes()).ok();
}
// `list` de los ancestros de cada fichero de la clausura: pkgconf y los tests de
// configure NECESITAN escanear (p.ej. /usr/lib/pkgconfig), pero listar no es leer.
// Landlock separa READ_DIR de READ_FILE ⇒ se concede el listado y se sigue denegando la
// lectura de lo no declarado (§4.1).
writeln!(f, "# dirs de la clausura (listar, no leer)").ok();
for d in &dirs {
writeln!(f, "list {d}").ok();
}
writeln!(f, "# clausura ({} ficheros)", ro.len()).ok();
ro.sort();
ro.dedup();
for r in &ro {
writeln!(f, "ro {r}").ok();
}
Ok(())
}
/// Arranca el lector y deja todo listo para la fase. El lector se lanza ANTES del build y no
/// se le da salida hasta que confirma que escucha: si el build arrancara antes, sus primeras
/// denegaciones —el canario entre ellas— se emitirían sin nadie al otro lado y el veredicto
/// sería `SinEvidencia` por una carrera, no por un problema real.
pub fn preparar(
&self,
deps: &[PathBuf],
contratos: &[String],
id: &str,
) -> takana_core::Result<Corrida> {
let tmp = std::env::temp_dir().join(format!("harkaq-{id}-{}", std::process::id()));
std::fs::create_dir_all(&tmp).ok();
let policy = tmp.join("policy");
self.derivar_politica(deps, contratos, &policy)?;
// El canario va en la RAÍZ, no en /tmp: la política concede `rw /tmp` (es el HOME del
// build), así que un canario ahí caería DENTRO de la clausura, se leería sin problema y
// no denunciaría nada ⇒ SinEvidencia eterno. Tiene que vivir donde la política no
// alcanza, por construcción. La raíz del sandbox es escribible (`--tmp-overlay /`).
let canary = format!("/harkaq-canary-{id}-{}", std::process::id());
let canary_src = tmp.join("canary");
std::fs::write(&canary_src, b"harkaq\n").ok();
let verdict = tmp.join("verdict.json");
let vf = std::fs::File::create(&verdict).map_err(|e| {
takana_core::Error::Other(anyhow::anyhow!("crear verdict: {e}"))
})?;
let reader = Command::new(self.bin.join("harkaq-audit"))
.arg("--canary")
.arg(&canary)
.arg("--timeout")
.arg(std::env::var("HARKAQ_TIMEOUT").unwrap_or_else(|_| "1800".into()))
.stdout(Stdio::from(vf))
.stderr(Stdio::inherit())
.spawn()
.map_err(|e| {
takana_core::Error::Other(anyhow::anyhow!(
"spawn harkaq-audit ({}): {e} — ¿compilado y con `setcap cap_audit_read`?",
self.bin.display()
))
})?;
// El lector necesita estar en el multicast antes de que el build empiece. `--ready-fd` es
// la vía limpia; acá basta una pausa corta porque el bwrap que sigue tarda mucho más en
// levantar los namespaces que el lector en bindear.
std::thread::sleep(std::time::Duration::from_millis(300));
Ok(Corrida {
policy,
canary,
canary_src,
verdict,
reader,
})
}
/// Cierra la corrida: despierta al lector y devuelve el veredicto crudo (JSON).
pub fn recoger(&self, mut c: Corrida) -> String {
// Gracia para que llegue el `deallocated` del dominio (el kernel lo emite con retraso,
// vía RCU/workqueue) antes de despertar al lector.
std::thread::sleep(std::time::Duration::from_millis(1500));
unsafe {
libc::kill(c.reader.id() as i32, libc::SIGTERM);
}
let _ = c.reader.wait();
std::fs::read_to_string(&c.verdict).unwrap_or_else(|e| {
format!("{{\"estado\":\"SinEvidencia\",\"motivo\":\"no pude leer el verdict: {e}\"}}")
})
}
}
/// Recorre el árbol de una dep y traduce cada fichero a su ruta DENTRO del sandbox. La
/// traducción es sólo quitar el prefijo del store: el resto del path ya es el del sandbox,
/// porque `--overlay-src <dep>` funde `<dep>/usr/...` en `/usr/...`.
fn recorrer(
raiz: &Path,
dir: &Path,
ro: &mut Vec<String>,
dirs: &mut std::collections::BTreeSet<String>,
) -> takana_core::Result<()> {
let rd = match std::fs::read_dir(dir) {
Ok(r) => r,
Err(_) => return Ok(()),
};
for e in rd.flatten() {
let p = e.path();
// `.hammer/` es metadata del artefacto (recipe.toml), no parte de lo que el build
// consume: si el build lo leyera, esa denegación es legítima y la queremos ver.
if p.file_name().is_some_and(|n| n == ".hammer") {
continue;
}
let ft = match e.file_type() {
Ok(t) => t,
Err(_) => continue,
};
if ft.is_dir() {
recorrer(raiz, &p, ro, dirs)?;
} else if let Ok(rel) = p.strip_prefix(raiz) {
let dentro = format!("/{}", rel.display());
if let Some(d) = Path::new(&dentro).parent() {
let mut d = d.to_path_buf();
while d.as_os_str() != "/" && !d.as_os_str().is_empty() {
dirs.insert(d.display().to_string());
d = match d.parent() {
Some(p) => p.to_path_buf(),
None => break,
};
}
}
ro.push(dentro);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// Arma un artefacto de store de mentira: `<tmp>/<hash>-zlib/usr/include/zlib.h`, etc.
fn dep_falsa(nombre: &str) -> PathBuf {
let raiz = std::env::temp_dir().join(format!("harkaq-t-{}-{nombre}", std::process::id()));
let _ = std::fs::remove_dir_all(&raiz);
for d in ["usr/include", "usr/lib/pkgconfig", ".hammer"] {
std::fs::create_dir_all(raiz.join(d)).unwrap();
}
std::fs::write(raiz.join("usr/include/zlib.h"), b"h").unwrap();
std::fs::write(raiz.join("usr/lib/libz.a"), b"a").unwrap();
std::fs::write(raiz.join("usr/lib/pkgconfig/zlib.pc"), b"p").unwrap();
std::fs::write(raiz.join(".hammer/recipe.toml"), b"r").unwrap();
raiz
}
#[test]
fn la_clausura_traduce_del_store_al_sandbox() {
// El corazón de D1. `--overlay-src <dep>` funde `<dep>/usr/...` en `/usr/...`, así que
// traducir es quitar el prefijo del store. Puro: se testea sin kernel, en cualquier
// máquina, que es justamente lo que el SDD §4 exige de harkaq-policy.
let dep = dep_falsa("clausura");
let (mut ro, mut dirs) = (Vec::new(), Default::default());
recorrer(&dep, &dep, &mut ro, &mut dirs).unwrap();
ro.sort();
assert_eq!(
ro,
vec!["/usr/include/zlib.h", "/usr/lib/libz.a", "/usr/lib/pkgconfig/zlib.pc"]
);
let _ = std::fs::remove_dir_all(&dep);
}
#[test]
fn la_metadata_del_artefacto_no_es_clausura() {
// `.hammer/recipe.toml` es metadata, no algo que el build consuma. Si el build lo
// leyera, esa denegación es legítima y la queremos VER, no conceder.
let dep = dep_falsa("metadata");
let (mut ro, mut dirs) = (Vec::new(), Default::default());
recorrer(&dep, &dep, &mut ro, &mut dirs).unwrap();
assert!(!ro.iter().any(|r| r.contains(".hammer")), "ro={ro:?}");
let _ = std::fs::remove_dir_all(&dep);
}
#[test]
fn cada_fichero_deja_listables_sus_ancestros() {
// Sin `list` en los ancestros, un `ls /usr/lib/pkgconfig` de pkgconf sería un falso
// positivo. Listar no es leer: Landlock separa READ_DIR de READ_FILE (§4.1).
let dep = dep_falsa("dirs");
let (mut ro, mut dirs) = (Vec::new(), Default::default());
recorrer(&dep, &dep, &mut ro, &mut dirs).unwrap();
for d in ["/usr", "/usr/lib", "/usr/lib/pkgconfig", "/usr/include"] {
assert!(dirs.contains(d), "falta `list {d}` en {dirs:?}");
}
let _ = std::fs::remove_dir_all(&dep);
}
#[test]
fn apagado_por_defecto() {
// Requisito duro: sin HARKAQ=1 no se enciende nada. 700+ artefactos sellados dependen de
// que el sandbox no cambie por añadir un diagnóstico.
if std::env::var("HARKAQ").is_err() {
assert!(Harkaq::from_env().is_none());
}
}
}
+479
View File
@@ -0,0 +1,479 @@
//! Hidratación: del store al FHS. Ver `docs/03-hydration.md`.
//!
//! Estrategia primaria (`LinkMode::Static`): hardlink directo. Cero copia, cero RPATH que
//! reescribir. El FHS queda con archivos reales (no symlinks) compartiendo inode con el store.
//! Pisar un archivo del FHS rompe el hardlink (CoW al kernel) sin tocar el artefacto del store
//! — base de rollback (`hammer hydrate <hash>` lo restaura).
//!
//! Estrategia secundaria (`LinkMode::Dynamic`): los ELF que necesitan normalización de
//! interpreter/RPATH se **copian** (no se hardlinkean: `patchelf` reescribe el binario y un
//! hardlink mutaría el inode del store) y se parchean con `patchelf` según el [`DynamicSpec`].
//! Los archivos no-ELF (datos, scripts, libs ya correctas) siguen hardlinkeándose como en
//! estático. Ver `docs/03-hydration.md` §4.
use std::os::unix::fs::FileTypeExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use takana_core::LinkMode;
#[derive(Debug, Clone)]
pub struct HydratedFile {
pub src: PathBuf,
pub dst: PathBuf,
}
#[derive(Debug, Default)]
pub struct HydrateReport {
pub files: Vec<HydratedFile>,
pub dirs_created: usize,
pub symlinks: usize,
/// Cuántos ELF se copiaron+parchearon con patchelf (sólo en modo dinámico).
pub patched: usize,
}
/// Parámetros de la hidratación dinámica: qué fijarle a cada ELF antes de proyectarlo. Ambos
/// son opcionales; si los dos son `None`, el modo dinámico se comporta como el estático
/// (nada que parchear ⇒ hardlink directo). En producción salen del contexto de la distro
/// (loader musl + layout de `/usr/lib`).
#[derive(Debug, Clone, Default)]
pub struct DynamicSpec {
/// Interpreter (dynamic loader) a fijar, p. ej. `/lib/ld-musl-x86_64.so.1`.
pub interpreter: Option<String>,
/// RPATH a fijar, p. ej. `/usr/lib:/lib`.
pub rpath: Option<String>,
}
impl DynamicSpec {
/// `true` si hay algo que patchelf debería reescribir.
fn has_work(&self) -> bool {
self.interpreter.is_some() || self.rpath.is_some()
}
}
/// Proyecta el contenido de `artifact_dir` al árbol bajo `target_fhs`. Conserva la jerarquía
/// relativa: `<artifact>/usr/bin/grep` → `<target>/usr/bin/grep`.
///
/// Sobre archivos preexistentes en el target, sobrescribe atómicamente (link tmp + rename) —
/// los archivos antiguos se descartan, pero los inodes del store siguen intactos.
pub fn hydrate(
artifact_dir: &Path,
target_fhs: &Path,
mode: LinkMode,
dynamic: Option<&DynamicSpec>,
) -> takana_core::Result<HydrateReport> {
// `is_dir()` a secas acepta un directorio VACÍO: hydrate proyectaría 0 ficheros y devolvería
// un HydrateReport de éxito. Es el mismo agujero que ya se tapó en `Store::has` (2026-08-10) y
// en `Store::seal` (2026-08-29): un vacío no es un artefacto, es un nombre. Acá duele especial
// porque el escritorio se hidrata así (`hammer hydrate <hash> --into`), y un FHS proyectado
// desde nada sale "OK" y falla mucho más tarde, sin rastro de cuál artefacto faltaba.
let vacio = std::fs::read_dir(artifact_dir)
.map(|mut e| e.next().is_none())
.unwrap_or(true);
if !artifact_dir.is_dir() || vacio {
return Err(takana_core::Error::Store(format!(
"artefacto inexistente o VACÍO: {}",
artifact_dir.display()
)));
}
// En modo dinámico con patcheo real, exigimos que `patchelf` exista de antemano: mejor
// fallar limpio que dejar el FHS a medio proyectar.
let spec = match mode {
LinkMode::Dynamic => {
let spec = dynamic.cloned().unwrap_or_default();
if spec.has_work() {
ensure_patchelf()?;
}
Some(spec)
}
LinkMode::Static => None,
};
std::fs::create_dir_all(target_fhs)?;
// La raíz REAL contra la que se mide la contención (ver `no_escapa`). Se resuelve una sola vez:
// si el propio `target_fhs` es un symlink, eso es elección de quien hidrata y vale como raíz.
let raiz = target_fhs.canonicalize().map_err(|e| {
takana_core::Error::Store(format!("canonicalize {}: {e}", target_fhs.display()))
})?;
let mut report = HydrateReport::default();
walk_and_link(artifact_dir, artifact_dir, target_fhs, &raiz, spec.as_ref(), &mut report)?;
Ok(report)
}
/// El destino no puede salirse de la raíz. Se comprueba **por directorio**, no por fichero.
///
/// EL FALLO QUE TAPA (medido 2026-08-30, `tests/hidratacion_no_escapa.rs`): hidratar es
/// `target_fhs.join(rel)` y escribir por ruta, así que **un symlink de directorio ya presente en el
/// FHS se sigue**. Un artefacto que trae `usr/share/pkg → /algún/lado` deja ese symlink puesto (se
/// replican literales, y así debe ser), y la hidratación SIGUIENTE escribe `usr/share/pkg/archivo`
/// **fuera del root** — devolviendo `Ok`. Con `--into` sobre una imagen, eso es escribir en el
/// sistema anfitrión diciendo que todo fue bien: exactamente el fallo de CLAUDE.md §3.
///
/// Hoy el corpus NO lo dispara —de 160 symlinks absolutos ninguno apunta a un directorio, y de
/// ~42 000 relativos ninguno sale de su artefacto— pero nada lo impedía.
///
/// Semántica elegida: symlinks **permitidos mientras no salgan** (un `/lib → usr/lib` de usr-merge,
/// o los `22@2x → 22` de los temas de iconos, tienen que seguir funcionando). Es la de
/// `openat2(RESOLVE_IN_ROOT)`; acá se hace con `canonicalize` porque las escrituras siguen siendo
/// por ruta (`hard_link`, `rename`, `patchelf`). La diferencia que queda: `canonicalize`+escribir
/// es comprobar-y-después-usar, o sea que un adversario CONCURRENTE podría cambiar el componente
/// entre las dos. Para el fallo real —un symlink persistente puesto por otro artefacto— alcanza y
/// es determinista; la versión sin carrera es mover las escrituras a `openat2` + `linkat`/`renameat`
/// (SDD 25 §7-H3), y eso arrastra el camino de `patchelf`.
fn no_escapa(raiz: &Path, dst: &Path) -> takana_core::Result<()> {
let real = dst.canonicalize().map_err(|e| {
takana_core::Error::Store(format!("canonicalize {}: {e}", dst.display()))
})?;
if !real.starts_with(raiz) {
return Err(takana_core::Error::Store(format!(
"la hidratación se saldría del root: {} lleva a {}, fuera de {}.\n\
Es un symlink de directorio ya presente en el FHS (probablemente puesto por otro \
artefacto). No se escribe a través de él: borralo o hidratá sobre un root limpio.",
dst.display(),
real.display(),
raiz.display()
)));
}
Ok(())
}
fn walk_and_link(
root: &Path,
cur: &Path,
target_fhs: &Path,
raiz: &Path,
spec: Option<&DynamicSpec>,
report: &mut HydrateReport,
) -> takana_core::Result<()> {
for entry in std::fs::read_dir(cur)? {
let entry = entry?;
let src = entry.path();
let rel = src.strip_prefix(root).expect("rel under root");
let dst = target_fhs.join(rel);
let ft = entry.file_type()?;
if ft.is_dir() {
std::fs::create_dir_all(&dst)?;
// Acá y no en cada fichero: los ficheros cuelgan de un directorio ya comprobado, y
// `canonicalize` por fichero costaría un realpath por entrada (SDD 25 T8/§9.4).
no_escapa(raiz, &dst)?;
report.dirs_created += 1;
walk_and_link(root, &src, target_fhs, raiz, spec, report)?;
} else if ft.is_symlink() {
// Replicamos el symlink con el mismo target literal (no resolvemos).
let link_target = std::fs::read_link(&src)?;
atomic_replace_symlink(&link_target, &dst)?;
report.symlinks += 1;
report.files.push(HydratedFile { src, dst });
} else if ft.is_file() {
// Modo dinámico: un ELF que necesita interpreter/RPATH se copia y se parchea
// (hardlinkearlo mutaría el store). Todo lo demás se hardlinkea como en estático.
let patch = match spec {
Some(s) if s.has_work() && is_elf(&src)? => Some(s),
_ => None,
};
match patch {
Some(s) => {
atomic_copy_and_patch(&src, &dst, s)?;
report.patched += 1;
}
None => atomic_hardlink(&src, &dst)?,
}
report.files.push(HydratedFile { src, dst });
} else if ft.is_block_device() || ft.is_char_device() || ft.is_fifo() || ft.is_socket() {
// Los artefactos del lab no deberían contener nodos especiales; lo señalamos.
return Err(takana_core::Error::Other(anyhow::anyhow!(
"tipo no soportado en {}: {ft:?}",
src.display()
)));
}
}
Ok(())
}
/// Crea un hardlink `dst → src` reemplazando atómicamente cualquier archivo previo en `dst`.
/// Implementación: `link(src, dst.tmp)` + `rename(dst.tmp, dst)`. La técnica garantiza que
/// el archivo en `dst` nunca queda en estado intermedio y que el inode del store no se toca.
fn atomic_hardlink(src: &Path, dst: &Path) -> takana_core::Result<()> {
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = with_suffix(dst, ".hammer-tmp");
// Limpia un tmp olvidado de una corrida anterior.
let _ = std::fs::remove_file(&tmp);
std::fs::hard_link(src, &tmp).map_err(|e| {
takana_core::Error::Store(format!(
"hardlink {}{}: {e}",
src.display(),
tmp.display()
))
})?;
// rename sobreescribe atómicamente si dst es un archivo (no si es directorio).
if let Err(e) = std::fs::rename(&tmp, dst) {
let _ = std::fs::remove_file(&tmp);
return Err(takana_core::Error::Store(format!(
"rename {}{}: {e}",
tmp.display(),
dst.display()
)));
}
Ok(())
}
/// `true` si el archivo empieza por el magic ELF (`\x7fELF`). Errores de I/O se propagan;
/// un archivo más corto que 4 bytes simplemente no es ELF.
fn is_elf(path: &Path) -> takana_core::Result<bool> {
use std::io::Read;
let mut f = std::fs::File::open(path)?;
let mut magic = [0u8; 4];
match f.read_exact(&mut magic) {
Ok(()) => Ok(magic == [0x7f, b'E', b'L', b'F']),
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => Ok(false),
Err(e) => Err(e.into()),
}
}
/// Verifica que `patchelf` esté disponible antes de empezar a proyectar.
fn ensure_patchelf() -> takana_core::Result<()> {
match Command::new("patchelf").arg("--version").output() {
Ok(o) if o.status.success() => Ok(()),
Ok(o) => Err(takana_core::Error::Other(anyhow::anyhow!(
"patchelf falló al invocarse: {}",
String::from_utf8_lossy(&o.stderr).trim()
))),
Err(e) => Err(takana_core::Error::Other(anyhow::anyhow!(
"hidratación dinámica requiere `patchelf` en el PATH y no se pudo ejecutar: {e}"
))),
}
}
/// Copia `src → dst` atómicamente (copy a tmp → patchelf el tmp → rename), preservando los
/// permisos (`std::fs::copy` los replica, incluido el bit de ejecución). Patcheamos el tmp
/// **antes** del rename para que `dst` nunca quede en un estado a medio parchear.
fn atomic_copy_and_patch(src: &Path, dst: &Path, spec: &DynamicSpec) -> takana_core::Result<()> {
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = with_suffix(dst, ".hammer-tmp");
let _ = std::fs::remove_file(&tmp);
std::fs::copy(src, &tmp).map_err(|e| {
takana_core::Error::Store(format!("copy {}{}: {e}", src.display(), tmp.display()))
})?;
if let Err(e) = run_patchelf(&tmp, spec) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
if let Err(e) = std::fs::rename(&tmp, dst) {
let _ = std::fs::remove_file(&tmp);
return Err(takana_core::Error::Store(format!(
"rename {}{}: {e}",
tmp.display(),
dst.display()
)));
}
Ok(())
}
/// Ejecuta `patchelf [--set-interpreter I] [--set-rpath R] <file>`. Asume que el caller ya
/// comprobó (`has_work`) que hay al menos una de las dos.
fn run_patchelf(file: &Path, spec: &DynamicSpec) -> takana_core::Result<()> {
let mut cmd = Command::new("patchelf");
if let Some(interp) = &spec.interpreter {
cmd.arg("--set-interpreter").arg(interp);
}
if let Some(rpath) = &spec.rpath {
cmd.arg("--set-rpath").arg(rpath);
}
cmd.arg(file);
let out = cmd.output().map_err(|e| {
takana_core::Error::Other(anyhow::anyhow!(
"no pude ejecutar patchelf sobre {}: {e}",
file.display()
))
})?;
if !out.status.success() {
return Err(takana_core::Error::Other(anyhow::anyhow!(
"patchelf falló sobre {}: {}",
file.display(),
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(())
}
fn atomic_replace_symlink(link_target: &Path, dst: &Path) -> takana_core::Result<()> {
use std::os::unix::fs::symlink;
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = with_suffix(dst, ".hammer-tmp");
let _ = std::fs::remove_file(&tmp);
symlink(link_target, &tmp).map_err(|e| {
takana_core::Error::Store(format!("symlink {}{}: {e}", link_target.display(), tmp.display()))
})?;
if let Err(e) = std::fs::rename(&tmp, dst) {
let _ = std::fs::remove_file(&tmp);
return Err(takana_core::Error::Store(format!(
"rename {}{}: {e}",
tmp.display(),
dst.display()
)));
}
Ok(())
}
fn with_suffix(p: &Path, suffix: &str) -> PathBuf {
let mut s = p.as_os_str().to_owned();
s.push(suffix);
PathBuf::from(s)
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::MetadataExt;
fn populate(dir: &Path) {
std::fs::create_dir_all(dir.join("usr/bin")).unwrap();
std::fs::write(dir.join("usr/bin/hello"), b"#!/bin/sh\necho hi\n").unwrap();
std::fs::create_dir_all(dir.join("usr/share/doc")).unwrap();
std::fs::write(dir.join("usr/share/doc/README"), b"docs").unwrap();
// symlink: usr/sbin/hello -> ../bin/hello (target relativo)
std::fs::create_dir_all(dir.join("usr/sbin")).unwrap();
std::os::unix::fs::symlink("../bin/hello", dir.join("usr/sbin/hello")).unwrap();
}
#[test]
fn hydrate_creates_hardlinks_with_shared_inode() {
let artifact = tempfile::tempdir().unwrap();
let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
populate(artifact.path());
let report = hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap();
assert_eq!(report.files.len(), 3); // hello, README, symlink
assert_eq!(report.symlinks, 1);
let src_ino = std::fs::metadata(artifact.path().join("usr/bin/hello")).unwrap().ino();
let dst_ino = std::fs::metadata(target.path().join("usr/bin/hello")).unwrap().ino();
assert_eq!(src_ino, dst_ino, "hardlink ⇒ mismo inode");
}
#[test]
fn hydrate_overwrites_existing_without_touching_store() {
let artifact = tempfile::tempdir().unwrap();
let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
populate(artifact.path());
// Pre-existe un archivo distinto en el target.
std::fs::create_dir_all(target.path().join("usr/bin")).unwrap();
std::fs::write(target.path().join("usr/bin/hello"), b"contenido viejo").unwrap();
hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap();
// El target ahora apunta al inode del store.
let src_ino = std::fs::metadata(artifact.path().join("usr/bin/hello")).unwrap().ino();
let dst_ino = std::fs::metadata(target.path().join("usr/bin/hello")).unwrap().ino();
assert_eq!(src_ino, dst_ino);
// El contenido viejo desapareció; el del store sigue intacto.
let got = std::fs::read_to_string(target.path().join("usr/bin/hello")).unwrap();
assert!(got.starts_with("#!/bin/sh"));
let store_still = std::fs::read_to_string(artifact.path().join("usr/bin/hello")).unwrap();
assert!(store_still.starts_with("#!/bin/sh"));
}
#[test]
fn hydrate_is_idempotent() {
let artifact = tempfile::tempdir().unwrap();
let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
populate(artifact.path());
hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap();
// Segundo paso no debe fallar.
hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap();
}
#[test]
fn hydrate_replicates_symlink_target_literally() {
let artifact = tempfile::tempdir().unwrap();
let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
populate(artifact.path());
hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap();
let link = std::fs::read_link(target.path().join("usr/sbin/hello")).unwrap();
assert_eq!(link, std::path::PathBuf::from("../bin/hello"));
}
#[test]
fn hydrate_dynamic_sin_spec_se_comporta_como_estatico() {
// Sin interpreter ni rpath no hay nada que parchear ⇒ hardlink directo (mismo inode),
// sin necesitar patchelf. Es el caso que prueba que el modo dinámico no rompe.
let artifact = tempfile::tempdir().unwrap();
let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
populate(artifact.path());
let report = hydrate(artifact.path(), target.path(), LinkMode::Dynamic, None).unwrap();
assert_eq!(report.patched, 0);
let src_ino = std::fs::metadata(artifact.path().join("usr/bin/hello")).unwrap().ino();
let dst_ino = std::fs::metadata(target.path().join("usr/bin/hello")).unwrap().ino();
assert_eq!(src_ino, dst_ino, "sin spec ⇒ hardlink ⇒ mismo inode");
}
#[test]
fn is_elf_detecta_magic() {
let d = tempfile::tempdir().unwrap();
let elf = d.path().join("bin");
std::fs::write(&elf, [0x7f, b'E', b'L', b'F', 0x02, 0x01]).unwrap();
let noelf = d.path().join("script");
std::fs::write(&noelf, b"#!/bin/sh\n").unwrap();
let corto = d.path().join("corto");
std::fs::write(&corto, b"hi").unwrap();
assert!(is_elf(&elf).unwrap());
assert!(!is_elf(&noelf).unwrap());
assert!(!is_elf(&corto).unwrap(), "archivo <4 bytes no es ELF");
}
#[test]
fn hydrate_dynamic_con_spec_pero_sin_patchelf_falla_limpio() {
// Con un interpreter a fijar y un "ELF" presente, se exige patchelf. En este entorno
// patchelf no está instalado ⇒ debe fallar con mensaje claro ANTES de proyectar.
if Command::new("patchelf").arg("--version").output().map(|o| o.status.success()).unwrap_or(false) {
eprintln!("patchelf presente; salto el test del camino de error");
return;
}
let artifact = tempfile::tempdir().unwrap();
let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
std::fs::create_dir_all(artifact.path().join("usr/bin")).unwrap();
std::fs::write(artifact.path().join("usr/bin/elf"), [0x7f, b'E', b'L', b'F', 0, 0, 0, 0]).unwrap();
let spec = DynamicSpec {
interpreter: Some("/lib/ld-musl-x86_64.so.1".into()),
rpath: Some("/usr/lib:/lib".into()),
};
let err = hydrate(artifact.path(), target.path(), LinkMode::Dynamic, Some(&spec))
.unwrap_err()
.to_string();
assert!(err.contains("patchelf"), "{err}");
}
/// Camino feliz real: requiere patchelf + un ELF de verdad. Gated como el resto de tests
/// dependientes del host (`HAMMER_HOST_ELF_TESTS=1`).
#[test]
fn hydrate_dynamic_patchea_elf_real() {
if std::env::var("HAMMER_HOST_ELF_TESTS").is_err() {
return;
}
let host_bin = std::path::Path::new("/bin/sh");
if !host_bin.exists() {
return;
}
let artifact = tempfile::tempdir().unwrap();
let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
std::fs::create_dir_all(artifact.path().join("usr/bin")).unwrap();
std::fs::copy(host_bin, artifact.path().join("usr/bin/sh")).unwrap();
let spec = DynamicSpec { interpreter: None, rpath: Some("/usr/lib:/lib".into()) };
let report = hydrate(artifact.path(), target.path(), LinkMode::Dynamic, Some(&spec)).unwrap();
assert_eq!(report.patched, 1);
// El binario proyectado es una COPIA (distinto inode del artefacto), porque patchelf lo
// reescribió sin tocar el store.
let src_ino = std::fs::metadata(artifact.path().join("usr/bin/sh")).unwrap().ino();
let dst_ino = std::fs::metadata(target.path().join("usr/bin/sh")).unwrap().ino();
assert_ne!(src_ino, dst_ino, "ELF parcheado ⇒ copia ⇒ inode distinto");
}
}
File diff suppressed because it is too large Load Diff
+746
View File
@@ -0,0 +1,746 @@
//! 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<String>,
}
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 hammer-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::<BuildFailure>(),
_ => None,
}
}
}
/// Empuja `line` a un ring buffer acotado a `cap` líneas (descarta la más antigua).
fn push_tail(buf: &mut VecDeque<String>, 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<u8>,
}
#[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<PathBuf>,
/// 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<PathBuf>,
/// 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::<String>::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::<Vec<_>>().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<CmdOutcome> {
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::<String>::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<PathBuf> {
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 `<store>/../`: 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 `<hash>-<name>` ⇒ 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 <dep>/. <merged>/` 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<String> {
let mut v: Vec<String> = [
"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 <cmd>` final por
/// `harkaq-exec --policy ... -- sh -c "<probe del canario>; <cmd>"`.
///
/// 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<String> {
// 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 <cmd>` 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, <cmd>
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<String> {
let mut args: Vec<String> = 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 `<store>/<64hex>-<name>` 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 (`<tmp>/hdl-<id>/<N>`)
// 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 hammer 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=$(hammer … 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<R: std::io::Read + Send + 'static>(
reader: R,
tail: Arc<Mutex<VecDeque<String>>>,
) -> 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. `hammer 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 (`hammer-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/<tool>` 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<PathBuf>) -> 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");
}
}
+473
View File
@@ -0,0 +1,473 @@
//! Puente entre `takana_core::Mutation::SourcePatch` y el laboratorio.
//!
//! Convierte la mutación serializada en una `Recipe` efímera + un archivo de patch
//! temporal, y delega en `build` para sellar el artefacto en el store. Luego se hidrata.
//!
//! Este módulo vive en `hammer-build` (no en `hammer-core`) porque depende del store y del
//! sandbox. `hammer-core` debe permanecer puro (sin red ni mounts) para que el watcher y los
//! tests no arrastren toda la cadena del lab.
use std::path::{Path, PathBuf};
use takana_core::{
swm::{Mutation, SwmBuild},
ArtifactHash, Compiler, LinkMode, Recipe, Store,
};
use crate::{build, BuildConfig};
/// Construye una `SourcePatch` y devuelve el hash sellado en el store. El llamador hidrata
/// luego (típicamente al overlay activo).
///
/// `scratch_root` sirve para escribir el patch inline a disco — usamos `cfg.work_root` por
/// defecto si el caller no quiere gestionarlo a mano.
pub fn build_source_patch(
mutation: &Mutation,
cfg: &BuildConfig,
store: &Store,
scratch_root: Option<&Path>,
name: Option<&str>,
) -> takana_core::Result<ArtifactHash> {
let catalog_dir = catalog_dir_for(cfg, scratch_root);
// El NOMBRE de la receta sintetizada decide el sufijo del store (`<hash>-<name>`) Y la
// clave de cache-hit (`store.has(hash, name)`). Si quien llama conoce el nombre del paquete
// (p.ej. `install ripgrep`), lo pasa: así reproduce como "ripgrep" y cache-hitea el artefacto
// del corpus, en vez de derivar "rg" del target_bin y sellar un duplicado bajo otro nombre.
let recipe = recipe_from_source_patch(mutation, name, &catalog_dir)?;
let expected = match mutation {
Mutation::SourcePatch { expected_hash, .. } => expected_hash.clone(),
_ => None,
};
let hash = build(&recipe, cfg, store)?;
if let Some(want) = &expected {
let want_hex = want.strip_prefix("b3:").unwrap_or(want);
let got_hex = hash
.as_str()
.strip_prefix("b3:")
.expect("of_inputs siempre devuelve 'b3:'");
if want_hex != got_hex {
return Err(takana_core::Error::Recipe(format!(
"expected_hash no coincide: declarado=b3:{want_hex}, obtenido={hash}"
)));
}
}
Ok(hash)
}
/// El directorio-catálogo donde se materializan los patches y los `{dep}.toml`. Determinista a
/// partir de `cfg.work_root` (o `scratch_root`): por eso `install` puede pre-poblarlo con los
/// `{dep}.toml` y `build_source_patch` los encontrará al resolver build-deps del mismo `base_dir`.
pub fn catalog_dir_for(cfg: &BuildConfig, scratch_root: Option<&Path>) -> PathBuf {
scratch_root.unwrap_or(&cfg.work_root).join("swm-recipes")
}
/// Sintetiza (SIN construir) la `Recipe` efímera de un `source_patch`: parsea el origen,
/// materializa el patch inline/remoto a disco bajo `catalog_dir`, y arma la receta con sus
/// `deps` y `base_dir = catalog_dir` (para que las build-deps resuelvan `{dep}.toml` ahí).
///
/// `name` sobreescribe el nombre derivado del `target_bin`. Es OBLIGATORIO al escribir un
/// `{dep}.toml`: el nombre de la receta DEBE ser el de la dep para que el dependiente la resuelva.
pub fn recipe_from_source_patch(
mutation: &Mutation,
name: Option<&str>,
catalog_dir: &Path,
) -> takana_core::Result<Recipe> {
let (repo, commit, tarball, sha256, strip_components, patch, patch_url, build_cfg, target_bin, deps, evidence, slots) =
match mutation {
Mutation::SourcePatch {
repo, commit, tarball, sha256, strip_components, patch, patch_url, build,
target_bin, deps, evidence, slots, ..
} => (
repo, commit, tarball, sha256, strip_components, patch, patch_url, build,
target_bin, deps, evidence, slots,
),
_ => {
return Err(takana_core::Error::Recipe(
"recipe_from_source_patch: la mutación no es 'source_patch'".into(),
));
}
};
// git xor tarball — misma regla que el schema (`swm_source_kind`).
let source_kind = takana_core::swm::swm_source_kind(
repo.as_deref(),
commit.as_deref(),
tarball.as_deref(),
sha256.as_deref(),
)
.map_err(takana_core::Error::Recipe)?;
std::fs::create_dir_all(catalog_dir)?;
// Clave estable del origen para nombrar el patch a disco: el commit (git) o el sha256
// (tarball). Idéntica reaplicación ⇒ mismo nombre ⇒ hash de receta determinista.
let source_key: &str = match &source_kind {
takana_core::SourceKind::Git { commit, .. } => commit,
takana_core::SourceKind::Tarball { sha256, .. } => sha256,
// Inalcanzable por construcción —`swm_source_kind` sólo produce Git o Tarball— pero se
// escribe como error y no como `unreachable!`: esto es una librería, y un panic aquí
// mataría al llamador por una receta mal formada en vez de decírselo.
takana_core::SourceKind::Dir { .. } => {
return Err(takana_core::Error::Recipe(
"un .swm no puede llevar un source por directorio".into(),
))
}
};
let mut patches: Vec<String> = Vec::new();
if patch.is_some() || patch_url.is_some() {
// Patch bajo un nombre derivado del nombre-de-receta + clave de origen, para no colisionar
// entre `{dep}.patch` distintos en el mismo catálogo.
let label = name.map(|n| n.to_string()).unwrap_or_else(|| derive_name(target_bin));
// `source.patches` guarda el NOMBRE relativo a `base_dir` (= catalog_dir); el lab resuelve
// `base_dir.join(nombre)` UNA vez al construir (fetch::apply_patches / Recipe hash). Guardar el
// path completo acá lo haría re-unir a base_dir aguas abajo (doble/triple prefijo).
let filename = format!("{label}-{source_key}.patch");
let patch_path = catalog_dir.join(&filename);
if let Some(text) = patch {
std::fs::write(&patch_path, text.as_bytes())?;
} else if let Some(url) = patch_url {
tracing::info!(%url, "fetch: descargando patch remoto");
crate::download::fetch_url_to_file(url, &patch_path)?;
}
patches.push(filename);
}
let name = name.map(|n| n.to_string()).unwrap_or_else(|| derive_name(target_bin));
synthesize_recipe(
&name,
&source_kind,
build_cfg,
*strip_components,
deps,
evidence,
slots,
patches,
catalog_dir,
)
}
/// Nombre legible para el directorio del store. `/usr/bin/grep` → `grep`. Si el path no
/// tiene basename (raro), usamos `swm-bin`. El nombre afecta SÓLO al sufijo legible
/// (`<hash>-<name>`), no al hash; cambiarlo no rompe la caché del store.
fn derive_name(target_bin: &str) -> String {
Path::new(target_bin)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("swm-bin")
.to_string()
}
#[allow(clippy::too_many_arguments)]
fn synthesize_recipe(
name: &str,
source: &takana_core::SourceKind<'_>,
build_cfg: &SwmBuild,
strip_components: Option<usize>,
deps: &takana_core::Deps,
evidence: &takana_core::Evidence,
slots: &takana_core::Slots,
patches: Vec<String>,
base_dir: &Path,
) -> takana_core::Result<Recipe> {
let compiler = parse_compiler(&build_cfg.compiler)?;
let link = parse_link(&build_cfg.link)?;
// Sección `[source]` y sufijo de versión según el modo de origen. El validador de
// `Source::kind` ya garantiza git xor tarball; acá sólo emitimos el TOML correspondiente.
let (version_key, source_block) = match source {
takana_core::SourceKind::Git { repo, commit } => (
*commit,
format!("repo = \"{repo}\"\ncommit = \"{commit}\"\n"),
),
takana_core::SourceKind::Tarball { url, sha256 } => (
*sha256,
format!("tarball = \"{url}\"\nsha256 = \"{sha256}\"\n"),
),
// Ver la nota del `source_key` de arriba: un `.swm` no puede traer un `dir`.
takana_core::SourceKind::Dir { .. } => {
return Err(takana_core::Error::Recipe(
"un .swm no puede llevar un source por directorio".into(),
))
}
};
// Construimos la receta vía TOML para reusar las defaults y el validador de `Source`.
let toml_text = format!(
r#"
name = "{name}"
version = "swm-{version_short}"
[source]
{source_block}
[build]
compiler = "{compiler}"
target = "{target}"
link = "{link}"
flags = []
"#,
name = name,
version_short = &version_key[..version_key.len().min(12)],
source_block = source_block,
compiler = compiler.as_str(),
target = build_cfg.target,
link = link.as_str(),
);
let mut recipe = Recipe::from_toml(&toml_text)?;
recipe.build.flags = build_cfg.flags.clone();
// Fidelidad de reconstrucción: fases custom, zig por-paquete y strip del tarball viajan en
// el .swm (si vienen) y se reinyectan en la receta efímera tal cual los tenía el original.
recipe.build.phases = build_cfg.phases.clone();
recipe.build.zig_version = build_cfg.zig_version.clone();
if let Some(n) = strip_components {
recipe.source.strip_components = n;
}
// `patches` ya viene como nombres relativos a `base_dir` (o rutas absolutas que generamos
// nosotros): el lab los resuelve con `base_dir.join(p)` UNA vez al construir. NO los re-unimos
// acá — hacerlo doblaba el prefijo `base_dir` y el join del build lo triplicaba (bug que el
// dogfood de `jq`, primer paquete con patch + build-dep instalado, destapó).
recipe.source.patches = patches;
// Deps por NOMBRE (de la receta original): el lab las resolverá como `{dep}.toml` bajo el
// `base_dir` — por eso base_dir = el catálogo (no "/"): los patches son absolutos y no lo
// necesitan, pero la resolución de build-deps SÍ. `install` puebla el catálogo con los
// `{dep}.toml` antes de construir; sin deps, el dir simplemente no se consulta.
recipe.deps = deps.clone();
// Evidencia de comportamiento (H1): viaja en el .swm y se reinyecta en la receta efímera para
// que el checker la vea al reproducir. No afecta el hash del artefacto (comportamiento ≠ identidad).
recipe.evidence = evidence.clone();
// Superficies del sistema (H4b): viajan en el .swm y se reinyectan en la receta efímera para
// que el gate de compatibilidad de `install` las evalúe. Como la evidencia, no afectan el hash.
recipe.slots = slots.clone();
recipe.base_dir = base_dir.to_path_buf();
Ok(recipe)
}
fn parse_compiler(s: &str) -> takana_core::Result<Compiler> {
match s {
"zig-cc" => Ok(Compiler::ZigCc),
"clang" => Ok(Compiler::Clang),
"gcc" => Ok(Compiler::Gcc),
other => Err(takana_core::Error::Recipe(format!(
"compiler desconocido: '{other}' (esperado zig-cc/clang/gcc)"
))),
}
}
fn parse_link(s: &str) -> takana_core::Result<LinkMode> {
match s {
"static" => Ok(LinkMode::Static),
"dynamic" => Ok(LinkMode::Dynamic),
other => Err(takana_core::Error::Recipe(format!(
"link desconocido: '{other}' (esperado static/dynamic)"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slots_sobreviven_receta_a_swm_a_receta() {
// H4b: el gate de compatibilidad depende de que las superficies declaradas en la receta
// viajen intactas por el `.swm` y se recuperen al reproducir. Ejercita AMBAS direcciones
// del puente (Swm::from_recipe → recipe_from_source_patch).
let toml = r#"
name = "tema-oscuro"
version = "1.0"
[source]
repo = "git://x/tema.git"
commit = "abc123"
[build]
compiler = "zig-cc"
[slots.claims]
logo = "b3:azul"
[slots.requires]
"wayland-protocol" = "b3:stock"
"#;
let recipe = takana_core::Recipe::from_toml(toml).unwrap();
let swm =
takana_core::Swm::from_recipe(&recipe, "/usr/bin/tema", None, None, "2026-06-06").unwrap();
// El .swm YAML debe llevar el bloque slots.
let yaml = swm.to_yaml().unwrap();
assert!(yaml.contains("slots"), "el .swm debe transportar slots: {yaml}");
// Vuelta: source_patch → Recipe recupera exactamente los slots.
let d = tempfile::tempdir().unwrap();
let mutation = swm
.mutations
.iter()
.find(|m| matches!(m, Mutation::SourcePatch { .. }))
.unwrap();
let back = recipe_from_source_patch(mutation, Some("tema-oscuro"), d.path()).unwrap();
assert_eq!(back.slots, recipe.slots);
assert_eq!(back.slots.claims.get("logo").unwrap(), "b3:azul");
assert_eq!(back.slots.requires.get("wayland-protocol").unwrap(), "b3:stock");
}
fn fake_swm_build() -> SwmBuild {
SwmBuild {
compiler: "zig-cc".into(),
target: "x86_64-linux-musl".into(),
link: "static".into(),
flags: vec!["--enable-foo".into()],
phases: Default::default(),
zig_version: None,
}
}
#[test]
fn derive_name_from_target() {
assert_eq!(derive_name("/usr/bin/grep"), "grep");
assert_eq!(derive_name("/bin/ls"), "ls");
assert_eq!(derive_name("/"), "swm-bin");
}
#[test]
fn synthesize_recipe_basic() {
let d = tempfile::tempdir().unwrap();
let r = synthesize_recipe(
"grep",
&takana_core::SourceKind::Git {
repo: "git://example/grep.git",
commit: "a1b2c3d4e5f6a7b8c9",
},
&fake_swm_build(),
None,
&takana_core::Deps::default(),
&takana_core::Evidence::default(),
&takana_core::Slots::default(),
vec![],
d.path(),
)
.unwrap();
assert_eq!(r.name, "grep");
assert!(r.version.starts_with("swm-a1b2c3d4"));
assert_eq!(r.build.flags, vec!["--enable-foo"]);
match r.source.kind().unwrap() {
takana_core::SourceKind::Git { repo, commit } => {
assert_eq!(repo, "git://example/grep.git");
assert_eq!(commit, "a1b2c3d4e5f6a7b8c9");
}
_ => panic!("modo git esperado"),
}
}
#[test]
fn synthesize_recipe_tarball() {
let d = tempfile::tempdir().unwrap();
let r = synthesize_recipe(
"grep",
&takana_core::SourceKind::Tarball {
url: "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz",
sha256: "abcdef0123456789",
},
&fake_swm_build(),
None,
&takana_core::Deps::default(),
&takana_core::Evidence::default(),
&takana_core::Slots::default(),
vec![],
d.path(),
)
.unwrap();
assert_eq!(r.name, "grep");
assert!(r.version.starts_with("swm-abcdef012345"));
match r.source.kind().unwrap() {
takana_core::SourceKind::Tarball { url, sha256 } => {
assert_eq!(url, "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz");
assert_eq!(sha256, "abcdef0123456789");
}
_ => panic!("modo tarball esperado"),
}
}
#[test]
fn inline_patch_resuelve_con_un_solo_join_de_base_dir() {
// Regresión: `source.patches` debe guardar el NOMBRE relativo a base_dir, de modo que el
// lab resuelva `base_dir.join(p)` UNA sola vez. El bug doblaba/triplicaba el prefijo
// `swm-recipes/` (lo destapó el dogfood de `jq`, primer paquete con patch instalado).
let d = tempfile::tempdir().unwrap();
let catalog = d.path().join("swm-recipes");
let m = Mutation::SourcePatch {
repo: None,
commit: None,
tarball: Some("https://x/jq-1.8.1.tar.gz".into()),
sha256: Some("2be64e7129cecb11d5906290eba10af694fb9e3e7f9fc208a311dc33ca837eb0".into()),
strip_components: None,
patch: Some("--- a\n+++ b\n".into()),
patch_url: None,
build: fake_swm_build(),
target_bin: "/usr/bin/jq".into(),
expected_hash: None,
deps: Default::default(),
evidence: Default::default(),
slots: Default::default(),
};
let recipe = recipe_from_source_patch(&m, None, &catalog).unwrap();
assert_eq!(recipe.source.patches.len(), 1);
let p = &recipe.source.patches[0];
// El nombre NO debe llevar embebido el dir del catálogo (eso causaba el doble prefijo).
assert!(!p.contains("swm-recipes"), "patch debe ser relativo a base_dir, no llevar el catálogo: {p}");
// Y base_dir.join(p) — el ÚNICO join que hace el lab — debe dar el fichero real escrito.
let resolved = recipe.base_dir.join(p);
assert!(resolved.is_file(), "base_dir.join(patch) debe existir en un solo paso: {}", resolved.display());
assert_eq!(std::fs::read(&resolved).unwrap(), b"--- a\n+++ b\n");
}
#[test]
fn parse_compiler_and_link() {
assert_eq!(parse_compiler("zig-cc").unwrap(), Compiler::ZigCc);
assert!(parse_compiler("xlc").is_err());
assert_eq!(parse_link("static").unwrap(), LinkMode::Static);
assert!(parse_link("partial").is_err());
}
#[test]
fn rejects_non_source_patch_mutation() {
let m = Mutation::ConfigEdit {
file: "/etc/x".into(),
inline_diff: "- a\n+ b\n".into(),
};
let d = tempfile::tempdir().unwrap();
let store = Store::open(d.path().join("store")).unwrap();
let cfg = BuildConfig::defaults_for_store(store.root());
let err = build_source_patch(&m, &cfg, &store, None, None).unwrap_err().to_string();
assert!(err.contains("source_patch"), "{err}");
}
#[test]
fn patch_url_is_downloaded_before_build() {
// Servimos el patch por file:// (sin red). build_source_patch debe descargarlo y
// materializarlo en swm-recipes/<commit>.patch ANTES de fallar aguas abajo por no
// haber rootfs de build en este entorno. Eso prueba que el patch_url quedó cableado.
let d = tempfile::tempdir().unwrap();
let patch_src = d.path().join("remote.patch");
std::fs::write(&patch_src, b"--- a\n+++ b\n").unwrap();
let commit = "deadbeefcafe";
let m = Mutation::SourcePatch {
repo: Some("git://x".into()),
commit: Some(commit.into()),
tarball: None,
sha256: None,
strip_components: None,
patch: None,
patch_url: Some(format!("file://{}", patch_src.display())),
build: fake_swm_build(),
target_bin: "/bin/x".into(),
expected_hash: None,
deps: Default::default(),
evidence: Default::default(),
slots: Default::default(),
};
let store = Store::open(d.path().join("store")).unwrap();
let cfg = BuildConfig::defaults_for_store(store.root());
let scratch = d.path().join("scratch");
// Falla (no hay rootfs), pero el patch ya debe estar descargado.
let _ = build_source_patch(&m, &cfg, &store, Some(&scratch), None);
// El patch se materializa como `{label}-{source_key}.patch` (label = derive_name del
// target_bin) para no colisionar entre deps en el catálogo.
let landed = scratch.join("swm-recipes").join(format!("x-{commit}.patch"));
assert!(landed.is_file(), "el patch remoto debió descargarse a {}", landed.display());
assert_eq!(std::fs::read(&landed).unwrap(), b"--- a\n+++ b\n");
}
}
@@ -0,0 +1,162 @@
//! Integration test del path autotools:
//! - Repo git con `configure.ac` + `Makefile.am` + `hello.c` (sin `configure` generado).
//! - Receta SIN `[build.phases]` → la heurística debe detectar AutoconfRaw y emitir
//! `autoreconf -fi && ./configure --prefix=/usr` + `make` + `make install DESTDIR=/out`.
//! - El binario instalado en el store debe correr en el sandbox.
//!
//! Skip transparente si el rootfs Alpine o zig no están bootstrappeados.
use std::path::{Path, PathBuf};
use std::process::Command;
use takana_build::{build, BuildConfig};
use takana_core::{Recipe, Store};
const HELLO_C: &str = r#"
#include <stdio.h>
int main(void) { puts("hammer-autotools OK"); return 0; }
"#;
const CONFIGURE_AC: &str = r#"
AC_INIT([helloat], [1.0])
AM_INIT_AUTOMAKE([foreign -Wall])
AC_PROG_CC
AC_CONFIG_FILES([Makefile])
AC_OUTPUT
"#;
const MAKEFILE_AM: &str = r#"
bin_PROGRAMS = helloat
helloat_SOURCES = hello.c
"#;
const RECIPE_TOML: &str = r#"
name = "helloat"
version = "1.0"
[source]
repo = "PLACEHOLDER_REPO_URL"
commit = "PLACEHOLDER_COMMIT"
[build]
compiler = "zig-cc"
target = "x86_64-linux-musl"
link = "static"
"#;
fn project_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf()
}
fn skip_if_no_layout(cfg: &BuildConfig) -> bool {
if !cfg.rootfs.join("bin/busybox").is_file() {
eprintln!("SKIP: rootfs Alpine no encontrado en {}.", cfg.rootfs.display());
return true;
}
// Las tools de autotools se metieron con `apk add make autoconf automake m4 ...`
// — comprobamos uno para no engañarnos si el rootfs es el minirootfs pelado.
if !cfg.rootfs.join("usr/bin/autoreconf").is_file() {
eprintln!(
"SKIP: rootfs sin autotools. Corre: bwrap --bind {} / --share-net /sbin/apk add make autoconf automake m4 patch coreutils libtool pkgconf bash",
cfg.rootfs.display()
);
return true;
}
if !cfg.zig_dir.join("zig").is_file() {
eprintln!("SKIP: zig no encontrado en {}.", cfg.zig_dir.display());
return true;
}
false
}
fn git(args: &[&str], cwd: &Path) {
let st = Command::new("git").args(args).current_dir(cwd).status().expect("git");
assert!(st.success(), "git {args:?} falló");
}
fn init_repo(upstream: &Path) -> String {
std::fs::create_dir_all(upstream).unwrap();
std::fs::write(upstream.join("configure.ac"), CONFIGURE_AC).unwrap();
std::fs::write(upstream.join("Makefile.am"), MAKEFILE_AM).unwrap();
std::fs::write(upstream.join("hello.c"), HELLO_C).unwrap();
git(&["init", "-q", "-b", "main"], upstream);
git(&["config", "user.email", "test@hammer"], upstream);
git(&["config", "user.name", "hammer-test"], upstream);
git(&["add", "."], upstream);
git(&["commit", "-q", "-m", "init"], upstream);
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(upstream)
.output()
.unwrap();
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
#[test]
fn build_autotools_hello_end_to_end() {
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
let project = project_root();
let cfg = BuildConfig {
rootfs: project.join(".dev-fs/alpine"),
zig_dir: project.join(".dev-fs/tools/zig"),
work_root: tempfile::tempdir().unwrap().keep(),
cache_root: Some(project.join(".dev-fs/cache")),
};
if skip_if_no_layout(&cfg) {
return;
}
let tmp = tempfile::tempdir().unwrap();
let store_root = tmp.path().join("store");
std::fs::create_dir_all(&store_root).unwrap();
let upstream = tmp.path().join("upstream");
let commit = init_repo(&upstream);
let repo_url = format!("file://{}", upstream.display());
let recipe_dir = tmp.path().join("recipe");
std::fs::create_dir_all(&recipe_dir).unwrap();
let toml = RECIPE_TOML
.replace("PLACEHOLDER_REPO_URL", &repo_url)
.replace("PLACEHOLDER_COMMIT", &commit);
let recipe_path = recipe_dir.join("helloat.toml");
std::fs::write(&recipe_path, toml).unwrap();
let recipe = Recipe::load_from_path(&recipe_path).expect("load recipe");
let store = Store::open(&store_root).unwrap();
let h = build(&recipe, &cfg, &store).expect("build helloat");
let sealed = store.path_of(&h, &recipe.name);
let bin = sealed.join("usr/bin/helloat");
assert!(bin.is_file(), "esperaba {}", bin.display());
let header = std::fs::read(&bin).unwrap();
assert_eq!(&header[..4], b"\x7fELF", "es ELF");
let out = Command::new("bwrap")
.args([
"--overlay-src",
cfg.rootfs.to_str().unwrap(),
"--tmp-overlay",
"/",
"--proc",
"/proc",
"--dev",
"/dev",
"--ro-bind",
sealed.to_str().unwrap(),
"/art",
"--unshare-all",
"/art/usr/bin/helloat",
])
.output()
.expect("spawn bwrap");
assert!(out.status.success(), "helloat falló: {:?}", out);
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hammer-autotools OK");
}
@@ -0,0 +1,166 @@
//! End-to-end del primer entregable: receta `grep` real → build → hidratación → ejecución
//! dentro del rootfs Alpine. Es el test que cierra Fase 0 + Fase 1.
//!
//! Doblemente gateado para no penalizar `cargo test` por defecto:
//! - Requiere `.dev-fs/` bootstrapeado (rootfs Alpine + zig + cache).
//! - Requiere `HAMMER_NETWORK_TESTS=1` (descarga ~3 MB del tarball de GNU).
//!
//! Comparte la dev cache (`.dev-fs/cache`) para que la segunda corrida en una máquina dada
//! reuse los objetos de musl y termine en segundos.
use std::path::PathBuf;
use std::process::Command;
use takana_build::{build, run_hydrate, BuildConfig};
use takana_core::{LinkMode, Recipe, Store};
const RECIPE_TOML: &str = r#"
name = "grep"
version = "3.12"
[source]
tarball = "https://ftp.gnu.org/gnu/grep/grep-3.12.tar.gz"
sha256 = "badda546dfc4b9d97e992e2c35f3b5c7f20522ffcbe2f01ba1e9cdcbe7644cdc"
[build]
compiler = "zig-cc"
target = "x86_64-linux-musl"
link = "static"
flags = ["--disable-perl-regexp", "--disable-nls"]
"#;
fn project_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf()
}
fn skip_with_reason(reason: &str) -> bool {
eprintln!("SKIP end_to_end_grep: {reason}");
true
}
fn should_skip(cfg: &BuildConfig) -> bool {
if std::env::var("HAMMER_NETWORK_TESTS").ok().as_deref() != Some("1") {
return skip_with_reason(
"HAMMER_NETWORK_TESTS != 1 (este test descarga ~3 MB de ftp.gnu.org y compila ~30s).",
);
}
if !cfg.rootfs.join("bin/busybox").is_file() {
return skip_with_reason("rootfs Alpine no bootstrapped (corre scripts/bootstrap-devfs.sh).");
}
if !cfg.rootfs.join("usr/bin/autoreconf").is_file()
|| !cfg.rootfs.join("usr/bin/ld").is_file()
{
return skip_with_reason(
"rootfs sin build tools (autoreconf/ld). Re-corre bootstrap-devfs.sh.",
);
}
if !cfg.zig_dir.join("zig").is_file() {
return skip_with_reason("zig no encontrado en .dev-fs/tools/zig.");
}
false
}
#[test]
fn grep_real_build_hydrate_run() {
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
let project = project_root();
// El store y la salida hidratada DEBEN vivir en el mismo filesystem (los hardlinks no
// cruzan device boundaries). Mantenemos ambos bajo el repo, en un subdir efímero.
let scratch = project.join("work").join("test-e2e-grep");
let _ = std::fs::remove_dir_all(&scratch);
std::fs::create_dir_all(&scratch).unwrap();
let store_root = scratch.join("store");
let work_root = scratch.join("work");
std::fs::create_dir_all(&store_root).unwrap();
std::fs::create_dir_all(&work_root).unwrap();
let cfg = BuildConfig {
rootfs: project.join(".dev-fs/alpine"),
zig_dir: project.join(".dev-fs/tools/zig"),
work_root,
cache_root: Some(project.join(".dev-fs/cache")),
};
if should_skip(&cfg) {
return;
}
// 1) Receta
let recipe_dir = scratch.join("recipe");
std::fs::create_dir_all(&recipe_dir).unwrap();
let recipe_path = recipe_dir.join("grep.toml");
std::fs::write(&recipe_path, RECIPE_TOML).unwrap();
let recipe = Recipe::load_from_path(&recipe_path).expect("load recipe");
// 2) Build
let store = Store::open(&store_root).unwrap();
let h = build(&recipe, &cfg, &store).expect("build grep");
let sealed = store.path_of(&h, &recipe.name);
let grep = sealed.join("usr/bin/grep");
assert!(grep.is_file(), "esperaba {}", grep.display());
// ELF estático: cabecera ELF + ningún DT_NEEDED (chequeo barato con `file`/`readelf` si
// existen, fallback al magic byte).
let header = std::fs::read(&grep).unwrap();
assert_eq!(&header[..4], b"\x7fELF", "grep debe ser ELF");
// 3) Hidratación al mismo filesystem
let fhs = scratch.join("fhs");
let report = run_hydrate(&sealed, &fhs, LinkMode::Static, None).expect("hydrate");
assert!(report.files.iter().any(|f| f.dst.ends_with("usr/bin/grep")));
// Compartir inode con el store: la propiedad central de hidratar por hardlink.
use std::os::unix::fs::MetadataExt;
let src_ino = std::fs::metadata(&grep).unwrap().ino();
let dst_ino = std::fs::metadata(fhs.join("usr/bin/grep")).unwrap().ino();
assert_eq!(src_ino, dst_ino, "hardlink: inode debe coincidir");
// 4) Ejecutar dentro del rootfs Alpine bindeando el binario hidratado como /usr/bin/grep.
// Ejercitamos un patrón regex y un literal; v3.12 NO debe dar "memory exhausted".
let out = Command::new("bwrap")
.args([
"--overlay-src",
cfg.rootfs.to_str().unwrap(),
"--tmp-overlay",
"/",
"--ro-bind",
fhs.join("usr/bin/grep").to_str().unwrap(),
"/usr/bin/grep",
"--proc",
"/proc",
"--dev",
"/dev",
"--unshare-all",
"/usr/bin/grep",
"-oE",
"[0-9]+",
])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("spawn bwrap");
use std::io::Write;
out.stdin
.as_ref()
.unwrap()
.write_all(b"prefijo 12345 sufijo\n")
.unwrap();
let out = out.wait_with_output().expect("wait bwrap");
assert!(
out.status.success(),
"grep en bwrap falló: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "12345");
// 5) Cache hit en segunda build: mismo hash.
let h2 = build(&recipe, &cfg, &store).expect("rebuild grep");
assert_eq!(h, h2, "segunda build debe ser cache hit");
}
@@ -0,0 +1,184 @@
//! Integration test del pipeline completo de Fase 0:
//! 1. Inicializa un repo git local con un `hello.c`.
//! 2. Escribe una receta TOML que apunta a ese repo con phases override.
//! 3. Llama a `takana_build::build` con un store/work temporales.
//! 4. Verifica que el artefacto sellado contiene `usr/bin/hello`, es ELF estático y
//! se ejecuta dentro del sandbox imprimiendo el output esperado.
//!
//! El test se SALTA (con un mensaje claro) si el host no tiene el rootfs Alpine
//! y/o zig instalados en `.dev-fs/`. Eso permite que `cargo test` no falle en máquinas
//! limpias; el desarrollador lee el mensaje y corre el bootstrap.
use std::path::{Path, PathBuf};
use std::process::Command;
use takana_build::{build, BuildConfig};
use takana_core::{Recipe, Store};
const HELLO_C: &str = r#"
#include <stdio.h>
int main(void) { puts("hammer-e2e OK"); return 0; }
"#;
const RECIPE_TOML: &str = r#"
name = "hello"
version = "1.0"
[source]
repo = "PLACEHOLDER_REPO_URL"
commit = "PLACEHOLDER_COMMIT"
[build]
compiler = "zig-cc"
target = "x86_64-linux-musl"
link = "static"
[build.phases]
compile = "zig cc -static -O2 -o hello hello.c"
install = "install -D -m755 hello /out/usr/bin/hello"
"#;
fn project_root() -> PathBuf {
// CARGO_MANIFEST_DIR = crates/hammer-build → subir dos para llegar al workspace root.
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf()
}
fn skip_if_no_layout(cfg: &BuildConfig) -> bool {
if !cfg.rootfs.join("bin/busybox").is_file() {
eprintln!(
"SKIP: rootfs Alpine no encontrado en {} — corre el bootstrap (ver README).",
cfg.rootfs.display()
);
return true;
}
if !cfg.zig_dir.join("zig").is_file() {
eprintln!(
"SKIP: zig no encontrado en {} — instalalo en .dev-fs/tools/zig.",
cfg.zig_dir.display()
);
return true;
}
if Command::new("git").arg("--version").output().is_err() {
eprintln!("SKIP: git no disponible en PATH.");
return true;
}
false
}
fn git(args: &[&str], cwd: &Path) {
let st = Command::new("git")
.args(args)
.current_dir(cwd)
.status()
.expect("git spawn");
assert!(st.success(), "git {args:?} falló");
}
fn init_repo_with_hello(repo_dir: &Path) -> String {
std::fs::create_dir_all(repo_dir).unwrap();
std::fs::write(repo_dir.join("hello.c"), HELLO_C).unwrap();
git(&["init", "-q", "-b", "main"], repo_dir);
// Identidad local para el commit, sin tocar la global del usuario.
git(&["config", "user.email", "test@hammer"], repo_dir);
git(&["config", "user.name", "hammer-test"], repo_dir);
git(&["add", "."], repo_dir);
git(&["commit", "-q", "-m", "hello"], repo_dir);
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo_dir)
.output()
.expect("rev-parse");
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
#[test]
fn build_hello_end_to_end() {
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
let project = project_root();
let rootfs = project.join(".dev-fs/alpine");
let zig_dir = project.join(".dev-fs/tools/zig");
let tmp = tempfile::tempdir().unwrap();
let store_root = tmp.path().join("store");
let work_root = tmp.path().join("work");
std::fs::create_dir_all(&store_root).unwrap();
std::fs::create_dir_all(&work_root).unwrap();
let cfg = BuildConfig {
rootfs: rootfs.clone(),
zig_dir: zig_dir.clone(),
work_root,
// Reusa la caché global del dev — la primera corrida tras `cargo clean` paga
// ~30s recompilando musl; las siguientes terminan en segundos.
cache_root: Some(project.join(".dev-fs/cache")),
};
if skip_if_no_layout(&cfg) {
return;
}
// 1) repo upstream simulado
let upstream = tmp.path().join("upstream-hello");
let commit = init_repo_with_hello(&upstream);
let repo_url = format!("file://{}", upstream.display());
// 2) escribir receta
let recipe_dir = tmp.path().join("recipe");
std::fs::create_dir_all(&recipe_dir).unwrap();
let toml = RECIPE_TOML
.replace("PLACEHOLDER_REPO_URL", &repo_url)
.replace("PLACEHOLDER_COMMIT", &commit);
let recipe_path = recipe_dir.join("hello.toml");
std::fs::write(&recipe_path, toml).unwrap();
let recipe = Recipe::load_from_path(&recipe_path).expect("load recipe");
// 3) build
let store = Store::open(&store_root).unwrap();
let h = build(&recipe, &cfg, &store).expect("build hello");
let sealed = store.path_of(&h, &recipe.name);
assert!(sealed.is_dir(), "store dir debe existir: {}", sealed.display());
// 4) artefacto correcto
let hello = sealed.join("usr/bin/hello");
assert!(hello.is_file(), "usr/bin/hello debe existir");
let meta = std::fs::metadata(&hello).unwrap();
use std::os::unix::fs::PermissionsExt;
assert_eq!(meta.permissions().mode() & 0o111, 0o111, "ejecutable");
// Read-only en el store
assert_eq!(meta.permissions().mode() & 0o222, 0, "read-only en el store");
// ELF
let header = std::fs::read(&hello).unwrap();
assert_eq!(&header[..4], b"\x7fELF", "es ELF");
// 5) ejecuta dentro de bwrap+rootfs y observa el output
let out = Command::new("bwrap")
.args([
"--overlay-src",
rootfs.to_str().unwrap(),
"--tmp-overlay",
"/",
"--proc",
"/proc",
"--dev",
"/dev",
"--ro-bind",
sealed.to_str().unwrap(),
"/artefacto",
"--unshare-all",
"/artefacto/usr/bin/hello",
])
.output()
.expect("spawn bwrap run hello");
assert!(out.status.success(), "hello no corrió: {:?}", out);
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hammer-e2e OK");
// 6) cache: segunda build con misma receta no rehidrata
let h2 = build(&recipe, &cfg, &store).expect("second build");
assert_eq!(h, h2);
}
+164
View File
@@ -0,0 +1,164 @@
//! Tests del modo tarball del fetch — completamente locales, sin red ni rootfs.
//!
//! Estrategia:
//! - Genero un tarball en un tempdir con contenido conocido.
//! - Calculo su sha256 real con la misma crate (`sha2`) que usa el fetch.
//! - Sirvo el tarball vía URL `file://` (curl la maneja nativamente).
//! - Llamo a `takana_build::fetch::fetch` con una receta TOML que apunta a esa URL.
//!
//! Cubre: happy path con strip_components, mismatch de sha256 no envenena la caché, reuso
//! de caché en segunda llamada, strip_components = 0 vs 1.
use std::path::{Path, PathBuf};
use std::process::Command;
use takana_build::fetch::fetch;
use takana_core::Recipe;
use sha2::{Digest, Sha256};
/// Crea `<dir>/inner/{a.txt,sub/b.txt}` y empaqueta `inner/` en `<dir>/payload.tar.gz`.
/// El tarball tiene UN componente top-level (`inner/`) — strip_components=1 lo elimina.
fn make_tarball(dir: &Path) -> PathBuf {
let inner = dir.join("inner");
std::fs::create_dir_all(inner.join("sub")).unwrap();
std::fs::write(inner.join("a.txt"), b"hola desde a\n").unwrap();
std::fs::write(inner.join("sub/b.txt"), b"hola desde b\n").unwrap();
let tar_path = dir.join("payload.tar.gz");
let st = Command::new("tar")
.arg("-czf")
.arg(&tar_path)
.arg("-C")
.arg(dir)
.arg("inner")
.status()
.expect("spawn tar");
assert!(st.success(), "tar -czf falló");
tar_path
}
fn sha256_hex(path: &Path) -> String {
let mut f = std::fs::File::open(path).unwrap();
let mut hasher = Sha256::new();
std::io::copy(&mut f, &mut hasher).unwrap();
hex::encode(hasher.finalize())
}
fn recipe_for(url: &str, sha: &str, strip: Option<usize>) -> Recipe {
let strip_line = match strip {
Some(n) => format!("strip_components = {n}\n"),
None => String::new(),
};
let toml = format!(
r#"
name = "fixture"
version = "0"
[source]
tarball = "{url}"
sha256 = "{sha}"
{strip_line}
[build]
"#
);
Recipe::from_toml(&toml).unwrap()
}
#[test]
fn happy_path_strip_components_default_one() {
let fix = tempfile::tempdir().unwrap();
let tar_path = make_tarball(fix.path());
let sha = sha256_hex(&tar_path);
let url = format!("file://{}", tar_path.display());
let recipe = recipe_for(&url, &sha, None);
let work_root = tempfile::tempdir().unwrap();
let tree = fetch(&recipe, work_root.path()).expect("fetch tarball");
// strip_components=1 ⇒ no debe quedar `inner/`, los archivos suben un nivel.
assert!(tree.join("a.txt").is_file(), "a.txt debe existir en la raíz del tree");
assert!(tree.join("sub/b.txt").is_file(), "sub/b.txt debe existir");
assert!(!tree.join("inner").exists(), "inner/ debe haber sido stripped");
// El tarball quedó cacheado por su sha256.
let cached = work_root.path().join("tarballs").join(format!("{sha}.tar"));
assert!(cached.is_file(), "tarball debe quedar cacheado en {}", cached.display());
}
#[test]
fn strip_components_zero_preserves_top_level() {
let fix = tempfile::tempdir().unwrap();
let tar_path = make_tarball(fix.path());
let sha = sha256_hex(&tar_path);
let url = format!("file://{}", tar_path.display());
let recipe = recipe_for(&url, &sha, Some(0));
let work_root = tempfile::tempdir().unwrap();
let tree = fetch(&recipe, work_root.path()).expect("fetch tarball strip=0");
assert!(tree.join("inner/a.txt").is_file(), "con strip=0, inner/ debe existir");
}
#[test]
fn sha256_mismatch_fails_and_does_not_poison_cache() {
let fix = tempfile::tempdir().unwrap();
let tar_path = make_tarball(fix.path());
let url = format!("file://{}", tar_path.display());
let wrong_sha = "0000000000000000000000000000000000000000000000000000000000000000";
let recipe = recipe_for(&url, wrong_sha, None);
let work_root = tempfile::tempdir().unwrap();
let err = fetch(&recipe, work_root.path()).unwrap_err().to_string();
assert!(err.contains("sha256 mismatch"), "msg = {err}");
// Crítico: la caché NO debe contener un archivo con el sha incorrecto, porque la próxima
// corrida lo daría por bueno sin re-verificar (sólo verificamos cuando lo descargamos).
let cached = work_root.path().join("tarballs").join(format!("{wrong_sha}.tar"));
assert!(!cached.exists(), "caché no debe contener {}", cached.display());
// Tampoco debe quedar el .partial colgado.
let partial = work_root
.path()
.join("tarballs")
.join(format!("{wrong_sha}.tar.partial"));
assert!(!partial.exists(), "no debe quedar partial");
}
#[test]
fn second_fetch_reuses_cached_tarball() {
let fix = tempfile::tempdir().unwrap();
let tar_path = make_tarball(fix.path());
let sha = sha256_hex(&tar_path);
let url = format!("file://{}", tar_path.display());
let recipe = recipe_for(&url, &sha, None);
let work_root = tempfile::tempdir().unwrap();
let _ = fetch(&recipe, work_root.path()).expect("first fetch");
// Para forzar que la segunda corrida NO pueda descargar, "rompo" la fuente: muevo el
// tarball original a otro lado. Si el fetch va a la caché, sigue funcionando; si intenta
// re-descargar, falla.
std::fs::remove_file(&tar_path).unwrap();
let tree2 = fetch(&recipe, work_root.path()).expect("second fetch (debe usar caché)");
assert!(tree2.join("a.txt").is_file());
}
#[test]
fn cached_tarball_with_wrong_content_is_rejected() {
// Si un atacante o un bug deja en la caché un archivo con el nombre <sha>.tar pero
// contenido distinto, NO debemos confiar. Verificamos sha256 incluso para entradas
// de la caché.
let fix = tempfile::tempdir().unwrap();
let tar_path = make_tarball(fix.path());
let sha = sha256_hex(&tar_path);
let url = format!("file://{}", tar_path.display());
let work_root = tempfile::tempdir().unwrap();
let tarballs = work_root.path().join("tarballs");
std::fs::create_dir_all(&tarballs).unwrap();
// Plantar un archivo basura con el nombre del sha esperado.
std::fs::write(tarballs.join(format!("{sha}.tar")), b"contenido FALSO").unwrap();
let recipe = recipe_for(&url, &sha, None);
let err = fetch(&recipe, work_root.path()).unwrap_err().to_string();
assert!(err.contains("sha256 mismatch"), "msg = {err}");
}
@@ -0,0 +1,76 @@
//! La hidratación no escribe fuera de su root, aunque el FHS ya traiga un symlink que salga.
//!
//! EL FALLO (medido 2026-08-30, antes de `no_escapa`): hidratar es `target_fhs.join(rel)` y
//! escribir por ruta. Un artefacto que trae `usr/share/pkg → /algún/lado` deja ese symlink puesto
//! —los symlinks se replican literales, y así debe ser— y la hidratación SIGUIENTE escribía
//! `usr/share/pkg/archivo` **fuera del root**, devolviendo `Ok(1)`. Con `hammer hydrate --into`
//! sobre una imagen, eso es escribir en el sistema anfitrión diciendo que todo fue bien.
//!
//! El corpus no lo disparaba (de 160 symlinks absolutos ninguno apunta a un directorio, y de
//! ~42 000 relativos ninguno sale de su artefacto), así que esto es una mina desactivada, no un
//! incendio apagado. Por eso el test: lo que no dispara nadie tampoco lo nota nadie.
use std::path::Path;
use takana_build::hydrate::hydrate;
use takana_core::LinkMode;
/// Artefacto que trae `usr/share/pkg` como symlink a `destino`.
fn artefacto_con_symlink(dir: &Path, destino: &Path) {
std::fs::create_dir_all(dir.join("usr/share")).unwrap();
std::os::unix::fs::symlink(destino, dir.join("usr/share/pkg")).unwrap();
}
/// Artefacto que trae un fichero DEBAJO de esa misma ruta.
fn artefacto_con_fichero(dir: &Path) {
std::fs::create_dir_all(dir.join("usr/share/pkg")).unwrap();
std::fs::write(dir.join("usr/share/pkg/archivo"), b"contenido").unwrap();
}
#[test]
fn un_symlink_del_fhs_que_sale_del_root_no_deja_escribir() {
let fuera = tempfile::tempdir().unwrap();
let fhs = tempfile::tempdir().unwrap();
let a = tempfile::tempdir().unwrap();
let b = tempfile::tempdir().unwrap();
artefacto_con_symlink(a.path(), fuera.path());
hydrate(a.path(), fhs.path(), LinkMode::Static, None).unwrap();
artefacto_con_fichero(b.path());
let r = hydrate(b.path(), fhs.path(), LinkMode::Static, None);
assert!(r.is_err(), "hidratar a través del symlink tiene que FALLAR, no devolver Ok");
let msg = format!("{}", r.unwrap_err());
assert!(msg.contains("se saldría del root"), "el error tiene que decir qué pasó: {msg}");
assert!(
!fuera.path().join("archivo").exists(),
"ESCAPÓ: el fichero se escribió fuera del target_fhs"
);
}
/// El contrapeso: un symlink de directorio que se queda DENTRO tiene que seguir funcionando.
/// Es el caso de usr-merge (`/lib → usr/lib`) y el de los temas de iconos (`22@2x → 22`), o sea
/// la mayoría de los symlinks de directorio que hay en el corpus. Un guardián que los rompiera
/// sería peor que el agujero que tapa.
#[test]
fn un_symlink_que_se_queda_dentro_sigue_hidratando() {
let fhs = tempfile::tempdir().unwrap();
let a = tempfile::tempdir().unwrap();
let b = tempfile::tempdir().unwrap();
// `usr/share/pkg → ../otro` (relativo y dentro), con el destino existiendo.
std::fs::create_dir_all(a.path().join("usr/otro")).unwrap();
std::fs::write(a.path().join("usr/otro/.marca"), b"x").unwrap();
std::fs::create_dir_all(a.path().join("usr/share")).unwrap();
std::os::unix::fs::symlink("../otro", a.path().join("usr/share/pkg")).unwrap();
hydrate(a.path(), fhs.path(), LinkMode::Static, None).unwrap();
artefacto_con_fichero(b.path());
let r = hydrate(b.path(), fhs.path(), LinkMode::Static, None);
assert!(r.is_ok(), "un symlink que no sale del root no puede bloquear la hidratación: {r:?}");
assert!(
fhs.path().join("usr/otro/archivo").exists(),
"el fichero tiene que aterrizar donde apunta el symlink, dentro del root"
);
}
+73
View File
@@ -0,0 +1,73 @@
//! Guardián de SDD 25 §7-H4: **ningún camino de spawn de hammer usa `pre_exec`**.
//!
//! POR QUÉ. `std::process::Command` de Rust usa `posix_spawn` cuando puede, y `posix_spawn` es
//! PLANO respecto al tamaño del padre: a 256 MB tocados, `fork+exec` costó 3 949 µs contra 231 µs
//! (SDD 25 T1, **17×**). En cuanto se le pone un `pre_exec`, Rust apaga ese camino y cae a
//! `fork+exec`, que copia el espacio de direcciones del padre. hammer lanza builds desde un
//! proceso que puede tener cientos de MB mapeados.
//!
//! Hoy `hammer-build::sandbox` lanza `bwrap` sin `pre_exec` — y hasta ahora eso era SUERTE, no una
//! regla: nadie lo comprobaba. Esto lo vuelve una regla.
//!
//! CUÁNDO SE PUEDE APAGAR. Si algún día hace falta de verdad (un `setsid`, un `unshare` que bwrap
//! no cubra), la respuesta no es borrar el test: es escribir acá por qué ese caso paga los 17× y
//! ponerlo en la lista de excepciones, para que el precio quede a la vista de quien lo lea.
use std::path::{Path, PathBuf};
fn raiz_del_workspace() -> PathBuf {
// CARGO_MANIFEST_DIR = crates/hammer-build
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(Path::parent)
.expect("crates/<crate> tiene dos ancestros")
.to_path_buf()
}
fn rs_de(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
rs_de(&p, out);
} else if p.extension().is_some_and(|x| x == "rs") {
out.push(p);
}
}
}
#[test]
fn ningun_spawn_de_hammer_usa_pre_exec() {
let raiz = raiz_del_workspace();
let crates = raiz.join("crates");
assert!(crates.is_dir(), "no encontré {} — ¿se movió el workspace?", crates.display());
let mut fuentes = Vec::new();
rs_de(&crates, &mut fuentes);
// Un barrido que no encuentra fuentes aprueba por vacío, que es el fallo de CLAUDE.md §3.
assert!(fuentes.len() > 20, "sólo {} fuentes: el barrido no miró nada", fuentes.len());
let mut culpables = Vec::new();
for f in &fuentes {
if f == Path::new(file!()) || f.ends_with("tests/sin_pre_exec.rs") {
continue;
}
let Ok(txt) = std::fs::read_to_string(f) else { continue };
for (i, l) in txt.lines().enumerate() {
let limpia = l.trim_start();
if limpia.starts_with("//") || limpia.starts_with("*") {
continue; // documentarlo no es usarlo
}
if l.contains("pre_exec") {
culpables.push(format!("{}:{}: {}", f.display(), i + 1, l.trim()));
}
}
}
assert!(
culpables.is_empty(),
"SDD 25 §7-H4: `pre_exec` apaga el camino `posix_spawn` de Rust y devuelve el spawn a \
`fork+exec` (17× más caro con 256 MB en el padre, SDD 25 T1). Aparece en:\n {}",
culpables.join("\n ")
);
}