//! Integration test del pipeline completo de Fase 0: //! 1. Inicializa un repo git local con un `hello.c`. //! 2. Escribe una receta TOML que apunta a ese repo con phases override. //! 3. Llama a `hammer_build::build` con un store/work temporales. //! 4. Verifica que el artefacto sellado contiene `usr/bin/hello`, es ELF estático y //! se ejecuta dentro del sandbox imprimiendo el output esperado. //! //! El test se SALTA (con un mensaje claro) si el host no tiene el rootfs Alpine //! y/o zig instalados en `.dev-fs/`. Eso permite que `cargo test` no falle en máquinas //! limpias; el desarrollador lee el mensaje y corre el bootstrap. use std::path::{Path, PathBuf}; use std::process::Command; use hammer_build::{build, BuildConfig}; use hammer_core::{Recipe, Store}; const HELLO_C: &str = r#" #include int main(void) { puts("hammer-e2e OK"); return 0; } "#; const RECIPE_TOML: &str = r#" name = "hello" version = "1.0" [source] repo = "PLACEHOLDER_REPO_URL" commit = "PLACEHOLDER_COMMIT" [build] compiler = "zig-cc" target = "x86_64-linux-musl" link = "static" [build.phases] compile = "zig cc -static -O2 -o hello hello.c" install = "install -D -m755 hello /out/usr/bin/hello" "#; fn project_root() -> PathBuf { // CARGO_MANIFEST_DIR = crates/hammer-build → subir dos para llegar al workspace root. PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap() .parent() .unwrap() .to_path_buf() } fn skip_if_no_layout(cfg: &BuildConfig) -> bool { if !cfg.rootfs.join("bin/busybox").is_file() { eprintln!( "SKIP: rootfs Alpine no encontrado en {} — corre el bootstrap (ver README).", cfg.rootfs.display() ); return true; } if !cfg.zig_dir.join("zig").is_file() { eprintln!( "SKIP: zig no encontrado en {} — instalalo en .dev-fs/tools/zig.", cfg.zig_dir.display() ); return true; } if Command::new("git").arg("--version").output().is_err() { eprintln!("SKIP: git no disponible en PATH."); return true; } false } fn git(args: &[&str], cwd: &Path) { let st = Command::new("git") .args(args) .current_dir(cwd) .status() .expect("git spawn"); assert!(st.success(), "git {args:?} falló"); } fn init_repo_with_hello(repo_dir: &Path) -> String { std::fs::create_dir_all(repo_dir).unwrap(); std::fs::write(repo_dir.join("hello.c"), HELLO_C).unwrap(); git(&["init", "-q", "-b", "main"], repo_dir); // Identidad local para el commit, sin tocar la global del usuario. git(&["config", "user.email", "test@hammer"], repo_dir); git(&["config", "user.name", "hammer-test"], repo_dir); git(&["add", "."], repo_dir); git(&["commit", "-q", "-m", "hello"], repo_dir); let out = Command::new("git") .args(["rev-parse", "HEAD"]) .current_dir(repo_dir) .output() .expect("rev-parse"); String::from_utf8(out.stdout).unwrap().trim().to_string() } #[test] fn build_hello_end_to_end() { let _ = tracing_subscriber::fmt().with_test_writer().try_init(); let project = project_root(); let rootfs = project.join(".dev-fs/alpine"); let zig_dir = project.join(".dev-fs/tools/zig"); let tmp = tempfile::tempdir().unwrap(); let store_root = tmp.path().join("store"); let work_root = tmp.path().join("work"); std::fs::create_dir_all(&store_root).unwrap(); std::fs::create_dir_all(&work_root).unwrap(); let cfg = BuildConfig { rootfs: rootfs.clone(), zig_dir: zig_dir.clone(), work_root, // Reusa la caché global del dev — la primera corrida tras `cargo clean` paga // ~30s recompilando musl; las siguientes terminan en segundos. cache_root: Some(project.join(".dev-fs/cache")), }; if skip_if_no_layout(&cfg) { return; } // 1) repo upstream simulado let upstream = tmp.path().join("upstream-hello"); let commit = init_repo_with_hello(&upstream); let repo_url = format!("file://{}", upstream.display()); // 2) escribir receta let recipe_dir = tmp.path().join("recipe"); std::fs::create_dir_all(&recipe_dir).unwrap(); let toml = RECIPE_TOML .replace("PLACEHOLDER_REPO_URL", &repo_url) .replace("PLACEHOLDER_COMMIT", &commit); let recipe_path = recipe_dir.join("hello.toml"); std::fs::write(&recipe_path, toml).unwrap(); let recipe = Recipe::load_from_path(&recipe_path).expect("load recipe"); // 3) build let store = Store::open(&store_root).unwrap(); let h = build(&recipe, &cfg, &store).expect("build hello"); let sealed = store.path_of(&h, &recipe.name); assert!(sealed.is_dir(), "store dir debe existir: {}", sealed.display()); // 4) artefacto correcto let hello = sealed.join("usr/bin/hello"); assert!(hello.is_file(), "usr/bin/hello debe existir"); let meta = std::fs::metadata(&hello).unwrap(); use std::os::unix::fs::PermissionsExt; assert_eq!(meta.permissions().mode() & 0o111, 0o111, "ejecutable"); // Read-only en el store assert_eq!(meta.permissions().mode() & 0o222, 0, "read-only en el store"); // ELF let header = std::fs::read(&hello).unwrap(); assert_eq!(&header[..4], b"\x7fELF", "es ELF"); // 5) ejecuta dentro de bwrap+rootfs y observa el output let out = Command::new("bwrap") .args([ "--overlay-src", rootfs.to_str().unwrap(), "--tmp-overlay", "/", "--proc", "/proc", "--dev", "/dev", "--ro-bind", sealed.to_str().unwrap(), "/artefacto", "--unshare-all", "/artefacto/usr/bin/hello", ]) .output() .expect("spawn bwrap run hello"); assert!(out.status.success(), "hello no corrió: {:?}", out); assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hammer-e2e OK"); // 6) cache: segunda build con misma receta no rehidrata let h2 = build(&recipe, &cfg, &store).expect("second build"); assert_eq!(h, h2); }