gioser

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…

TOML 1477Shell 179Rust 106Markdown 953595 Definitions

⎇ 2d81bc6eaac793f03f0e57c28112d74927fdd02d crates/hammer-build/src/lib.rs Rust · 774 lines · 35.0 KB History Who touched what raw

Etapa G capa-de-adaptación #1: el lab honra `build.compiler` (escape de linker declarativo) 2d81bc6ea · sergio ·

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