//! El laboratorio: compila una receta de forma hermética y la sella en el store; e hidrata //! artefactos al FHS. Ver `docs/02-build-lab.md` y `docs/03-hydration.md`. use std::path::{Path, PathBuf}; use hammer_core::{ArtifactHash, LinkMode, Phases, Recipe, Store}; pub mod config; pub mod download; pub mod fetch; pub mod hydrate; pub mod sandbox; pub mod swm_bridge; pub use config::BuildConfig; pub use hydrate::{hydrate as run_hydrate, DynamicSpec, HydrateReport, HydratedFile}; pub use sandbox::{BuildFailure, Sandbox}; pub use swm_bridge::build_source_patch; /// Calcula el `ArtifactHash` de una receta, resolviendo recursivamente sus deps de build. /// Ver `docs/02-build-lab.md` §2 y §5. /// /// **Catálogo = `base_dir` de la receta.** Cada nombre en `deps.build` se resuelve al hermano /// `/.toml`; su hash entra en el de la receta (Merkle sobre el subgrafo de build). /// Así dos recetas con la misma fuente pero distinto grafo de deps hashean distinto, y cambiar /// una dep transitiva invalida la caché de todo lo que la usa. /// /// Errores: una receta con `deps.build` pero sin `base_dir` (parseada de TOML crudo, no cargada /// de disco) no puede resolver su catálogo; un `.toml` ausente, o un ciclo de deps, también /// fallan con un mensaje que nombra la cadena. pub fn artifact_hash(recipe: &Recipe, _store: &Store) -> hammer_core::Result { let mut stack: Vec = Vec::new(); artifact_hash_rec(recipe, &mut stack) } fn artifact_hash_rec(recipe: &Recipe, stack: &mut Vec) -> hammer_core::Result { if stack.iter().any(|n| n == &recipe.name) { stack.push(recipe.name.clone()); return Err(hammer_core::Error::Recipe(format!( "ciclo de dependencias de build: {}", stack.join(" -> ") ))); } let dep_hashes = resolve_build_dep_hashes(recipe, stack)?; let inputs = recipe.hash_inputs(&dep_hashes)?; let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect(); Ok(ArtifactHash::of_inputs(&refs)) } /// Resuelve y hashea, en orden de declaración, cada `deps.build` de `recipe` cargándolo del /// catálogo (su `base_dir`). `stack` lleva la cadena en curso para detectar ciclos. fn resolve_build_dep_hashes( recipe: &Recipe, stack: &mut Vec, ) -> hammer_core::Result> { if recipe.deps.build.is_empty() { return Ok(Vec::new()); } if recipe.base_dir.as_os_str().is_empty() { return Err(hammer_core::Error::Recipe(format!( "la receta '{}' declara deps de build {:?} pero no tiene base_dir; \ cárgala con Recipe::load_from_path para resolver el catálogo", recipe.name, recipe.deps.build ))); } stack.push(recipe.name.clone()); let mut hashes = Vec::with_capacity(recipe.deps.build.len()); for dep in &recipe.deps.build { let dep_path = recipe.base_dir.join(format!("{dep}.toml")); let dep_recipe = Recipe::load_from_path(&dep_path).map_err(|e| { hammer_core::Error::Recipe(format!( "no pude cargar la dep de build '{dep}' de '{}' ({}): {e}", recipe.name, dep_path.display() )) })?; hashes.push(artifact_hash_rec(&dep_recipe, stack)?); } stack.pop(); Ok(hashes) } /// Construye (si falta) cada `deps.build` y devuelve sus directorios sellados en el store, para /// apilarlos como capas overlay del sandbox (variante b: bwrap necesita libcap.a + sys/capability.h, /// ausentes del toolchain Alpine). Recursivo vía `build`: una dep con sus propias deps se materializa /// transitivamente. Los ciclos ya los corta `artifact_hash` (con su `stack`) antes de llegar acá. fn materialize_build_deps( recipe: &Recipe, cfg: &BuildConfig, store: &Store, ) -> hammer_core::Result> { if recipe.deps.build.is_empty() { return Ok(Vec::new()); } if recipe.base_dir.as_os_str().is_empty() { return Err(hammer_core::Error::Recipe(format!( "la receta '{}' declara deps de build {:?} pero no tiene base_dir; \ cárgala con Recipe::load_from_path para resolver el catálogo", recipe.name, recipe.deps.build ))); } let mut dirs = Vec::with_capacity(recipe.deps.build.len()); for dep in &recipe.deps.build { let dep_path = recipe.base_dir.join(format!("{dep}.toml")); let dep_recipe = Recipe::load_from_path(&dep_path).map_err(|e| { hammer_core::Error::Recipe(format!( "no pude cargar la dep de build '{dep}' de '{}' ({}): {e}", recipe.name, dep_path.display() )) })?; let dep_hash = build(&dep_recipe, cfg, store)?; dirs.push(store.path_of(&dep_hash, &dep_recipe.name)); } Ok(dirs) } /// Compila una receta y devuelve el hash del artefacto sellado en el store. /// Si el hash ya existe en el store, devuelve sin recompilar (caché). pub fn build( recipe: &Recipe, cfg: &BuildConfig, store: &Store, ) -> hammer_core::Result { let h = artifact_hash(recipe, store)?; if store.has(&h, &recipe.name) { tracing::info!(hash = %h, name = %recipe.name, "caché: artefacto ya en el store"); return Ok(h); } sandbox::ensure_layout(&cfg.rootfs, &cfg.zig_dir)?; // Materializa las deps de build (las construye si faltan) ANTES del fetch: sus árboles sellados // se apilan como capas overlay del sandbox para que headers/libs/pkgconfig estén en /usr. let dep_dirs = materialize_build_deps(recipe, cfg, store)?; if !dep_dirs.is_empty() { tracing::info!(n = dep_dirs.len(), "deps de build materializadas como capas overlay"); } tracing::info!(hash = %h, name = %recipe.name, "fetch"); let src_tree = fetch::fetch(recipe, &cfg.work_root)?; if !recipe.source.patches.is_empty() { tracing::info!(n = recipe.source.patches.len(), "aplicando patches"); fetch::apply_patches(recipe, &src_tree)?; } // Recetas Cargo: vendoreamos las deps (red permitida en el fetch) para que el build sea // hermético `--offline` dentro del sandbox. Tras los patches, por si tocaron Cargo.toml/lock. if matches!(detect_build_system(&src_tree), BuildSys::Cargo) { tracing::info!("cargo: vendoreando deps para build offline"); fetch::vendor_cargo_deps(&src_tree)?; } let out_dir = unique_out_dir(&cfg.work_root, &recipe.name, &h)?; let mut env: Vec<(String, String)> = Vec::new(); if matches!(recipe.build.link, LinkMode::Static) { // autotools/cmake leen LDFLAGS para el link final; -static fuerza el binario // monolítico que pide nuestra estrategia de hidratación primaria. env.push(("LDFLAGS".into(), "-static".into())); } if let Some(cache) = &cfg.cache_root { std::fs::create_dir_all(cache.join("zig"))?; } let sb = Sandbox { rootfs: cfg.rootfs.clone(), zig_dir: cfg.zig_dir.clone(), src_dir: src_tree.clone(), out_dir: out_dir.clone(), cache_dir: cfg.cache_root.clone(), deps: dep_dirs, env, }; let phases = resolve_phases(recipe, &src_tree)?; if let Some(cmd) = &phases.configure { tracing::info!(cmd, "phase: configure"); sb.run(cmd)?; } if let Some(cmd) = &phases.compile { tracing::info!(cmd, "phase: compile"); sb.run(cmd)?; } else { return Err(hammer_core::Error::Recipe( "receta sin fase 'compile' y heurística no encontró ninguna".into(), )); } if let Some(cmd) = &phases.install { tracing::info!(cmd, "phase: install"); sb.run(cmd)?; } else { return Err(hammer_core::Error::Recipe( "receta sin fase 'install' y heurística no encontró ninguna".into(), )); } // Sidecar de provenance: escribimos la receta serializada DENTRO de out_dir antes del // seal, para que quede congelada en el árbol read-only del store. `hammer export` la // lee de vuelta para emitir `source_patch` con la receta original en vez de un // `file_drop` opaco. Ver `docs/04-overlay.md` §provenance (Fase 4). let sidecar_path = out_dir.join(hammer_core::store::RECIPE_SIDECAR_REL); if let Some(parent) = sidecar_path.parent() { std::fs::create_dir_all(parent)?; } let toml_text = recipe.to_toml()?; std::fs::write(&sidecar_path, toml_text.as_bytes())?; let sealed = store.seal(&out_dir, &h, &recipe.name)?; tracing::info!(path = %sealed.display(), "sealed"); Ok(h) } /// Sistemas de build que reconoce la heurística por inspección del árbol del source. /// Ver `docs/02-build-lab.md` §4. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum BuildSys { /// `configure.ac` (o `.in`) sin `configure` generado: hay que correr `autoreconf` primero. AutoconfRaw, /// `configure` ya está en el tarball release: vamos directo a `./configure`. AutoconfReady, /// `CMakeLists.txt`. CMake, /// `meson.build`. Meson, /// `Cargo.toml`: crate o workspace Rust. Build con `cargo`, link con `zig cc`. Cargo, /// `Makefile` plano (o `makefile`) sin sistema generador. Make, /// No detectamos nada: la receta debe traer overrides en `[build.phases]`. Unknown, } fn detect_build_system(src: &Path) -> BuildSys { let has = |p: &str| src.join(p).exists(); // El orden importa: cuando coexisten varios (un tarball release de autotools trae el // `configure` regenerado y un `Makefile` huérfano de un build previo), preferimos el // sistema "más alto" que sí sabe regenerar todo. if has("configure.ac") || has("configure.in") { if has("configure") { return BuildSys::AutoconfReady; } return BuildSys::AutoconfRaw; } if has("configure") { return BuildSys::AutoconfReady; } if has("CMakeLists.txt") { return BuildSys::CMake; } if has("meson.build") { return BuildSys::Meson; } // Cargo gana sobre un `Makefile` huérfano (algunos crates traen un wrapper `make`), // pero cede ante autotools/cmake/meson (un políglota C+Rust suele querer el sistema C). if has("Cargo.toml") { return BuildSys::Cargo; } if has("Makefile") || has("makefile") || has("GNUmakefile") { return BuildSys::Make; } BuildSys::Unknown } /// Target nativo del sandbox del lab (Alpine x86_64 musl). Una receta Cargo cuyo `build.target` /// sea éste se construye **nativa** (sin `--target`): el rust del sandbox sólo trae su std nativo /// (`x86_64-alpine-linux-musl`), no el `x86_64-unknown-linux-musl` que pediría un cross, y no hay /// rustup para agregarlo. Ver `docs/runbooks/stage1-vm-boot.md` §8 (primera corrida). const SANDBOX_NATIVE_TARGET: &str = "x86_64-linux-musl"; /// Traduce el triple estilo zig/hammer (`x86_64-linux-musl`) al de rustc /// (`x86_64-unknown-linux-musl`). Un triple ya completo (con vendor) o ajeno al patrón /// `-linux-` se devuelve tal cual: la receta puede dar el triple exacto. fn rustc_triple(target: &str) -> String { match target.split('-').collect::>().as_slice() { [arch, "linux", libc] => format!("{arch}-unknown-linux-{libc}"), _ => target.to_string(), } } /// Nombre de la env `CARGO_TARGET__LINKER` para un triple de rustc. fn cargo_linker_env_var(triple: &str) -> String { format!("CARGO_TARGET_{}_LINKER", triple.to_uppercase().replace(['-', '.'], "_")) } /// Devuelve los comandos a ejecutar para cada fase: overrides explícitos en la receta /// ganan; el resto lo deriva la heurística según el sistema de build detectado. fn resolve_phases(recipe: &Recipe, src: &Path) -> hammer_core::Result { let mut out = recipe.build.phases.clone(); let bs = detect_build_system(src); let flags = recipe.build.flags.join(" "); let flags_suffix = if flags.is_empty() { String::new() } else { format!(" {flags}") }; if out.configure.is_none() { out.configure = match bs { BuildSys::AutoconfRaw => Some(format!( "autoreconf -fi && ./configure --prefix=/usr{flags_suffix}" )), BuildSys::AutoconfReady => Some(format!("./configure --prefix=/usr{flags_suffix}")), BuildSys::CMake => Some(format!( "cmake -S . -B _build -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Release{flags_suffix}" )), BuildSys::Meson => Some(format!("meson setup _build --prefix=/usr{flags_suffix}")), // Cargo no tiene fase configure separada. BuildSys::Cargo | BuildSys::Make | BuildSys::Unknown => None, }; } if out.compile.is_none() { out.compile = match bs { BuildSys::AutoconfRaw | BuildSys::AutoconfReady | BuildSys::Make => { Some(r#"make -j"$(nproc)""#.to_string()) } BuildSys::CMake => Some(r#"cmake --build _build -j "$(nproc)""#.to_string()), BuildSys::Meson => Some("meson compile -C _build".to_string()), BuildSys::Cargo => { // Hermético y reproducible: `--offline` (deps vendoreadas en la fuente) + // `--locked` (Cargo.lock fijo, coherente con ADR 0006). El link va por un // wrapper `zig cc` — cargo no acepta un linker de dos palabras, y zig provee // el `libgcc_s` que el link dinámico musl pide (ver M2 del plan arje↔hammer). if recipe.build.target == SANDBOX_NATIVE_TARGET { // NATIVO: el sandbox del lab YA es x86_64 musl, así que no cruzamos. El rust del // sandbox sólo trae su std nativo (`x86_64-alpine-linux-musl`), no // `x86_64-unknown-linux-musl`; con `--target` fallaría "can't find crate for // core" (no hay rustup). Ver docs/runbooks/stage1-vm-boot.md §8. // // El wrapper `.hammer-zig-cc` hace DOS cosas: (1) es un único ejecutable, lo que // resuelve el `zig cc` de dos palabras que cargo (linker) y cc-rs (CC) // mal-parsean; (2) **sanea el triple**: cc-rs (build scripts que compilan C/asm, // p.ej. blake3) detecta el host triple de Alpine rust y pasa // `--target=x86_64-alpine-linux-musl`, pero zig no conoce el vendor "alpine" // (`UnknownOperatingSystem`); lo reescribimos a `x86_64-linux-musl`. Se usa como // CC (cc-rs) y como linker (RUSTFLAGS, target-agnóstico — el default sería // cc/gcc, ausente en el sandbox zig-only). // Flags de codegen del BINARIO FINAL, vía `cargo rustc -- …` (NO RUSTFLAGS): en // build nativo RUSTFLAGS también alcanza a los proc-macros (deps), y // `+crt-static`/`relocation-model=static` los rompe ("cannot produce // proc-macro" — un proc-macro es un dylib PIC). `cargo rustc -- …` los aplica // sólo a la crate top-level, dejando los proc-macros intactos. let bin_flags = match recipe.build.link { // Estático AUTOCONTENIDO y no-PIE: `+crt-static` mete musl dentro (sin // DT_NEEDED — el rust de Alpine enlaza musl dinámico por defecto, lo que deja // `libc.musl-x86_64.so.1` y rompe el boot, runbook §8); `relocation-model=static` // ⇒ ET_EXEC sin interpreter (vía de oro: como busybox, sin libc.so/loader/soname). LinkMode::Static => "-C target-feature=+crt-static -C relocation-model=static", // Dinámico: apaga crt-static para poder dlopen (necesita el loader/musl en el rootfs). LinkMode::Dynamic => "-C target-feature=-crt-static", }; // `-C link-self-contained=no` CONDICIONAL: el link (de TODO — deps, // build-scripts, proc-macros y la crate top) lo hace `zig cc`, que ya aporta // sus startfiles (crt1.o). Un rust VANILLA con target musl // `x86_64-unknown-linux-musl` trae CRT AUTOCONTENIDO (self-contained/rcrt1.o) y // lo pasaría también ⇒ `ld.lld: duplicate symbol: _start`; hay que apagarlo. PERO // el rust de Alpine (host `x86_64-alpine-linux-musl`) PARCHEA la opción para que // sea un ERROR ("option `-C link-self-contained` is not supported on this // target") y NO usa self-contained — el baseline 9adefb82 linkea con zig sin // chocar. Así que: añadir el flag SÓLO si el host del rustc NO es `-alpine-` // (i.e. un rust vanilla como el hammer-rust del frente self-host); para Alpine, // omitirlo. RUSTFLAGS alcanza a todas las unidades; los `bin_flags` (cargo rustc // --) sólo a la top. Some(format!( "printf '%s\\n' '#!/bin/sh' 'for a do' \ 'case \"$a\" in --target=*) a=--target=x86_64-linux-musl ;; esac' \ 'set -- \"$@\" \"$a\"' 'shift' 'done' 'exec zig cc -mcpu=baseline \"$@\"' \ > \"$PWD/.hammer-zig-cc\" && chmod +x \"$PWD/.hammer-zig-cc\" && \ RF=\"-C linker=$PWD/.hammer-zig-cc\"; \ rustc -vV | grep -q 'host: .*-alpine-' || RF=\"$RF -C link-self-contained=no\"; \ CC=\"$PWD/.hammer-zig-cc\" RUSTFLAGS=\"$RF\" \ cargo rustc --release --locked --offline{flags_suffix} -- {bin_flags}" )) } else { // CROSS real (otro target que zig sí entiende): `--target` + linker por env // per-triple. Hermético y reproducible: `--offline` (deps vendoreadas) + // `--locked` (Cargo.lock fijo, ADR 0006). zig provee el `libgcc_s` que el link // dinámico musl pide (ver M2 del plan arje↔hammer). let setup = "printf '#!/bin/sh\\nexec zig cc -mcpu=baseline \"$@\"\\n' > \"$PWD/.hammer-zig-cc\" && \ chmod +x \"$PWD/.hammer-zig-cc\" && "; let triple = rustc_triple(&recipe.build.target); let linker_var = cargo_linker_env_var(&triple); let rustflags = match recipe.build.link { // musl es crt-static por defecto; el dinámico lo apaga para poder dlopen. LinkMode::Dynamic => "RUSTFLAGS='-C target-feature=-crt-static' ", LinkMode::Static => "", }; Some(format!( "{setup}{rustflags}{linker_var}=\"$PWD/.hammer-zig-cc\" \ cargo build --release --locked --offline --target {triple}{flags_suffix}" )) } } BuildSys::Unknown => None, }; } if out.install.is_none() { out.install = match bs { BuildSys::AutoconfRaw | BuildSys::AutoconfReady | BuildSys::Make => { Some("make install DESTDIR=/out PREFIX=/usr".to_string()) } BuildSys::CMake => Some("DESTDIR=/out cmake --install _build".to_string()), BuildSys::Meson => Some("DESTDIR=/out meson install -C _build --no-rebuild".to_string()), BuildSys::Cargo => { // Copia los ejecutables finales (top del dir release, bit de ejecución) a // /out/usr/bin. `-perm -100` / `-exec cp \;` son busybox-safe (rootfs Alpine). // Build nativo ⇒ `target/release`; cross ⇒ `target//release`. let rel = if recipe.build.target == SANDBOX_NATIVE_TARGET { "target/release".to_string() } else { format!("target/{}/release", rustc_triple(&recipe.build.target)) }; Some(format!( "mkdir -p /out/usr/bin && \ find {rel} -maxdepth 1 -type f -perm -100 \ -exec cp {{}} /out/usr/bin/ \\;" )) } BuildSys::Unknown => None, }; } Ok(out) } #[cfg(test)] mod heuristic_tests { use super::*; fn touch(dir: &Path, name: &str) { std::fs::write(dir.join(name), b"").unwrap(); } fn recipe(flags: &[&str]) -> Recipe { let mut r = Recipe::from_toml( r#" name = "x" version = "0" [source] repo = "git://x" commit = "deadbeef" [build] "#, ) .unwrap(); r.build.flags = flags.iter().map(|s| (*s).to_string()).collect(); r } #[test] fn detect_autoconf_raw_when_only_configure_ac() { let d = tempfile::tempdir().unwrap(); touch(d.path(), "configure.ac"); assert_eq!(detect_build_system(d.path()), BuildSys::AutoconfRaw); let p = resolve_phases(&recipe(&[]), d.path()).unwrap(); assert!(p.configure.as_deref().unwrap().starts_with("autoreconf -fi &&")); assert!(p.install.as_deref().unwrap().contains("DESTDIR=/out")); } #[test] fn detect_autoconf_ready_when_configure_exists() { let d = tempfile::tempdir().unwrap(); touch(d.path(), "configure.ac"); touch(d.path(), "configure"); assert_eq!(detect_build_system(d.path()), BuildSys::AutoconfReady); let p = resolve_phases(&recipe(&["--enable-foo"]), d.path()).unwrap(); let c = p.configure.unwrap(); assert!(c.starts_with("./configure --prefix=/usr"), "{c}"); assert!(c.ends_with("--enable-foo"), "{c}"); } #[test] fn detect_cmake() { let d = tempfile::tempdir().unwrap(); touch(d.path(), "CMakeLists.txt"); assert_eq!(detect_build_system(d.path()), BuildSys::CMake); let p = resolve_phases(&recipe(&[]), d.path()).unwrap(); assert!(p.configure.unwrap().contains("-DCMAKE_INSTALL_PREFIX=/usr")); assert!(p.compile.unwrap().contains("cmake --build _build")); assert!(p.install.unwrap().contains("DESTDIR=/out")); } #[test] fn detect_meson() { let d = tempfile::tempdir().unwrap(); touch(d.path(), "meson.build"); assert_eq!(detect_build_system(d.path()), BuildSys::Meson); let p = resolve_phases(&recipe(&[]), d.path()).unwrap(); assert!(p.configure.unwrap().contains("meson setup _build")); assert!(p.install.unwrap().contains("meson install")); } #[test] fn detect_cargo() { let d = tempfile::tempdir().unwrap(); touch(d.path(), "Cargo.toml"); assert_eq!(detect_build_system(d.path()), BuildSys::Cargo); // Target default (x86_64-linux-musl) = nativo del sandbox ⇒ build SIN --target. let p = resolve_phases(&recipe(&[]), d.path()).unwrap(); assert!(p.configure.is_none(), "cargo no usa configure"); let c = p.compile.unwrap(); // Nativo: `cargo rustc` (no `cargo build`) para pasar los flags de codegen tras `--` sólo // a la crate final; sin `--target` (lo cross sí; el único --target es el del wrapper saneador). assert!(c.contains("cargo rustc --release"), "{c}"); assert!(c.contains("--locked") && c.contains("--offline"), "{c}"); assert!(!c.contains("--offline --target"), "nativo: cargo sin --target ({c})"); assert!(c.contains("a=--target=x86_64-linux-musl"), "wrapper sanea el triple ({c})"); assert!(c.contains("-- -C target-feature=+crt-static"), "crt-static scoped tras -- ({c})"); assert!(c.contains("relocation-model=static"), "no-PIE: ET_EXEC sin interpreter ({c})"); assert!(c.contains("CC=\"$PWD/.hammer-zig-cc\""), "CC = wrapper para cc-rs ({c})"); // El linker se arma en `RF` (que además gana `-C link-self-contained=no` cuando el host no es // Alpine) y se aplica vía `RUSTFLAGS="$RF"`. assert!(c.contains("RF=\"-C linker=$PWD/.hammer-zig-cc"), "linker en RF ({c})"); assert!(c.contains("RUSTFLAGS=\"$RF\""), "RF aplicado por RUSTFLAGS ({c})"); let i = p.install.unwrap(); assert!(i.contains("/out/usr/bin"), "{i}"); assert!(i.contains("find target/release"), "nativo instala de target/release ({i})"); } #[test] fn cargo_cross_target_keeps_triple() { // Un target NO nativo (cross real) sí pasa --target + el linker per-triple. let d = tempfile::tempdir().unwrap(); touch(d.path(), "Cargo.toml"); let mut r = recipe(&[]); r.build.target = "aarch64-linux-musl".into(); let p = resolve_phases(&r, d.path()).unwrap(); let c = p.compile.unwrap(); assert!(c.contains("--target aarch64-unknown-linux-musl"), "{c}"); assert!(c.contains("CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER="), "{c}"); assert!(p.install.unwrap().contains("target/aarch64-unknown-linux-musl/release")); } #[test] fn cargo_wins_over_stray_makefile_but_not_cmake() { let d = tempfile::tempdir().unwrap(); touch(d.path(), "Cargo.toml"); touch(d.path(), "Makefile"); assert_eq!(detect_build_system(d.path()), BuildSys::Cargo); // pero un proyecto C+Rust con CMakeLists prefiere el sistema C touch(d.path(), "CMakeLists.txt"); assert_eq!(detect_build_system(d.path()), BuildSys::CMake); } #[test] fn cargo_flags_go_to_compile() { let d = tempfile::tempdir().unwrap(); touch(d.path(), "Cargo.toml"); let p = resolve_phases(&recipe(&["--features", "gpu"]), d.path()).unwrap(); // Los flags de la receta van como args de cargo (antes del `--` que separa los de rustc). assert!(p.compile.unwrap().contains("--features gpu -- ")); } #[test] fn cargo_dynamic_disables_crt_static() { let d = tempfile::tempdir().unwrap(); touch(d.path(), "Cargo.toml"); let mut r = recipe(&[]); r.build.link = hammer_core::LinkMode::Dynamic; let c = resolve_phases(&r, d.path()).unwrap().compile.unwrap(); assert!(c.contains("target-feature=-crt-static"), "{c}"); } #[test] fn rustc_triple_translation() { assert_eq!(rustc_triple("x86_64-linux-musl"), "x86_64-unknown-linux-musl"); assert_eq!(rustc_triple("aarch64-linux-gnu"), "aarch64-unknown-linux-gnu"); // un triple ya completo pasa intacto assert_eq!(rustc_triple("x86_64-unknown-linux-musl"), "x86_64-unknown-linux-musl"); assert_eq!( cargo_linker_env_var("x86_64-unknown-linux-musl"), "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER" ); } #[test] fn detect_make() { let d = tempfile::tempdir().unwrap(); touch(d.path(), "Makefile"); assert_eq!(detect_build_system(d.path()), BuildSys::Make); let p = resolve_phases(&recipe(&[]), d.path()).unwrap(); assert!(p.configure.is_none(), "make plano no necesita configure"); assert!(p.compile.unwrap().starts_with("make")); } #[test] fn unknown_returns_no_phases() { let d = tempfile::tempdir().unwrap(); // sólo un README, ningún archivo característico. std::fs::write(d.path().join("README"), b"hi").unwrap(); assert_eq!(detect_build_system(d.path()), BuildSys::Unknown); let p = resolve_phases(&recipe(&[]), d.path()).unwrap(); assert!(p.compile.is_none()); assert!(p.install.is_none()); } #[test] fn recipe_override_wins_over_heuristic() { let d = tempfile::tempdir().unwrap(); touch(d.path(), "Makefile"); let mut r = recipe(&[]); r.build.phases.compile = Some("zig cc -static -o foo foo.c".to_string()); r.build.phases.install = Some("cp foo /out/bin/foo".to_string()); let p = resolve_phases(&r, d.path()).unwrap(); assert_eq!(p.compile.unwrap(), "zig cc -static -o foo foo.c"); assert_eq!(p.install.unwrap(), "cp foo /out/bin/foo"); } } fn unique_out_dir( work_root: &Path, name: &str, h: &ArtifactHash, ) -> hammer_core::Result { let dir = work_root .join("out") .join(h.store_dir_name(name)); if dir.exists() { std::fs::remove_dir_all(&dir)?; } std::fs::create_dir_all(&dir)?; Ok(dir) } /// Proyecta un artefacto del store al FHS (real o de overlay). Resuelve el directorio del /// artefacto a partir del hash y delega en [`hydrate::hydrate`]. pub fn hydrate( h: &ArtifactHash, store: &Store, target_fhs: &Path, mode: LinkMode, dynamic: Option<&hydrate::DynamicSpec>, ) -> hammer_core::Result { let artifact_dir = store.find_by_hash(h.as_str())?; hydrate::hydrate(&artifact_dir, target_fhs, mode, dynamic) } #[cfg(test)] mod dep_hash_tests { use super::*; /// Escribe `/.toml` con `source.commit = commit` y, si hay, `deps.build = deps`. fn write_recipe(dir: &Path, name: &str, commit: &str, deps: &[&str]) { let deps_block = if deps.is_empty() { String::new() } else { let list = deps.iter().map(|d| format!("\"{d}\"")).collect::>().join(", "); format!("\n[deps]\nbuild = [{list}]\n") }; let toml = format!( "name = \"{name}\"\nversion = \"1\"\n[source]\nrepo = \"git://{name}\"\ncommit = \"{commit}\"\n[build]\n{deps_block}" ); std::fs::write(dir.join(format!("{name}.toml")), toml).unwrap(); } fn load(dir: &Path, name: &str) -> Recipe { Recipe::load_from_path(dir.join(format!("{name}.toml"))).unwrap() } fn store_in(dir: &Path) -> Store { Store::open(dir.join("store")).unwrap() } #[test] fn dep_contributes_to_hash() { let d = tempfile::tempdir().unwrap(); let store = store_in(d.path()); write_recipe(d.path(), "base", "b0", &[]); write_recipe(d.path(), "app", "a0", &["base"]); write_recipe(d.path(), "app_nodep", "a0", &[]); // misma fuente, sin la dep let with_dep = artifact_hash(&load(d.path(), "app"), &store).unwrap(); let no_dep = artifact_hash(&load(d.path(), "app_nodep"), &store).unwrap(); // `app_nodep` se llama distinto, pero el nombre no entra al hash: la única diferencia // de entrada es la presencia de la dep. (Renombramos sólo para tener dos .toml.) assert_ne!(with_dep, no_dep, "la dep de build debe entrar al hash"); } #[test] fn transitive_dep_change_propagates() { let d = tempfile::tempdir().unwrap(); let store = store_in(d.path()); write_recipe(d.path(), "base", "b0", &[]); write_recipe(d.path(), "lib", "l0", &["base"]); write_recipe(d.path(), "app", "a0", &["lib"]); let before = artifact_hash(&load(d.path(), "app"), &store).unwrap(); // Cambiar la fuente de la dep transitiva `base` debe re-hashear `app`. write_recipe(d.path(), "base", "b1", &[]); let after = artifact_hash(&load(d.path(), "app"), &store).unwrap(); assert_ne!(before, after, "un cambio transitivo debe propagarse"); } #[test] fn missing_dep_toml_errors_with_names() { let d = tempfile::tempdir().unwrap(); let store = store_in(d.path()); write_recipe(d.path(), "app", "a0", &["pcre2"]); // sin pcre2.toml let err = artifact_hash(&load(d.path(), "app"), &store).unwrap_err().to_string(); assert!(err.contains("pcre2"), "el error debe nombrar la dep: {err}"); assert!(err.contains("app"), "y la receta que la pide: {err}"); } #[test] fn cycle_is_detected() { let d = tempfile::tempdir().unwrap(); let store = store_in(d.path()); write_recipe(d.path(), "a", "a0", &["b"]); write_recipe(d.path(), "b", "b0", &["a"]); let err = artifact_hash(&load(d.path(), "a"), &store).unwrap_err().to_string(); assert!(err.contains("ciclo"), "esperaba ciclo: {err}"); assert!(err.contains("a -> b -> a"), "debe mostrar la cadena: {err}"); } #[test] fn deps_without_base_dir_errors() { let store = Store::open(tempfile::tempdir().unwrap().path().join("store")).unwrap(); // Parseada de TOML crudo ⇒ base_dir vacío; con deps no hay catálogo que resolver. let r = Recipe::from_toml( "name=\"x\"\nversion=\"1\"\n[source]\nrepo=\"git://x\"\ncommit=\"c0\"\n[build]\n[deps]\nbuild=[\"y\"]\n", ) .unwrap(); let err = artifact_hash(&r, &store).unwrap_err().to_string(); assert!(err.contains("base_dir"), "debe explicar la falta de base_dir: {err}"); } #[test] fn no_deps_unaffected() { let d = tempfile::tempdir().unwrap(); let store = store_in(d.path()); write_recipe(d.path(), "solo", "s0", &[]); let a = artifact_hash(&load(d.path(), "solo"), &store).unwrap(); let b = artifact_hash(&load(d.path(), "solo"), &store).unwrap(); assert_eq!(a, b, "sin deps el hash es estable"); } }