//! Puente entre `takana_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 `takana-build` (no en `takana-core`) porque depende del store y del //! sandbox. `takana-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 takana_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>, name: Option<&str>, ) -> takana_core::Result { let catalog_dir = catalog_dir_for(cfg, scratch_root); // El NOMBRE de la receta sintetizada decide el sufijo del store (`-`) Y la // clave de cache-hit (`store.has(hash, name)`). Si quien llama conoce el nombre del paquete // (p.ej. `install ripgrep`), lo pasa: así reproduce como "ripgrep" y cache-hitea el artefacto // del corpus, en vez de derivar "rg" del target_bin y sellar un duplicado bajo otro nombre. let recipe = recipe_from_source_patch(mutation, name, &catalog_dir)?; let expected = match mutation { Mutation::SourcePatch { expected_hash, .. } => expected_hash.clone(), _ => None, }; 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(takana_core::Error::Recipe(format!( "expected_hash no coincide: declarado=b3:{want_hex}, obtenido={hash}" ))); } } Ok(hash) } /// El directorio-catálogo donde se materializan los patches y los `{dep}.toml`. Determinista a /// partir de `cfg.work_root` (o `scratch_root`): por eso `install` puede pre-poblarlo con los /// `{dep}.toml` y `build_source_patch` los encontrará al resolver build-deps del mismo `base_dir`. pub fn catalog_dir_for(cfg: &BuildConfig, scratch_root: Option<&Path>) -> PathBuf { scratch_root.unwrap_or(&cfg.work_root).join("swm-recipes") } /// Sintetiza (SIN construir) la `Recipe` efímera de un `source_patch`: parsea el origen, /// materializa el patch inline/remoto a disco bajo `catalog_dir`, y arma la receta con sus /// `deps` y `base_dir = catalog_dir` (para que las build-deps resuelvan `{dep}.toml` ahí). /// /// `name` sobreescribe el nombre derivado del `target_bin`. Es OBLIGATORIO al escribir un /// `{dep}.toml`: el nombre de la receta DEBE ser el de la dep para que el dependiente la resuelva. pub fn recipe_from_source_patch( mutation: &Mutation, name: Option<&str>, catalog_dir: &Path, ) -> takana_core::Result { let (repo, commit, tarball, sha256, strip_components, patch, patch_list, patch_url, build_cfg, target_bin, deps, evidence, slots) = match mutation { Mutation::SourcePatch { repo, commit, tarball, sha256, strip_components, patch, patches, patch_url, build, target_bin, deps, evidence, slots, .. } => ( repo, commit, tarball, sha256, strip_components, patch, patches, patch_url, build, target_bin, deps, evidence, slots, ), _ => { return Err(takana_core::Error::Recipe( "recipe_from_source_patch: la mutación no es 'source_patch'".into(), )); } }; // git xor tarball — misma regla que el schema (`swm_source_kind`). let source_kind = takana_core::swm::swm_source_kind( repo.as_deref(), commit.as_deref(), tarball.as_deref(), sha256.as_deref(), ) .map_err(takana_core::Error::Recipe)?; std::fs::create_dir_all(catalog_dir)?; // Clave estable del origen para nombrar el patch 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 { takana_core::SourceKind::Git { commit, .. } => commit, takana_core::SourceKind::Tarball { sha256, .. } => sha256, // Inalcanzable por construcción —`swm_source_kind` sólo produce Git o Tarball— pero se // escribe como error y no como `unreachable!`: esto es una librería, y un panic aquí // mataría al llamador por una receta mal formada en vez de decírselo. takana_core::SourceKind::Dir { .. } => { return Err(takana_core::Error::Recipe( "un .swm no puede llevar un source por directorio".into(), )) } }; // Los parches se materializan **uno por fichero y en el orden del manifiesto**: el lab los // aplica en ese orden y `Recipe::hash_inputs` mete una entrada por fichero. Unirlos en uno // solo sella en otra dirección — ver `Mutation::SourcePatch::patches`. let mut patches: Vec = Vec::new(); if !patch_list.is_empty() || patch.is_some() || patch_url.is_some() { // Patch bajo un nombre derivado del nombre-de-receta + clave de origen, para no colisionar // entre `{dep}.patch` distintos en el mismo catálogo. let label = name.map(|n| n.to_string()).unwrap_or_else(|| derive_name(target_bin)); // `source.patches` guarda el NOMBRE relativo a `base_dir` (= catalog_dir); el lab resuelve // `base_dir.join(nombre)` UNA vez al construir (fetch::apply_patches / Recipe hash). Guardar el // path completo acá lo haría re-unir a base_dir aguas abajo (doble/triple prefijo). if !patch_list.is_empty() { // El índice va en el nombre para conservar el orden Y para no pisar un parche con otro // cuando la receta trae varios del mismo origen. for (i, text) in patch_list.iter().enumerate() { let filename = format!("{label}-{source_key}-{i:02}.patch"); std::fs::write(catalog_dir.join(&filename), text.as_bytes())?; patches.push(filename); } } else { // Formato histórico: un único parche inline (o remoto). let filename = format!("{label}-{source_key}.patch"); let patch_path = catalog_dir.join(&filename); if let Some(text) = patch { std::fs::write(&patch_path, text.as_bytes())?; } 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(filename); } } let name = name.map(|n| n.to_string()).unwrap_or_else(|| derive_name(target_bin)); synthesize_recipe( &name, &source_kind, build_cfg, *strip_components, deps, evidence, slots, patches, catalog_dir, ) } /// 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() } #[allow(clippy::too_many_arguments)] fn synthesize_recipe( name: &str, source: &takana_core::SourceKind<'_>, build_cfg: &SwmBuild, strip_components: Option, deps: &takana_core::Deps, evidence: &takana_core::Evidence, slots: &takana_core::Slots, patches: Vec, base_dir: &Path, ) -> takana_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 { takana_core::SourceKind::Git { repo, commit } => ( *commit, format!("repo = \"{repo}\"\ncommit = \"{commit}\"\n"), ), takana_core::SourceKind::Tarball { url, sha256 } => ( *sha256, format!("tarball = \"{url}\"\nsha256 = \"{sha256}\"\n"), ), // Ver la nota del `source_key` de arriba: un `.swm` no puede traer un `dir`. takana_core::SourceKind::Dir { .. } => { return Err(takana_core::Error::Recipe( "un .swm no puede llevar un source por directorio".into(), )) } }; // 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(); // Fidelidad de reconstrucción: fases custom, zig por-paquete y strip del tarball viajan en // el .swm (si vienen) y se reinyectan en la receta efímera tal cual los tenía el original. recipe.build.phases = build_cfg.phases.clone(); recipe.build.zig_version = build_cfg.zig_version.clone(); // `strip_debug` ENTRA EN `hash_inputs`: sin esta línea el receptor reconstruye con `None`, // sella en otra dirección y el `expected_hash` anclado no coincide NUNCA. Ver la doc del campo // en `swm::SwmBuild::strip_debug`. recipe.build.strip_debug = build_cfg.strip_debug; if let Some(n) = strip_components { recipe.source.strip_components = n; } // `patches` ya viene como nombres relativos a `base_dir` (o rutas absolutas que generamos // nosotros): el lab los resuelve con `base_dir.join(p)` UNA vez al construir. NO los re-unimos // acá — hacerlo doblaba el prefijo `base_dir` y el join del build lo triplicaba (bug que el // dogfood de `jq`, primer paquete con patch + build-dep instalado, destapó). recipe.source.patches = patches; // Deps por NOMBRE (de la receta original): el lab las resolverá como `{dep}.toml` bajo el // `base_dir` — por eso base_dir = el catálogo (no "/"): los patches son absolutos y no lo // necesitan, pero la resolución de build-deps SÍ. `install` puebla el catálogo con los // `{dep}.toml` antes de construir; sin deps, el dir simplemente no se consulta. recipe.deps = deps.clone(); // Evidencia de comportamiento (H1): viaja en el .swm y se reinyecta en la receta efímera para // que el checker la vea al reproducir. No afecta el hash del artefacto (comportamiento ≠ identidad). recipe.evidence = evidence.clone(); // Superficies del sistema (H4b): viajan en el .swm y se reinyectan en la receta efímera para // que el gate de compatibilidad de `install` las evalúe. Como la evidencia, no afectan el hash. recipe.slots = slots.clone(); recipe.base_dir = base_dir.to_path_buf(); Ok(recipe) } fn parse_compiler(s: &str) -> takana_core::Result { match s { "zig-cc" => Ok(Compiler::ZigCc), "clang" => Ok(Compiler::Clang), "gcc" => Ok(Compiler::Gcc), other => Err(takana_core::Error::Recipe(format!( "compiler desconocido: '{other}' (esperado zig-cc/clang/gcc)" ))), } } fn parse_link(s: &str) -> takana_core::Result { match s { "static" => Ok(LinkMode::Static), "dynamic" => Ok(LinkMode::Dynamic), other => Err(takana_core::Error::Recipe(format!( "link desconocido: '{other}' (esperado static/dynamic)" ))), } } #[cfg(test)] mod tests { use super::*; #[test] fn slots_sobreviven_receta_a_swm_a_receta() { // H4b: el gate de compatibilidad depende de que las superficies declaradas en la receta // viajen intactas por el `.swm` y se recuperen al reproducir. Ejercita AMBAS direcciones // del puente (Swm::from_recipe → recipe_from_source_patch). let toml = r#" name = "tema-oscuro" version = "1.0" [source] repo = "git://x/tema.git" commit = "abc123" [build] compiler = "zig-cc" [slots.claims] logo = "b3:azul" [slots.requires] "wayland-protocol" = "b3:stock" "#; let recipe = takana_core::Recipe::from_toml(toml).unwrap(); let swm = takana_core::Swm::from_recipe(&recipe, "/usr/bin/tema", Vec::new(), None, "2026-06-06").unwrap(); // El .swm YAML debe llevar el bloque slots. let yaml = swm.to_yaml().unwrap(); assert!(yaml.contains("slots"), "el .swm debe transportar slots: {yaml}"); // Vuelta: source_patch → Recipe recupera exactamente los slots. let d = tempfile::tempdir().unwrap(); let mutation = swm .mutations .iter() .find(|m| matches!(m, Mutation::SourcePatch { .. })) .unwrap(); let back = recipe_from_source_patch(mutation, Some("tema-oscuro"), d.path()).unwrap(); assert_eq!(back.slots, recipe.slots); assert_eq!(back.slots.claims.get("logo").unwrap(), "b3:azul"); assert_eq!(back.slots.requires.get("wayland-protocol").unwrap(), "b3:stock"); } fn fake_swm_build() -> SwmBuild { SwmBuild { compiler: "zig-cc".into(), target: "x86_64-linux-musl".into(), link: "static".into(), flags: vec!["--enable-foo".into()], phases: Default::default(), zig_version: None, // Agregado en 9bc51889 («no viajaba en el paquete, y es ENTRADA DE HASH»): este // constructor de test se quedó sin él y dejó al crate ENTERO sin compilar sus tests. strip_debug: None, } } #[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", &takana_core::SourceKind::Git { repo: "git://example/grep.git", commit: "a1b2c3d4e5f6a7b8c9", }, &fake_swm_build(), None, &takana_core::Deps::default(), &takana_core::Evidence::default(), &takana_core::Slots::default(), 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() { takana_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", &takana_core::SourceKind::Tarball { url: "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz", sha256: "abcdef0123456789", }, &fake_swm_build(), None, &takana_core::Deps::default(), &takana_core::Evidence::default(), &takana_core::Slots::default(), vec![], d.path(), ) .unwrap(); assert_eq!(r.name, "grep"); assert!(r.version.starts_with("swm-abcdef012345")); match r.source.kind().unwrap() { takana_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"), } } /// Regresión del 2026-09-21: **una receta con VARIOS parches tiene que reproducir su hash**. /// /// `pack` concatenaba los N parches en el `patch` inline único, y `Recipe::hash_inputs` mete /// **una entrada por parche** (con `of_inputs` length-prefijado) ⇒ el receptor sellaba en otra /// dirección y el `expected_hash` anclado no coincidía NUNCA. Medido sobre `zsh` (6 parches): /// anclado `b3:0be3630d…`, reproducido `b3:d6329a62…`. Afectaba a 16 recetas del corpus. /// /// El test compara los `hash_inputs` —no el hash final— porque así, cuando falle, dice *qué* /// entrada se movió en vez de sólo que dos hex difieren. Lleva su **control negativo**: con /// los parches concatenados (el formato viejo) los inputs TIENEN que diferir, o el test /// pasaría por la razón equivocada. #[test] fn varios_parches_viajan_sueltos_y_el_hash_reproduce() { let d = tempfile::tempdir().unwrap(); let recipes = d.path().join("recipes"); std::fs::create_dir_all(&recipes).unwrap(); std::fs::write(recipes.join("uno.patch"), b"--- a/uno\n+++ b/uno\n").unwrap(); std::fs::write(recipes.join("dos.patch"), b"--- a/dos\n+++ b/dos\n").unwrap(); let toml = r#" name = "multi" version = "1.0" [source] tarball = "https://x/multi-1.0.tar.gz" sha256 = "abcdef0123456789" patches = ["uno.patch", "dos.patch"] [build] compiler = "zig-cc" target = "x86_64-linux-musl" link = "static" "#; let mut original = Recipe::from_toml(toml).unwrap(); original.base_dir = recipes.clone(); let textos: Vec = original .source .patches .iter() .map(|p| std::fs::read_to_string(recipes.join(p)).unwrap()) .collect(); let swm = takana_core::Swm::from_recipe(&original, "/usr/bin/multi", textos.clone(), None, "dev") .unwrap(); let catalog = d.path().join("swm-recipes"); let rehecha = recipe_from_source_patch(&swm.mutations[0], Some("multi"), &catalog).unwrap(); assert_eq!( rehecha.source.patches.len(), 2, "los parches tienen que llegar SUELTOS, no unidos: {:?}", rehecha.source.patches ); let lab = takana_core::lab::LabFingerprint::for_tests(); assert_eq!( original.hash_inputs(&[], &lab).unwrap(), rehecha.hash_inputs(&[], &lab).unwrap(), "la receta rehecha desde el .swm debe hashear igual que la del corpus" ); // Control negativo: el formato viejo (los dos parches en un solo `patch`) NO reproduce. let Mutation::SourcePatch { tarball, sha256, build, deps, evidence, slots, .. } = swm.mutations[0].clone() else { panic!("esperaba source_patch"); }; let concatenado = Mutation::SourcePatch { repo: None, commit: None, tarball, sha256, strip_components: None, patch: Some(textos.concat()), patches: Vec::new(), patch_url: None, build, target_bin: "/usr/bin/multi".into(), expected_hash: None, deps, evidence, slots, }; let viejo = d.path().join("swm-recipes-viejo"); let rehecha_vieja = recipe_from_source_patch(&concatenado, Some("multi"), &viejo).unwrap(); assert_ne!( original.hash_inputs(&[], &lab).unwrap(), rehecha_vieja.hash_inputs(&[], &lab).unwrap(), "control: concatenar los parches TIENE que mover el hash (si no, este test no prueba nada)" ); } #[test] fn inline_patch_resuelve_con_un_solo_join_de_base_dir() { // Regresión: `source.patches` debe guardar el NOMBRE relativo a base_dir, de modo que el // lab resuelva `base_dir.join(p)` UNA sola vez. El bug doblaba/triplicaba el prefijo // `swm-recipes/` (lo destapó el dogfood de `jq`, primer paquete con patch instalado). let d = tempfile::tempdir().unwrap(); let catalog = d.path().join("swm-recipes"); let m = Mutation::SourcePatch { repo: None, commit: None, tarball: Some("https://x/jq-1.8.1.tar.gz".into()), sha256: Some("2be64e7129cecb11d5906290eba10af694fb9e3e7f9fc208a311dc33ca837eb0".into()), strip_components: None, patch: Some("--- a\n+++ b\n".into()), patches: Vec::new(), patch_url: None, build: fake_swm_build(), target_bin: "/usr/bin/jq".into(), expected_hash: None, deps: Default::default(), evidence: Default::default(), slots: Default::default(), }; let recipe = recipe_from_source_patch(&m, None, &catalog).unwrap(); assert_eq!(recipe.source.patches.len(), 1); let p = &recipe.source.patches[0]; // El nombre NO debe llevar embebido el dir del catálogo (eso causaba el doble prefijo). assert!(!p.contains("swm-recipes"), "patch debe ser relativo a base_dir, no llevar el catálogo: {p}"); // Y base_dir.join(p) — el ÚNICO join que hace el lab — debe dar el fichero real escrito. let resolved = recipe.base_dir.join(p); assert!(resolved.is_file(), "base_dir.join(patch) debe existir en un solo paso: {}", resolved.display()); assert_eq!(std::fs::read(&resolved).unwrap(), b"--- a\n+++ b\n"); } #[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, 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, strip_components: None, patch: None, patches: Vec::new(), patch_url: Some(format!("file://{}", patch_src.display())), build: fake_swm_build(), target_bin: "/bin/x".into(), expected_hash: None, deps: Default::default(), evidence: Default::default(), slots: Default::default(), }; 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), None); // El patch se materializa como `{label}-{source_key}.patch` (label = derive_name del // target_bin) para no colisionar entre deps en el catálogo. let landed = scratch.join("swm-recipes").join(format!("x-{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"); } }