//! 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 takana_build::{build, run_hydrate, BuildConfig}; use takana_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, None).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"); }