diff --git a/crates/hammer-build/tests/end_to_end_grep.rs b/crates/hammer-build/tests/end_to_end_grep.rs new file mode 100644 index 00000000..238222b4 --- /dev/null +++ b/crates/hammer-build/tests/end_to_end_grep.rs @@ -0,0 +1,166 @@ +//! End-to-end del primer entregable: receta `grep` real → build → hidratación → ejecución +//! dentro del rootfs Alpine. Es el test que cierra Fase 0 + Fase 1. +//! +//! Doblemente gateado para no penalizar `cargo test` por defecto: +//! - Requiere `.dev-fs/` bootstrapeado (rootfs Alpine + zig + cache). +//! - Requiere `HAMMER_NETWORK_TESTS=1` (descarga ~3 MB del tarball de GNU). +//! +//! Comparte la dev cache (`.dev-fs/cache`) para que la segunda corrida en una máquina dada +//! reuse los objetos de musl y termine en segundos. + +use std::path::PathBuf; +use std::process::Command; + +use hammer_build::{build, run_hydrate, BuildConfig}; +use hammer_core::{LinkMode, Recipe, Store}; + +const RECIPE_TOML: &str = r#" +name = "grep" +version = "3.12" + +[source] +tarball = "https://ftp.gnu.org/gnu/grep/grep-3.12.tar.gz" +sha256 = "badda546dfc4b9d97e992e2c35f3b5c7f20522ffcbe2f01ba1e9cdcbe7644cdc" + +[build] +compiler = "zig-cc" +target = "x86_64-linux-musl" +link = "static" +flags = ["--disable-perl-regexp", "--disable-nls"] +"#; + +fn project_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .to_path_buf() +} + +fn skip_with_reason(reason: &str) -> bool { + eprintln!("SKIP end_to_end_grep: {reason}"); + true +} + +fn should_skip(cfg: &BuildConfig) -> bool { + if std::env::var("HAMMER_NETWORK_TESTS").ok().as_deref() != Some("1") { + return skip_with_reason( + "HAMMER_NETWORK_TESTS != 1 (este test descarga ~3 MB de ftp.gnu.org y compila ~30s).", + ); + } + if !cfg.rootfs.join("bin/busybox").is_file() { + return skip_with_reason("rootfs Alpine no bootstrapped (corre scripts/bootstrap-devfs.sh)."); + } + if !cfg.rootfs.join("usr/bin/autoreconf").is_file() + || !cfg.rootfs.join("usr/bin/ld").is_file() + { + return skip_with_reason( + "rootfs sin build tools (autoreconf/ld). Re-corre bootstrap-devfs.sh.", + ); + } + if !cfg.zig_dir.join("zig").is_file() { + return skip_with_reason("zig no encontrado en .dev-fs/tools/zig."); + } + false +} + +#[test] +fn grep_real_build_hydrate_run() { + let _ = tracing_subscriber::fmt().with_test_writer().try_init(); + + let project = project_root(); + // El store y la salida hidratada DEBEN vivir en el mismo filesystem (los hardlinks no + // cruzan device boundaries). Mantenemos ambos bajo el repo, en un subdir efímero. + let scratch = project.join("work").join("test-e2e-grep"); + let _ = std::fs::remove_dir_all(&scratch); + std::fs::create_dir_all(&scratch).unwrap(); + let store_root = scratch.join("store"); + let work_root = scratch.join("work"); + std::fs::create_dir_all(&store_root).unwrap(); + std::fs::create_dir_all(&work_root).unwrap(); + + let cfg = BuildConfig { + rootfs: project.join(".dev-fs/alpine"), + zig_dir: project.join(".dev-fs/tools/zig"), + work_root, + cache_root: Some(project.join(".dev-fs/cache")), + }; + if should_skip(&cfg) { + return; + } + + // 1) Receta + let recipe_dir = scratch.join("recipe"); + std::fs::create_dir_all(&recipe_dir).unwrap(); + let recipe_path = recipe_dir.join("grep.toml"); + std::fs::write(&recipe_path, RECIPE_TOML).unwrap(); + let recipe = Recipe::load_from_path(&recipe_path).expect("load recipe"); + + // 2) Build + let store = Store::open(&store_root).unwrap(); + let h = build(&recipe, &cfg, &store).expect("build grep"); + let sealed = store.path_of(&h, &recipe.name); + let grep = sealed.join("usr/bin/grep"); + assert!(grep.is_file(), "esperaba {}", grep.display()); + + // ELF estático: cabecera ELF + ningún DT_NEEDED (chequeo barato con `file`/`readelf` si + // existen, fallback al magic byte). + let header = std::fs::read(&grep).unwrap(); + assert_eq!(&header[..4], b"\x7fELF", "grep debe ser ELF"); + + // 3) Hidratación al mismo filesystem + let fhs = scratch.join("fhs"); + let report = run_hydrate(&sealed, &fhs, LinkMode::Static).expect("hydrate"); + assert!(report.files.iter().any(|f| f.dst.ends_with("usr/bin/grep"))); + + // Compartir inode con el store: la propiedad central de hidratar por hardlink. + use std::os::unix::fs::MetadataExt; + let src_ino = std::fs::metadata(&grep).unwrap().ino(); + let dst_ino = std::fs::metadata(fhs.join("usr/bin/grep")).unwrap().ino(); + assert_eq!(src_ino, dst_ino, "hardlink: inode debe coincidir"); + + // 4) Ejecutar dentro del rootfs Alpine bindeando el binario hidratado como /usr/bin/grep. + // Ejercitamos un patrón regex y un literal; v3.12 NO debe dar "memory exhausted". + let out = Command::new("bwrap") + .args([ + "--overlay-src", + cfg.rootfs.to_str().unwrap(), + "--tmp-overlay", + "/", + "--ro-bind", + fhs.join("usr/bin/grep").to_str().unwrap(), + "/usr/bin/grep", + "--proc", + "/proc", + "--dev", + "/dev", + "--unshare-all", + "/usr/bin/grep", + "-oE", + "[0-9]+", + ]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn bwrap"); + + use std::io::Write; + out.stdin + .as_ref() + .unwrap() + .write_all(b"prefijo 12345 sufijo\n") + .unwrap(); + let out = out.wait_with_output().expect("wait bwrap"); + assert!( + out.status.success(), + "grep en bwrap falló: stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "12345"); + + // 5) Cache hit en segunda build: mismo hash. + let h2 = build(&recipe, &cfg, &store).expect("rebuild grep"); + assert_eq!(h, h2, "segunda build debe ser cache hit"); +} diff --git a/crates/hammer-build/tests/fetch_tarball.rs b/crates/hammer-build/tests/fetch_tarball.rs new file mode 100644 index 00000000..d8b31f77 --- /dev/null +++ b/crates/hammer-build/tests/fetch_tarball.rs @@ -0,0 +1,164 @@ +//! Tests del modo tarball del fetch — completamente locales, sin red ni rootfs. +//! +//! Estrategia: +//! - Genero un tarball en un tempdir con contenido conocido. +//! - Calculo su sha256 real con la misma crate (`sha2`) que usa el fetch. +//! - Sirvo el tarball vía URL `file://` (curl la maneja nativamente). +//! - Llamo a `hammer_build::fetch::fetch` con una receta TOML que apunta a esa URL. +//! +//! Cubre: happy path con strip_components, mismatch de sha256 no envenena la caché, reuso +//! de caché en segunda llamada, strip_components = 0 vs 1. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use hammer_build::fetch::fetch; +use hammer_core::Recipe; +use sha2::{Digest, Sha256}; + +/// Crea `