//! Puente entre `hammer_core::Mutation::SourcePatch` y el laboratorio. //! //! Convierte la mutación serializada en una `Recipe` efímera + un archivo de patch //! temporal, y delega en `build` para sellar el artefacto en el store. Luego se hidrata. //! //! Este módulo vive en `hammer-build` (no en `hammer-core`) porque depende del store y del //! sandbox. `hammer-core` debe permanecer puro (sin red ni mounts) para que el watcher y los //! tests no arrastren toda la cadena del lab. use std::path::{Path, PathBuf}; use hammer_core::{ swm::{Mutation, SwmBuild}, ArtifactHash, Compiler, LinkMode, Recipe, Store, }; use crate::{build, BuildConfig}; /// Construye una `SourcePatch` y devuelve el hash sellado en el store. El llamador hidrata /// luego (típicamente al overlay activo). /// /// `scratch_root` sirve para escribir el patch inline a disco — usamos `cfg.work_root` por /// defecto si el caller no quiere gestionarlo a mano. pub fn build_source_patch( mutation: &Mutation, cfg: &BuildConfig, store: &Store, scratch_root: Option<&Path>, ) -> hammer_core::Result { let (repo, commit, tarball, sha256, patch, patch_url, build_cfg, target_bin, expected) = match mutation { Mutation::SourcePatch { repo, commit, tarball, sha256, patch, patch_url, build, target_bin, expected_hash, } => ( repo, commit, tarball, sha256, patch, patch_url, build, target_bin, expected_hash, ), _ => { return Err(hammer_core::Error::Recipe( "build_source_patch: la mutación no es 'source_patch'".into(), )); } }; // git xor tarball — misma regla que el schema (`swm_source_kind`). let source_kind = hammer_core::swm::swm_source_kind( repo.as_deref(), commit.as_deref(), tarball.as_deref(), sha256.as_deref(), ) .map_err(hammer_core::Error::Recipe)?; let scratch = scratch_root.unwrap_or(&cfg.work_root).to_path_buf(); let recipe_dir = scratch.join("swm-recipes"); std::fs::create_dir_all(&recipe_dir)?; // Clave estable del origen para nombrar artefactos a disco: el commit (git) o el // sha256 (tarball). Idéntica reaplicación ⇒ mismo nombre ⇒ hash de receta determinista. let source_key: &str = match &source_kind { hammer_core::SourceKind::Git { commit, .. } => commit, hammer_core::SourceKind::Tarball { sha256, .. } => sha256, }; // Materializamos el patch a disco bajo un nombre derivado de la clave de origen (estable: // si el .swm se reaplica, reusamos el mismo archivo y el hash de la receta no depende de // aleatoriedad). Origen: inline (`patch`) o remoto (`patch_url`); el schema garantiza que // no vengan ambos. let mut patches: Vec = Vec::new(); let patch_path = recipe_dir.join(format!("{source_key}.patch")); if let Some(text) = patch { std::fs::write(&patch_path, text.as_bytes())?; patches.push(patch_path.to_string_lossy().into_owned()); } else if let Some(url) = patch_url { tracing::info!(%url, "fetch: descargando patch remoto"); crate::download::fetch_url_to_file(url, &patch_path)?; patches.push(patch_path.to_string_lossy().into_owned()); } let name = derive_name(target_bin); let recipe = synthesize_recipe(&name, &source_kind, build_cfg, patches, &recipe_dir)?; let hash = build(&recipe, cfg, store)?; if let Some(want) = expected { let want_hex = want.strip_prefix("b3:").unwrap_or(want); let got_hex = hash .as_str() .strip_prefix("b3:") .expect("of_inputs siempre devuelve 'b3:'"); if want_hex != got_hex { return Err(hammer_core::Error::Recipe(format!( "expected_hash no coincide: declarado=b3:{want_hex}, obtenido={hash}" ))); } } Ok(hash) } /// Nombre legible para el directorio del store. `/usr/bin/grep` → `grep`. Si el path no /// tiene basename (raro), usamos `swm-bin`. El nombre afecta SÓLO al sufijo legible /// (`-`), no al hash; cambiarlo no rompe la caché del store. fn derive_name(target_bin: &str) -> String { Path::new(target_bin) .file_name() .and_then(|s| s.to_str()) .unwrap_or("swm-bin") .to_string() } fn synthesize_recipe( name: &str, source: &hammer_core::SourceKind<'_>, build_cfg: &SwmBuild, patches: Vec, base_dir: &Path, ) -> hammer_core::Result { let compiler = parse_compiler(&build_cfg.compiler)?; let link = parse_link(&build_cfg.link)?; // Sección `[source]` y sufijo de versión según el modo de origen. El validador de // `Source::kind` ya garantiza git xor tarball; acá sólo emitimos el TOML correspondiente. let (version_key, source_block) = match source { hammer_core::SourceKind::Git { repo, commit } => ( *commit, format!("repo = \"{repo}\"\ncommit = \"{commit}\"\n"), ), hammer_core::SourceKind::Tarball { url, sha256 } => ( *sha256, format!("tarball = \"{url}\"\nsha256 = \"{sha256}\"\n"), ), }; // Construimos la receta vía TOML para reusar las defaults y el validador de `Source`. let toml_text = format!( r#" name = "{name}" version = "swm-{version_short}" [source] {source_block} [build] compiler = "{compiler}" target = "{target}" link = "{link}" flags = [] "#, name = name, version_short = &version_key[..version_key.len().min(12)], source_block = source_block, compiler = compiler.as_str(), target = build_cfg.target, link = link.as_str(), ); let mut recipe = Recipe::from_toml(&toml_text)?; recipe.build.flags = build_cfg.flags.clone(); // Resolución de patches relativos al recipe_dir donde acabamos de escribir los inline. // Aceptamos rutas absolutas tal cual: las hemos generado nosotros. recipe.source.patches = patches .into_iter() .map(|p| { let path = Path::new(&p); if path.is_absolute() { p } else { base_dir.join(&p).to_string_lossy().into_owned() } }) .collect(); recipe.base_dir = PathBuf::from("/"); // patches absolutos: base_dir es irrelevante Ok(recipe) } fn parse_compiler(s: &str) -> hammer_core::Result { match s { "zig-cc" => Ok(Compiler::ZigCc), "clang" => Ok(Compiler::Clang), "gcc" => Ok(Compiler::Gcc), other => Err(hammer_core::Error::Recipe(format!( "compiler desconocido: '{other}' (esperado zig-cc/clang/gcc)" ))), } } fn parse_link(s: &str) -> hammer_core::Result { match s { "static" => Ok(LinkMode::Static), "dynamic" => Ok(LinkMode::Dynamic), other => Err(hammer_core::Error::Recipe(format!( "link desconocido: '{other}' (esperado static/dynamic)" ))), } } #[cfg(test)] mod tests { use super::*; fn fake_swm_build() -> SwmBuild { SwmBuild { compiler: "zig-cc".into(), target: "x86_64-linux-musl".into(), link: "static".into(), flags: vec!["--enable-foo".into()], } } #[test] fn derive_name_from_target() { assert_eq!(derive_name("/usr/bin/grep"), "grep"); assert_eq!(derive_name("/bin/ls"), "ls"); assert_eq!(derive_name("/"), "swm-bin"); } #[test] fn synthesize_recipe_basic() { let d = tempfile::tempdir().unwrap(); let r = synthesize_recipe( "grep", &hammer_core::SourceKind::Git { repo: "git://example/grep.git", commit: "a1b2c3d4e5f6a7b8c9", }, &fake_swm_build(), vec![], d.path(), ) .unwrap(); assert_eq!(r.name, "grep"); assert!(r.version.starts_with("swm-a1b2c3d4")); assert_eq!(r.build.flags, vec!["--enable-foo"]); match r.source.kind().unwrap() { hammer_core::SourceKind::Git { repo, commit } => { assert_eq!(repo, "git://example/grep.git"); assert_eq!(commit, "a1b2c3d4e5f6a7b8c9"); } _ => panic!("modo git esperado"), } } #[test] fn synthesize_recipe_tarball() { let d = tempfile::tempdir().unwrap(); let r = synthesize_recipe( "grep", &hammer_core::SourceKind::Tarball { url: "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz", sha256: "abcdef0123456789", }, &fake_swm_build(), vec![], d.path(), ) .unwrap(); assert_eq!(r.name, "grep"); assert!(r.version.starts_with("swm-abcdef012345")); match r.source.kind().unwrap() { hammer_core::SourceKind::Tarball { url, sha256 } => { assert_eq!(url, "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz"); assert_eq!(sha256, "abcdef0123456789"); } _ => panic!("modo tarball esperado"), } } #[test] fn parse_compiler_and_link() { assert_eq!(parse_compiler("zig-cc").unwrap(), Compiler::ZigCc); assert!(parse_compiler("xlc").is_err()); assert_eq!(parse_link("static").unwrap(), LinkMode::Static); assert!(parse_link("partial").is_err()); } #[test] fn rejects_non_source_patch_mutation() { let m = Mutation::ConfigEdit { file: "/etc/x".into(), inline_diff: "- a\n+ b\n".into(), }; let d = tempfile::tempdir().unwrap(); let store = Store::open(d.path().join("store")).unwrap(); let cfg = BuildConfig::defaults_for_store(store.root()); let err = build_source_patch(&m, &cfg, &store, None).unwrap_err().to_string(); assert!(err.contains("source_patch"), "{err}"); } #[test] fn patch_url_is_downloaded_before_build() { // Servimos el patch por file:// (sin red). build_source_patch debe descargarlo y // materializarlo en swm-recipes/.patch ANTES de fallar aguas abajo por no // haber rootfs de build en este entorno. Eso prueba que el patch_url quedó cableado. let d = tempfile::tempdir().unwrap(); let patch_src = d.path().join("remote.patch"); std::fs::write(&patch_src, b"--- a\n+++ b\n").unwrap(); let commit = "deadbeefcafe"; let m = Mutation::SourcePatch { repo: Some("git://x".into()), commit: Some(commit.into()), tarball: None, sha256: None, patch: None, patch_url: Some(format!("file://{}", patch_src.display())), build: fake_swm_build(), target_bin: "/bin/x".into(), expected_hash: None, }; let store = Store::open(d.path().join("store")).unwrap(); let cfg = BuildConfig::defaults_for_store(store.root()); let scratch = d.path().join("scratch"); // Falla (no hay rootfs), pero el patch ya debe estar descargado. let _ = build_source_patch(&m, &cfg, &store, Some(&scratch)); let landed = scratch.join("swm-recipes").join(format!("{commit}.patch")); assert!(landed.is_file(), "el patch remoto debió descargarse a {}", landed.display()); assert_eq!(std::fs::read(&landed).unwrap(), b"--- a\n+++ b\n"); } }