sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
⎇ bc4d0c8aead387176a1c09e7fa82fe981e50d140
crates/hammer-build/src/lib.rs
History
Who touched what
raw
gnome: los 7 typelibs CERRADOS — librsvg y ibus, y dos deudas viejas pagadas de paso
1//! El laboratorio: compila una receta de forma hermética y la sella en el store; e hidrata
2//! artefactos al FHS. Ver `docs/02-build-lab.md` y `docs/03-hydration.md`.
3
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7use hammer_core::{ArtifactHash, Compiler, LinkMode, Phases, Recipe, Store};
8
9pub mod config;
10pub mod download;
11pub mod fetch;
12pub mod harkaq;
13pub mod hydrate;
14pub mod sandbox;
15pub mod swm_bridge;
16
17pub use config::BuildConfig;
18pub use hydrate::{hydrate as run_hydrate, DynamicSpec, HydrateReport, HydratedFile};
19pub use sandbox::{BuildFailure, Sandbox};
20pub use swm_bridge::build_source_patch;
21
22/// Calcula el `ArtifactHash` de una receta, resolviendo recursivamente sus deps de build.
23/// Ver `docs/02-build-lab.md` §2 y §5.
24///
25/// **Catálogo = `base_dir` de la receta.** Cada nombre en `deps.build` se resuelve al hermano
26/// `<base_dir>/<dep>.toml`; su hash entra en el de la receta (Merkle sobre el subgrafo de build).
27/// Así dos recetas con la misma fuente pero distinto grafo de deps hashean distinto, y cambiar
28/// una dep transitiva invalida la caché de todo lo que la usa.
29///
30/// Errores: una receta con `deps.build` pero sin `base_dir` (parseada de TOML crudo, no cargada
31/// de disco) no puede resolver su catálogo; un `<dep>.toml` ausente, o un ciclo de deps, también
32/// fallan con un mensaje que nombra la cadena.
33pub fn artifact_hash(recipe: &Recipe, _store: &Store) -> hammer_core::Result<ArtifactHash> {
34 let mut stack: Vec<String> = Vec::new();
35 // Memo por path canónico de receta: el grafo de build es un DAG-diamante (p.ej. TODO KF6/Qt
36 // converge en qtbase/kcoreaddons, alcanzables por decenas de rutas). Sin memoizar, el walk
37 // recursivo es exponencial en NÚMERO DE CAMINOS y cuelga en paquetes grandes (plasma-workspace,
38 // ~90 deps). El hash de una receta es función pura de su subárbol ⇒ computar una vez por nodo.
39 // Clave = path resuelto (no `name`): dos variantes homónimas (incoming-kde/libxml2 vs canónica)
40 // no deben colisionar. Ver granja-promote-colisiones.
41 let mut memo: HashMap<PathBuf, ArtifactHash> = HashMap::new();
42 artifact_hash_rec(recipe, &mut stack, &mut memo)
43}
44
45fn artifact_hash_rec(
46 recipe: &Recipe,
47 stack: &mut Vec<String>,
48 memo: &mut HashMap<PathBuf, ArtifactHash>,
49) -> hammer_core::Result<ArtifactHash> {
50 if stack.iter().any(|n| n == &recipe.name) {
51 stack.push(recipe.name.clone());
52 return Err(hammer_core::Error::Recipe(format!(
53 "ciclo de dependencias de build: {}",
54 stack.join(" -> ")
55 )));
56 }
57
58 let dep_hashes = resolve_build_dep_hashes(recipe, stack, memo)?;
59 let inputs = recipe.hash_inputs(&dep_hashes)?;
60 let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect();
61 Ok(ArtifactHash::of_inputs(&refs))
62}
63
64/// Resuelve la ruta de una dep de build: primero como HERMANO (`base_dir/{dep}.toml`), y si no
65/// existe, en el catálogo PADRE (`base_dir/../{dep}.toml`). El fallback al padre permite que una
66/// receta en `recipes/incoming/` (cola de staging) dependa de una del corpus `recipes/` sin
67/// duplicarla ni symlinkearla — destraba la frontera de deps-del-corpus (openssl, zlib, …) en la
68/// granja, que construye desde `incoming/`. Si ninguna existe, devuelve la ruta hermana (para que
69/// el error apunte al lugar esperado).
70fn resolve_dep_path(base_dir: &Path, dep: &str) -> PathBuf {
71 let sibling = base_dir.join(format!("{dep}.toml"));
72 if sibling.exists() {
73 return sibling;
74 }
75 if let Some(parent) = base_dir.parent() {
76 let in_parent = parent.join(format!("{dep}.toml"));
77 if in_parent.exists() {
78 return in_parent;
79 }
80 }
81 sibling
82}
83
84/// Resuelve y hashea, en orden de declaración, cada `deps.build` de `recipe` cargándolo del
85/// catálogo (su `base_dir`). `stack` lleva la cadena en curso para detectar ciclos.
86fn resolve_build_dep_hashes(
87 recipe: &Recipe,
88 stack: &mut Vec<String>,
89 memo: &mut HashMap<PathBuf, ArtifactHash>,
90) -> hammer_core::Result<Vec<ArtifactHash>> {
91 if recipe.deps.build.is_empty() {
92 return Ok(Vec::new());
93 }
94 if recipe.base_dir.as_os_str().is_empty() {
95 return Err(hammer_core::Error::Recipe(format!(
96 "la receta '{}' declara deps de build {:?} pero no tiene base_dir; \
97 cárgala con Recipe::load_from_path para resolver el catálogo",
98 recipe.name, recipe.deps.build
99 )));
100 }
101
102 stack.push(recipe.name.clone());
103 let mut hashes = Vec::with_capacity(recipe.deps.build.len());
104 for dep in &recipe.deps.build {
105 let dep_path = resolve_dep_path(&recipe.base_dir, dep);
106 // Clave del memo: path canónico (colapsa `./a/../b` y symlinks a una identidad estable).
107 let key = dep_path.canonicalize().unwrap_or_else(|_| dep_path.clone());
108 if let Some(h) = memo.get(&key) {
109 hashes.push(h.clone());
110 continue;
111 }
112 let dep_recipe = Recipe::load_from_path(&dep_path).map_err(|e| {
113 hammer_core::Error::Recipe(format!(
114 "no pude cargar la dep de build '{dep}' de '{}' ({}): {e}",
115 recipe.name,
116 dep_path.display()
117 ))
118 })?;
119 let h = artifact_hash_rec(&dep_recipe, stack, memo)?;
120 memo.insert(key, h.clone());
121 hashes.push(h);
122 }
123 stack.pop();
124 Ok(hashes)
125}
126
127/// Construye (si falta) cada `deps.build` y devuelve sus directorios sellados en el store, para
128/// apilarlos como capas overlay del sandbox (variante b: bwrap necesita libcap.a + sys/capability.h,
129/// ausentes del toolchain Alpine). Recursivo vía `build`: una dep con sus propias deps se materializa
130/// transitivamente. Los ciclos ya los corta `artifact_hash` (con su `stack`) antes de llegar acá.
131fn materialize_build_deps(
132 recipe: &Recipe,
133 cfg: &BuildConfig,
134 store: &Store,
135) -> hammer_core::Result<Vec<PathBuf>> {
136 if recipe.deps.build.is_empty() {
137 return Ok(Vec::new());
138 }
139 if recipe.base_dir.as_os_str().is_empty() {
140 return Err(hammer_core::Error::Recipe(format!(
141 "la receta '{}' declara deps de build {:?} pero no tiene base_dir; \
142 cárgala con Recipe::load_from_path para resolver el catálogo",
143 recipe.name, recipe.deps.build
144 )));
145 }
146 let mut dirs = Vec::with_capacity(recipe.deps.build.len());
147 for dep in &recipe.deps.build {
148 let dep_path = resolve_dep_path(&recipe.base_dir, dep);
149 let dep_recipe = Recipe::load_from_path(&dep_path).map_err(|e| {
150 hammer_core::Error::Recipe(format!(
151 "no pude cargar la dep de build '{dep}' de '{}' ({}): {e}",
152 recipe.name,
153 dep_path.display()
154 ))
155 })?;
156 let dep_hash = build(&dep_recipe, cfg, store)?;
157 dirs.push(store.path_of(&dep_hash, &dep_recipe.name));
158 }
159 Ok(dirs)
160}
161
162/// Resuelve el directorio de zig para una receta: el por defecto del lab (`cfg.zig_dir`), salvo que
163/// la receta fije `build.zig_version`, en cuyo caso se usa el zig de esa versión, ubicado como
164/// **hermano** del por defecto (`<…>/zig-x86_64-linux-<version>`, el layout de `.dev-fs/tools/`).
165/// Escotilla por-receta para esquivar la regresión de zig 0.14 sin tocar el toolchain global.
166fn effective_zig_dir(cfg: &BuildConfig, recipe: &Recipe) -> hammer_core::Result<PathBuf> {
167 let Some(version) = &recipe.build.zig_version else {
168 return Ok(cfg.zig_dir.clone());
169 };
170 let parent = cfg.zig_dir.parent().unwrap_or_else(|| Path::new("."));
171 let dir = parent.join(format!("zig-x86_64-linux-{version}"));
172 if !dir.join("zig").is_file() {
173 return Err(hammer_core::Error::Recipe(format!(
174 "zig_version=\"{version}\": no encuentro el ejecutable zig en {} \
175 (bajá ese zig a {}/)",
176 dir.display(),
177 parent.display(),
178 )));
179 }
180 Ok(dir)
181}
182
183/// Compila una receta y devuelve el hash del artefacto sellado en el store.
184/// Si el hash ya existe en el store, devuelve sin recompilar (caché).
185pub fn build(
186 recipe: &Recipe,
187 cfg: &BuildConfig,
188 store: &Store,
189) -> hammer_core::Result<ArtifactHash> {
190 let h = artifact_hash(recipe, store)?;
191 if store.has(&h, &recipe.name) {
192 tracing::info!(hash = %h, name = %recipe.name, "caché: artefacto ya en el store");
193 return Ok(h);
194 }
195 // zig efectivo de ESTA receta: el por defecto del lab, salvo que la receta fije `zig_version`
196 // (escotilla para esquivar la regresión de zig 0.14 en ciertos binarios musl; ver SDD 11 §7.2b).
197 let zig_dir = effective_zig_dir(cfg, recipe)?;
198 sandbox::ensure_layout(&cfg.rootfs, &zig_dir)?;
199
200 // Materializa las deps de build (las construye si faltan) ANTES del fetch: sus árboles sellados
201 // se apilan como capas overlay del sandbox para que headers/libs/pkgconfig estén en /usr.
202 let dep_dirs = materialize_build_deps(recipe, cfg, store)?;
203 if !dep_dirs.is_empty() {
204 tracing::info!(n = dep_dirs.len(), "deps de build materializadas como capas overlay");
205 }
206
207 tracing::info!(hash = %h, name = %recipe.name, "fetch");
208 let src_tree = fetch::fetch(recipe, &cfg.work_root)?;
209
210 if !recipe.source.patches.is_empty() {
211 tracing::info!(n = recipe.source.patches.len(), "aplicando patches");
212 fetch::apply_patches(recipe, &src_tree)?;
213 }
214
215 // Receta efectiva para resolver las FASES: puede ganar un `-p <pkg>` si la fuente es un
216 // workspace Cargo virtual (ver inject_cargo_package_selector). El hash `h` ya está fijado desde
217 // los flags ORIGINALES de la receta; el `-p` es resolución determinista del lab (misma fuente →
218 // mismo paquete) ⇒ no afecta la reproducibilidad ni el sidecar (que guarda la receta original).
219 let mut eff_recipe = recipe.clone();
220
221 // Recetas Cargo: vendoreamos las deps (red permitida en el fetch) para que el build sea
222 // hermético `--offline` dentro del sandbox. Tras los patches, por si tocaron Cargo.toml/lock.
223 // `cargo_vendor = true` fuerza esta rama en árboles POLÍGLOTAS, donde `detect_build_system`
224 // —que asume un sistema por árbol— eligió el de C. librsvg es el arquetipo: `configure.ac` gana
225 // por precedencia, pero su Makefile llama a `cargo build`, y sin vendoreo ese cargo sale a la
226 // red dentro del sandbox hermético. Ver `Source::cargo_vendor`.
227 if matches!(detect_build_system(&src_tree), BuildSys::Cargo)
228 || eff_recipe.source.cargo_vendor == Some(true)
229 {
230 // El source se copia DENTRO del repo hammer (que es un workspace Cargo) ⇒ si el Cargo.toml
231 // de la fuente NO declara su propio `[workspace]`, cargo cree que pertenece al workspace de
232 // hammer y `cargo vendor` aborta. Inyectamos un `[workspace]` vacío para aislarla. SÓLO si
233 // no lo tiene ya (las recetas del corpus que lo parchean a mano siguen funcionando sin
234 // duplicar la tabla). Esto desbloquea genéricamente los imports Rust (Etapa G) sin un patch
235 // por receta. Ver memoria 'cargo-recipe crate suelto'.
236 ensure_cargo_workspace_isolation(&src_tree)?;
237 // Extractor de workspaces (Etapa G): si el root es virtual, resuelve `-p <pkg>` del bin.
238 // SÓLO cuando el árbol ES una receta Cargo. En un políglota (`cargo_vendor = true`) el
239 // producto lo arma el sistema de build de C —que llama a cargo con sus propios argumentos—
240 // y meterle un `-p` a los flags de la receta sería inyectar una opción de Cargo en un
241 // `./configure`. Acá el forzado pide vendoreo, nada más.
242 if matches!(detect_build_system(&src_tree), BuildSys::Cargo) {
243 inject_cargo_package_selector(&mut eff_recipe, &src_tree);
244 }
245 tracing::info!("cargo: vendoreando deps para build offline");
246 fetch::vendor_cargo_deps(
247 &src_tree,
248 eff_recipe.source.cargo_vendor_dir.as_deref().unwrap_or("vendor"),
249 )?;
250 }
251
252 // Recetas Go: análogo a Cargo. Si el árbol trae `go.mod`, vendoreamos los módulos (red en el
253 // fetch) para que `go build -mod=vendor` resuelva offline en el sandbox. El toolchain `go` lo
254 // aporta `deps.build = ["go"]` (recipes/go.toml) DENTRO del sandbox; el vendoring usa el `go`
255 // del host (lo provee el lab). No hay BuildSys::Go: las recetas Go fijan el compile en
256 // `[build.phases]` (ver tandas/staged-2026-06-26/amfora.toml como plantilla).
257 // El módulo Go puede estar en un subdir (recipe `build.subdir`, p.ej. dolt→go); vendoreamos ahí.
258 let go_root = if eff_recipe.build.subdir.is_empty() {
259 src_tree.clone()
260 } else {
261 src_tree.join(&eff_recipe.build.subdir)
262 };
263 if go_root.join("go.mod").is_file() || go_root.join("go.work").is_file() {
264 tracing::info!("go: vendoreando módulos para build offline");
265 fetch::vendor_go_deps(&go_root)?;
266 }
267
268 let out_dir = unique_out_dir(&cfg.work_root, &recipe.name, &h)?;
269 let mut env: Vec<(String, String)> = Vec::new();
270 if matches!(recipe.build.link, LinkMode::Static) {
271 // autotools/cmake leen LDFLAGS para el link final; -static fuerza el binario
272 // monolítico que pide nuestra estrategia de hidratación primaria.
273 env.push(("LDFLAGS".into(), "-static".into()));
274 }
275 // El `compiler` de la receta decide el CC/CXX/AR reales; el default del sandbox es `zig cc`.
276 // Esto vuelve DECLARATIVO el escape a un toolchain real (gcc de Alpine = musl + GNU `ld`) para
277 // paquetes que el `lld` estricto de zig NO linkea (símbolos duplicados, flags que zig filtra —
278 // ver Etapa G, lz4). `self.env` pisa los defaults del sandbox. gcc/clang default a x86-64
279 // genérico (reproducible, sin `-march=native`), no hace falta `-mcpu=baseline`.
280 match recipe.build.compiler {
281 Compiler::ZigCc => {}
282 Compiler::Gcc => {
283 env.push(("CC".into(), "gcc".into()));
284 env.push(("CXX".into(), "g++".into()));
285 env.push(("AR".into(), "ar".into()));
286 }
287 Compiler::Clang => {
288 env.push(("CC".into(), "clang".into()));
289 env.push(("CXX".into(), "clang++".into()));
290 env.push(("AR".into(), "llvm-ar".into()));
291 }
292 }
293 // Autotools (Etapa G Fase 3): exporta CBUILD/CHOST con el triple nativo SANEADO del sandbox
294 // (`x86_64-linux-musl`, el mismo que el wrapper zig-cc emite). Las recetas importadas de Alpine
295 // traducen `./configure --build=$CBUILD --host=$CHOST` literal del abuild (`translate_abuild` los
296 // deja intactos a propósito); sin estas envs `$CBUILD/$CHOST` quedan VACÍAS y autotools cae a
297 // `config.guess`, que detecta `x86_64-alpine-linux-musl` — vendor que zig NO conoce
298 // (`UnknownOperatingSystem`). Resuelve el residuo autotools que antes era trabajo a mano. Inerte
299 // para Cargo/CMake/Meson (no leen estas envs).
300 env.push(("CBUILD".into(), SANDBOX_NATIVE_TARGET.to_string()));
301 env.push(("CHOST".into(), SANDBOX_NATIVE_TARGET.to_string()));
302 // *-sys C-FFI (Etapa G): si la receta declara `openssl` como dep, apuntá `openssl-sys` a la
303 // openssl hammer-built materializada en /usr (estática, musl). Sin esto openssl-sys busca en el
304 // host y aborta ("Could not find directory of OpenSSL installation"). Condicional al dep ⇒ no
305 // rompe recetas que usen openssl vendored. Destraba la frontera cargo-* (reqwest/git tools).
306 if recipe.deps.build.iter().any(|d| d == "openssl") {
307 env.push(("OPENSSL_DIR".into(), "/usr".into()));
308 env.push(("OPENSSL_STATIC".into(), "1".into()));
309 env.push(("OPENSSL_NO_VENDOR".into(), "1".into()));
310 }
311 if let Some(cache) = &cfg.cache_root {
312 std::fs::create_dir_all(cache.join("zig"))?;
313 }
314 let sb = Sandbox {
315 rootfs: cfg.rootfs.clone(),
316 zig_dir: zig_dir.clone(),
317 src_dir: src_tree.clone(),
318 out_dir: out_dir.clone(),
319 cache_dir: cfg.cache_root.clone(),
320 deps: dep_dirs,
321 env,
322 };
323
324 let phases = resolve_phases(&eff_recipe, &src_tree)?;
325 if let Some(cmd) = &phases.configure {
326 tracing::info!(cmd, "phase: configure");
327 sb.run(cmd)?;
328 }
329 if let Some(cmd) = &phases.compile {
330 tracing::info!(cmd, "phase: compile");
331 sb.run(cmd)?;
332 } else {
333 return Err(hammer_core::Error::Recipe(
334 "receta sin fase 'compile' y heurística no encontró ninguna".into(),
335 ));
336 }
337 if let Some(cmd) = &phases.install {
338 tracing::info!(cmd, "phase: install");
339 sb.run(cmd)?;
340 } else {
341 return Err(hammer_core::Error::Recipe(
342 "receta sin fase 'install' y heurística no encontró ninguna".into(),
343 ));
344 }
345
346 // Sidecar de provenance: escribimos la receta serializada DENTRO de out_dir antes del
347 // seal, para que quede congelada en el árbol read-only del store. `hammer export` la
348 // lee de vuelta para emitir `source_patch` con la receta original en vez de un
349 // `file_drop` opaco. Ver `docs/04-overlay.md` §provenance (Fase 4).
350 let sidecar_path = out_dir.join(hammer_core::store::RECIPE_SIDECAR_REL);
351 if let Some(parent) = sidecar_path.parent() {
352 std::fs::create_dir_all(parent)?;
353 }
354 let toml_text = recipe.to_toml()?;
355 std::fs::write(&sidecar_path, toml_text.as_bytes())?;
356
357 let sealed = store.seal(&out_dir, &h, &recipe.name)?;
358 tracing::info!(path = %sealed.display(), "sealed");
359 Ok(h)
360}
361
362/// El resultado de correr un check de evidencia, con su veredicto (H1b).
363#[derive(Debug, Clone)]
364pub struct EvidenceOutcome {
365 pub kind: hammer_core::EvidenceKind,
366 pub cmd: String,
367 pub outcome: hammer_core::CheckOutcome,
368}
369
370/// Reporte de la ejecución de TODA la evidencia de una receta (H1b). `all_passed` es el gate:
371/// si es `false`, el bucle agéntico NO debe proponer la mutación (SDD 15 §H1: "falla ⇒ no se propone").
372#[derive(Debug, Clone)]
373pub struct EvidenceReport {
374 pub results: Vec<EvidenceOutcome>,
375}
376
377impl EvidenceReport {
378 pub fn all_passed(&self) -> bool {
379 self.results.iter().all(|r| r.outcome.passed)
380 }
381 /// El estrato de confianza MÁS ALTO que pasó (para reportar "verificado hasta <nivel>"). `None`
382 /// si no hay checks. No promete que ese nivel *baste* — sólo que lo declarado a ese nivel pasó.
383 pub fn max_level_passed(&self) -> Option<hammer_core::EvidenceKind> {
384 self.results
385 .iter()
386 .filter(|r| r.outcome.passed)
387 .map(|r| r.kind)
388 .max()
389 }
390}
391
392/// Corre la evidencia de comportamiento de una receta (H1b, proof-carrying recipes). Reproduce el
393/// artefacto (cache-hit si ya está sellado), levanta un sandbox reproducible con la fuente +
394/// build-deps + el artefacto instalado montado como capa overlay (sus `/usr/bin` en PATH ⇒ los
395/// smoke tests tipo `bin --version` encuentran el binario), y ejecuta cada check comparando
396/// (exit, blake3(stdout)) contra lo declarado. Es "un runner de comandos con hash del output", no un
397/// framework — la confianza vive acá (pequeño y auditable), no en el generador de la receta.
398///
399/// Frontera honesta: garantiza que *lo declarado pasa*, no que *lo declarado basta* (eso lo juzga el
400/// humano). Los checks corren con el entorno BASE del sandbox; un check que recompila (proptest) usa
401/// el toolchain que traigan las build-deps en PATH.
402pub fn run_evidence(
403 recipe: &Recipe,
404 cfg: &BuildConfig,
405 store: &Store,
406) -> hammer_core::Result<EvidenceReport> {
407 recipe.evidence.validate()?;
408 if recipe.evidence.checks.is_empty() {
409 return Ok(EvidenceReport { results: Vec::new() });
410 }
411
412 // 1. Reproducir: construir/sellar el artefacto (cache-hit si existe).
413 let h = build(recipe, cfg, store)?;
414 let artifact_dir = store.path_of(&h, &recipe.name);
415
416 // 2. Preparar el sandbox: mismas capas que el build (rootfs + build-deps) MÁS el artefacto
417 // instalado, para que sus binarios estén en /usr/bin (PATH). La fuente se re-materializa
418 // para los checks que la necesitan (proptest/kani corren `cargo test` en /src).
419 let zig_dir = effective_zig_dir(cfg, recipe)?;
420 sandbox::ensure_layout(&cfg.rootfs, &zig_dir)?;
421 let mut dep_dirs = materialize_build_deps(recipe, cfg, store)?;
422 dep_dirs.push(artifact_dir);
423
424 let src_tree = fetch::fetch(recipe, &cfg.work_root)?;
425 if !recipe.source.patches.is_empty() {
426 fetch::apply_patches(recipe, &src_tree)?;
427 }
428 let out_dir = unique_out_dir(&cfg.work_root, &format!("{}-evidence", recipe.name), &h)?;
429
430 let sb = Sandbox {
431 rootfs: cfg.rootfs.clone(),
432 zig_dir,
433 src_dir: src_tree,
434 out_dir,
435 cache_dir: cfg.cache_root.clone(),
436 deps: dep_dirs,
437 env: Vec::new(),
438 };
439
440 // 3. Ejecutar cada check y evaluarlo (la comparación exit/hash es pura, vive en hammer-core).
441 let mut results = Vec::with_capacity(recipe.evidence.checks.len());
442 for c in &recipe.evidence.checks {
443 tracing::info!(kind = c.kind.as_str(), cmd = %c.cmd, "evidence: check");
444 let out = sb.run_capture(&c.cmd)?;
445 let outcome = c.evaluate(out.exit_code, &out.stdout);
446 tracing::info!(passed = outcome.passed, detail = %outcome.detail, "evidence: veredicto");
447 results.push(EvidenceOutcome {
448 kind: c.kind,
449 cmd: c.cmd.clone(),
450 outcome,
451 });
452 }
453 Ok(EvidenceReport { results })
454}
455
456/// Sistemas de build que reconoce la heurística por inspección del árbol del source.
457/// Ver `docs/02-build-lab.md` §4.
458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
459enum BuildSys {
460 /// `configure.ac` (o `.in`) sin `configure` generado: hay que correr `autoreconf` primero.
461 AutoconfRaw,
462 /// `configure` ya está en el tarball release: vamos directo a `./configure`.
463 AutoconfReady,
464 /// `CMakeLists.txt`.
465 CMake,
466 /// `meson.build`.
467 Meson,
468 /// `Cargo.toml`: crate o workspace Rust. Build con `cargo`, link con `zig cc`.
469 Cargo,
470 /// `go.mod`: módulo Go. Build con `go install` del paquete main (auto-detectado),
471 /// deps vendoreadas en el fetch (`vendor_go_deps`). El toolchain `go` llega por deps.build.
472 Go,
473 /// `Makefile` plano (o `makefile`) sin sistema generador.
474 Make,
475 /// No detectamos nada: la receta debe traer overrides en `[build.phases]`.
476 Unknown,
477}
478
479/// ¿El `Cargo.toml` raíz es un MANIFIESTO VIRTUAL? (declara `[workspace]` pero NO `[package]`).
480/// Es el patrón del ecosistema CLI Rust moderno (sd, fd, …): el root sólo agrupa miembros y el
481/// binario vive en un sub-paquete. En ese caso `cargo … --bin X` solo falla: hace falta `-p <pkg>`.
482/// Un crate suelto (con `[package]`, aunque `ensure_cargo_workspace_isolation` le agregue
483/// `[workspace]`) NO es virtual ⇒ no se toca (ripgrep y el corpus quedan idénticos).
484fn cargo_root_is_virtual(src: &Path) -> bool {
485 let Ok(text) = std::fs::read_to_string(src.join("Cargo.toml")) else {
486 return false;
487 };
488 let mut has_ws = false;
489 let mut has_pkg = false;
490 for l in text.lines() {
491 let t = l.trim_start();
492 if t.starts_with("[workspace]") || t.starts_with("[workspace.") {
493 has_ws = true;
494 }
495 if t.starts_with("[package]") {
496 has_pkg = true;
497 }
498 }
499 has_ws && !has_pkg
500}
501
502/// Lee los MEMBERS del workspace (vía `cargo metadata`, la fuente autoritativa: expande globs,
503/// `[[bin]]`, bins por convención `src/bin/*.rs`, y workspaces anidados — todo lo que un parseo a
504/// mano del TOML erraría) y devuelve el NOMBRE del paquete que expone un bin llamado `bin`. Es el
505/// "extractor de workspaces" de la Etapa G: convierte un workspace virtual en un `-p <pkg>` exacto.
506fn resolve_cargo_bin_package(src: &Path, bin: &str) -> Option<String> {
507 let out = std::process::Command::new("cargo")
508 .args(["metadata", "--no-deps", "--format-version", "1"])
509 .current_dir(src)
510 .output()
511 .ok()?;
512 if !out.status.success() {
513 return None;
514 }
515 let meta: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
516 for pkg in meta.get("packages")?.as_array()? {
517 let pkg_name = pkg.get("name")?.as_str()?;
518 for tgt in pkg.get("targets")?.as_array()? {
519 let is_bin = tgt
520 .get("kind")
521 .and_then(|k| k.as_array())
522 .map(|ks| ks.iter().any(|x| x.as_str() == Some("bin")))
523 .unwrap_or(false);
524 if is_bin && tgt.get("name").and_then(|n| n.as_str()) == Some(bin) {
525 return Some(pkg_name.to_string());
526 }
527 }
528 }
529 None
530}
531
532/// Si la fuente es un workspace VIRTUAL y los flags piden `--bin X` sin `-p`, resuelve el paquete
533/// real que expone `X` y antepone `-p <pkg>`. Deja los flags intactos en cualquier otro caso
534/// (crate suelto, ya con `-p`, o bin irresoluble). Determinista (misma fuente → mismo paquete) ⇒
535/// reproducible. Sin esto, casi todo el CLI Rust moderno (workspaces virtuales) exigía `-p` a mano.
536fn inject_cargo_package_selector(recipe: &mut Recipe, src: &Path) {
537 let flags = &recipe.build.flags;
538 if flags.iter().any(|f| f == "-p" || f == "--package") {
539 return; // el autor ya eligió el paquete
540 }
541 if !cargo_root_is_virtual(src) {
542 return; // crate suelto: `--bin` solo alcanza
543 }
544 // bin = el argumento que sigue a `--bin`.
545 let Some(bin) = flags.iter().position(|f| f == "--bin").and_then(|i| flags.get(i + 1)).cloned()
546 else {
547 return;
548 };
549 if let Some(pkg) = resolve_cargo_bin_package(src, &bin) {
550 tracing::info!(%bin, %pkg, "cargo: workspace virtual ⇒ -p resuelto automáticamente");
551 let mut new_flags = vec!["-p".to_string(), pkg];
552 new_flags.extend(recipe.build.flags.iter().cloned());
553 recipe.build.flags = new_flags;
554 }
555}
556
557/// Garantiza que el `Cargo.toml` raíz de la fuente declare un `[workspace]` propio, para que cargo
558/// NO la considere parte del workspace del repo hammer (que la contiene en `work/sources/`). Append
559/// idempotente: si ya hay `[workspace]` (crate suelto que es su propio workspace, o una receta que
560/// lo parchea), no toca nada. Caso virtual-manifest (root ya es `[workspace]`) tampoco se toca.
561fn ensure_cargo_workspace_isolation(src: &Path) -> hammer_core::Result<()> {
562 let manifest = src.join("Cargo.toml");
563 let text = std::fs::read_to_string(&manifest)?;
564 // Heurística simple: ¿hay una tabla `[workspace]` (al inicio de línea)? Cubre `[workspace]` y
565 // `[workspace.members]`/`[workspace.dependencies]` (un root virtual siempre trae alguna).
566 let has_workspace = text
567 .lines()
568 .any(|l| l.trim_start().starts_with("[workspace]") || l.trim_start().starts_with("[workspace."));
569 if !has_workspace {
570 let mut patched = text;
571 if !patched.ends_with('\n') {
572 patched.push('\n');
573 }
574 patched.push_str("\n[workspace]\n");
575 std::fs::write(&manifest, patched)?;
576 tracing::info!("cargo: inyectado [workspace] vacío para aislar la fuente del repo hammer");
577 }
578 Ok(())
579}
580
581fn detect_build_system(src: &Path) -> BuildSys {
582 let has = |p: &str| src.join(p).exists();
583 // `go.mod`/`go.work` PRIMERO: un módulo Go suele traer también un `configure`/`Makefile` AUXILIAR
584 // (man pages, completions, packaging), pero el binario se hace con `go build`. Ningún paquete
585 // C/Rust del corpus trae go.mod. `go.work` cubre los WORKSPACE multimódulo cuya RAÍZ no tiene
586 // go.mod (sólo go.work + submódulos) — sin esto caían a Makefile/Unknown.
587 if has("go.mod") || has("go.work") {
588 return BuildSys::Go;
589 }
590 // El orden importa: cuando coexisten varios (un tarball release de autotools trae el
591 // `configure` regenerado y un `Makefile` huérfano de un build previo), preferimos el
592 // sistema "más alto" que sí sabe regenerar todo.
593 if has("configure.ac") || has("configure.in") {
594 if has("configure") {
595 return BuildSys::AutoconfReady;
596 }
597 return BuildSys::AutoconfRaw;
598 }
599 if has("configure") {
600 return BuildSys::AutoconfReady;
601 }
602 if has("CMakeLists.txt") {
603 return BuildSys::CMake;
604 }
605 if has("meson.build") {
606 return BuildSys::Meson;
607 }
608 // Cargo gana sobre un `Makefile` huérfano (algunos crates traen un wrapper `make`),
609 // pero cede ante autotools/cmake/meson (un políglota C+Rust suele querer el sistema C).
610 if has("Cargo.toml") {
611 return BuildSys::Cargo;
612 }
613 if has("Makefile") || has("makefile") || has("GNUmakefile") {
614 return BuildSys::Make;
615 }
616 BuildSys::Unknown
617}
618
619/// Detecta el directorio del paquete `main` PRINCIPAL de un módulo Go, relativo a `src`, para
620/// `go install`. Busca por FILESYSTEM (no compila ⇒ no se contamina con mains de ejemplo rotos,
621/// como los de `docs/`/`scripts/` que rompen `go list ./...`): archivos `.go` cuya primera línea
622/// significativa es `package main`, descartando dirs auxiliares (vendor/docs/examples/tests/
623/// scripts/tools/internal y ocultos/`_`). Elige el principal por heurística: raíz > `cmd/<x>` >
624/// menor profundidad. Devuelve un path estilo go (`.`, `./cmd/mlr`). Una receta puede forzar el
625/// paquete vía `build.flags` (gana sobre la auto-detección).
626fn detect_go_main(src: &Path, pkg_name: &str) -> String {
627 fn is_main_go(path: &Path) -> bool {
628 let Ok(text) = std::fs::read_to_string(path) else { return false };
629 // El primer token significativo de un .go es la cláusula `package` (tras build-constraints
630 // `//go:build`, comentarios de línea y el header de licencia). Hay que saltar comentarios de
631 // BLOQUE `/* ... */` correctamente: muchos headers Apache son `/*\nCopyright ...\n*/` y las
632 // líneas internas NO arrancan con `*` ⇒ un filtro por-línea ingenuo cortaría ahí y fallaría
633 // (p.ej. cmd/helm/helm.go). Escaneamos rastreando el estado dentro/fuera de bloque.
634 let mut in_block = false;
635 for line in text.lines() {
636 let mut l = line.trim();
637 if in_block {
638 match l.find("*/") {
639 Some(i) => {
640 l = l[i + 2..].trim();
641 in_block = false;
642 }
643 None => continue,
644 }
645 }
646 if l.is_empty() || l.starts_with("//") {
647 continue;
648 }
649 if let Some(i) = l.find("/*") {
650 let before = l[..i].trim();
651 if !before.is_empty() {
652 return before.starts_with("package main");
653 }
654 match l[i + 2..].find("*/") {
655 Some(j) => {
656 let after = l[i + 2 + j + 2..].trim();
657 if after.is_empty() {
658 continue;
659 }
660 return after.starts_with("package main");
661 }
662 None => {
663 in_block = true;
664 continue;
665 }
666 }
667 }
668 return l.starts_with("package main");
669 }
670 false
671 }
672 const SKIP: &[&str] = &[
673 "vendor", "docs", "doc", "example", "examples", "test", "tests", "testdata", "script",
674 "scripts", "tools", "internal", "third_party",
675 ];
676 let mut found: Vec<PathBuf> = Vec::new();
677 let mut stack = vec![src.to_path_buf()];
678 while let Some(dir) = stack.pop() {
679 let Ok(entries) = std::fs::read_dir(&dir) else { continue };
680 let mut has_main = false;
681 for e in entries.flatten() {
682 let p = e.path();
683 if p.is_dir() {
684 let name = e.file_name().to_string_lossy().to_string();
685 if name.starts_with('.') || name.starts_with('_') || SKIP.contains(&name.as_str()) {
686 continue;
687 }
688 stack.push(p);
689 } else if !has_main
690 && p.extension().map(|x| x == "go").unwrap_or(false)
691 && !p.to_string_lossy().ends_with("_test.go")
692 && is_main_go(&p)
693 {
694 has_main = true;
695 }
696 }
697 if has_main {
698 if let Ok(rel) = dir.strip_prefix(src) {
699 found.push(rel.to_path_buf());
700 }
701 }
702 }
703 // raíz (prioridad 0) > cmd/<x> (1) > resto (2); a igual clase, el dir cuyo nombre coincide con el
704 // de la receta (p.ej. cmd/eksctl para la receta `eksctl`) gana sobre otros mains hermanos (cmd/
705 // schema, un helper de codegen que panica) — sin esto detect elegía arbitrariamente entre varios
706 // mains y sellaba el binario EQUIVOCADO; luego, a igual nombre, menor profundidad.
707 let key = |p: &PathBuf| -> (u8, u8, usize) {
708 let s = p.to_string_lossy();
709 let depth = p.components().count();
710 let name_match = if p.file_name().map(|f| f == pkg_name).unwrap_or(false) { 0 } else { 1 };
711 let class = if s.is_empty() {
712 0
713 } else if s.starts_with("cmd/") || s == "cmd" {
714 1
715 } else {
716 2
717 };
718 (class, name_match, depth)
719 };
720 found.sort_by_key(key);
721 match found.first() {
722 Some(p) if !p.as_os_str().is_empty() => format!("./{}", p.to_string_lossy()),
723 _ => ".".to_string(),
724 }
725}
726
727/// Target nativo del sandbox del lab (Alpine x86_64 musl). Una receta Cargo cuyo `build.target`
728/// sea éste se construye **nativa** (sin `--target`): el rust del sandbox sólo trae su std nativo
729/// (`x86_64-alpine-linux-musl`), no el `x86_64-unknown-linux-musl` que pediría un cross, y no hay
730/// rustup para agregarlo. Ver `docs/runbooks/stage1-vm-boot.md` §8 (primera corrida).
731const SANDBOX_NATIVE_TARGET: &str = "x86_64-linux-musl";
732
733/// Traduce el triple estilo zig/hammer (`x86_64-linux-musl`) al de rustc
734/// (`x86_64-unknown-linux-musl`). Un triple ya completo (con vendor) o ajeno al patrón
735/// `<arch>-linux-<libc>` se devuelve tal cual: la receta puede dar el triple exacto.
736fn rustc_triple(target: &str) -> String {
737 match target.split('-').collect::<Vec<_>>().as_slice() {
738 [arch, "linux", libc] => format!("{arch}-unknown-linux-{libc}"),
739 _ => target.to_string(),
740 }
741}
742
743/// Nombre de la env `CARGO_TARGET_<TRIPLE>_LINKER` para un triple de rustc.
744fn cargo_linker_env_var(triple: &str) -> String {
745 format!("CARGO_TARGET_{}_LINKER", triple.to_uppercase().replace(['-', '.'], "_"))
746}
747
748/// Devuelve los comandos a ejecutar para cada fase: overrides explícitos en la receta
749/// ganan; el resto lo deriva la heurística según el sistema de build detectado.
750fn resolve_phases(recipe: &Recipe, src: &Path) -> hammer_core::Result<Phases> {
751 let mut out = recipe.build.phases.clone();
752 // Raíz efectiva del módulo Go: `<src>/<subdir>` si la receta lo fija (dolt→go, csvtk→subdir),
753 // si no `src`. detect/detect_go_main operan ahí; el compile hace `cd <subdir>` primero.
754 let go_src = if recipe.build.subdir.is_empty() {
755 src.to_path_buf()
756 } else {
757 src.join(&recipe.build.subdir)
758 };
759 let bs = detect_build_system(&go_src);
760 let flags = recipe.build.flags.join(" ");
761 let flags_suffix = if flags.is_empty() { String::new() } else { format!(" {flags}") };
762
763 // Autotools (Etapa G Fase 3): fija `--build`/`--host` al triple nativo saneado para que
764 // `config.guess` no detecte el vendor `alpine` que zig rechaza, e iguala lo que el wrapper zig-cc
765 // produce. Sólo si la receta no los puso ya en sus flags (juicio per-paquete gana). build==host
766 // ⇒ NATIVO (no cross): autotools sigue corriendo sus AC_RUN tests; sólo normaliza el triple.
767 let autotools_triple = if recipe.build.flags.iter().any(|f| {
768 f.starts_with("--build=") || f.starts_with("--host=")
769 }) {
770 String::new()
771 } else {
772 format!(" --build={SANDBOX_NATIVE_TARGET} --host={SANDBOX_NATIVE_TARGET}")
773 };
774
775 if out.configure.is_none() {
776 out.configure = match bs {
777 BuildSys::AutoconfRaw => Some(format!(
778 "autoreconf -fi && ./configure --prefix=/usr{autotools_triple}{flags_suffix}"
779 )),
780 BuildSys::AutoconfReady => {
781 Some(format!("./configure --prefix=/usr{autotools_triple}{flags_suffix}"))
782 }
783 BuildSys::CMake => Some(format!(
784 "cmake -S . -B _build -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Release{flags_suffix}"
785 )),
786 BuildSys::Meson => Some(format!("meson setup _build --prefix=/usr{flags_suffix}")),
787 // Cargo/Go no tienen fase configure separada.
788 BuildSys::Cargo | BuildSys::Go | BuildSys::Make | BuildSys::Unknown => None,
789 };
790 }
791 if out.compile.is_none() {
792 out.compile = match bs {
793 BuildSys::AutoconfRaw | BuildSys::AutoconfReady | BuildSys::Make => {
794 Some(r#"make -j"$(nproc)""#.to_string())
795 }
796 BuildSys::CMake => Some(r#"cmake --build _build -j "$(nproc)""#.to_string()),
797 BuildSys::Meson => Some("meson compile -C _build".to_string()),
798 BuildSys::Cargo => {
799 // Hermético y reproducible: `--offline` (deps vendoreadas en la fuente) +
800 // `--locked` (Cargo.lock fijo, coherente con ADR 0006). El link va por un
801 // wrapper `zig cc` — cargo no acepta un linker de dos palabras, y zig provee
802 // el `libgcc_s` que el link dinámico musl pide (ver M2 del plan arje↔hammer).
803 if recipe.build.target == SANDBOX_NATIVE_TARGET {
804 // NATIVO: el sandbox del lab YA es x86_64 musl, así que no cruzamos. El rust del
805 // sandbox sólo trae su std nativo (`x86_64-alpine-linux-musl`), no
806 // `x86_64-unknown-linux-musl`; con `--target` fallaría "can't find crate for
807 // core" (no hay rustup). Ver docs/runbooks/stage1-vm-boot.md §8.
808 //
809 // El wrapper `.hammer-zig-cc` hace DOS cosas: (1) es un único ejecutable, lo que
810 // resuelve el `zig cc` de dos palabras que cargo (linker) y cc-rs (CC)
811 // mal-parsean; (2) **sanea el triple**: cc-rs (build scripts que compilan C/asm,
812 // p.ej. blake3) detecta el host triple de Alpine rust y pasa
813 // `--target=x86_64-alpine-linux-musl`, pero zig no conoce el vendor "alpine"
814 // (`UnknownOperatingSystem`); lo reescribimos a `x86_64-linux-musl`. Se usa como
815 // CC (cc-rs) y como linker (RUSTFLAGS, target-agnóstico — el default sería
816 // cc/gcc, ausente en el sandbox zig-only).
817 // Flags de codegen del BINARIO FINAL, vía `cargo rustc -- …` (NO RUSTFLAGS): en
818 // build nativo RUSTFLAGS también alcanza a los proc-macros (deps), y
819 // `+crt-static`/`relocation-model=static` los rompe ("cannot produce
820 // proc-macro" — un proc-macro es un dylib PIC). `cargo rustc -- …` los aplica
821 // sólo a la crate top-level, dejando los proc-macros intactos.
822 let bin_flags = match recipe.build.link {
823 // Estático AUTOCONTENIDO y no-PIE: `+crt-static` mete musl dentro (sin
824 // DT_NEEDED — el rust de Alpine enlaza musl dinámico por defecto, lo que deja
825 // `libc.musl-x86_64.so.1` y rompe el boot, runbook §8); `relocation-model=static`
826 // ⇒ ET_EXEC sin interpreter (vía de oro: como busybox, sin libc.so/loader/soname).
827 LinkMode::Static => "-C target-feature=+crt-static -C relocation-model=static",
828 // Dinámico: apaga crt-static para poder dlopen (necesita el loader/musl en el rootfs).
829 LinkMode::Dynamic => "-C target-feature=-crt-static",
830 };
831 // `-C link-self-contained=no` CONDICIONAL: el link (de TODO — deps,
832 // build-scripts, proc-macros y la crate top) lo hace `zig cc`, que ya aporta
833 // sus startfiles (crt1.o). Un rust VANILLA con target musl
834 // `x86_64-unknown-linux-musl` trae CRT AUTOCONTENIDO (self-contained/rcrt1.o) y
835 // lo pasaría también ⇒ `ld.lld: duplicate symbol: _start`; hay que apagarlo. PERO
836 // el rust de Alpine (host `x86_64-alpine-linux-musl`) PARCHEA la opción para que
837 // sea un ERROR ("option `-C link-self-contained` is not supported on this
838 // target") y NO usa self-contained — el baseline 9adefb82 linkea con zig sin
839 // chocar. Así que: añadir el flag SÓLO si el host del rustc NO es `-alpine-`
840 // (i.e. un rust vanilla como el hammer-rust del frente self-host); para Alpine,
841 // omitirlo. RUSTFLAGS alcanza a todas las unidades; los `bin_flags` (cargo rustc
842 // --) sólo a la top.
843 // CC para los build-scripts (cc-rs compila el C de deps como libgit2-sys,
844 // openssl-sys, libc-stdhandle). Por defecto = el wrapper zig-cc. PERO zig-cc
845 // MISCOMPILA/RECHAZA muchos de esos C (mismo frente que file/jq en C puro: la
846 // granja lo reveló — delta/gitui/jless mueren ahí). Con `compiler="gcc"` la receta
847 // declara el GUETO: cc-rs usa gcc (el Alpine musl gcc del sandbox, probado en C),
848 // y el linker sigue siendo zig-cc — los .o/.a de gcc-musl y el Rust zig-musl son
849 // ABI-compatibles (ambos musl, mismo ELF x86-64). Aditivo: una receta Rust sin
850 // `compiler=` queda en ZigCc ⇒ baseline (ripgrep/fd/…) intacto.
851 let cc_for_scripts = match recipe.build.compiler {
852 Compiler::Gcc => "gcc",
853 Compiler::Clang => "clang",
854 Compiler::ZigCc => "$PWD/.hammer-zig-cc",
855 };
856 Some(format!(
857 "printf '%s\\n' '#!/bin/sh' 'for a do' \
858 'case \"$a\" in --target=*) a=--target=x86_64-linux-musl ;; esac' \
859 'set -- \"$@\" \"$a\"' 'shift' 'done' 'exec zig cc -mcpu=baseline \"$@\"' \
860 > \"$PWD/.hammer-zig-cc\" && chmod +x \"$PWD/.hammer-zig-cc\" && \
861 RF=\"-C linker=$PWD/.hammer-zig-cc\"; \
862 rustc -vV | grep -q 'host: .*-alpine-' || RF=\"$RF -C link-self-contained=no\"; \
863 CC=\"{cc_for_scripts}\" RUSTFLAGS=\"$RF\" \
864 cargo rustc --release --locked --offline{flags_suffix} -- {bin_flags}"
865 ))
866 } else {
867 // CROSS real (otro target que zig sí entiende): `--target` + linker por env
868 // per-triple. Hermético y reproducible: `--offline` (deps vendoreadas) +
869 // `--locked` (Cargo.lock fijo, ADR 0006). zig provee el `libgcc_s` que el link
870 // dinámico musl pide (ver M2 del plan arje↔hammer).
871 let setup = "printf '#!/bin/sh\\nexec zig cc -mcpu=baseline \"$@\"\\n' > \"$PWD/.hammer-zig-cc\" && \
872 chmod +x \"$PWD/.hammer-zig-cc\" && ";
873 let triple = rustc_triple(&recipe.build.target);
874 let linker_var = cargo_linker_env_var(&triple);
875 let rustflags = match recipe.build.link {
876 // musl es crt-static por defecto; el dinámico lo apaga para poder dlopen.
877 LinkMode::Dynamic => "RUSTFLAGS='-C target-feature=-crt-static' ",
878 LinkMode::Static => "",
879 };
880 Some(format!(
881 "{setup}{rustflags}{linker_var}=\"$PWD/.hammer-zig-cc\" \
882 cargo build --release --locked --offline --target {triple}{flags_suffix}"
883 ))
884 }
885 }
886 BuildSys::Go => {
887 // Un solo patrón GENÉRICO para todo proyecto Go: `go install` del paquete main
888 // auto-detectado (o el que fije `build.flags`). go install nombra el binario solo
889 // (cmd/mlr→mlr, raíz→<módulo>) y lo deja en GOBIN=/out/usr/bin ⇒ no hace falta saber
890 // el nombre. Offline+reproducible: vendor (vendor_go_deps) + GOPROXY=off + -trimpath
891 // -buildid=. El toolchain `go` llega por deps.build=["go"] (overlay).
892 let pkg = if recipe.build.flags.is_empty() {
893 detect_go_main(&go_src, &recipe.name)
894 } else {
895 flags.clone()
896 };
897 // Si el módulo está en subdir, el compile entra ahí primero (el sandbox arranca en /src).
898 let cd_prefix = if recipe.build.subdir.is_empty() {
899 String::new()
900 } else {
901 format!("cd {} && ", recipe.build.subdir)
902 };
903 // GOCACHE/GOTMPDIR en /src (disk-backed, per-build, lo limpia el watchdog), NO en
904 // /tmp: el sandbox monta `--tmpfs /tmp` (~½ RAM); un proyecto Go ENORME (telegraf,
905 // vault) desborda ese tmpfs con sus objetos compilados + el temp del linker al mapear
906 // el binario → "link: mapping output file failed: no space left on device" aunque el
907 // disco tenga sitio. /src es el árbol de fuentes bind-montado (en disco) y no se sella
908 // (sólo /out) ⇒ inocuo. GOTMPDIR debe pre-existir. No entra al hash de input (es la
909 // fase generada, no un campo de la receta) ⇒ no re-sella el corpus Go.
910 // CGO opt-in (recipe `cgo = true`): drivers con C bundled (sqlite3 de mattn en
911 // usql/sq/dolt) necesitan cgo. CC="zig cc" (musl nativo del sandbox) + link estático
912 // por el linker externo (`-linkmode=external -extldflags=-static`). Default: CGO off
913 // (Go puro estático). Validado: usql (sqlite, estático y corre).
914 let (cgo_env, ldflags) = if recipe.build.cgo {
915 (
916 "CGO_ENABLED=1 CC=\"zig cc\" CXX=\"zig c++\"",
917 "-ldflags=\"-buildid= -linkmode=external -extldflags=-static\"",
918 )
919 } else {
920 ("CGO_ENABLED=0", "-ldflags=-buildid=")
921 };
922 Some(format!(
923 "{cd_prefix}export GOCACHE=/src/.gocache GOTMPDIR=/src/.gotmp GOPATH=/tmp/gopath \
924 GOTOOLCHAIN=local {cgo_env} GOFLAGS=-mod=vendor GOPROXY=off \
925 GOBIN=/out/usr/bin; mkdir -p /src/.gotmp; \
926 go install -trimpath {ldflags} {pkg}"
927 ))
928 }
929 BuildSys::Unknown => None,
930 };
931 }
932 if out.install.is_none() {
933 out.install = match bs {
934 BuildSys::AutoconfRaw | BuildSys::AutoconfReady | BuildSys::Make => {
935 Some("make install DESTDIR=/out PREFIX=/usr".to_string())
936 }
937 BuildSys::CMake => Some("DESTDIR=/out cmake --install _build".to_string()),
938 BuildSys::Meson => Some("DESTDIR=/out meson install -C _build --no-rebuild".to_string()),
939 BuildSys::Cargo => {
940 // Copia los ejecutables finales (top del dir release, bit de ejecución) a
941 // /out/usr/bin. `-perm -100` / `-exec cp \;` son busybox-safe (rootfs Alpine).
942 // Build nativo ⇒ `target/release`; cross ⇒ `target/<triple>/release`.
943 let rel = if recipe.build.target == SANDBOX_NATIVE_TARGET {
944 "target/release".to_string()
945 } else {
946 format!("target/{}/release", rustc_triple(&recipe.build.target))
947 };
948 Some(format!(
949 "mkdir -p /out/usr/bin && \
950 find {rel} -maxdepth 1 -type f -perm -100 \
951 -exec cp {{}} /out/usr/bin/ \\;"
952 ))
953 }
954 // `go install` ya dejó los binarios en GOBIN=/out/usr/bin: nada que hacer.
955 BuildSys::Go => Some("true".to_string()),
956 BuildSys::Unknown => None,
957 };
958 }
959 Ok(out)
960}
961
962#[cfg(test)]
963mod heuristic_tests {
964 use super::*;
965
966 fn touch(dir: &Path, name: &str) {
967 std::fs::write(dir.join(name), b"").unwrap();
968 }
969
970 fn recipe(flags: &[&str]) -> Recipe {
971 let mut r = Recipe::from_toml(
972 r#"
973name = "x"
974version = "0"
975[source]
976repo = "git://x"
977commit = "deadbeef"
978[build]
979"#,
980 )
981 .unwrap();
982 r.build.flags = flags.iter().map(|s| (*s).to_string()).collect();
983 r
984 }
985
986 #[test]
987 fn go_subdir_compiles_in_subdir() {
988 // dolt-like: go.mod en subdir `go/`. Con build.subdir="go" el lab detecta Go ahí y el compile
989 // entra al subdir primero.
990 let d = tempfile::tempdir().unwrap();
991 std::fs::create_dir_all(d.path().join("go/cmd/dolt")).unwrap();
992 touch(d.path(), "go/go.mod");
993 std::fs::write(d.path().join("go/cmd/dolt/main.go"), b"package main\nfunc main(){}\n").unwrap();
994 let mut r = recipe(&[]);
995 r.name = "dolt".into();
996 r.build.subdir = "go".into();
997 let p = resolve_phases(&r, d.path()).unwrap();
998 let c = p.compile.unwrap();
999 assert!(c.starts_with("cd go && "), "entra al subdir: {c}");
1000 assert!(c.contains("go install -trimpath") && c.trim_end().ends_with("./cmd/dolt"), "{c}");
1001 }
1002
1003 #[test]
1004 fn go_mod_wins_over_configure_and_derives_go_install() {
1005 // miller-like: go.mod + configure auxiliar ⇒ Go gana, y el compile es `go install`.
1006 let d = tempfile::tempdir().unwrap();
1007 touch(d.path(), "go.mod");
1008 touch(d.path(), "configure");
1009 std::fs::write(d.path().join("main.go"), b"package main\nfunc main(){}\n").unwrap();
1010 assert_eq!(detect_build_system(d.path()), BuildSys::Go);
1011 let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
1012 assert!(p.configure.is_none());
1013 let c = p.compile.unwrap();
1014 assert!(c.contains("go install -trimpath"), "{c}");
1015 assert!(c.ends_with(" ."), "raíz ⇒ paquete `.`: {c}");
1016 assert_eq!(p.install.as_deref(), Some("true"));
1017 }
1018
1019 #[test]
1020 fn go_cgo_knob_switches_compile_env() {
1021 // cgo=false (default) ⇒ CGO_ENABLED=0, Go puro; cgo=true ⇒ CGO_ENABLED=1 + CC zig + estático.
1022 let d = tempfile::tempdir().unwrap();
1023 touch(d.path(), "go.mod");
1024 std::fs::write(d.path().join("main.go"), b"package main\nfunc main(){}\n").unwrap();
1025 let off = resolve_phases(&recipe(&[]), d.path()).unwrap().compile.unwrap();
1026 assert!(off.contains("CGO_ENABLED=0"), "{off}");
1027 let mut r = recipe(&[]);
1028 r.build.cgo = true;
1029 let on = resolve_phases(&r, d.path()).unwrap().compile.unwrap();
1030 assert!(on.contains("CGO_ENABLED=1") && on.contains("zig cc") && on.contains("-extldflags=-static"), "{on}");
1031 }
1032
1033 #[test]
1034 fn detect_go_when_only_go_work() {
1035 // workspace multimódulo sin go.mod en la raíz (sólo go.work) ⇒ sigue siendo Go (antes caía a
1036 // Makefile/Unknown). El vendoring usará `go work vendor`.
1037 let d = tempfile::tempdir().unwrap();
1038 touch(d.path(), "go.work");
1039 touch(d.path(), "Makefile"); // un Makefile auxiliar no debe ganarle a Go
1040 assert_eq!(detect_build_system(d.path()), BuildSys::Go);
1041 }
1042
1043 #[test]
1044 fn detect_go_main_picks_cmd_over_docs() {
1045 // main real en cmd/mlr + main de ejemplo roto en docs/ ⇒ elige cmd/mlr, ignora docs.
1046 let d = tempfile::tempdir().unwrap();
1047 for sub in ["cmd/mlr", "docs/example"] {
1048 std::fs::create_dir_all(d.path().join(sub)).unwrap();
1049 std::fs::write(d.path().join(sub).join("main.go"), b"package main\nfunc main(){}\n").unwrap();
1050 }
1051 assert_eq!(detect_go_main(d.path(), "mlr"), "./cmd/mlr");
1052 }
1053
1054 #[test]
1055 fn detect_go_main_prefers_cmd_matching_recipe_name() {
1056 // eksctl-like: cmd/eksctl (CLI real) + cmd/schema (helper que panica). El dir que coincide
1057 // con el name de la receta gana sobre el hermano ⇒ no sella el binario equivocado.
1058 let d = tempfile::tempdir().unwrap();
1059 for sub in ["cmd/schema", "cmd/eksctl"] {
1060 std::fs::create_dir_all(d.path().join(sub)).unwrap();
1061 std::fs::write(d.path().join(sub).join("main.go"), b"package main\nfunc main(){}\n").unwrap();
1062 }
1063 assert_eq!(detect_go_main(d.path(), "eksctl"), "./cmd/eksctl");
1064 }
1065
1066 #[test]
1067 fn detect_go_main_sees_main_under_block_comment_header() {
1068 // header de licencia `/* ... */` con líneas internas SIN `*` (estilo Apache de helm):
1069 // el main en cmd/helm debe detectarse igual (regresión del corte ingenuo por-línea).
1070 let d = tempfile::tempdir().unwrap();
1071 std::fs::create_dir_all(d.path().join("cmd/helm")).unwrap();
1072 std::fs::write(
1073 d.path().join("cmd/helm/helm.go"),
1074 b"/*\nCopyright The Helm Authors.\nLicensed under the Apache License 2.0\n*/\n\npackage main\n\nfunc main() {}\n",
1075 )
1076 .unwrap();
1077 assert_eq!(detect_go_main(d.path(), "helm"), "./cmd/helm");
1078 }
1079
1080 #[test]
1081 fn detect_autoconf_raw_when_only_configure_ac() {
1082 let d = tempfile::tempdir().unwrap();
1083 touch(d.path(), "configure.ac");
1084 assert_eq!(detect_build_system(d.path()), BuildSys::AutoconfRaw);
1085 let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
1086 assert!(p.configure.as_deref().unwrap().starts_with("autoreconf -fi &&"));
1087 assert!(p.install.as_deref().unwrap().contains("DESTDIR=/out"));
1088 }
1089
1090 #[test]
1091 fn detect_autoconf_ready_when_configure_exists() {
1092 let d = tempfile::tempdir().unwrap();
1093 touch(d.path(), "configure.ac");
1094 touch(d.path(), "configure");
1095 assert_eq!(detect_build_system(d.path()), BuildSys::AutoconfReady);
1096 let p = resolve_phases(&recipe(&["--enable-foo"]), d.path()).unwrap();
1097 let c = p.configure.unwrap();
1098 assert!(c.starts_with("./configure --prefix=/usr"), "{c}");
1099 assert!(c.ends_with("--enable-foo"), "{c}");
1100 }
1101
1102 #[test]
1103 fn autotools_injects_native_triple() {
1104 // Etapa G Fase 3: configure heurístico fija --build/--host al triple saneado.
1105 let d = tempfile::tempdir().unwrap();
1106 touch(d.path(), "configure.ac");
1107 touch(d.path(), "configure");
1108 let c = resolve_phases(&recipe(&[]), d.path()).unwrap().configure.unwrap();
1109 assert!(c.contains("--build=x86_64-linux-musl"), "{c}");
1110 assert!(c.contains("--host=x86_64-linux-musl"), "{c}");
1111 }
1112
1113 #[test]
1114 fn autotools_triple_yields_to_recipe_flags() {
1115 // Si la receta ya fija el triple (juicio per-paquete), no duplicamos.
1116 let d = tempfile::tempdir().unwrap();
1117 touch(d.path(), "configure.ac");
1118 touch(d.path(), "configure");
1119 let c = resolve_phases(&recipe(&["--host=aarch64-linux-musl"]), d.path())
1120 .unwrap()
1121 .configure
1122 .unwrap();
1123 assert!(c.contains("--host=aarch64-linux-musl"), "{c}");
1124 assert!(!c.contains("x86_64-linux-musl"), "no inyecta si la receta ya lo puso ({c})");
1125 }
1126
1127 #[test]
1128 fn cmake_ignores_autotools_triple() {
1129 // El triple es exclusivo de autotools; CMake/Meson/Cargo no lo reciben.
1130 let d = tempfile::tempdir().unwrap();
1131 touch(d.path(), "CMakeLists.txt");
1132 let c = resolve_phases(&recipe(&[]), d.path()).unwrap().configure.unwrap();
1133 assert!(!c.contains("--host="), "{c}");
1134 }
1135
1136 #[test]
1137 fn detect_cmake() {
1138 let d = tempfile::tempdir().unwrap();
1139 touch(d.path(), "CMakeLists.txt");
1140 assert_eq!(detect_build_system(d.path()), BuildSys::CMake);
1141 let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
1142 assert!(p.configure.unwrap().contains("-DCMAKE_INSTALL_PREFIX=/usr"));
1143 assert!(p.compile.unwrap().contains("cmake --build _build"));
1144 assert!(p.install.unwrap().contains("DESTDIR=/out"));
1145 }
1146
1147 #[test]
1148 fn detect_meson() {
1149 let d = tempfile::tempdir().unwrap();
1150 touch(d.path(), "meson.build");
1151 assert_eq!(detect_build_system(d.path()), BuildSys::Meson);
1152 let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
1153 assert!(p.configure.unwrap().contains("meson setup _build"));
1154 assert!(p.install.unwrap().contains("meson install"));
1155 }
1156
1157 #[test]
1158 fn detect_cargo() {
1159 let d = tempfile::tempdir().unwrap();
1160 touch(d.path(), "Cargo.toml");
1161 assert_eq!(detect_build_system(d.path()), BuildSys::Cargo);
1162 // Target default (x86_64-linux-musl) = nativo del sandbox ⇒ build SIN --target.
1163 let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
1164 assert!(p.configure.is_none(), "cargo no usa configure");
1165 let c = p.compile.unwrap();
1166 // Nativo: `cargo rustc` (no `cargo build`) para pasar los flags de codegen tras `--` sólo
1167 // a la crate final; sin `--target` (lo cross sí; el único --target es el del wrapper saneador).
1168 assert!(c.contains("cargo rustc --release"), "{c}");
1169 assert!(c.contains("--locked") && c.contains("--offline"), "{c}");
1170 assert!(!c.contains("--offline --target"), "nativo: cargo sin --target ({c})");
1171 assert!(c.contains("a=--target=x86_64-linux-musl"), "wrapper sanea el triple ({c})");
1172 assert!(c.contains("-- -C target-feature=+crt-static"), "crt-static scoped tras -- ({c})");
1173 assert!(c.contains("relocation-model=static"), "no-PIE: ET_EXEC sin interpreter ({c})");
1174 assert!(c.contains("CC=\"$PWD/.hammer-zig-cc\""), "CC = wrapper para cc-rs ({c})");
1175 // El linker se arma en `RF` (que además gana `-C link-self-contained=no` cuando el host no es
1176 // Alpine) y se aplica vía `RUSTFLAGS="$RF"`.
1177 assert!(c.contains("RF=\"-C linker=$PWD/.hammer-zig-cc"), "linker en RF ({c})");
1178 assert!(c.contains("RUSTFLAGS=\"$RF\""), "RF aplicado por RUSTFLAGS ({c})");
1179 let i = p.install.unwrap();
1180 assert!(i.contains("/out/usr/bin"), "{i}");
1181 assert!(i.contains("find target/release"), "nativo instala de target/release ({i})");
1182 }
1183
1184 #[test]
1185 fn cargo_gcc_ghetto_uses_gcc_for_build_scripts() {
1186 // Receta Rust con `compiler="gcc"`: el GUETO para crates con dep-C (libgit2-sys/openssl-sys/
1187 // libc-stdhandle) que zig-cc rechaza. cc-rs debe usar gcc (CC=gcc), pero el LINKER sigue
1188 // siendo el wrapper zig-cc (objetos gcc-musl + Rust zig-musl son ABI-compatibles).
1189 let d = tempfile::tempdir().unwrap();
1190 touch(d.path(), "Cargo.toml");
1191 let mut r = recipe(&[]);
1192 r.build.compiler = Compiler::Gcc;
1193 let c = resolve_phases(&r, d.path()).unwrap().compile.unwrap();
1194 assert!(c.contains("CC=\"gcc\""), "cc-rs usa gcc en el gueto ({c})");
1195 assert!(!c.contains("CC=\"$PWD/.hammer-zig-cc\""), "CC ya no es el wrapper ({c})");
1196 // El linker NO cambia: sigue el wrapper zig-cc.
1197 assert!(c.contains("RF=\"-C linker=$PWD/.hammer-zig-cc"), "linker sigue zig-cc ({c})");
1198 assert!(c.contains("cargo rustc --release"), "sigue el camino cargo nativo ({c})");
1199 }
1200
1201 #[test]
1202 fn cargo_default_compiler_keeps_zig_cc_for_build_scripts() {
1203 // Sin `compiler=` (default ZigCc): CC sigue siendo el wrapper — baseline ripgrep/fd intacto.
1204 let d = tempfile::tempdir().unwrap();
1205 touch(d.path(), "Cargo.toml");
1206 let c = resolve_phases(&recipe(&[]), d.path()).unwrap().compile.unwrap();
1207 assert!(c.contains("CC=\"$PWD/.hammer-zig-cc\""), "default: cc-rs usa el wrapper zig ({c})");
1208 }
1209
1210 #[test]
1211 fn cargo_cross_target_keeps_triple() {
1212 // Un target NO nativo (cross real) sí pasa --target + el linker per-triple.
1213 let d = tempfile::tempdir().unwrap();
1214 touch(d.path(), "Cargo.toml");
1215 let mut r = recipe(&[]);
1216 r.build.target = "aarch64-linux-musl".into();
1217 let p = resolve_phases(&r, d.path()).unwrap();
1218 let c = p.compile.unwrap();
1219 assert!(c.contains("--target aarch64-unknown-linux-musl"), "{c}");
1220 assert!(c.contains("CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER="), "{c}");
1221 assert!(p.install.unwrap().contains("target/aarch64-unknown-linux-musl/release"));
1222 }
1223
1224 #[test]
1225 fn cargo_wins_over_stray_makefile_but_not_cmake() {
1226 let d = tempfile::tempdir().unwrap();
1227 touch(d.path(), "Cargo.toml");
1228 touch(d.path(), "Makefile");
1229 assert_eq!(detect_build_system(d.path()), BuildSys::Cargo);
1230 // pero un proyecto C+Rust con CMakeLists prefiere el sistema C
1231 touch(d.path(), "CMakeLists.txt");
1232 assert_eq!(detect_build_system(d.path()), BuildSys::CMake);
1233 }
1234
1235 #[test]
1236 fn cargo_flags_go_to_compile() {
1237 let d = tempfile::tempdir().unwrap();
1238 touch(d.path(), "Cargo.toml");
1239 let p = resolve_phases(&recipe(&["--features", "gpu"]), d.path()).unwrap();
1240 // Los flags de la receta van como args de cargo (antes del `--` que separa los de rustc).
1241 assert!(p.compile.unwrap().contains("--features gpu -- "));
1242 }
1243
1244 #[test]
1245 fn cargo_dynamic_disables_crt_static() {
1246 let d = tempfile::tempdir().unwrap();
1247 touch(d.path(), "Cargo.toml");
1248 let mut r = recipe(&[]);
1249 r.build.link = hammer_core::LinkMode::Dynamic;
1250 let c = resolve_phases(&r, d.path()).unwrap().compile.unwrap();
1251 assert!(c.contains("target-feature=-crt-static"), "{c}");
1252 }
1253
1254 #[test]
1255 fn rustc_triple_translation() {
1256 assert_eq!(rustc_triple("x86_64-linux-musl"), "x86_64-unknown-linux-musl");
1257 assert_eq!(rustc_triple("aarch64-linux-gnu"), "aarch64-unknown-linux-gnu");
1258 // un triple ya completo pasa intacto
1259 assert_eq!(rustc_triple("x86_64-unknown-linux-musl"), "x86_64-unknown-linux-musl");
1260 assert_eq!(
1261 cargo_linker_env_var("x86_64-unknown-linux-musl"),
1262 "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER"
1263 );
1264 }
1265
1266 #[test]
1267 fn detect_make() {
1268 let d = tempfile::tempdir().unwrap();
1269 touch(d.path(), "Makefile");
1270 assert_eq!(detect_build_system(d.path()), BuildSys::Make);
1271 let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
1272 assert!(p.configure.is_none(), "make plano no necesita configure");
1273 assert!(p.compile.unwrap().starts_with("make"));
1274 }
1275
1276 #[test]
1277 fn unknown_returns_no_phases() {
1278 let d = tempfile::tempdir().unwrap();
1279 // sólo un README, ningún archivo característico.
1280 std::fs::write(d.path().join("README"), b"hi").unwrap();
1281 assert_eq!(detect_build_system(d.path()), BuildSys::Unknown);
1282 let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
1283 assert!(p.compile.is_none());
1284 assert!(p.install.is_none());
1285 }
1286
1287 #[test]
1288 fn recipe_override_wins_over_heuristic() {
1289 let d = tempfile::tempdir().unwrap();
1290 touch(d.path(), "Makefile");
1291 let mut r = recipe(&[]);
1292 r.build.phases.compile = Some("zig cc -static -o foo foo.c".to_string());
1293 r.build.phases.install = Some("cp foo /out/bin/foo".to_string());
1294 let p = resolve_phases(&r, d.path()).unwrap();
1295 assert_eq!(p.compile.unwrap(), "zig cc -static -o foo foo.c");
1296 assert_eq!(p.install.unwrap(), "cp foo /out/bin/foo");
1297 }
1298}
1299
1300
1301fn unique_out_dir(
1302 work_root: &Path,
1303 name: &str,
1304 h: &ArtifactHash,
1305) -> hammer_core::Result<PathBuf> {
1306 let dir = work_root
1307 .join("out")
1308 .join(h.store_dir_name(name));
1309 if dir.exists() {
1310 std::fs::remove_dir_all(&dir)?;
1311 }
1312 std::fs::create_dir_all(&dir)?;
1313 Ok(dir)
1314}
1315
1316/// Proyecta un artefacto del store al FHS (real o de overlay). Resuelve el directorio del
1317/// artefacto a partir del hash y delega en [`hydrate::hydrate`].
1318pub fn hydrate(
1319 h: &ArtifactHash,
1320 store: &Store,
1321 target_fhs: &Path,
1322 mode: LinkMode,
1323 dynamic: Option<&hydrate::DynamicSpec>,
1324) -> hammer_core::Result<HydrateReport> {
1325 let artifact_dir = store.find_by_hash(h.as_str())?;
1326 hydrate::hydrate(&artifact_dir, target_fhs, mode, dynamic)
1327}
1328
1329#[cfg(test)]
1330mod dep_hash_tests {
1331 use super::*;
1332
1333 /// Escribe `<dir>/<name>.toml` con `source.commit = commit` y, si hay, `deps.build = deps`.
1334 fn write_recipe(dir: &Path, name: &str, commit: &str, deps: &[&str]) {
1335 let deps_block = if deps.is_empty() {
1336 String::new()
1337 } else {
1338 let list = deps.iter().map(|d| format!("\"{d}\"")).collect::<Vec<_>>().join(", ");
1339 format!("\n[deps]\nbuild = [{list}]\n")
1340 };
1341 let toml = format!(
1342 "name = \"{name}\"\nversion = \"1\"\n[source]\nrepo = \"git://{name}\"\ncommit = \"{commit}\"\n[build]\n{deps_block}"
1343 );
1344 std::fs::write(dir.join(format!("{name}.toml")), toml).unwrap();
1345 }
1346
1347 fn load(dir: &Path, name: &str) -> Recipe {
1348 Recipe::load_from_path(dir.join(format!("{name}.toml"))).unwrap()
1349 }
1350
1351 fn store_in(dir: &Path) -> Store {
1352 Store::open(dir.join("store")).unwrap()
1353 }
1354
1355 #[test]
1356 fn dep_contributes_to_hash() {
1357 let d = tempfile::tempdir().unwrap();
1358 let store = store_in(d.path());
1359 write_recipe(d.path(), "base", "b0", &[]);
1360 write_recipe(d.path(), "app", "a0", &["base"]);
1361 write_recipe(d.path(), "app_nodep", "a0", &[]); // misma fuente, sin la dep
1362
1363 let with_dep = artifact_hash(&load(d.path(), "app"), &store).unwrap();
1364 let no_dep = artifact_hash(&load(d.path(), "app_nodep"), &store).unwrap();
1365 // `app_nodep` se llama distinto, pero el nombre no entra al hash: la única diferencia
1366 // de entrada es la presencia de la dep. (Renombramos sólo para tener dos .toml.)
1367 assert_ne!(with_dep, no_dep, "la dep de build debe entrar al hash");
1368 }
1369
1370 #[test]
1371 fn transitive_dep_change_propagates() {
1372 let d = tempfile::tempdir().unwrap();
1373 let store = store_in(d.path());
1374 write_recipe(d.path(), "base", "b0", &[]);
1375 write_recipe(d.path(), "lib", "l0", &["base"]);
1376 write_recipe(d.path(), "app", "a0", &["lib"]);
1377 let before = artifact_hash(&load(d.path(), "app"), &store).unwrap();
1378
1379 // Cambiar la fuente de la dep transitiva `base` debe re-hashear `app`.
1380 write_recipe(d.path(), "base", "b1", &[]);
1381 let after = artifact_hash(&load(d.path(), "app"), &store).unwrap();
1382 assert_ne!(before, after, "un cambio transitivo debe propagarse");
1383 }
1384
1385 #[test]
1386 fn missing_dep_toml_errors_with_names() {
1387 let d = tempfile::tempdir().unwrap();
1388 let store = store_in(d.path());
1389 write_recipe(d.path(), "app", "a0", &["pcre2"]); // sin pcre2.toml
1390 let err = artifact_hash(&load(d.path(), "app"), &store).unwrap_err().to_string();
1391 assert!(err.contains("pcre2"), "el error debe nombrar la dep: {err}");
1392 assert!(err.contains("app"), "y la receta que la pide: {err}");
1393 }
1394
1395 #[test]
1396 fn cycle_is_detected() {
1397 let d = tempfile::tempdir().unwrap();
1398 let store = store_in(d.path());
1399 write_recipe(d.path(), "a", "a0", &["b"]);
1400 write_recipe(d.path(), "b", "b0", &["a"]);
1401 let err = artifact_hash(&load(d.path(), "a"), &store).unwrap_err().to_string();
1402 assert!(err.contains("ciclo"), "esperaba ciclo: {err}");
1403 assert!(err.contains("a -> b -> a"), "debe mostrar la cadena: {err}");
1404 }
1405
1406 #[test]
1407 fn deps_without_base_dir_errors() {
1408 let store = Store::open(tempfile::tempdir().unwrap().path().join("store")).unwrap();
1409 // Parseada de TOML crudo ⇒ base_dir vacío; con deps no hay catálogo que resolver.
1410 let r = Recipe::from_toml(
1411 "name=\"x\"\nversion=\"1\"\n[source]\nrepo=\"git://x\"\ncommit=\"c0\"\n[build]\n[deps]\nbuild=[\"y\"]\n",
1412 )
1413 .unwrap();
1414 let err = artifact_hash(&r, &store).unwrap_err().to_string();
1415 assert!(err.contains("base_dir"), "debe explicar la falta de base_dir: {err}");
1416 }
1417
1418 #[test]
1419 fn no_deps_unaffected() {
1420 let d = tempfile::tempdir().unwrap();
1421 let store = store_in(d.path());
1422 write_recipe(d.path(), "solo", "s0", &[]);
1423 let a = artifact_hash(&load(d.path(), "solo"), &store).unwrap();
1424 let b = artifact_hash(&load(d.path(), "solo"), &store).unwrap();
1425 assert_eq!(a, b, "sin deps el hash es estable");
1426 }
1427}