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