//! El laboratorio: compila una receta de forma hermética y la sella en el store; e hidrata //! artefactos al FHS. Ver `docs/02-build-lab.md` y `docs/03-hydration.md`. use std::collections::HashMap; use std::path::{Path, PathBuf}; use takana_core::{ArtifactHash, Compiler, LinkMode, Phases, Recipe, Store}; pub mod config; pub mod download; pub mod fetch; pub mod harkaq; pub mod hydrate; pub mod sandbox; pub mod swm_bridge; pub use config::BuildConfig; pub use hydrate::{hydrate as run_hydrate, DynamicSpec, HydrateReport, HydratedFile}; pub use sandbox::{BuildFailure, Sandbox}; pub use swm_bridge::build_source_patch; /// Calcula el `ArtifactHash` de una receta, resolviendo recursivamente sus deps de build. /// Ver `docs/02-build-lab.md` §2 y §5. /// /// **Catálogo = `base_dir` de la receta.** Cada nombre en `deps.build` se resuelve al hermano /// `/.toml`; su hash entra en el de la receta (Merkle sobre el subgrafo de build). /// Así dos recetas con la misma fuente pero distinto grafo de deps hashean distinto, y cambiar /// una dep transitiva invalida la caché de todo lo que la usa. /// /// Errores: una receta con `deps.build` pero sin `base_dir` (parseada de TOML crudo, no cargada /// de disco) no puede resolver su catálogo; un `.toml` ausente, o un ciclo de deps, también /// fallan con un mensaje que nombra la cadena. pub fn artifact_hash( recipe: &Recipe, _store: &Store, lab: &takana_core::lab::LabFingerprint, ) -> takana_core::Result { let mut stack: Vec = Vec::new(); // Memo por path canónico de receta: el grafo de build es un DAG-diamante (p.ej. TODO KF6/Qt // converge en qtbase/kcoreaddons, alcanzables por decenas de rutas). Sin memoizar, el walk // recursivo es exponencial en NÚMERO DE CAMINOS y cuelga en paquetes grandes (plasma-workspace, // ~90 deps). El hash de una receta es función pura de su subárbol ⇒ computar una vez por nodo. // Clave = path resuelto (no `name`): dos variantes homónimas (incoming-kde/libxml2 vs canónica) // no deben colisionar. Ver granja-promote-colisiones. let mut memo: HashMap = HashMap::new(); artifact_hash_rec(recipe, &mut stack, &mut memo, lab) } fn artifact_hash_rec( recipe: &Recipe, stack: &mut Vec, memo: &mut HashMap, lab: &takana_core::lab::LabFingerprint, ) -> takana_core::Result { if stack.iter().any(|n| n == &recipe.name) { stack.push(recipe.name.clone()); return Err(takana_core::Error::Recipe(format!( "ciclo de dependencias de build: {}", stack.join(" -> ") ))); } let dep_hashes = resolve_build_dep_hashes(recipe, stack, memo, lab)?; let inputs = recipe.hash_inputs(&dep_hashes, lab)?; let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect(); Ok(ArtifactHash::of_inputs(&refs)) } /// Resuelve la ruta de una dep de build: primero como HERMANO (`base_dir/{dep}.toml`), y si no /// existe, en el catálogo PADRE (`base_dir/../{dep}.toml`). El fallback al padre permite que una /// receta en `recipes/incoming/` (cola de staging) dependa de una del corpus `recipes/` sin /// duplicarla ni symlinkearla — destraba la frontera de deps-del-corpus (openssl, zlib, …) en la /// granja, que construye desde `incoming/`. Si ninguna existe, devuelve la ruta hermana (para que /// el error apunte al lugar esperado). fn resolve_dep_path(base_dir: &Path, dep: &str) -> PathBuf { let sibling = base_dir.join(format!("{dep}.toml")); if sibling.exists() { return sibling; } if let Some(parent) = base_dir.parent() { let in_parent = parent.join(format!("{dep}.toml")); if in_parent.exists() { return in_parent; } } sibling } /// Resuelve y hashea, en orden de declaración, cada `deps.build` de `recipe` cargándolo del /// catálogo (su `base_dir`). `stack` lleva la cadena en curso para detectar ciclos. fn resolve_build_dep_hashes( recipe: &Recipe, stack: &mut Vec, memo: &mut HashMap, lab: &takana_core::lab::LabFingerprint, ) -> takana_core::Result> { if recipe.deps.build.is_empty() { return Ok(Vec::new()); } if recipe.base_dir.as_os_str().is_empty() { return Err(takana_core::Error::Recipe(format!( "la receta '{}' declara deps de build {:?} pero no tiene base_dir; \ cárgala con Recipe::load_from_path para resolver el catálogo", recipe.name, recipe.deps.build ))); } stack.push(recipe.name.clone()); let mut hashes = Vec::with_capacity(recipe.deps.build.len()); for dep in &recipe.deps.build { let dep_path = resolve_dep_path(&recipe.base_dir, dep); // Clave del memo: path canónico (colapsa `./a/../b` y symlinks a una identidad estable). let key = dep_path.canonicalize().unwrap_or_else(|_| dep_path.clone()); if let Some(h) = memo.get(&key) { hashes.push(h.clone()); continue; } let dep_recipe = Recipe::load_from_path(&dep_path).map_err(|e| { takana_core::Error::Recipe(format!( "no pude cargar la dep de build '{dep}' de '{}' ({}): {e}", recipe.name, dep_path.display() )) })?; let h = artifact_hash_rec(&dep_recipe, stack, memo, lab)?; memo.insert(key, h.clone()); hashes.push(h); } stack.pop(); Ok(hashes) } /// Construye (si falta) cada `deps.build` y devuelve sus directorios sellados en el store, para /// apilarlos como capas overlay del sandbox (variante b: bwrap necesita libcap.a + sys/capability.h, /// ausentes del toolchain Alpine). Recursivo vía `build`: una dep con sus propias deps se materializa /// transitivamente. Los ciclos ya los corta `artifact_hash` (con su `stack`) antes de llegar acá. fn materialize_build_deps( recipe: &Recipe, cfg: &BuildConfig, store: &Store, ) -> takana_core::Result> { if recipe.deps.build.is_empty() { return Ok(Vec::new()); } if recipe.base_dir.as_os_str().is_empty() { return Err(takana_core::Error::Recipe(format!( "la receta '{}' declara deps de build {:?} pero no tiene base_dir; \ cárgala con Recipe::load_from_path para resolver el catálogo", recipe.name, recipe.deps.build ))); } let mut dirs = Vec::with_capacity(recipe.deps.build.len()); for dep in &recipe.deps.build { let dep_path = resolve_dep_path(&recipe.base_dir, dep); let dep_recipe = Recipe::load_from_path(&dep_path).map_err(|e| { takana_core::Error::Recipe(format!( "no pude cargar la dep de build '{dep}' de '{}' ({}): {e}", recipe.name, dep_path.display() )) })?; let dep_hash = build(&dep_recipe, cfg, store)?; dirs.push(store.path_of(&dep_hash, &dep_recipe.name)); } Ok(dirs) } /// Resuelve el directorio de zig para una receta: el por defecto del lab (`cfg.zig_dir`), salvo que /// la receta fije `build.zig_version`, en cuyo caso se usa el zig de esa versión, ubicado como /// **hermano** del por defecto (`<…>/zig-x86_64-linux-`, el layout de `.dev-fs/tools/`). /// Escotilla por-receta para esquivar la regresión de zig 0.14 sin tocar el toolchain global. fn effective_zig_dir(cfg: &BuildConfig, recipe: &Recipe) -> takana_core::Result { let Some(version) = &recipe.build.zig_version else { return Ok(cfg.zig_dir.clone()); }; let parent = cfg.zig_dir.parent().unwrap_or_else(|| Path::new(".")); let dir = parent.join(format!("zig-x86_64-linux-{version}")); if !dir.join("zig").is_file() { return Err(takana_core::Error::Recipe(format!( "zig_version=\"{version}\": no encuentro el ejecutable zig en {} \ (bajá ese zig a {}/)", dir.display(), parent.display(), ))); } Ok(dir) } /// Compila una receta y devuelve el hash del artefacto sellado en el store. /// Si el hash ya existe en el store, devuelve sin recompilar (caché). pub fn build( recipe: &Recipe, cfg: &BuildConfig, store: &Store, ) -> takana_core::Result { // La huella sale del rootfs de ESTE cfg, que es el lab que de verdad va a compilar — no de // `docs/state/lab-toolchain.lock` ni de una ruta derivada del store. Derivarla del padre del // store ya mordió una vez (ver `BuildConfig::defaults_for_store_with_lab`): al anclar el store // de un worker a un volumen, el lab se buscaba donde no estaba. `cfg.rootfs` respeta // `TAKANA_LAB`/`TAKANA_ROOTFS` y es la única fuente que no puede desincronizarse del build. let lab = takana_core::lab::LabFingerprint::from_rootfs(&cfg.rootfs)?; let h = artifact_hash(recipe, store, &lab)?; if store.has(&h, &recipe.name) { tracing::info!(hash = %h, name = %recipe.name, "caché: artefacto ya en el store"); return Ok(h); } // zig efectivo de ESTA receta: el por defecto del lab, salvo que la receta fije `zig_version` // (escotilla para esquivar la regresión de zig 0.14 en ciertos binarios musl; ver SDD 11 §7.2b). let zig_dir = effective_zig_dir(cfg, recipe)?; sandbox::ensure_layout(&cfg.rootfs, &zig_dir)?; // Materializa las deps de build (las construye si faltan) ANTES del fetch: sus árboles sellados // se apilan como capas overlay del sandbox para que headers/libs/pkgconfig estén en /usr. let dep_dirs = materialize_build_deps(recipe, cfg, store)?; if !dep_dirs.is_empty() { tracing::info!(n = dep_dirs.len(), "deps de build materializadas como capas overlay"); } tracing::info!(hash = %h, name = %recipe.name, "fetch"); // ADR 0012 DECIDIDO (2026-09-22): el árbol de fuentes se toma con un lock EXCLUSIVO por receta // que vive hasta el `seal` —`_guarda_arbol` es RAII—, no sólo durante la extracción. Un lock que // cubriera nada más el `tar -x` no cierra la carrera: el árbol se muta después (patches, // vendoreo) y se bindea para compilar. Ver la doc de `fetch::GuardaArbol` para por qué esto NO // serializa la granja (dos recetas distintas no comparten su árbol) ni puede deadlockear (las // deps se construyen ANTES, así que los locks no se anidan). let (src_tree, _guarda_arbol) = fetch::fetch(recipe, &cfg.work_root)?; // RE-CHEQUEO tras el lock (double-checked locking). Si dos procesos pedían la MISMA receta, el // que esperó encuentra el artefacto ya sellado por el otro y sale por cache hit en vez de // reconstruirlo — que además dejaría dos `seal` compitiendo por el mismo nombre en el store. if store.has(&h, &recipe.name) { tracing::info!(hash = %h, name = %recipe.name, "caché: otro build lo selló mientras esperábamos el árbol"); return Ok(h); } if !recipe.source.patches.is_empty() { tracing::info!(n = recipe.source.patches.len(), "aplicando patches"); fetch::apply_patches(recipe, &src_tree)?; } // Receta efectiva para resolver las FASES: puede ganar un `-p ` si la fuente es un // workspace Cargo virtual (ver inject_cargo_package_selector). El hash `h` ya está fijado desde // los flags ORIGINALES de la receta; el `-p` es resolución determinista del lab (misma fuente → // mismo paquete) ⇒ no afecta la reproducibilidad ni el sidecar (que guarda la receta original). let mut eff_recipe = recipe.clone(); // Recetas Cargo: vendoreamos las deps (red permitida en el fetch) para que el build sea // hermético `--offline` dentro del sandbox. Tras los patches, por si tocaron Cargo.toml/lock. // `cargo_vendor = true` fuerza esta rama en árboles POLÍGLOTAS, donde `detect_build_system` // —que asume un sistema por árbol— eligió el de C. librsvg es el arquetipo: `configure.ac` gana // por precedencia, pero su Makefile llama a `cargo build`, y sin vendoreo ese cargo sale a la // red dentro del sandbox hermético. Ver `Source::cargo_vendor`. if matches!(detect_build_system(&src_tree), BuildSys::Cargo) || eff_recipe.source.cargo_vendor == Some(true) { // El source se copia DENTRO del repo takana (que es un workspace Cargo) ⇒ si el Cargo.toml // de la fuente NO declara su propio `[workspace]`, cargo cree que pertenece al workspace de // takana y `cargo vendor` aborta. Inyectamos un `[workspace]` vacío para aislarla. SÓLO si // no lo tiene ya (las recetas del corpus que lo parchean a mano siguen funcionando sin // duplicar la tabla). Esto desbloquea genéricamente los imports Rust (Etapa G) sin un patch // por receta. Ver memoria 'cargo-recipe crate suelto'. ensure_cargo_workspace_isolation(&src_tree)?; // Extractor de workspaces (Etapa G): si el root es virtual, resuelve `-p ` del bin. // SÓLO cuando el árbol ES una receta Cargo. En un políglota (`cargo_vendor = true`) el // producto lo arma el sistema de build de C —que llama a cargo con sus propios argumentos— // y meterle un `-p` a los flags de la receta sería inyectar una opción de Cargo en un // `./configure`. Acá el forzado pide vendoreo, nada más. if matches!(detect_build_system(&src_tree), BuildSys::Cargo) { inject_cargo_package_selector(&mut eff_recipe, &src_tree); } tracing::info!("cargo: vendoreando deps para build offline"); fetch::vendor_cargo_deps( &src_tree, eff_recipe.source.cargo_vendor_dir.as_deref().unwrap_or("vendor"), )?; } // Recetas Go: análogo a Cargo. Si el árbol trae `go.mod`, vendoreamos los módulos (red en el // fetch) para que `go build -mod=vendor` resuelva offline en el sandbox. El toolchain `go` lo // aporta `deps.build = ["go"]` (recipes/go.toml) DENTRO del sandbox; el vendoring usa el `go` // del host (lo provee el lab). No hay BuildSys::Go: las recetas Go fijan el compile en // `[build.phases]` (ver tandas/staged-2026-06-26/amfora.toml como plantilla). // El módulo Go puede estar en un subdir (recipe `build.subdir`, p.ej. dolt→go); vendoreamos ahí. let go_root = if eff_recipe.build.subdir.is_empty() { src_tree.clone() } else { src_tree.join(&eff_recipe.build.subdir) }; if go_root.join("go.mod").is_file() || go_root.join("go.work").is_file() { tracing::info!("go: vendoreando módulos para build offline"); fetch::vendor_go_deps(&go_root)?; } let out_dir = unique_out_dir(&cfg.work_root, &recipe.name, &h)?; let mut env: Vec<(String, String)> = Vec::new(); if matches!(recipe.build.link, LinkMode::Static) { // autotools/cmake leen LDFLAGS para el link final; -static fuerza el binario // monolítico que pide nuestra estrategia de hidratación primaria. env.push(("LDFLAGS".into(), "-static".into())); } // El `compiler` de la receta decide el CC/CXX/AR reales; el default del sandbox es `zig cc`. // Esto vuelve DECLARATIVO el escape a un toolchain real (gcc de Alpine = musl + GNU `ld`) para // paquetes que el `lld` estricto de zig NO linkea (símbolos duplicados, flags que zig filtra — // ver Etapa G, lz4). `self.env` pisa los defaults del sandbox. gcc/clang default a x86-64 // genérico (reproducible, sin `-march=native`), no hace falta `-mcpu=baseline`. match recipe.build.compiler { Compiler::ZigCc => {} Compiler::Gcc => { env.push(("CC".into(), "gcc".into())); env.push(("CXX".into(), "g++".into())); env.push(("AR".into(), "ar".into())); } Compiler::Clang => { env.push(("CC".into(), "clang".into())); env.push(("CXX".into(), "clang++".into())); env.push(("AR".into(), "llvm-ar".into())); } } // Autotools (Etapa G Fase 3): exporta CBUILD/CHOST con el triple nativo SANEADO del sandbox // (`x86_64-linux-musl`, el mismo que el wrapper zig-cc emite). Las recetas importadas de Alpine // traducen `./configure --build=$CBUILD --host=$CHOST` literal del abuild (`translate_abuild` los // deja intactos a propósito); sin estas envs `$CBUILD/$CHOST` quedan VACÍAS y autotools cae a // `config.guess`, que detecta `x86_64-alpine-linux-musl` — vendor que zig NO conoce // (`UnknownOperatingSystem`). Resuelve el residuo autotools que antes era trabajo a mano. Inerte // para Cargo/CMake/Meson (no leen estas envs). env.push(("CBUILD".into(), SANDBOX_NATIVE_TARGET.to_string())); env.push(("CHOST".into(), SANDBOX_NATIVE_TARGET.to_string())); // *-sys C-FFI (Etapa G): si la receta declara `openssl` como dep, apuntá `openssl-sys` a la // openssl takana-built materializada en /usr (estática, musl). Sin esto openssl-sys busca en el // host y aborta ("Could not find directory of OpenSSL installation"). Condicional al dep ⇒ no // rompe recetas que usen openssl vendored. Destraba la frontera cargo-* (reqwest/git tools). if recipe.deps.build.iter().any(|d| d == "openssl") { env.push(("OPENSSL_DIR".into(), "/usr".into())); env.push(("OPENSSL_STATIC".into(), "1".into())); env.push(("OPENSSL_NO_VENDOR".into(), "1".into())); } if let Some(cache) = &cfg.cache_root { std::fs::create_dir_all(cache.join("zig"))?; } let sb = Sandbox { rootfs: cfg.rootfs.clone(), zig_dir: zig_dir.clone(), src_dir: src_tree.clone(), out_dir: out_dir.clone(), cache_dir: cfg.cache_root.clone(), deps: dep_dirs, env, }; let phases = resolve_phases(&eff_recipe, &src_tree)?; if let Some(cmd) = &phases.configure { tracing::info!(cmd, "phase: configure"); sb.run(cmd)?; } if let Some(cmd) = &phases.compile { tracing::info!(cmd, "phase: compile"); sb.run(cmd)?; } else { return Err(takana_core::Error::Recipe( "receta sin fase 'compile' y heurística no encontró ninguna".into(), )); } if let Some(cmd) = &phases.install { tracing::info!(cmd, "phase: install"); sb.run(cmd)?; } else { return Err(takana_core::Error::Recipe( "receta sin fase 'install' y heurística no encontró ninguna".into(), )); } // ── SEPARAR LA INFO DE DEPURACIÓN (SDD 23) ────────────────────────────────────────────────── // Corre DESPUÉS del install, sobre `/out`, y sólo si la receta lo pide con `strip_debug = true`. // // POR QUÉ ACÁ Y NO EN LA FASE `install` DE CADA RECETA: de las 1161 recetas, **383 no tienen // fase install explícita** — usan la heurística por defecto. Meterlo receta a receta obligaría a // escribir a mano ese install por defecto en las 383, con el riesgo de no clavarlo exacto y // cambiar el comportamiento sin querer. El lab lo hace una vez, igual para todas. // // ⚠ ESTO CAMBIA EL CONTENIDO DEL ARTEFACTO, así que el campo que lo activa **entra en // `hash_inputs`** (ver la doc de `Build::strip_debug`). Si no entrara, el lab podría cambiar lo // que sale del build sin mover el hash y el store MENTIRÍA. // // `zig objcopy` en vez del `strip` de binutils: zig SIEMPRE está en el sandbox (`/opt/zig`), así // que activar esto no obliga a añadir una dep de build a cada receta. // // El `|| true` por fichero es deliberado: `/out` tiene scripts, datos y cabeceras además de ELF, // y objcopy falla en ellos. Fallar el build por no poder strippear un `.h` sería absurdo; lo que // importa es que los ELF queden sin `.debug_*`. if recipe.build.strip_debug == Some(true) { // ⚠ A FICHERO TEMPORAL Y LUEGO `mv` — NUNCA in-place. `zig objcopy --strip-debug X X` // (mismo fichero como entrada y salida) **TRUNCA EL FICHERO A 0 BYTES**. El 2026-08-08 eso // destruyó los artefactos del piloto, y lo peor es que los TRES indicadores decían que iba // bien: el tamaño cayó un 84% (porque los ficheros quedaron vacíos), `why-differs` dijo // REPRODUCE (porque dos árboles vacíos son idénticos) y no quedaban secciones `.debug_` // (porque no quedaba ninguna sección). Se cazó al ejecutar el binario: 0 bytes. // ⇒ La verificación de un artefacto tiene que incluir que SIGA FUNCIONANDO, no sólo que // pese menos y reproduzca. Un artefacto vacío cumple las dos y no sirve para nada. // Herramienta: el `strip` de **binutils**, no `zig objcopy`. Se intentó con zig porque está // siempre en el sandbox y no obligaría a declarar una dep — pero no se pudo confirmar que su // `objcopy` acepte `--strip-debug`, y el resultado fue un no-op silencioso: los binarios // quedaron intactos y el tamaño no bajó. Preferible una dep explícita que funciona a una // comodidad que no se sabe si hace algo. // ⇒ Las recetas con `strip_debug = true` deben declarar `binutils` en `[deps].build`. // // ⚠ `-D` (= `--enable-deterministic-archives`) NO ES OPCIONAL. Sin él, `strip` reescribe los // `.a` con los timestamps/uid/gid reales de cada corrida ⇒ el artefacto sigue divergiendo, // sólo que ahora por la CABECERA `ar` en vez de por `.debug_*`. Medido en el piloto: quitar // el debug arregló una fuga de no-determinismo y abrió otra, y `why-differs` la nombró // exacto («ar-cabecera → archivar en modo determinista»). Arreglar media causa deja el // invariante igual de roto. let cmd = r#"find /out -type f \( -perm -u+x -o -name '*.so' -o -name '*.so.*' -o -name '*.a' \) -exec sh -c 'strip -D --strip-debug "$1" 2>/dev/null || true' _ {} \;"#; tracing::info!("phase: strip-debug (separando .debug_* del artefacto)"); sb.run(cmd)?; } // Sidecar de provenance: escribimos la receta serializada DENTRO de out_dir antes del // seal, para que quede congelada en el árbol read-only del store. `takana export` la // lee de vuelta para emitir `source_patch` con la receta original en vez de un // `file_drop` opaco. Ver `docs/04-overlay.md` §provenance (Fase 4). let sidecar_path = out_dir.join(takana_core::store::RECIPE_SIDECAR_REL); if let Some(parent) = sidecar_path.parent() { std::fs::create_dir_all(parent)?; } let toml_text = recipe.to_toml()?; std::fs::write(&sidecar_path, toml_text.as_bytes())?; let sealed = store.seal(&out_dir, &h, &recipe.name)?; tracing::info!(path = %sealed.display(), "sealed"); Ok(h) } /// El resultado de correr un check de evidencia, con su veredicto (H1b). #[derive(Debug, Clone)] pub struct EvidenceOutcome { pub kind: takana_core::EvidenceKind, pub cmd: String, pub outcome: takana_core::CheckOutcome, } /// Reporte de la ejecución de TODA la evidencia de una receta (H1b). `all_passed` es el gate: /// si es `false`, el bucle agéntico NO debe proponer la mutación (SDD 15 §H1: "falla ⇒ no se propone"). #[derive(Debug, Clone)] pub struct EvidenceReport { pub results: Vec, } impl EvidenceReport { pub fn all_passed(&self) -> bool { self.results.iter().all(|r| r.outcome.passed) } /// El estrato de confianza MÁS ALTO que pasó (para reportar "verificado hasta "). `None` /// si no hay checks. No promete que ese nivel *baste* — sólo que lo declarado a ese nivel pasó. pub fn max_level_passed(&self) -> Option { self.results .iter() .filter(|r| r.outcome.passed) .map(|r| r.kind) .max() } } /// Corre la evidencia de comportamiento de una receta (H1b, proof-carrying recipes). Reproduce el /// artefacto (cache-hit si ya está sellado), levanta un sandbox reproducible con la fuente + /// build-deps + el artefacto instalado montado como capa overlay (sus `/usr/bin` en PATH ⇒ los /// smoke tests tipo `bin --version` encuentran el binario), y ejecuta cada check comparando /// (exit, blake3(stdout)) contra lo declarado. Es "un runner de comandos con hash del output", no un /// framework — la confianza vive acá (pequeño y auditable), no en el generador de la receta. /// /// Frontera honesta: garantiza que *lo declarado pasa*, no que *lo declarado basta* (eso lo juzga el /// humano). Los checks corren con el entorno BASE del sandbox; un check que recompila (proptest) usa /// el toolchain que traigan las build-deps en PATH. pub fn run_evidence( recipe: &Recipe, cfg: &BuildConfig, store: &Store, ) -> takana_core::Result { recipe.evidence.validate()?; if recipe.evidence.checks.is_empty() { return Ok(EvidenceReport { results: Vec::new() }); } // 1. Reproducir: construir/sellar el artefacto (cache-hit si existe). let h = build(recipe, cfg, store)?; let artifact_dir = store.path_of(&h, &recipe.name); // 2. Preparar el sandbox: mismas capas que el build (rootfs + build-deps) MÁS el artefacto // instalado, para que sus binarios estén en /usr/bin (PATH). La fuente se re-materializa // para los checks que la necesitan (proptest/kani corren `cargo test` en /src). let zig_dir = effective_zig_dir(cfg, recipe)?; sandbox::ensure_layout(&cfg.rootfs, &zig_dir)?; let mut dep_dirs = materialize_build_deps(recipe, cfg, store)?; dep_dirs.push(artifact_dir); // Misma guarda que el build: este camino re-materializa el árbol y le aplica los patches, o sea // que MUTA estado compartido. Sin el lock, verificar la evidencia de una receta podía romper el // árbol de un build en curso de la misma receta. let (src_tree, _guarda_arbol) = fetch::fetch(recipe, &cfg.work_root)?; if !recipe.source.patches.is_empty() { fetch::apply_patches(recipe, &src_tree)?; } let out_dir = unique_out_dir(&cfg.work_root, &format!("{}-evidence", recipe.name), &h)?; let sb = Sandbox { rootfs: cfg.rootfs.clone(), zig_dir, src_dir: src_tree, out_dir, cache_dir: cfg.cache_root.clone(), deps: dep_dirs, env: Vec::new(), }; // 3. Ejecutar cada check y evaluarlo (la comparación exit/hash es pura, vive en takana-core). let mut results = Vec::with_capacity(recipe.evidence.checks.len()); for c in &recipe.evidence.checks { tracing::info!(kind = c.kind.as_str(), cmd = %c.cmd, "evidence: check"); let out = sb.run_capture(&c.cmd)?; let outcome = c.evaluate(out.exit_code, &out.stdout); tracing::info!(passed = outcome.passed, detail = %outcome.detail, "evidence: veredicto"); results.push(EvidenceOutcome { kind: c.kind, cmd: c.cmd.clone(), outcome, }); } Ok(EvidenceReport { results }) } /// Sistemas de build que reconoce la heurística por inspección del árbol del source. /// Ver `docs/02-build-lab.md` §4. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum BuildSys { /// `configure.ac` (o `.in`) sin `configure` generado: hay que correr `autoreconf` primero. AutoconfRaw, /// `configure` ya está en el tarball release: vamos directo a `./configure`. AutoconfReady, /// `CMakeLists.txt`. CMake, /// `meson.build`. Meson, /// `Cargo.toml`: crate o workspace Rust. Build con `cargo`, link con `zig cc`. Cargo, /// `go.mod`: módulo Go. Build con `go install` del paquete main (auto-detectado), /// deps vendoreadas en el fetch (`vendor_go_deps`). El toolchain `go` llega por deps.build. Go, /// `Makefile` plano (o `makefile`) sin sistema generador. Make, /// No detectamos nada: la receta debe traer overrides en `[build.phases]`. Unknown, } /// ¿El `Cargo.toml` raíz es un MANIFIESTO VIRTUAL? (declara `[workspace]` pero NO `[package]`). /// Es el patrón del ecosistema CLI Rust moderno (sd, fd, …): el root sólo agrupa miembros y el /// binario vive en un sub-paquete. En ese caso `cargo … --bin X` solo falla: hace falta `-p `. /// Un crate suelto (con `[package]`, aunque `ensure_cargo_workspace_isolation` le agregue /// `[workspace]`) NO es virtual ⇒ no se toca (ripgrep y el corpus quedan idénticos). fn cargo_root_is_virtual(src: &Path) -> bool { let Ok(text) = std::fs::read_to_string(src.join("Cargo.toml")) else { return false; }; let mut has_ws = false; let mut has_pkg = false; for l in text.lines() { let t = l.trim_start(); if t.starts_with("[workspace]") || t.starts_with("[workspace.") { has_ws = true; } if t.starts_with("[package]") { has_pkg = true; } } has_ws && !has_pkg } /// Lee los MEMBERS del workspace (vía `cargo metadata`, la fuente autoritativa: expande globs, /// `[[bin]]`, bins por convención `src/bin/*.rs`, y workspaces anidados — todo lo que un parseo a /// mano del TOML erraría) y devuelve el NOMBRE del paquete que expone un bin llamado `bin`. Es el /// "extractor de workspaces" de la Etapa G: convierte un workspace virtual en un `-p ` exacto. fn resolve_cargo_bin_package(src: &Path, bin: &str) -> Option { let out = std::process::Command::new("cargo") .args(["metadata", "--no-deps", "--format-version", "1"]) .current_dir(src) .output() .ok()?; if !out.status.success() { return None; } let meta: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?; for pkg in meta.get("packages")?.as_array()? { let pkg_name = pkg.get("name")?.as_str()?; for tgt in pkg.get("targets")?.as_array()? { let is_bin = tgt .get("kind") .and_then(|k| k.as_array()) .map(|ks| ks.iter().any(|x| x.as_str() == Some("bin"))) .unwrap_or(false); if is_bin && tgt.get("name").and_then(|n| n.as_str()) == Some(bin) { return Some(pkg_name.to_string()); } } } None } /// Si la fuente es un workspace VIRTUAL y los flags piden `--bin X` sin `-p`, resuelve el paquete /// real que expone `X` y antepone `-p `. Deja los flags intactos en cualquier otro caso /// (crate suelto, ya con `-p`, o bin irresoluble). Determinista (misma fuente → mismo paquete) ⇒ /// reproducible. Sin esto, casi todo el CLI Rust moderno (workspaces virtuales) exigía `-p` a mano. fn inject_cargo_package_selector(recipe: &mut Recipe, src: &Path) { let flags = &recipe.build.flags; if flags.iter().any(|f| f == "-p" || f == "--package") { return; // el autor ya eligió el paquete } if !cargo_root_is_virtual(src) { return; // crate suelto: `--bin` solo alcanza } // bin = el argumento que sigue a `--bin`. let Some(bin) = flags.iter().position(|f| f == "--bin").and_then(|i| flags.get(i + 1)).cloned() else { return; }; if let Some(pkg) = resolve_cargo_bin_package(src, &bin) { tracing::info!(%bin, %pkg, "cargo: workspace virtual ⇒ -p resuelto automáticamente"); let mut new_flags = vec!["-p".to_string(), pkg]; new_flags.extend(recipe.build.flags.iter().cloned()); recipe.build.flags = new_flags; } } /// Garantiza que el `Cargo.toml` raíz de la fuente declare un `[workspace]` propio, para que cargo /// NO la considere parte del workspace del repo takana (que la contiene en `work/sources/`). Append /// idempotente: si ya hay `[workspace]` (crate suelto que es su propio workspace, o una receta que /// lo parchea), no toca nada. Caso virtual-manifest (root ya es `[workspace]`) tampoco se toca. fn ensure_cargo_workspace_isolation(src: &Path) -> takana_core::Result<()> { let manifest = src.join("Cargo.toml"); let text = std::fs::read_to_string(&manifest)?; // Heurística simple: ¿hay una tabla `[workspace]` (al inicio de línea)? Cubre `[workspace]` y // `[workspace.members]`/`[workspace.dependencies]` (un root virtual siempre trae alguna). let has_workspace = text .lines() .any(|l| l.trim_start().starts_with("[workspace]") || l.trim_start().starts_with("[workspace.")); if !has_workspace { let mut patched = text; if !patched.ends_with('\n') { patched.push('\n'); } patched.push_str("\n[workspace]\n"); std::fs::write(&manifest, patched)?; tracing::info!("cargo: inyectado [workspace] vacío para aislar la fuente del repo hammer"); } Ok(()) } fn detect_build_system(src: &Path) -> BuildSys { let has = |p: &str| src.join(p).exists(); // `go.mod`/`go.work` PRIMERO: un módulo Go suele traer también un `configure`/`Makefile` AUXILIAR // (man pages, completions, packaging), pero el binario se hace con `go build`. Ningún paquete // C/Rust del corpus trae go.mod. `go.work` cubre los WORKSPACE multimódulo cuya RAÍZ no tiene // go.mod (sólo go.work + submódulos) — sin esto caían a Makefile/Unknown. if has("go.mod") || has("go.work") { return BuildSys::Go; } // El orden importa: cuando coexisten varios (un tarball release de autotools trae el // `configure` regenerado y un `Makefile` huérfano de un build previo), preferimos el // sistema "más alto" que sí sabe regenerar todo. if has("configure.ac") || has("configure.in") { if has("configure") { return BuildSys::AutoconfReady; } return BuildSys::AutoconfRaw; } if has("configure") { return BuildSys::AutoconfReady; } if has("CMakeLists.txt") { return BuildSys::CMake; } if has("meson.build") { return BuildSys::Meson; } // Cargo gana sobre un `Makefile` huérfano (algunos crates traen un wrapper `make`), // pero cede ante autotools/cmake/meson (un políglota C+Rust suele querer el sistema C). if has("Cargo.toml") { return BuildSys::Cargo; } if has("Makefile") || has("makefile") || has("GNUmakefile") { return BuildSys::Make; } BuildSys::Unknown } /// Detecta el directorio del paquete `main` PRINCIPAL de un módulo Go, relativo a `src`, para /// `go install`. Busca por FILESYSTEM (no compila ⇒ no se contamina con mains de ejemplo rotos, /// como los de `docs/`/`scripts/` que rompen `go list ./...`): archivos `.go` cuya primera línea /// significativa es `package main`, descartando dirs auxiliares (vendor/docs/examples/tests/ /// scripts/tools/internal y ocultos/`_`). Elige el principal por heurística: raíz > `cmd/` > /// menor profundidad. Devuelve un path estilo go (`.`, `./cmd/mlr`). Una receta puede forzar el /// paquete vía `build.flags` (gana sobre la auto-detección). fn detect_go_main(src: &Path, pkg_name: &str) -> String { fn is_main_go(path: &Path) -> bool { let Ok(text) = std::fs::read_to_string(path) else { return false }; // El primer token significativo de un .go es la cláusula `package` (tras build-constraints // `//go:build`, comentarios de línea y el header de licencia). Hay que saltar comentarios de // BLOQUE `/* ... */` correctamente: muchos headers Apache son `/*\nCopyright ...\n*/` y las // líneas internas NO arrancan con `*` ⇒ un filtro por-línea ingenuo cortaría ahí y fallaría // (p.ej. cmd/helm/helm.go). Escaneamos rastreando el estado dentro/fuera de bloque. let mut in_block = false; for line in text.lines() { let mut l = line.trim(); if in_block { match l.find("*/") { Some(i) => { l = l[i + 2..].trim(); in_block = false; } None => continue, } } if l.is_empty() || l.starts_with("//") { continue; } if let Some(i) = l.find("/*") { let before = l[..i].trim(); if !before.is_empty() { return before.starts_with("package main"); } match l[i + 2..].find("*/") { Some(j) => { let after = l[i + 2 + j + 2..].trim(); if after.is_empty() { continue; } return after.starts_with("package main"); } None => { in_block = true; continue; } } } return l.starts_with("package main"); } false } const SKIP: &[&str] = &[ "vendor", "docs", "doc", "example", "examples", "test", "tests", "testdata", "script", "scripts", "tools", "internal", "third_party", ]; let mut found: Vec = Vec::new(); let mut stack = vec![src.to_path_buf()]; while let Some(dir) = stack.pop() { let Ok(entries) = std::fs::read_dir(&dir) else { continue }; let mut has_main = false; for e in entries.flatten() { let p = e.path(); if p.is_dir() { let name = e.file_name().to_string_lossy().to_string(); if name.starts_with('.') || name.starts_with('_') || SKIP.contains(&name.as_str()) { continue; } stack.push(p); } else if !has_main && p.extension().map(|x| x == "go").unwrap_or(false) && !p.to_string_lossy().ends_with("_test.go") && is_main_go(&p) { has_main = true; } } if has_main { if let Ok(rel) = dir.strip_prefix(src) { found.push(rel.to_path_buf()); } } } // raíz (prioridad 0) > cmd/ (1) > resto (2); a igual clase, el dir cuyo nombre coincide con el // de la receta (p.ej. cmd/eksctl para la receta `eksctl`) gana sobre otros mains hermanos (cmd/ // schema, un helper de codegen que panica) — sin esto detect elegía arbitrariamente entre varios // mains y sellaba el binario EQUIVOCADO; luego, a igual nombre, menor profundidad. let key = |p: &PathBuf| -> (u8, u8, usize) { let s = p.to_string_lossy(); let depth = p.components().count(); let name_match = if p.file_name().map(|f| f == pkg_name).unwrap_or(false) { 0 } else { 1 }; let class = if s.is_empty() { 0 } else if s.starts_with("cmd/") || s == "cmd" { 1 } else { 2 }; (class, name_match, depth) }; found.sort_by_key(key); match found.first() { Some(p) if !p.as_os_str().is_empty() => format!("./{}", p.to_string_lossy()), _ => ".".to_string(), } } /// Target nativo del sandbox del lab (Alpine x86_64 musl). Una receta Cargo cuyo `build.target` /// sea éste se construye **nativa** (sin `--target`): el rust del sandbox sólo trae su std nativo /// (`x86_64-alpine-linux-musl`), no el `x86_64-unknown-linux-musl` que pediría un cross, y no hay /// rustup para agregarlo. Ver `docs/runbooks/stage1-vm-boot.md` §8 (primera corrida). const SANDBOX_NATIVE_TARGET: &str = "x86_64-linux-musl"; /// Traduce el triple estilo zig/hammer (`x86_64-linux-musl`) al de rustc /// (`x86_64-unknown-linux-musl`). Un triple ya completo (con vendor) o ajeno al patrón /// `-linux-` se devuelve tal cual: la receta puede dar el triple exacto. fn rustc_triple(target: &str) -> String { match target.split('-').collect::>().as_slice() { [arch, "linux", libc] => format!("{arch}-unknown-linux-{libc}"), _ => target.to_string(), } } /// Nombre de la env `CARGO_TARGET__LINKER` para un triple de rustc. fn cargo_linker_env_var(triple: &str) -> String { format!("CARGO_TARGET_{}_LINKER", triple.to_uppercase().replace(['-', '.'], "_")) } /// Devuelve los comandos a ejecutar para cada fase: overrides explícitos en la receta /// ganan; el resto lo deriva la heurística según el sistema de build detectado. fn resolve_phases(recipe: &Recipe, src: &Path) -> takana_core::Result { let mut out = recipe.build.phases.clone(); // Raíz efectiva del módulo Go: `/` si la receta lo fija (dolt→go, csvtk→subdir), // si no `src`. detect/detect_go_main operan ahí; el compile hace `cd ` primero. let go_src = if recipe.build.subdir.is_empty() { src.to_path_buf() } else { src.join(&recipe.build.subdir) }; let bs = detect_build_system(&go_src); let flags = recipe.build.flags.join(" "); let flags_suffix = if flags.is_empty() { String::new() } else { format!(" {flags}") }; // Autotools (Etapa G Fase 3): fija `--build`/`--host` al triple nativo saneado para que // `config.guess` no detecte el vendor `alpine` que zig rechaza, e iguala lo que el wrapper zig-cc // produce. Sólo si la receta no los puso ya en sus flags (juicio per-paquete gana). build==host // ⇒ NATIVO (no cross): autotools sigue corriendo sus AC_RUN tests; sólo normaliza el triple. let autotools_triple = if recipe.build.flags.iter().any(|f| { f.starts_with("--build=") || f.starts_with("--host=") }) { String::new() } else { format!(" --build={SANDBOX_NATIVE_TARGET} --host={SANDBOX_NATIVE_TARGET}") }; if out.configure.is_none() { out.configure = match bs { BuildSys::AutoconfRaw => Some(format!( "autoreconf -fi && ./configure --prefix=/usr{autotools_triple}{flags_suffix}" )), BuildSys::AutoconfReady => { Some(format!("./configure --prefix=/usr{autotools_triple}{flags_suffix}")) } BuildSys::CMake => Some(format!( "cmake -S . -B _build -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Release{flags_suffix}" )), BuildSys::Meson => Some(format!("meson setup _build --prefix=/usr{flags_suffix}")), // Cargo/Go no tienen fase configure separada. BuildSys::Cargo | BuildSys::Go | BuildSys::Make | BuildSys::Unknown => None, }; } if out.compile.is_none() { out.compile = match bs { BuildSys::AutoconfRaw | BuildSys::AutoconfReady | BuildSys::Make => { Some(r#"make -j"$(nproc)""#.to_string()) } BuildSys::CMake => Some(r#"cmake --build _build -j "$(nproc)""#.to_string()), BuildSys::Meson => Some("meson compile -C _build".to_string()), BuildSys::Cargo => { // Hermético y reproducible: `--offline` (deps vendoreadas en la fuente) + // `--locked` (Cargo.lock fijo, coherente con ADR 0006). El link va por un // wrapper `zig cc` — cargo no acepta un linker de dos palabras, y zig provee // el `libgcc_s` que el link dinámico musl pide (ver M2 del plan arje↔takana). if recipe.build.target == SANDBOX_NATIVE_TARGET { // NATIVO: el sandbox del lab YA es x86_64 musl, así que no cruzamos. El rust del // sandbox sólo trae su std nativo (`x86_64-alpine-linux-musl`), no // `x86_64-unknown-linux-musl`; con `--target` fallaría "can't find crate for // core" (no hay rustup). Ver docs/runbooks/stage1-vm-boot.md §8. // // El wrapper `.hammer-zig-cc` hace DOS cosas: (1) es un único ejecutable, lo que // resuelve el `zig cc` de dos palabras que cargo (linker) y cc-rs (CC) // mal-parsean; (2) **sanea el triple**: cc-rs (build scripts que compilan C/asm, // p.ej. blake3) detecta el host triple de Alpine rust y pasa // `--target=x86_64-alpine-linux-musl`, pero zig no conoce el vendor "alpine" // (`UnknownOperatingSystem`); lo reescribimos a `x86_64-linux-musl`. Se usa como // CC (cc-rs) y como linker (RUSTFLAGS, target-agnóstico — el default sería // cc/gcc, ausente en el sandbox zig-only). // Flags de codegen del BINARIO FINAL, vía `cargo rustc -- …` (NO RUSTFLAGS): en // build nativo RUSTFLAGS también alcanza a los proc-macros (deps), y // `+crt-static`/`relocation-model=static` los rompe ("cannot produce // proc-macro" — un proc-macro es un dylib PIC). `cargo rustc -- …` los aplica // sólo a la crate top-level, dejando los proc-macros intactos. let bin_flags = match recipe.build.link { // Estático AUTOCONTENIDO y no-PIE: `+crt-static` mete musl dentro (sin // DT_NEEDED — el rust de Alpine enlaza musl dinámico por defecto, lo que deja // `libc.musl-x86_64.so.1` y rompe el boot, runbook §8); `relocation-model=static` // ⇒ ET_EXEC sin interpreter (vía de oro: como busybox, sin libc.so/loader/soname). LinkMode::Static => "-C target-feature=+crt-static -C relocation-model=static", // Dinámico: apaga crt-static para poder dlopen (necesita el loader/musl en el rootfs). LinkMode::Dynamic => "-C target-feature=-crt-static", }; // `-C link-self-contained=no` CONDICIONAL: el link (de TODO — deps, // build-scripts, proc-macros y la crate top) lo hace `zig cc`, que ya aporta // sus startfiles (crt1.o). Un rust VANILLA con target musl // `x86_64-unknown-linux-musl` trae CRT AUTOCONTENIDO (self-contained/rcrt1.o) y // lo pasaría también ⇒ `ld.lld: duplicate symbol: _start`; hay que apagarlo. PERO // el rust de Alpine (host `x86_64-alpine-linux-musl`) PARCHEA la opción para que // sea un ERROR ("option `-C link-self-contained` is not supported on this // target") y NO usa self-contained — el baseline 9adefb82 linkea con zig sin // chocar. Así que: añadir el flag SÓLO si el host del rustc NO es `-alpine-` // (i.e. un rust vanilla como el takana-rust del frente self-host); para Alpine, // omitirlo. RUSTFLAGS alcanza a todas las unidades; los `bin_flags` (cargo rustc // --) sólo a la top. // CC para los build-scripts (cc-rs compila el C de deps como libgit2-sys, // openssl-sys, libc-stdhandle). Por defecto = el wrapper zig-cc. PERO zig-cc // MISCOMPILA/RECHAZA muchos de esos C (mismo frente que file/jq en C puro: la // granja lo reveló — delta/gitui/jless mueren ahí). Con `compiler="gcc"` la receta // declara el GUETO: cc-rs usa gcc (el Alpine musl gcc del sandbox, probado en C), // y el linker sigue siendo zig-cc — los .o/.a de gcc-musl y el Rust zig-musl son // ABI-compatibles (ambos musl, mismo ELF x86-64). Aditivo: una receta Rust sin // `compiler=` queda en ZigCc ⇒ baseline (ripgrep/fd/…) intacto. let cc_for_scripts = match recipe.build.compiler { Compiler::Gcc => "gcc", Compiler::Clang => "clang", Compiler::ZigCc => "$PWD/.hammer-zig-cc", }; Some(format!( "printf '%s\\n' '#!/bin/sh' 'for a do' \ 'case \"$a\" in --target=*) a=--target=x86_64-linux-musl ;; esac' \ 'set -- \"$@\" \"$a\"' 'shift' 'done' 'exec zig cc -mcpu=baseline \"$@\"' \ > \"$PWD/.hammer-zig-cc\" && chmod +x \"$PWD/.hammer-zig-cc\" && \ RF=\"-C linker=$PWD/.hammer-zig-cc\"; \ rustc -vV | grep -q 'host: .*-alpine-' || RF=\"$RF -C link-self-contained=no\"; \ CC=\"{cc_for_scripts}\" RUSTFLAGS=\"$RF\" \ cargo rustc --release --locked --offline{flags_suffix} -- {bin_flags}" )) } else { // CROSS real (otro target que zig sí entiende): `--target` + linker por env // per-triple. Hermético y reproducible: `--offline` (deps vendoreadas) + // `--locked` (Cargo.lock fijo, ADR 0006). zig provee el `libgcc_s` que el link // dinámico musl pide (ver M2 del plan arje↔takana). let setup = "printf '#!/bin/sh\\nexec zig cc -mcpu=baseline \"$@\"\\n' > \"$PWD/.hammer-zig-cc\" && \ chmod +x \"$PWD/.hammer-zig-cc\" && "; let triple = rustc_triple(&recipe.build.target); let linker_var = cargo_linker_env_var(&triple); let rustflags = match recipe.build.link { // musl es crt-static por defecto; el dinámico lo apaga para poder dlopen. LinkMode::Dynamic => "RUSTFLAGS='-C target-feature=-crt-static' ", LinkMode::Static => "", }; Some(format!( "{setup}{rustflags}{linker_var}=\"$PWD/.hammer-zig-cc\" \ cargo build --release --locked --offline --target {triple}{flags_suffix}" )) } } BuildSys::Go => { // Un solo patrón GENÉRICO para todo proyecto Go: `go install` del paquete main // auto-detectado (o el que fije `build.flags`). go install nombra el binario solo // (cmd/mlr→mlr, raíz→) y lo deja en GOBIN=/out/usr/bin ⇒ no hace falta saber // el nombre. Offline+reproducible: vendor (vendor_go_deps) + GOPROXY=off + -trimpath // -buildid=. El toolchain `go` llega por deps.build=["go"] (overlay). let pkg = if recipe.build.flags.is_empty() { detect_go_main(&go_src, &recipe.name) } else { flags.clone() }; // Si el módulo está en subdir, el compile entra ahí primero (el sandbox arranca en /src). let cd_prefix = if recipe.build.subdir.is_empty() { String::new() } else { format!("cd {} && ", recipe.build.subdir) }; // GOCACHE/GOTMPDIR en /src (disk-backed, per-build, lo limpia el watchdog), NO en // /tmp: el sandbox monta `--tmpfs /tmp` (~½ RAM); un proyecto Go ENORME (telegraf, // vault) desborda ese tmpfs con sus objetos compilados + el temp del linker al mapear // el binario → "link: mapping output file failed: no space left on device" aunque el // disco tenga sitio. /src es el árbol de fuentes bind-montado (en disco) y no se sella // (sólo /out) ⇒ inocuo. GOTMPDIR debe pre-existir. No entra al hash de input (es la // fase generada, no un campo de la receta) ⇒ no re-sella el corpus Go. // CGO opt-in (recipe `cgo = true`): drivers con C bundled (sqlite3 de mattn en // usql/sq/dolt) necesitan cgo. CC="zig cc" (musl nativo del sandbox) + link estático // por el linker externo (`-linkmode=external -extldflags=-static`). Default: CGO off // (Go puro estático). Validado: usql (sqlite, estático y corre). let (cgo_env, ldflags) = if recipe.build.cgo { ( // ⚠ `-mcpu=baseline` NO es opcional y este sitio lo perdía: el sandbox // exporta `CC` apuntando a un wrapper que ya lo pone (ver `sandbox.rs`), y // esta línea lo PISABA con `zig cc` a secas — que por defecto es // `-mcpu=native` y hornea la ISA del builder. El síntoma no aparece al // compilar ni al sellar: aparece al EJECUTAR en otra CPU, como // `trap invalid opcode` (SIGILL). Medido 2026-09-14 con `gitea` dentro de // QEMU-TCG: el binario construido en el worker moría al arrancar, y el // mismo con baseline corre. Afecta a toda receta `cgo = true`. "CGO_ENABLED=1 CC=\"zig cc -mcpu=baseline\" CXX=\"zig c++ -mcpu=baseline\"", "-ldflags=\"-buildid= -linkmode=external -extldflags=-static\"", ) } else { ("CGO_ENABLED=0", "-ldflags=-buildid=") }; // ── GOTMPDIR: DENTRO del módulo con cgo apagado, FUERA con cgo encendido ────── // `-trimpath` reescribe rutas, pero cuál gana depende de dónde esté el work dir: // · si cae DENTRO del módulo, gana la reescritura del módulo y el componente // ALEATORIO `go-build` SOBREVIVE; // · si cae FUERA, Go reescribe el work dir entero a `/tmp/go-build` y el número // desaparece. // Con CGO off da igual: no hay fuentes C generadas, así que ninguna ruta del work // dir entra en el DWARF — las 15 recetas Go puras verificadas REPRODUCEN. Con cgo // sí entra, y ésa era la ÚNICA causa del no-determinismo de `gocryptfs`: dos // reconstrucciones difieren en SIETE bytes, todos en `.debug_str`, y son los // dígitos de `go-build598798091` contra `go-build270591055`. // // Medido con un módulo cgo de juguete, cachés limpias y en los dos sentidos // (2026-09-14): // · GOTMPDIR dentro ⇒ `go-build3806217352` aparece en el binario; dos builds DIFIEREN // · GOTMPDIR fuera ⇒ ninguna cadena `go-build`; dos builds IDÉNTICOS // // ⚠ Va a `/cache`, NO a `/tmp`: el sandbox monta `--tmpfs /tmp` (~½ RAM) y un // proyecto Go grande lo desborda con sus objetos — es la razón por la que esto // estaba en `/src` de entrada. `/cache` es un bind RW a disco (el mismo que usa la // caché de zig), así que conserva la propiedad de estar en disco Y queda fuera del // módulo. // // ⚠ Y se COMPRUEBA que `/cache` exista en vez de dar por hecho que está: con // `--tmp-overlay /` la raíz es escribible, así que un `mkdir -p /cache/gotmp` a // ciegas TRIUNFARÍA creando el directorio en la capa tmpfs — o sea en RAM, que es // exactamente el desbordamiento que se quería evitar, y en silencio. Si no está, // el build muere diciendo por qué. let gotmp_setup = if recipe.build.cgo { "[ -d /cache ] || { echo '!! falta el bind /cache: GOTMPDIR caería en la capa tmpfs (RAM) y un proyecto Go grande la desborda' >&2; exit 1; }; mkdir -p /cache/gotmp" } else { "mkdir -p /src/.gotmp" }; let gotmpdir = if recipe.build.cgo { "/cache/gotmp" } else { "/src/.gotmp" }; Some(format!( "{cd_prefix}export GOCACHE=/src/.gocache GOTMPDIR={gotmpdir} GOPATH=/tmp/gopath \ GOTOOLCHAIN=local {cgo_env} GOFLAGS=-mod=vendor GOPROXY=off \ GOBIN=/out/usr/bin; {gotmp_setup}; \ go install -trimpath {ldflags} {pkg}" )) } BuildSys::Unknown => None, }; } if out.install.is_none() { out.install = match bs { BuildSys::AutoconfRaw | BuildSys::AutoconfReady | BuildSys::Make => { Some("make install DESTDIR=/out PREFIX=/usr".to_string()) } BuildSys::CMake => Some("DESTDIR=/out cmake --install _build".to_string()), BuildSys::Meson => Some("DESTDIR=/out meson install -C _build --no-rebuild".to_string()), BuildSys::Cargo => { // Copia los ejecutables finales (top del dir release, bit de ejecución) a // /out/usr/bin. `-perm -100` / `-exec cp \;` son busybox-safe (rootfs Alpine). // Build nativo ⇒ `target/release`; cross ⇒ `target//release`. let rel = if recipe.build.target == SANDBOX_NATIVE_TARGET { "target/release".to_string() } else { format!("target/{}/release", rustc_triple(&recipe.build.target)) }; Some(format!( "mkdir -p /out/usr/bin && \ find {rel} -maxdepth 1 -type f -perm -100 \ -exec cp {{}} /out/usr/bin/ \\;" )) } // `go install` ya dejó los binarios en GOBIN=/out/usr/bin: nada que hacer. BuildSys::Go => Some("true".to_string()), BuildSys::Unknown => None, }; } Ok(out) } #[cfg(test)] mod heuristic_tests { use super::*; fn touch(dir: &Path, name: &str) { std::fs::write(dir.join(name), b"").unwrap(); } fn recipe(flags: &[&str]) -> Recipe { let mut r = Recipe::from_toml( r#" name = "x" version = "0" [source] repo = "git://x" commit = "deadbeef" [build] "#, ) .unwrap(); r.build.flags = flags.iter().map(|s| (*s).to_string()).collect(); r } #[test] fn go_subdir_compiles_in_subdir() { // dolt-like: go.mod en subdir `go/`. Con build.subdir="go" el lab detecta Go ahí y el compile // entra al subdir primero. let d = tempfile::tempdir().unwrap(); std::fs::create_dir_all(d.path().join("go/cmd/dolt")).unwrap(); touch(d.path(), "go/go.mod"); std::fs::write(d.path().join("go/cmd/dolt/main.go"), b"package main\nfunc main(){}\n").unwrap(); let mut r = recipe(&[]); r.name = "dolt".into(); r.build.subdir = "go".into(); let p = resolve_phases(&r, d.path()).unwrap(); let c = p.compile.unwrap(); assert!(c.starts_with("cd go && "), "entra al subdir: {c}"); assert!(c.contains("go install -trimpath") && c.trim_end().ends_with("./cmd/dolt"), "{c}"); } #[test] fn go_mod_wins_over_configure_and_derives_go_install() { // miller-like: go.mod + configure auxiliar ⇒ Go gana, y el compile es `go install`. let d = tempfile::tempdir().unwrap(); touch(d.path(), "go.mod"); touch(d.path(), "configure"); std::fs::write(d.path().join("main.go"), b"package main\nfunc main(){}\n").unwrap(); assert_eq!(detect_build_system(d.path()), BuildSys::Go); let p = resolve_phases(&recipe(&[]), d.path()).unwrap(); assert!(p.configure.is_none()); let c = p.compile.unwrap(); assert!(c.contains("go install -trimpath"), "{c}"); assert!(c.ends_with(" ."), "raíz ⇒ paquete `.`: {c}"); assert_eq!(p.install.as_deref(), Some("true")); } #[test] fn go_cgo_knob_switches_compile_env() { // cgo=false (default) ⇒ CGO_ENABLED=0, Go puro; cgo=true ⇒ CGO_ENABLED=1 + CC zig + estático. let d = tempfile::tempdir().unwrap(); touch(d.path(), "go.mod"); std::fs::write(d.path().join("main.go"), b"package main\nfunc main(){}\n").unwrap(); let off = resolve_phases(&recipe(&[]), d.path()).unwrap().compile.unwrap(); assert!(off.contains("CGO_ENABLED=0"), "{off}"); let mut r = recipe(&[]); r.build.cgo = true; let on = resolve_phases(&r, d.path()).unwrap().compile.unwrap(); assert!(on.contains("CGO_ENABLED=1") && on.contains("zig cc") && on.contains("-extldflags=-static"), "{on}"); } #[test] fn detect_go_when_only_go_work() { // workspace multimódulo sin go.mod en la raíz (sólo go.work) ⇒ sigue siendo Go (antes caía a // Makefile/Unknown). El vendoring usará `go work vendor`. let d = tempfile::tempdir().unwrap(); touch(d.path(), "go.work"); touch(d.path(), "Makefile"); // un Makefile auxiliar no debe ganarle a Go assert_eq!(detect_build_system(d.path()), BuildSys::Go); } #[test] fn detect_go_main_picks_cmd_over_docs() { // main real en cmd/mlr + main de ejemplo roto en docs/ ⇒ elige cmd/mlr, ignora docs. let d = tempfile::tempdir().unwrap(); for sub in ["cmd/mlr", "docs/example"] { std::fs::create_dir_all(d.path().join(sub)).unwrap(); std::fs::write(d.path().join(sub).join("main.go"), b"package main\nfunc main(){}\n").unwrap(); } assert_eq!(detect_go_main(d.path(), "mlr"), "./cmd/mlr"); } #[test] fn detect_go_main_prefers_cmd_matching_recipe_name() { // eksctl-like: cmd/eksctl (CLI real) + cmd/schema (helper que panica). El dir que coincide // con el name de la receta gana sobre el hermano ⇒ no sella el binario equivocado. let d = tempfile::tempdir().unwrap(); for sub in ["cmd/schema", "cmd/eksctl"] { std::fs::create_dir_all(d.path().join(sub)).unwrap(); std::fs::write(d.path().join(sub).join("main.go"), b"package main\nfunc main(){}\n").unwrap(); } assert_eq!(detect_go_main(d.path(), "eksctl"), "./cmd/eksctl"); } #[test] fn detect_go_main_sees_main_under_block_comment_header() { // header de licencia `/* ... */` con líneas internas SIN `*` (estilo Apache de helm): // el main en cmd/helm debe detectarse igual (regresión del corte ingenuo por-línea). let d = tempfile::tempdir().unwrap(); std::fs::create_dir_all(d.path().join("cmd/helm")).unwrap(); std::fs::write( d.path().join("cmd/helm/helm.go"), b"/*\nCopyright The Helm Authors.\nLicensed under the Apache License 2.0\n*/\n\npackage main\n\nfunc main() {}\n", ) .unwrap(); assert_eq!(detect_go_main(d.path(), "helm"), "./cmd/helm"); } #[test] fn detect_autoconf_raw_when_only_configure_ac() { let d = tempfile::tempdir().unwrap(); touch(d.path(), "configure.ac"); assert_eq!(detect_build_system(d.path()), BuildSys::AutoconfRaw); let p = resolve_phases(&recipe(&[]), d.path()).unwrap(); assert!(p.configure.as_deref().unwrap().starts_with("autoreconf -fi &&")); assert!(p.install.as_deref().unwrap().contains("DESTDIR=/out")); } #[test] fn detect_autoconf_ready_when_configure_exists() { let d = tempfile::tempdir().unwrap(); touch(d.path(), "configure.ac"); touch(d.path(), "configure"); assert_eq!(detect_build_system(d.path()), BuildSys::AutoconfReady); let p = resolve_phases(&recipe(&["--enable-foo"]), d.path()).unwrap(); let c = p.configure.unwrap(); assert!(c.starts_with("./configure --prefix=/usr"), "{c}"); assert!(c.ends_with("--enable-foo"), "{c}"); } #[test] fn autotools_injects_native_triple() { // Etapa G Fase 3: configure heurístico fija --build/--host al triple saneado. let d = tempfile::tempdir().unwrap(); touch(d.path(), "configure.ac"); touch(d.path(), "configure"); let c = resolve_phases(&recipe(&[]), d.path()).unwrap().configure.unwrap(); assert!(c.contains("--build=x86_64-linux-musl"), "{c}"); assert!(c.contains("--host=x86_64-linux-musl"), "{c}"); } #[test] fn autotools_triple_yields_to_recipe_flags() { // Si la receta ya fija el triple (juicio per-paquete), no duplicamos. let d = tempfile::tempdir().unwrap(); touch(d.path(), "configure.ac"); touch(d.path(), "configure"); let c = resolve_phases(&recipe(&["--host=aarch64-linux-musl"]), d.path()) .unwrap() .configure .unwrap(); assert!(c.contains("--host=aarch64-linux-musl"), "{c}"); assert!(!c.contains("x86_64-linux-musl"), "no inyecta si la receta ya lo puso ({c})"); } #[test] fn cmake_ignores_autotools_triple() { // El triple es exclusivo de autotools; CMake/Meson/Cargo no lo reciben. let d = tempfile::tempdir().unwrap(); touch(d.path(), "CMakeLists.txt"); let c = resolve_phases(&recipe(&[]), d.path()).unwrap().configure.unwrap(); assert!(!c.contains("--host="), "{c}"); } #[test] fn detect_cmake() { let d = tempfile::tempdir().unwrap(); touch(d.path(), "CMakeLists.txt"); assert_eq!(detect_build_system(d.path()), BuildSys::CMake); let p = resolve_phases(&recipe(&[]), d.path()).unwrap(); assert!(p.configure.unwrap().contains("-DCMAKE_INSTALL_PREFIX=/usr")); assert!(p.compile.unwrap().contains("cmake --build _build")); assert!(p.install.unwrap().contains("DESTDIR=/out")); } #[test] fn detect_meson() { let d = tempfile::tempdir().unwrap(); touch(d.path(), "meson.build"); assert_eq!(detect_build_system(d.path()), BuildSys::Meson); let p = resolve_phases(&recipe(&[]), d.path()).unwrap(); assert!(p.configure.unwrap().contains("meson setup _build")); assert!(p.install.unwrap().contains("meson install")); } #[test] fn detect_cargo() { let d = tempfile::tempdir().unwrap(); touch(d.path(), "Cargo.toml"); assert_eq!(detect_build_system(d.path()), BuildSys::Cargo); // Target default (x86_64-linux-musl) = nativo del sandbox ⇒ build SIN --target. let p = resolve_phases(&recipe(&[]), d.path()).unwrap(); assert!(p.configure.is_none(), "cargo no usa configure"); let c = p.compile.unwrap(); // Nativo: `cargo rustc` (no `cargo build`) para pasar los flags de codegen tras `--` sólo // a la crate final; sin `--target` (lo cross sí; el único --target es el del wrapper saneador). assert!(c.contains("cargo rustc --release"), "{c}"); assert!(c.contains("--locked") && c.contains("--offline"), "{c}"); assert!(!c.contains("--offline --target"), "nativo: cargo sin --target ({c})"); assert!(c.contains("a=--target=x86_64-linux-musl"), "wrapper sanea el triple ({c})"); assert!(c.contains("-- -C target-feature=+crt-static"), "crt-static scoped tras -- ({c})"); assert!(c.contains("relocation-model=static"), "no-PIE: ET_EXEC sin interpreter ({c})"); assert!(c.contains("CC=\"$PWD/.hammer-zig-cc\""), "CC = wrapper para cc-rs ({c})"); // El linker se arma en `RF` (que además gana `-C link-self-contained=no` cuando el host no es // Alpine) y se aplica vía `RUSTFLAGS="$RF"`. assert!(c.contains("RF=\"-C linker=$PWD/.hammer-zig-cc"), "linker en RF ({c})"); assert!(c.contains("RUSTFLAGS=\"$RF\""), "RF aplicado por RUSTFLAGS ({c})"); let i = p.install.unwrap(); assert!(i.contains("/out/usr/bin"), "{i}"); assert!(i.contains("find target/release"), "nativo instala de target/release ({i})"); } #[test] fn cargo_gcc_ghetto_uses_gcc_for_build_scripts() { // Receta Rust con `compiler="gcc"`: el GUETO para crates con dep-C (libgit2-sys/openssl-sys/ // libc-stdhandle) que zig-cc rechaza. cc-rs debe usar gcc (CC=gcc), pero el LINKER sigue // siendo el wrapper zig-cc (objetos gcc-musl + Rust zig-musl son ABI-compatibles). let d = tempfile::tempdir().unwrap(); touch(d.path(), "Cargo.toml"); let mut r = recipe(&[]); r.build.compiler = Compiler::Gcc; let c = resolve_phases(&r, d.path()).unwrap().compile.unwrap(); assert!(c.contains("CC=\"gcc\""), "cc-rs usa gcc en el gueto ({c})"); assert!(!c.contains("CC=\"$PWD/.hammer-zig-cc\""), "CC ya no es el wrapper ({c})"); // El linker NO cambia: sigue el wrapper zig-cc. assert!(c.contains("RF=\"-C linker=$PWD/.hammer-zig-cc"), "linker sigue zig-cc ({c})"); assert!(c.contains("cargo rustc --release"), "sigue el camino cargo nativo ({c})"); } #[test] fn cargo_default_compiler_keeps_zig_cc_for_build_scripts() { // Sin `compiler=` (default ZigCc): CC sigue siendo el wrapper — baseline ripgrep/fd intacto. let d = tempfile::tempdir().unwrap(); touch(d.path(), "Cargo.toml"); let c = resolve_phases(&recipe(&[]), d.path()).unwrap().compile.unwrap(); assert!(c.contains("CC=\"$PWD/.hammer-zig-cc\""), "default: cc-rs usa el wrapper zig ({c})"); } #[test] fn cargo_cross_target_keeps_triple() { // Un target NO nativo (cross real) sí pasa --target + el linker per-triple. let d = tempfile::tempdir().unwrap(); touch(d.path(), "Cargo.toml"); let mut r = recipe(&[]); r.build.target = "aarch64-linux-musl".into(); let p = resolve_phases(&r, d.path()).unwrap(); let c = p.compile.unwrap(); assert!(c.contains("--target aarch64-unknown-linux-musl"), "{c}"); assert!(c.contains("CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER="), "{c}"); assert!(p.install.unwrap().contains("target/aarch64-unknown-linux-musl/release")); } #[test] fn cargo_wins_over_stray_makefile_but_not_cmake() { let d = tempfile::tempdir().unwrap(); touch(d.path(), "Cargo.toml"); touch(d.path(), "Makefile"); assert_eq!(detect_build_system(d.path()), BuildSys::Cargo); // pero un proyecto C+Rust con CMakeLists prefiere el sistema C touch(d.path(), "CMakeLists.txt"); assert_eq!(detect_build_system(d.path()), BuildSys::CMake); } #[test] fn cargo_flags_go_to_compile() { let d = tempfile::tempdir().unwrap(); touch(d.path(), "Cargo.toml"); let p = resolve_phases(&recipe(&["--features", "gpu"]), d.path()).unwrap(); // Los flags de la receta van como args de cargo (antes del `--` que separa los de rustc). assert!(p.compile.unwrap().contains("--features gpu -- ")); } #[test] fn cargo_dynamic_disables_crt_static() { let d = tempfile::tempdir().unwrap(); touch(d.path(), "Cargo.toml"); let mut r = recipe(&[]); r.build.link = takana_core::LinkMode::Dynamic; let c = resolve_phases(&r, d.path()).unwrap().compile.unwrap(); assert!(c.contains("target-feature=-crt-static"), "{c}"); } #[test] fn rustc_triple_translation() { assert_eq!(rustc_triple("x86_64-linux-musl"), "x86_64-unknown-linux-musl"); assert_eq!(rustc_triple("aarch64-linux-gnu"), "aarch64-unknown-linux-gnu"); // un triple ya completo pasa intacto assert_eq!(rustc_triple("x86_64-unknown-linux-musl"), "x86_64-unknown-linux-musl"); assert_eq!( cargo_linker_env_var("x86_64-unknown-linux-musl"), "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER" ); } #[test] fn detect_make() { let d = tempfile::tempdir().unwrap(); touch(d.path(), "Makefile"); assert_eq!(detect_build_system(d.path()), BuildSys::Make); let p = resolve_phases(&recipe(&[]), d.path()).unwrap(); assert!(p.configure.is_none(), "make plano no necesita configure"); assert!(p.compile.unwrap().starts_with("make")); } #[test] fn unknown_returns_no_phases() { let d = tempfile::tempdir().unwrap(); // sólo un README, ningún archivo característico. std::fs::write(d.path().join("README"), b"hi").unwrap(); assert_eq!(detect_build_system(d.path()), BuildSys::Unknown); let p = resolve_phases(&recipe(&[]), d.path()).unwrap(); assert!(p.compile.is_none()); assert!(p.install.is_none()); } #[test] fn recipe_override_wins_over_heuristic() { let d = tempfile::tempdir().unwrap(); touch(d.path(), "Makefile"); let mut r = recipe(&[]); r.build.phases.compile = Some("zig cc -static -o foo foo.c".to_string()); r.build.phases.install = Some("cp foo /out/bin/foo".to_string()); let p = resolve_phases(&r, d.path()).unwrap(); assert_eq!(p.compile.unwrap(), "zig cc -static -o foo foo.c"); assert_eq!(p.install.unwrap(), "cp foo /out/bin/foo"); } } fn unique_out_dir( work_root: &Path, name: &str, h: &ArtifactHash, ) -> takana_core::Result { let dir = work_root .join("out") .join(h.store_dir_name(name)); if dir.exists() { std::fs::remove_dir_all(&dir)?; } std::fs::create_dir_all(&dir)?; Ok(dir) } /// Proyecta un artefacto del store al FHS (real o de overlay). Resuelve el directorio del /// artefacto a partir del hash y delega en [`hydrate::hydrate`]. pub fn hydrate( h: &ArtifactHash, store: &Store, target_fhs: &Path, mode: LinkMode, dynamic: Option<&hydrate::DynamicSpec>, ) -> takana_core::Result { let artifact_dir = store.find_by_hash(h.as_str())?; hydrate::hydrate(&artifact_dir, target_fhs, mode, dynamic) } #[cfg(test)] mod dep_hash_tests { use super::*; /// Escribe `/.toml` con `source.commit = commit` y, si hay, `deps.build = deps`. fn write_recipe(dir: &Path, name: &str, commit: &str, deps: &[&str]) { let deps_block = if deps.is_empty() { String::new() } else { let list = deps.iter().map(|d| format!("\"{d}\"")).collect::>().join(", "); format!("\n[deps]\nbuild = [{list}]\n") }; let toml = format!( "name = \"{name}\"\nversion = \"1\"\n[source]\nrepo = \"git://{name}\"\ncommit = \"{commit}\"\n[build]\n{deps_block}" ); std::fs::write(dir.join(format!("{name}.toml")), toml).unwrap(); } fn load(dir: &Path, name: &str) -> Recipe { Recipe::load_from_path(dir.join(format!("{name}.toml"))).unwrap() } fn store_in(dir: &Path) -> Store { Store::open(dir.join("store")).unwrap() } #[test] fn dep_contributes_to_hash() { let d = tempfile::tempdir().unwrap(); let store = store_in(d.path()); write_recipe(d.path(), "base", "b0", &[]); write_recipe(d.path(), "app", "a0", &["base"]); write_recipe(d.path(), "app_nodep", "a0", &[]); // misma fuente, sin la dep let with_dep = artifact_hash(&load(d.path(), "app"), &store, &takana_core::lab::LabFingerprint::for_tests()).unwrap(); let no_dep = artifact_hash(&load(d.path(), "app_nodep"), &store, &takana_core::lab::LabFingerprint::for_tests()).unwrap(); // `app_nodep` se llama distinto, pero el nombre no entra al hash: la única diferencia // de entrada es la presencia de la dep. (Renombramos sólo para tener dos .toml.) assert_ne!(with_dep, no_dep, "la dep de build debe entrar al hash"); } #[test] fn transitive_dep_change_propagates() { let d = tempfile::tempdir().unwrap(); let store = store_in(d.path()); write_recipe(d.path(), "base", "b0", &[]); write_recipe(d.path(), "lib", "l0", &["base"]); write_recipe(d.path(), "app", "a0", &["lib"]); let before = artifact_hash(&load(d.path(), "app"), &store, &takana_core::lab::LabFingerprint::for_tests()).unwrap(); // Cambiar la fuente de la dep transitiva `base` debe re-hashear `app`. write_recipe(d.path(), "base", "b1", &[]); let after = artifact_hash(&load(d.path(), "app"), &store, &takana_core::lab::LabFingerprint::for_tests()).unwrap(); assert_ne!(before, after, "un cambio transitivo debe propagarse"); } #[test] fn missing_dep_toml_errors_with_names() { let d = tempfile::tempdir().unwrap(); let store = store_in(d.path()); write_recipe(d.path(), "app", "a0", &["pcre2"]); // sin pcre2.toml let err = artifact_hash(&load(d.path(), "app"), &store, &takana_core::lab::LabFingerprint::for_tests()).unwrap_err().to_string(); assert!(err.contains("pcre2"), "el error debe nombrar la dep: {err}"); assert!(err.contains("app"), "y la receta que la pide: {err}"); } #[test] fn cycle_is_detected() { let d = tempfile::tempdir().unwrap(); let store = store_in(d.path()); write_recipe(d.path(), "a", "a0", &["b"]); write_recipe(d.path(), "b", "b0", &["a"]); let err = artifact_hash(&load(d.path(), "a"), &store, &takana_core::lab::LabFingerprint::for_tests()).unwrap_err().to_string(); assert!(err.contains("ciclo"), "esperaba ciclo: {err}"); assert!(err.contains("a -> b -> a"), "debe mostrar la cadena: {err}"); } #[test] fn deps_without_base_dir_errors() { let store = Store::open(tempfile::tempdir().unwrap().path().join("store")).unwrap(); // Parseada de TOML crudo ⇒ base_dir vacío; con deps no hay catálogo que resolver. let r = Recipe::from_toml( "name=\"x\"\nversion=\"1\"\n[source]\nrepo=\"git://x\"\ncommit=\"c0\"\n[build]\n[deps]\nbuild=[\"y\"]\n", ) .unwrap(); let err = artifact_hash(&r, &store, &takana_core::lab::LabFingerprint::for_tests()).unwrap_err().to_string(); assert!(err.contains("base_dir"), "debe explicar la falta de base_dir: {err}"); } #[test] fn no_deps_unaffected() { let d = tempfile::tempdir().unwrap(); let store = store_in(d.path()); write_recipe(d.path(), "solo", "s0", &[]); let a = artifact_hash(&load(d.path(), "solo"), &store, &takana_core::lab::LabFingerprint::for_tests()).unwrap(); let b = artifact_hash(&load(d.path(), "solo"), &store, &takana_core::lab::LabFingerprint::for_tests()).unwrap(); assert_eq!(a, b, "sin deps el hash es estable"); } }