From 3ab2b68e065a65027d7d44ef9335e5ca221c6873 Mon Sep 17 00:00:00 2001 From: Sergio Date: Tue, 9 Jun 2026 14:20:17 +0000 Subject: [PATCH] Tests: fetch tarball local + e2e grep gated en HAMMER_NETWORK_TESTS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `fetch_tarball.rs` (5 tests, sin red ni rootfs): genera tar.gz local, lo sirve vía `file://` y ejercita el path completo: strip_components default vs 0, sha256 mismatch que NO envenena caché ni deja .partial, reuso de caché en segunda llamada con la fuente original eliminada, y rechazo de un archivo cacheado con contenido distinto al sha esperado. - `end_to_end_grep.rs` (1 test, gated): grep 3.12 desde GNU, build + hidratación + ejecución en bwrap+Alpine + cache-hit en segunda build. Doble skip: HAMMER_NETWORK_TESTS!=1 o .dev-fs sin bootstrap. Comparte .dev-fs/cache con la dev para no repagar musl. Co-Authored-By: Claude Opus 4.7 --- crates/hammer-build/tests/end_to_end_grep.rs | 166 +++++++++++++++++++ crates/hammer-build/tests/fetch_tarball.rs | 164 ++++++++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 crates/hammer-build/tests/end_to_end_grep.rs create mode 100644 crates/hammer-build/tests/fetch_tarball.rs 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 `/inner/{a.txt,sub/b.txt}` y empaqueta `inner/` en `/payload.tar.gz`. +/// El tarball tiene UN componente top-level (`inner/`) — strip_components=1 lo elimina. +fn make_tarball(dir: &Path) -> PathBuf { + let inner = dir.join("inner"); + std::fs::create_dir_all(inner.join("sub")).unwrap(); + std::fs::write(inner.join("a.txt"), b"hola desde a\n").unwrap(); + std::fs::write(inner.join("sub/b.txt"), b"hola desde b\n").unwrap(); + + let tar_path = dir.join("payload.tar.gz"); + let st = Command::new("tar") + .arg("-czf") + .arg(&tar_path) + .arg("-C") + .arg(dir) + .arg("inner") + .status() + .expect("spawn tar"); + assert!(st.success(), "tar -czf falló"); + tar_path +} + +fn sha256_hex(path: &Path) -> String { + let mut f = std::fs::File::open(path).unwrap(); + let mut hasher = Sha256::new(); + std::io::copy(&mut f, &mut hasher).unwrap(); + hex::encode(hasher.finalize()) +} + +fn recipe_for(url: &str, sha: &str, strip: Option) -> Recipe { + let strip_line = match strip { + Some(n) => format!("strip_components = {n}\n"), + None => String::new(), + }; + let toml = format!( + r#" +name = "fixture" +version = "0" +[source] +tarball = "{url}" +sha256 = "{sha}" +{strip_line} +[build] +"# + ); + Recipe::from_toml(&toml).unwrap() +} + +#[test] +fn happy_path_strip_components_default_one() { + let fix = tempfile::tempdir().unwrap(); + let tar_path = make_tarball(fix.path()); + let sha = sha256_hex(&tar_path); + let url = format!("file://{}", tar_path.display()); + + let recipe = recipe_for(&url, &sha, None); + let work_root = tempfile::tempdir().unwrap(); + + let tree = fetch(&recipe, work_root.path()).expect("fetch tarball"); + // strip_components=1 ⇒ no debe quedar `inner/`, los archivos suben un nivel. + assert!(tree.join("a.txt").is_file(), "a.txt debe existir en la raíz del tree"); + assert!(tree.join("sub/b.txt").is_file(), "sub/b.txt debe existir"); + assert!(!tree.join("inner").exists(), "inner/ debe haber sido stripped"); + + // El tarball quedó cacheado por su sha256. + let cached = work_root.path().join("tarballs").join(format!("{sha}.tar")); + assert!(cached.is_file(), "tarball debe quedar cacheado en {}", cached.display()); +} + +#[test] +fn strip_components_zero_preserves_top_level() { + let fix = tempfile::tempdir().unwrap(); + let tar_path = make_tarball(fix.path()); + let sha = sha256_hex(&tar_path); + let url = format!("file://{}", tar_path.display()); + + let recipe = recipe_for(&url, &sha, Some(0)); + let work_root = tempfile::tempdir().unwrap(); + let tree = fetch(&recipe, work_root.path()).expect("fetch tarball strip=0"); + assert!(tree.join("inner/a.txt").is_file(), "con strip=0, inner/ debe existir"); +} + +#[test] +fn sha256_mismatch_fails_and_does_not_poison_cache() { + let fix = tempfile::tempdir().unwrap(); + let tar_path = make_tarball(fix.path()); + let url = format!("file://{}", tar_path.display()); + let wrong_sha = "0000000000000000000000000000000000000000000000000000000000000000"; + + let recipe = recipe_for(&url, wrong_sha, None); + let work_root = tempfile::tempdir().unwrap(); + let err = fetch(&recipe, work_root.path()).unwrap_err().to_string(); + assert!(err.contains("sha256 mismatch"), "msg = {err}"); + + // Crítico: la caché NO debe contener un archivo con el sha incorrecto, porque la próxima + // corrida lo daría por bueno sin re-verificar (sólo verificamos cuando lo descargamos). + let cached = work_root.path().join("tarballs").join(format!("{wrong_sha}.tar")); + assert!(!cached.exists(), "caché no debe contener {}", cached.display()); + // Tampoco debe quedar el .partial colgado. + let partial = work_root + .path() + .join("tarballs") + .join(format!("{wrong_sha}.tar.partial")); + assert!(!partial.exists(), "no debe quedar partial"); +} + +#[test] +fn second_fetch_reuses_cached_tarball() { + let fix = tempfile::tempdir().unwrap(); + let tar_path = make_tarball(fix.path()); + let sha = sha256_hex(&tar_path); + let url = format!("file://{}", tar_path.display()); + + let recipe = recipe_for(&url, &sha, None); + let work_root = tempfile::tempdir().unwrap(); + + let _ = fetch(&recipe, work_root.path()).expect("first fetch"); + + // Para forzar que la segunda corrida NO pueda descargar, "rompo" la fuente: muevo el + // tarball original a otro lado. Si el fetch va a la caché, sigue funcionando; si intenta + // re-descargar, falla. + std::fs::remove_file(&tar_path).unwrap(); + + let tree2 = fetch(&recipe, work_root.path()).expect("second fetch (debe usar caché)"); + assert!(tree2.join("a.txt").is_file()); +} + +#[test] +fn cached_tarball_with_wrong_content_is_rejected() { + // Si un atacante o un bug deja en la caché un archivo con el nombre .tar pero + // contenido distinto, NO debemos confiar. Verificamos sha256 incluso para entradas + // de la caché. + let fix = tempfile::tempdir().unwrap(); + let tar_path = make_tarball(fix.path()); + let sha = sha256_hex(&tar_path); + let url = format!("file://{}", tar_path.display()); + + let work_root = tempfile::tempdir().unwrap(); + let tarballs = work_root.path().join("tarballs"); + std::fs::create_dir_all(&tarballs).unwrap(); + // Plantar un archivo basura con el nombre del sha esperado. + std::fs::write(tarballs.join(format!("{sha}.tar")), b"contenido FALSO").unwrap(); + + let recipe = recipe_for(&url, &sha, None); + let err = fetch(&recipe, work_root.path()).unwrap_err().to_string(); + assert!(err.contains("sha256 mismatch"), "msg = {err}"); +}