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…
⎇ 98f0b2d228ffebb06941dfc1f436167ab40d8f21
crates/hammer-build/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
bootstrap: orquestador `all` + export del manifiesto (Etapa A)
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::path::{Path, PathBuf};
5
6use hammer_core::{ArtifactHash, LinkMode, Phases, Recipe, Store};
7
8pub mod config;
9pub mod download;
10pub mod fetch;
11pub mod hydrate;
12pub mod sandbox;
13pub mod swm_bridge;
14
15pub use config::BuildConfig;
16pub use hydrate::{hydrate as run_hydrate, DynamicSpec, HydrateReport, HydratedFile};
17pub use sandbox::{BuildFailure, Sandbox};
18pub use swm_bridge::build_source_patch;
19
20/// Calcula el `ArtifactHash` de una receta, resolviendo recursivamente sus deps de build.
21/// Ver `docs/02-build-lab.md` §2 y §5.
22///
23/// **Catálogo = `base_dir` de la receta.** Cada nombre en `deps.build` se resuelve al hermano
24/// `<base_dir>/<dep>.toml`; su hash entra en el de la receta (Merkle sobre el subgrafo de build).
25/// Así dos recetas con la misma fuente pero distinto grafo de deps hashean distinto, y cambiar
26/// una dep transitiva invalida la caché de todo lo que la usa.
27///
28/// Errores: una receta con `deps.build` pero sin `base_dir` (parseada de TOML crudo, no cargada
29/// de disco) no puede resolver su catálogo; un `<dep>.toml` ausente, o un ciclo de deps, también
30/// fallan con un mensaje que nombra la cadena.
31pub fn artifact_hash(recipe: &Recipe, _store: &Store) -> hammer_core::Result<ArtifactHash> {
32 let mut stack: Vec<String> = Vec::new();
33 artifact_hash_rec(recipe, &mut stack)
34}
35
36fn artifact_hash_rec(recipe: &Recipe, stack: &mut Vec<String>) -> hammer_core::Result<ArtifactHash> {
37 if stack.iter().any(|n| n == &recipe.name) {
38 stack.push(recipe.name.clone());
39 return Err(hammer_core::Error::Recipe(format!(
40 "ciclo de dependencias de build: {}",
41 stack.join(" -> ")
42 )));
43 }
44
45 let dep_hashes = resolve_build_dep_hashes(recipe, stack)?;
46 let inputs = recipe.hash_inputs(&dep_hashes)?;
47 let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect();
48 Ok(ArtifactHash::of_inputs(&refs))
49}
50
51/// Resuelve y hashea, en orden de declaración, cada `deps.build` de `recipe` cargándolo del
52/// catálogo (su `base_dir`). `stack` lleva la cadena en curso para detectar ciclos.
53fn resolve_build_dep_hashes(
54 recipe: &Recipe,
55 stack: &mut Vec<String>,
56) -> hammer_core::Result<Vec<ArtifactHash>> {
57 if recipe.deps.build.is_empty() {
58 return Ok(Vec::new());
59 }
60 if recipe.base_dir.as_os_str().is_empty() {
61 return Err(hammer_core::Error::Recipe(format!(
62 "la receta '{}' declara deps de build {:?} pero no tiene base_dir; \
63 cárgala con Recipe::load_from_path para resolver el catálogo",
64 recipe.name, recipe.deps.build
65 )));
66 }
67
68 stack.push(recipe.name.clone());
69 let mut hashes = Vec::with_capacity(recipe.deps.build.len());
70 for dep in &recipe.deps.build {
71 let dep_path = recipe.base_dir.join(format!("{dep}.toml"));
72 let dep_recipe = Recipe::load_from_path(&dep_path).map_err(|e| {
73 hammer_core::Error::Recipe(format!(
74 "no pude cargar la dep de build '{dep}' de '{}' ({}): {e}",
75 recipe.name,
76 dep_path.display()
77 ))
78 })?;
79 hashes.push(artifact_hash_rec(&dep_recipe, stack)?);
80 }
81 stack.pop();
82 Ok(hashes)
83}
84
85/// Construye (si falta) cada `deps.build` y devuelve sus directorios sellados en el store, para
86/// apilarlos como capas overlay del sandbox (variante b: bwrap necesita libcap.a + sys/capability.h,
87/// ausentes del toolchain Alpine). Recursivo vía `build`: una dep con sus propias deps se materializa
88/// transitivamente. Los ciclos ya los corta `artifact_hash` (con su `stack`) antes de llegar acá.
89fn materialize_build_deps(
90 recipe: &Recipe,
91 cfg: &BuildConfig,
92 store: &Store,
93) -> hammer_core::Result<Vec<PathBuf>> {
94 if recipe.deps.build.is_empty() {
95 return Ok(Vec::new());
96 }
97 if recipe.base_dir.as_os_str().is_empty() {
98 return Err(hammer_core::Error::Recipe(format!(
99 "la receta '{}' declara deps de build {:?} pero no tiene base_dir; \
100 cárgala con Recipe::load_from_path para resolver el catálogo",
101 recipe.name, recipe.deps.build
102 )));
103 }
104 let mut dirs = Vec::with_capacity(recipe.deps.build.len());
105 for dep in &recipe.deps.build {
106 let dep_path = recipe.base_dir.join(format!("{dep}.toml"));
107 let dep_recipe = Recipe::load_from_path(&dep_path).map_err(|e| {
108 hammer_core::Error::Recipe(format!(
109 "no pude cargar la dep de build '{dep}' de '{}' ({}): {e}",
110 recipe.name,
111 dep_path.display()
112 ))
113 })?;
114 let dep_hash = build(&dep_recipe, cfg, store)?;
115 dirs.push(store.path_of(&dep_hash, &dep_recipe.name));
116 }
117 Ok(dirs)
118}
119
120/// Compila una receta y devuelve el hash del artefacto sellado en el store.
121/// Si el hash ya existe en el store, devuelve sin recompilar (caché).
122pub fn build(
123 recipe: &Recipe,
124 cfg: &BuildConfig,
125 store: &Store,
126) -> hammer_core::Result<ArtifactHash> {
127 let h = artifact_hash(recipe, store)?;
128 if store.has(&h, &recipe.name) {
129 tracing::info!(hash = %h, name = %recipe.name, "caché: artefacto ya en el store");
130 return Ok(h);
131 }
132 sandbox::ensure_layout(&cfg.rootfs, &cfg.zig_dir)?;
133
134 // Materializa las deps de build (las construye si faltan) ANTES del fetch: sus árboles sellados
135 // se apilan como capas overlay del sandbox para que headers/libs/pkgconfig estén en /usr.
136 let dep_dirs = materialize_build_deps(recipe, cfg, store)?;
137 if !dep_dirs.is_empty() {
138 tracing::info!(n = dep_dirs.len(), "deps de build materializadas como capas overlay");
139 }
140
141 tracing::info!(hash = %h, name = %recipe.name, "fetch");
142 let src_tree = fetch::fetch(recipe, &cfg.work_root)?;
143
144 if !recipe.source.patches.is_empty() {
145 tracing::info!(n = recipe.source.patches.len(), "aplicando patches");
146 fetch::apply_patches(recipe, &src_tree)?;
147 }
148
149 // Recetas Cargo: vendoreamos las deps (red permitida en el fetch) para que el build sea
150 // hermético `--offline` dentro del sandbox. Tras los patches, por si tocaron Cargo.toml/lock.
151 if matches!(detect_build_system(&src_tree), BuildSys::Cargo) {
152 tracing::info!("cargo: vendoreando deps para build offline");
153 fetch::vendor_cargo_deps(&src_tree)?;
154 }
155
156 let out_dir = unique_out_dir(&cfg.work_root, &recipe.name, &h)?;
157 let mut env: Vec<(String, String)> = Vec::new();
158 if matches!(recipe.build.link, LinkMode::Static) {
159 // autotools/cmake leen LDFLAGS para el link final; -static fuerza el binario
160 // monolítico que pide nuestra estrategia de hidratación primaria.
161 env.push(("LDFLAGS".into(), "-static".into()));
162 }
163 if let Some(cache) = &cfg.cache_root {
164 std::fs::create_dir_all(cache.join("zig"))?;
165 }
166 let sb = Sandbox {
167 rootfs: cfg.rootfs.clone(),
168 zig_dir: cfg.zig_dir.clone(),
169 src_dir: src_tree.clone(),
170 out_dir: out_dir.clone(),
171 cache_dir: cfg.cache_root.clone(),
172 deps: dep_dirs,
173 env,
174 };
175
176 let phases = resolve_phases(recipe, &src_tree)?;
177 if let Some(cmd) = &phases.configure {
178 tracing::info!(cmd, "phase: configure");
179 sb.run(cmd)?;
180 }
181 if let Some(cmd) = &phases.compile {
182 tracing::info!(cmd, "phase: compile");
183 sb.run(cmd)?;
184 } else {
185 return Err(hammer_core::Error::Recipe(
186 "receta sin fase 'compile' y heurística no encontró ninguna".into(),
187 ));
188 }
189 if let Some(cmd) = &phases.install {
190 tracing::info!(cmd, "phase: install");
191 sb.run(cmd)?;
192 } else {
193 return Err(hammer_core::Error::Recipe(
194 "receta sin fase 'install' y heurística no encontró ninguna".into(),
195 ));
196 }
197
198 // Sidecar de provenance: escribimos la receta serializada DENTRO de out_dir antes del
199 // seal, para que quede congelada en el árbol read-only del store. `hammer export` la
200 // lee de vuelta para emitir `source_patch` con la receta original en vez de un
201 // `file_drop` opaco. Ver `docs/04-overlay.md` §provenance (Fase 4).
202 let sidecar_path = out_dir.join(hammer_core::store::RECIPE_SIDECAR_REL);
203 if let Some(parent) = sidecar_path.parent() {
204 std::fs::create_dir_all(parent)?;
205 }
206 let toml_text = recipe.to_toml()?;
207 std::fs::write(&sidecar_path, toml_text.as_bytes())?;
208
209 let sealed = store.seal(&out_dir, &h, &recipe.name)?;
210 tracing::info!(path = %sealed.display(), "sealed");
211 Ok(h)
212}
213
214/// Sistemas de build que reconoce la heurística por inspección del árbol del source.
215/// Ver `docs/02-build-lab.md` §4.
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217enum BuildSys {
218 /// `configure.ac` (o `.in`) sin `configure` generado: hay que correr `autoreconf` primero.
219 AutoconfRaw,
220 /// `configure` ya está en el tarball release: vamos directo a `./configure`.
221 AutoconfReady,
222 /// `CMakeLists.txt`.
223 CMake,
224 /// `meson.build`.
225 Meson,
226 /// `Cargo.toml`: crate o workspace Rust. Build con `cargo`, link con `zig cc`.
227 Cargo,
228 /// `Makefile` plano (o `makefile`) sin sistema generador.
229 Make,
230 /// No detectamos nada: la receta debe traer overrides en `[build.phases]`.
231 Unknown,
232}
233
234fn detect_build_system(src: &Path) -> BuildSys {
235 let has = |p: &str| src.join(p).exists();
236 // El orden importa: cuando coexisten varios (un tarball release de autotools trae el
237 // `configure` regenerado y un `Makefile` huérfano de un build previo), preferimos el
238 // sistema "más alto" que sí sabe regenerar todo.
239 if has("configure.ac") || has("configure.in") {
240 if has("configure") {
241 return BuildSys::AutoconfReady;
242 }
243 return BuildSys::AutoconfRaw;
244 }
245 if has("configure") {
246 return BuildSys::AutoconfReady;
247 }
248 if has("CMakeLists.txt") {
249 return BuildSys::CMake;
250 }
251 if has("meson.build") {
252 return BuildSys::Meson;
253 }
254 // Cargo gana sobre un `Makefile` huérfano (algunos crates traen un wrapper `make`),
255 // pero cede ante autotools/cmake/meson (un políglota C+Rust suele querer el sistema C).
256 if has("Cargo.toml") {
257 return BuildSys::Cargo;
258 }
259 if has("Makefile") || has("makefile") || has("GNUmakefile") {
260 return BuildSys::Make;
261 }
262 BuildSys::Unknown
263}
264
265/// Target nativo del sandbox del lab (Alpine x86_64 musl). Una receta Cargo cuyo `build.target`
266/// sea éste se construye **nativa** (sin `--target`): el rust del sandbox sólo trae su std nativo
267/// (`x86_64-alpine-linux-musl`), no el `x86_64-unknown-linux-musl` que pediría un cross, y no hay
268/// rustup para agregarlo. Ver `docs/runbooks/stage1-vm-boot.md` §8 (primera corrida).
269const SANDBOX_NATIVE_TARGET: &str = "x86_64-linux-musl";
270
271/// Traduce el triple estilo zig/hammer (`x86_64-linux-musl`) al de rustc
272/// (`x86_64-unknown-linux-musl`). Un triple ya completo (con vendor) o ajeno al patrón
273/// `<arch>-linux-<libc>` se devuelve tal cual: la receta puede dar el triple exacto.
274fn rustc_triple(target: &str) -> String {
275 match target.split('-').collect::<Vec<_>>().as_slice() {
276 [arch, "linux", libc] => format!("{arch}-unknown-linux-{libc}"),
277 _ => target.to_string(),
278 }
279}
280
281/// Nombre de la env `CARGO_TARGET_<TRIPLE>_LINKER` para un triple de rustc.
282fn cargo_linker_env_var(triple: &str) -> String {
283 format!("CARGO_TARGET_{}_LINKER", triple.to_uppercase().replace(['-', '.'], "_"))
284}
285
286/// Devuelve los comandos a ejecutar para cada fase: overrides explícitos en la receta
287/// ganan; el resto lo deriva la heurística según el sistema de build detectado.
288fn resolve_phases(recipe: &Recipe, src: &Path) -> hammer_core::Result<Phases> {
289 let mut out = recipe.build.phases.clone();
290 let bs = detect_build_system(src);
291 let flags = recipe.build.flags.join(" ");
292 let flags_suffix = if flags.is_empty() { String::new() } else { format!(" {flags}") };
293
294 if out.configure.is_none() {
295 out.configure = match bs {
296 BuildSys::AutoconfRaw => Some(format!(
297 "autoreconf -fi && ./configure --prefix=/usr{flags_suffix}"
298 )),
299 BuildSys::AutoconfReady => Some(format!("./configure --prefix=/usr{flags_suffix}")),
300 BuildSys::CMake => Some(format!(
301 "cmake -S . -B _build -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Release{flags_suffix}"
302 )),
303 BuildSys::Meson => Some(format!("meson setup _build --prefix=/usr{flags_suffix}")),
304 // Cargo no tiene fase configure separada.
305 BuildSys::Cargo | BuildSys::Make | BuildSys::Unknown => None,
306 };
307 }
308 if out.compile.is_none() {
309 out.compile = match bs {
310 BuildSys::AutoconfRaw | BuildSys::AutoconfReady | BuildSys::Make => {
311 Some(r#"make -j"$(nproc)""#.to_string())
312 }
313 BuildSys::CMake => Some(r#"cmake --build _build -j "$(nproc)""#.to_string()),
314 BuildSys::Meson => Some("meson compile -C _build".to_string()),
315 BuildSys::Cargo => {
316 // Hermético y reproducible: `--offline` (deps vendoreadas en la fuente) +
317 // `--locked` (Cargo.lock fijo, coherente con ADR 0006). El link va por un
318 // wrapper `zig cc` — cargo no acepta un linker de dos palabras, y zig provee
319 // el `libgcc_s` que el link dinámico musl pide (ver M2 del plan arje↔hammer).
320 if recipe.build.target == SANDBOX_NATIVE_TARGET {
321 // NATIVO: el sandbox del lab YA es x86_64 musl, así que no cruzamos. El rust del
322 // sandbox sólo trae su std nativo (`x86_64-alpine-linux-musl`), no
323 // `x86_64-unknown-linux-musl`; con `--target` fallaría "can't find crate for
324 // core" (no hay rustup). Ver docs/runbooks/stage1-vm-boot.md §8.
325 //
326 // El wrapper `.hammer-zig-cc` hace DOS cosas: (1) es un único ejecutable, lo que
327 // resuelve el `zig cc` de dos palabras que cargo (linker) y cc-rs (CC)
328 // mal-parsean; (2) **sanea el triple**: cc-rs (build scripts que compilan C/asm,
329 // p.ej. blake3) detecta el host triple de Alpine rust y pasa
330 // `--target=x86_64-alpine-linux-musl`, pero zig no conoce el vendor "alpine"
331 // (`UnknownOperatingSystem`); lo reescribimos a `x86_64-linux-musl`. Se usa como
332 // CC (cc-rs) y como linker (RUSTFLAGS, target-agnóstico — el default sería
333 // cc/gcc, ausente en el sandbox zig-only).
334 // Flags de codegen del BINARIO FINAL, vía `cargo rustc -- …` (NO RUSTFLAGS): en
335 // build nativo RUSTFLAGS también alcanza a los proc-macros (deps), y
336 // `+crt-static`/`relocation-model=static` los rompe ("cannot produce
337 // proc-macro" — un proc-macro es un dylib PIC). `cargo rustc -- …` los aplica
338 // sólo a la crate top-level, dejando los proc-macros intactos.
339 let bin_flags = match recipe.build.link {
340 // Estático AUTOCONTENIDO y no-PIE: `+crt-static` mete musl dentro (sin
341 // DT_NEEDED — el rust de Alpine enlaza musl dinámico por defecto, lo que deja
342 // `libc.musl-x86_64.so.1` y rompe el boot, runbook §8); `relocation-model=static`
343 // ⇒ ET_EXEC sin interpreter (vía de oro: como busybox, sin libc.so/loader/soname).
344 LinkMode::Static => "-C target-feature=+crt-static -C relocation-model=static",
345 // Dinámico: apaga crt-static para poder dlopen (necesita el loader/musl en el rootfs).
346 LinkMode::Dynamic => "-C target-feature=-crt-static",
347 };
348 // `-C link-self-contained=no` CONDICIONAL: el link (de TODO — deps,
349 // build-scripts, proc-macros y la crate top) lo hace `zig cc`, que ya aporta
350 // sus startfiles (crt1.o). Un rust VANILLA con target musl
351 // `x86_64-unknown-linux-musl` trae CRT AUTOCONTENIDO (self-contained/rcrt1.o) y
352 // lo pasaría también ⇒ `ld.lld: duplicate symbol: _start`; hay que apagarlo. PERO
353 // el rust de Alpine (host `x86_64-alpine-linux-musl`) PARCHEA la opción para que
354 // sea un ERROR ("option `-C link-self-contained` is not supported on this
355 // target") y NO usa self-contained — el baseline 9adefb82 linkea con zig sin
356 // chocar. Así que: añadir el flag SÓLO si el host del rustc NO es `-alpine-`
357 // (i.e. un rust vanilla como el hammer-rust del frente self-host); para Alpine,
358 // omitirlo. RUSTFLAGS alcanza a todas las unidades; los `bin_flags` (cargo rustc
359 // --) sólo a la top.
360 Some(format!(
361 "printf '%s\\n' '#!/bin/sh' 'for a do' \
362 'case \"$a\" in --target=*) a=--target=x86_64-linux-musl ;; esac' \
363 'set -- \"$@\" \"$a\"' 'shift' 'done' 'exec zig cc -mcpu=baseline \"$@\"' \
364 > \"$PWD/.hammer-zig-cc\" && chmod +x \"$PWD/.hammer-zig-cc\" && \
365 RF=\"-C linker=$PWD/.hammer-zig-cc\"; \
366 rustc -vV | grep -q 'host: .*-alpine-' || RF=\"$RF -C link-self-contained=no\"; \
367 CC=\"$PWD/.hammer-zig-cc\" RUSTFLAGS=\"$RF\" \
368 cargo rustc --release --locked --offline{flags_suffix} -- {bin_flags}"
369 ))
370 } else {
371 // CROSS real (otro target que zig sí entiende): `--target` + linker por env
372 // per-triple. Hermético y reproducible: `--offline` (deps vendoreadas) +
373 // `--locked` (Cargo.lock fijo, ADR 0006). zig provee el `libgcc_s` que el link
374 // dinámico musl pide (ver M2 del plan arje↔hammer).
375 let setup = "printf '#!/bin/sh\\nexec zig cc -mcpu=baseline \"$@\"\\n' > \"$PWD/.hammer-zig-cc\" && \
376 chmod +x \"$PWD/.hammer-zig-cc\" && ";
377 let triple = rustc_triple(&recipe.build.target);
378 let linker_var = cargo_linker_env_var(&triple);
379 let rustflags = match recipe.build.link {
380 // musl es crt-static por defecto; el dinámico lo apaga para poder dlopen.
381 LinkMode::Dynamic => "RUSTFLAGS='-C target-feature=-crt-static' ",
382 LinkMode::Static => "",
383 };
384 Some(format!(
385 "{setup}{rustflags}{linker_var}=\"$PWD/.hammer-zig-cc\" \
386 cargo build --release --locked --offline --target {triple}{flags_suffix}"
387 ))
388 }
389 }
390 BuildSys::Unknown => None,
391 };
392 }
393 if out.install.is_none() {
394 out.install = match bs {
395 BuildSys::AutoconfRaw | BuildSys::AutoconfReady | BuildSys::Make => {
396 Some("make install DESTDIR=/out PREFIX=/usr".to_string())
397 }
398 BuildSys::CMake => Some("DESTDIR=/out cmake --install _build".to_string()),
399 BuildSys::Meson => Some("DESTDIR=/out meson install -C _build --no-rebuild".to_string()),
400 BuildSys::Cargo => {
401 // Copia los ejecutables finales (top del dir release, bit de ejecución) a
402 // /out/usr/bin. `-perm -100` / `-exec cp \;` son busybox-safe (rootfs Alpine).
403 // Build nativo ⇒ `target/release`; cross ⇒ `target/<triple>/release`.
404 let rel = if recipe.build.target == SANDBOX_NATIVE_TARGET {
405 "target/release".to_string()
406 } else {
407 format!("target/{}/release", rustc_triple(&recipe.build.target))
408 };
409 Some(format!(
410 "mkdir -p /out/usr/bin && \
411 find {rel} -maxdepth 1 -type f -perm -100 \
412 -exec cp {{}} /out/usr/bin/ \\;"
413 ))
414 }
415 BuildSys::Unknown => None,
416 };
417 }
418 Ok(out)
419}
420
421#[cfg(test)]
422mod heuristic_tests {
423 use super::*;
424
425 fn touch(dir: &Path, name: &str) {
426 std::fs::write(dir.join(name), b"").unwrap();
427 }
428
429 fn recipe(flags: &[&str]) -> Recipe {
430 let mut r = Recipe::from_toml(
431 r#"
432name = "x"
433version = "0"
434[source]
435repo = "git://x"
436commit = "deadbeef"
437[build]
438"#,
439 )
440 .unwrap();
441 r.build.flags = flags.iter().map(|s| (*s).to_string()).collect();
442 r
443 }
444
445 #[test]
446 fn detect_autoconf_raw_when_only_configure_ac() {
447 let d = tempfile::tempdir().unwrap();
448 touch(d.path(), "configure.ac");
449 assert_eq!(detect_build_system(d.path()), BuildSys::AutoconfRaw);
450 let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
451 assert!(p.configure.as_deref().unwrap().starts_with("autoreconf -fi &&"));
452 assert!(p.install.as_deref().unwrap().contains("DESTDIR=/out"));
453 }
454
455 #[test]
456 fn detect_autoconf_ready_when_configure_exists() {
457 let d = tempfile::tempdir().unwrap();
458 touch(d.path(), "configure.ac");
459 touch(d.path(), "configure");
460 assert_eq!(detect_build_system(d.path()), BuildSys::AutoconfReady);
461 let p = resolve_phases(&recipe(&["--enable-foo"]), d.path()).unwrap();
462 let c = p.configure.unwrap();
463 assert!(c.starts_with("./configure --prefix=/usr"), "{c}");
464 assert!(c.ends_with("--enable-foo"), "{c}");
465 }
466
467 #[test]
468 fn detect_cmake() {
469 let d = tempfile::tempdir().unwrap();
470 touch(d.path(), "CMakeLists.txt");
471 assert_eq!(detect_build_system(d.path()), BuildSys::CMake);
472 let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
473 assert!(p.configure.unwrap().contains("-DCMAKE_INSTALL_PREFIX=/usr"));
474 assert!(p.compile.unwrap().contains("cmake --build _build"));
475 assert!(p.install.unwrap().contains("DESTDIR=/out"));
476 }
477
478 #[test]
479 fn detect_meson() {
480 let d = tempfile::tempdir().unwrap();
481 touch(d.path(), "meson.build");
482 assert_eq!(detect_build_system(d.path()), BuildSys::Meson);
483 let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
484 assert!(p.configure.unwrap().contains("meson setup _build"));
485 assert!(p.install.unwrap().contains("meson install"));
486 }
487
488 #[test]
489 fn detect_cargo() {
490 let d = tempfile::tempdir().unwrap();
491 touch(d.path(), "Cargo.toml");
492 assert_eq!(detect_build_system(d.path()), BuildSys::Cargo);
493 // Target default (x86_64-linux-musl) = nativo del sandbox ⇒ build SIN --target.
494 let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
495 assert!(p.configure.is_none(), "cargo no usa configure");
496 let c = p.compile.unwrap();
497 // Nativo: `cargo rustc` (no `cargo build`) para pasar los flags de codegen tras `--` sólo
498 // a la crate final; sin `--target` (lo cross sí; el único --target es el del wrapper saneador).
499 assert!(c.contains("cargo rustc --release"), "{c}");
500 assert!(c.contains("--locked") && c.contains("--offline"), "{c}");
501 assert!(!c.contains("--offline --target"), "nativo: cargo sin --target ({c})");
502 assert!(c.contains("a=--target=x86_64-linux-musl"), "wrapper sanea el triple ({c})");
503 assert!(c.contains("-- -C target-feature=+crt-static"), "crt-static scoped tras -- ({c})");
504 assert!(c.contains("relocation-model=static"), "no-PIE: ET_EXEC sin interpreter ({c})");
505 assert!(c.contains("CC=\"$PWD/.hammer-zig-cc\""), "CC = wrapper para cc-rs ({c})");
506 // El linker se arma en `RF` (que además gana `-C link-self-contained=no` cuando el host no es
507 // Alpine) y se aplica vía `RUSTFLAGS="$RF"`.
508 assert!(c.contains("RF=\"-C linker=$PWD/.hammer-zig-cc"), "linker en RF ({c})");
509 assert!(c.contains("RUSTFLAGS=\"$RF\""), "RF aplicado por RUSTFLAGS ({c})");
510 let i = p.install.unwrap();
511 assert!(i.contains("/out/usr/bin"), "{i}");
512 assert!(i.contains("find target/release"), "nativo instala de target/release ({i})");
513 }
514
515 #[test]
516 fn cargo_cross_target_keeps_triple() {
517 // Un target NO nativo (cross real) sí pasa --target + el linker per-triple.
518 let d = tempfile::tempdir().unwrap();
519 touch(d.path(), "Cargo.toml");
520 let mut r = recipe(&[]);
521 r.build.target = "aarch64-linux-musl".into();
522 let p = resolve_phases(&r, d.path()).unwrap();
523 let c = p.compile.unwrap();
524 assert!(c.contains("--target aarch64-unknown-linux-musl"), "{c}");
525 assert!(c.contains("CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER="), "{c}");
526 assert!(p.install.unwrap().contains("target/aarch64-unknown-linux-musl/release"));
527 }
528
529 #[test]
530 fn cargo_wins_over_stray_makefile_but_not_cmake() {
531 let d = tempfile::tempdir().unwrap();
532 touch(d.path(), "Cargo.toml");
533 touch(d.path(), "Makefile");
534 assert_eq!(detect_build_system(d.path()), BuildSys::Cargo);
535 // pero un proyecto C+Rust con CMakeLists prefiere el sistema C
536 touch(d.path(), "CMakeLists.txt");
537 assert_eq!(detect_build_system(d.path()), BuildSys::CMake);
538 }
539
540 #[test]
541 fn cargo_flags_go_to_compile() {
542 let d = tempfile::tempdir().unwrap();
543 touch(d.path(), "Cargo.toml");
544 let p = resolve_phases(&recipe(&["--features", "gpu"]), d.path()).unwrap();
545 // Los flags de la receta van como args de cargo (antes del `--` que separa los de rustc).
546 assert!(p.compile.unwrap().contains("--features gpu -- "));
547 }
548
549 #[test]
550 fn cargo_dynamic_disables_crt_static() {
551 let d = tempfile::tempdir().unwrap();
552 touch(d.path(), "Cargo.toml");
553 let mut r = recipe(&[]);
554 r.build.link = hammer_core::LinkMode::Dynamic;
555 let c = resolve_phases(&r, d.path()).unwrap().compile.unwrap();
556 assert!(c.contains("target-feature=-crt-static"), "{c}");
557 }
558
559 #[test]
560 fn rustc_triple_translation() {
561 assert_eq!(rustc_triple("x86_64-linux-musl"), "x86_64-unknown-linux-musl");
562 assert_eq!(rustc_triple("aarch64-linux-gnu"), "aarch64-unknown-linux-gnu");
563 // un triple ya completo pasa intacto
564 assert_eq!(rustc_triple("x86_64-unknown-linux-musl"), "x86_64-unknown-linux-musl");
565 assert_eq!(
566 cargo_linker_env_var("x86_64-unknown-linux-musl"),
567 "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER"
568 );
569 }
570
571 #[test]
572 fn detect_make() {
573 let d = tempfile::tempdir().unwrap();
574 touch(d.path(), "Makefile");
575 assert_eq!(detect_build_system(d.path()), BuildSys::Make);
576 let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
577 assert!(p.configure.is_none(), "make plano no necesita configure");
578 assert!(p.compile.unwrap().starts_with("make"));
579 }
580
581 #[test]
582 fn unknown_returns_no_phases() {
583 let d = tempfile::tempdir().unwrap();
584 // sólo un README, ningún archivo característico.
585 std::fs::write(d.path().join("README"), b"hi").unwrap();
586 assert_eq!(detect_build_system(d.path()), BuildSys::Unknown);
587 let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
588 assert!(p.compile.is_none());
589 assert!(p.install.is_none());
590 }
591
592 #[test]
593 fn recipe_override_wins_over_heuristic() {
594 let d = tempfile::tempdir().unwrap();
595 touch(d.path(), "Makefile");
596 let mut r = recipe(&[]);
597 r.build.phases.compile = Some("zig cc -static -o foo foo.c".to_string());
598 r.build.phases.install = Some("cp foo /out/bin/foo".to_string());
599 let p = resolve_phases(&r, d.path()).unwrap();
600 assert_eq!(p.compile.unwrap(), "zig cc -static -o foo foo.c");
601 assert_eq!(p.install.unwrap(), "cp foo /out/bin/foo");
602 }
603}
604
605
606fn unique_out_dir(
607 work_root: &Path,
608 name: &str,
609 h: &ArtifactHash,
610) -> hammer_core::Result<PathBuf> {
611 let dir = work_root
612 .join("out")
613 .join(h.store_dir_name(name));
614 if dir.exists() {
615 std::fs::remove_dir_all(&dir)?;
616 }
617 std::fs::create_dir_all(&dir)?;
618 Ok(dir)
619}
620
621/// Proyecta un artefacto del store al FHS (real o de overlay). Resuelve el directorio del
622/// artefacto a partir del hash y delega en [`hydrate::hydrate`].
623pub fn hydrate(
624 h: &ArtifactHash,
625 store: &Store,
626 target_fhs: &Path,
627 mode: LinkMode,
628 dynamic: Option<&hydrate::DynamicSpec>,
629) -> hammer_core::Result<HydrateReport> {
630 let artifact_dir = store.find_by_hash(h.as_str())?;
631 hydrate::hydrate(&artifact_dir, target_fhs, mode, dynamic)
632}
633
634#[cfg(test)]
635mod dep_hash_tests {
636 use super::*;
637
638 /// Escribe `<dir>/<name>.toml` con `source.commit = commit` y, si hay, `deps.build = deps`.
639 fn write_recipe(dir: &Path, name: &str, commit: &str, deps: &[&str]) {
640 let deps_block = if deps.is_empty() {
641 String::new()
642 } else {
643 let list = deps.iter().map(|d| format!("\"{d}\"")).collect::<Vec<_>>().join(", ");
644 format!("\n[deps]\nbuild = [{list}]\n")
645 };
646 let toml = format!(
647 "name = \"{name}\"\nversion = \"1\"\n[source]\nrepo = \"git://{name}\"\ncommit = \"{commit}\"\n[build]\n{deps_block}"
648 );
649 std::fs::write(dir.join(format!("{name}.toml")), toml).unwrap();
650 }
651
652 fn load(dir: &Path, name: &str) -> Recipe {
653 Recipe::load_from_path(dir.join(format!("{name}.toml"))).unwrap()
654 }
655
656 fn store_in(dir: &Path) -> Store {
657 Store::open(dir.join("store")).unwrap()
658 }
659
660 #[test]
661 fn dep_contributes_to_hash() {
662 let d = tempfile::tempdir().unwrap();
663 let store = store_in(d.path());
664 write_recipe(d.path(), "base", "b0", &[]);
665 write_recipe(d.path(), "app", "a0", &["base"]);
666 write_recipe(d.path(), "app_nodep", "a0", &[]); // misma fuente, sin la dep
667
668 let with_dep = artifact_hash(&load(d.path(), "app"), &store).unwrap();
669 let no_dep = artifact_hash(&load(d.path(), "app_nodep"), &store).unwrap();
670 // `app_nodep` se llama distinto, pero el nombre no entra al hash: la única diferencia
671 // de entrada es la presencia de la dep. (Renombramos sólo para tener dos .toml.)
672 assert_ne!(with_dep, no_dep, "la dep de build debe entrar al hash");
673 }
674
675 #[test]
676 fn transitive_dep_change_propagates() {
677 let d = tempfile::tempdir().unwrap();
678 let store = store_in(d.path());
679 write_recipe(d.path(), "base", "b0", &[]);
680 write_recipe(d.path(), "lib", "l0", &["base"]);
681 write_recipe(d.path(), "app", "a0", &["lib"]);
682 let before = artifact_hash(&load(d.path(), "app"), &store).unwrap();
683
684 // Cambiar la fuente de la dep transitiva `base` debe re-hashear `app`.
685 write_recipe(d.path(), "base", "b1", &[]);
686 let after = artifact_hash(&load(d.path(), "app"), &store).unwrap();
687 assert_ne!(before, after, "un cambio transitivo debe propagarse");
688 }
689
690 #[test]
691 fn missing_dep_toml_errors_with_names() {
692 let d = tempfile::tempdir().unwrap();
693 let store = store_in(d.path());
694 write_recipe(d.path(), "app", "a0", &["pcre2"]); // sin pcre2.toml
695 let err = artifact_hash(&load(d.path(), "app"), &store).unwrap_err().to_string();
696 assert!(err.contains("pcre2"), "el error debe nombrar la dep: {err}");
697 assert!(err.contains("app"), "y la receta que la pide: {err}");
698 }
699
700 #[test]
701 fn cycle_is_detected() {
702 let d = tempfile::tempdir().unwrap();
703 let store = store_in(d.path());
704 write_recipe(d.path(), "a", "a0", &["b"]);
705 write_recipe(d.path(), "b", "b0", &["a"]);
706 let err = artifact_hash(&load(d.path(), "a"), &store).unwrap_err().to_string();
707 assert!(err.contains("ciclo"), "esperaba ciclo: {err}");
708 assert!(err.contains("a -> b -> a"), "debe mostrar la cadena: {err}");
709 }
710
711 #[test]
712 fn deps_without_base_dir_errors() {
713 let store = Store::open(tempfile::tempdir().unwrap().path().join("store")).unwrap();
714 // Parseada de TOML crudo ⇒ base_dir vacío; con deps no hay catálogo que resolver.
715 let r = Recipe::from_toml(
716 "name=\"x\"\nversion=\"1\"\n[source]\nrepo=\"git://x\"\ncommit=\"c0\"\n[build]\n[deps]\nbuild=[\"y\"]\n",
717 )
718 .unwrap();
719 let err = artifact_hash(&r, &store).unwrap_err().to_string();
720 assert!(err.contains("base_dir"), "debe explicar la falta de base_dir: {err}");
721 }
722
723 #[test]
724 fn no_deps_unaffected() {
725 let d = tempfile::tempdir().unwrap();
726 let store = store_in(d.path());
727 write_recipe(d.path(), "solo", "s0", &[]);
728 let a = artifact_hash(&load(d.path(), "solo"), &store).unwrap();
729 let b = artifact_hash(&load(d.path(), "solo"), &store).unwrap();
730 assert_eq!(a, b, "sin deps el hash es estable");
731 }
732}