Tests: fetch tarball local + e2e grep gated en HAMMER_NETWORK_TESTS
- `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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
1fc8c97617
commit
3ab2b68e06
@@ -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");
|
||||
}
|
||||
@@ -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 `<dir>/inner/{a.txt,sub/b.txt}` y empaqueta `inner/` en `<dir>/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<usize>) -> 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 <sha>.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}");
|
||||
}
|
||||
Reference in New Issue
Block a user