//! El manifiesto `.swm` (Software Mutación). Ver `docs/06-swm-format.md`. //! //! Es la unidad de intercambio: receta de transformación sobre fuente pública + ediciones de //! config. NUNCA transporta binarios cocidos (salvo `FileDrop` con hash declarado y, opcional, //! contenido inline base64 para `.swm`s autocontenidos en tests/escenarios offline). use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Swm { pub swm_version: u32, pub base: Base, pub mutations: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub signature: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Base { pub distro_version: String, #[serde(default)] pub pins: BTreeMap, } /// Referencia a la base **local** — lo que el sistema receptor tiene cocido. Se compara /// contra `Swm::base` en `verify_base`. La construye el receptor leyendo, p. ej., /// `/etc/hammer/base.json`; en tests la fabricamos a mano. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BaseRef { pub distro_version: String, #[serde(default)] pub pins: BTreeMap, } impl From<&Base> for BaseRef { fn from(b: &Base) -> Self { BaseRef { distro_version: b.distro_version.clone(), pins: b.pins.clone(), } } } /// Resultado de `verify_base`. `Ok` ⇒ las pins del SWM coinciden con las locales (al menos /// para las llaves que el SWM declara) y la `distro_version` es idéntica. Cualquier /// discrepancia se materializa como variante con detalle: `apply` puede decidir abortar, /// el humano puede leer qué difiere. #[derive(Debug, Clone, PartialEq, Eq)] pub enum BaseCompat { /// La base local satisface todas las pins declaradas por el SWM. Ok, /// La `distro_version` no coincide. DistroMismatch { local: String, swm: String }, /// Una o más pins difieren entre la base local y el SWM. Cada entrada es /// `(pin_name, local_value, swm_value)`; un `local_value` ausente significa que la /// llave no existe en la base local. PinMismatch(Vec), } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PinDiff { pub name: String, pub local: Option, pub swm: String, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Mutation { SourcePatch { // Origen git (modo histórico) **o** tarball — exactamente uno, igual que // [`crate::recipe::Source`]. Antes sólo se modelaba git y los tarballs caían a // `file_drop`; ahora `takana export` los reconstruye como source_patch. Los campos // son `Option` (con `serde(default)`) para que los `.swm` git existentes —que sólo // traen `repo`+`commit`— sigan parseando sin cambios. #[serde(default, skip_serializing_if = "Option::is_none")] repo: Option, #[serde(default, skip_serializing_if = "Option::is_none")] commit: Option, #[serde(default, skip_serializing_if = "Option::is_none")] tarball: Option, #[serde(default, skip_serializing_if = "Option::is_none")] sha256: Option, /// Componentes a recortar al extraer un tarball (modo tarball). `None` ⇒ el default /// del lab (1, un único top-level `proyecto-version/`). Sólo se emite si difiere. #[serde(default, skip_serializing_if = "Option::is_none")] strip_components: Option, /// UN parche inline. **Formato histórico**: sólo sirve para recetas de un solo parche, y /// se sigue leyendo para no invalidar los `.swm` ya publicados. Lo que `pack` emite hoy es /// [`patches`](#variant.SourcePatch.field.patches) — ver por qué ahí. #[serde(default, skip_serializing_if = "Option::is_none")] patch: Option, /// Los parches inline **en orden y por separado**, tal como los listaba la receta. /// /// ⚠ **No es un lujo de formato: concatenarlos cambia el hash del artefacto.** /// [`Recipe::hash_inputs`](crate::Recipe::hash_inputs) mete **una entrada por parche** y /// `of_inputs` va con longitud prefijada, así que N parches unidos en uno solo hashean /// distinto que los N sueltos. Mientras `pack` los concatenaba en `patch`, el receptor /// reconstruía en otra dirección y el `expected_hash` anclado **no coincidía nunca**: /// medido sobre `zsh` (6 parches) el 2026-09-21 — anclado `b3:0be3630d…`, reproducido /// `b3:d6329a62…` tras recompilar entero. Afectaba a las 16 recetas del corpus con dos o /// más parches (waterfox 12, firefox y gnupg 11, parted/zsh/strace 6…). Es la misma /// familia que el `strip_debug` que no viajaba (SDD 28 §5.2): un campo que ENTRA en /// `hash_inputs` y no sobrevivía al viaje. /// /// Vacío ⇒ el paquete no trae parches inline (o los trae en el `patch` histórico). #[serde(default, skip_serializing_if = "Vec::is_empty")] patches: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] patch_url: Option, build: SwmBuild, target_bin: String, #[serde(default, skip_serializing_if = "Option::is_none")] expected_hash: Option, /// Dependencias del paquete por NOMBRE (las de la receta original). Las build-deps deben /// estar presentes como paquetes en el repo del receptor: `install` reconstruye con ellas /// un catálogo de recetas para que el lab las materialice en el sandbox al reproducir. /// Vacío ⇒ paquete autocontenido (el caso común, binarios estáticos sin deps). #[serde(default, skip_serializing_if = "crate::recipe::Deps::is_empty")] deps: crate::recipe::Deps, /// Evidencia de comportamiento que acompaña al paquete (H1 — proof-carrying recipes). El /// receptor puede correr `takana swm-verify --evidence` para reproducir cada check en el /// sandbox antes de aceptar la mutación. Vacío ⇒ paquete sin evidencia (sólo reproducibilidad). #[serde(default, skip_serializing_if = "crate::recipe::Evidence::is_empty")] evidence: crate::recipe::Evidence, /// Superficies del sistema que el paquete reclama/requiere (H4b — configs compatibles, SDD /// 15 §H4). El receptor las evalúa contra su estado instalado (`crate::compat`) antes de /// hidratar: un requisito que no resuelve aborta, un reclamo que pisa otra config pide /// elección. Vacío ⇒ paquete que no declara topología (no participa del gate). #[serde(default, skip_serializing_if = "crate::recipe::Slots::is_empty")] slots: crate::recipe::Slots, }, ConfigEdit { file: String, inline_diff: String, }, InitRule { action: String, service: String, command: String, }, FileDrop { path: String, /// hash BLAKE3 del contenido, declarado para verificación. content_hash: String, /// Contenido base64 inline (RFC 4648, sin saltos de línea). Si está presente, el /// receptor lo decodifica directamente; si no, debe usar `content_url`. Permite /// `.swm`s autocontenidos sin red — útil para tests, snapshots offline, e /// intercambio de pequeños binarios de datos (catálogos, listas) cuyo hash basta. #[serde(default, skip_serializing_if = "Option::is_none")] content_b64: Option, #[serde(default, skip_serializing_if = "Option::is_none")] content_url: Option, }, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SwmBuild { #[serde(default = "default_compiler")] pub compiler: String, #[serde(default = "default_target")] pub target: String, #[serde(default = "default_link")] pub link: String, #[serde(default)] pub flags: Vec, /// Overrides explícitos de las fases del build (configure/compile/install). Sin esto un /// `source_patch` sólo reproduciría builds heurísticos; con esto un paquete que viene de /// una `Recipe` con install custom (p. ej. los multicall coreutils/findutils) se rehace /// fiel. Vacío ⇒ heurística del lab, idéntico a una receta sin `[build.phases]`. #[serde(default, skip_serializing_if = "crate::recipe::Phases::is_empty")] pub phases: crate::recipe::Phases, /// Versión de zig por-paquete (escotilla a la regresión de zig 0.14; ver `Build::zig_version`). /// `None` ⇒ el zig por defecto del receptor. #[serde(default, skip_serializing_if = "Option::is_none")] pub zig_version: Option, /// Split de la info de depuración (`Build::strip_debug`, SDD 23 etapa 4). /// /// **Tiene que viajar, y que faltara era un bug de reproducibilidad medible**: `strip_debug` /// ENTRA EN `hash_inputs` (cambia el contenido del artefacto), así que un `.swm` que no lo /// llevara hacía que el receptor reconstruyera con `strip_debug` en `None` y sellara en OTRA /// dirección. El `expected_hash` anclado por `pack --build` no coincidía nunca y /// `install --require-signed` abortaba — para 40 recetas del corpus, 18 de las 88 del perfil /// `servidor`. Encontrado el 2026-09-10 instalando `takana` desde su propio repo (SDD 28 §5). #[serde(default, skip_serializing_if = "Option::is_none")] pub strip_debug: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Signature { pub by: String, pub alg: String, pub sig: String, } fn default_compiler() -> String { "zig-cc".into() } fn default_target() -> String { "x86_64-linux-musl".into() } fn default_link() -> String { "static".into() } impl Swm { pub fn from_yaml(s: &str) -> crate::Result { serde_yaml::from_str(s).map_err(|e| crate::Error::Serde(e.to_string())) } pub fn to_yaml(&self) -> crate::Result { serde_yaml::to_string(self).map_err(|e| crate::Error::Serde(e.to_string())) } /// Sanity checks **estructurales** del manifiesto. Lo que aquí se valida no depende del /// sistema local — sólo del documento. Llamarlo antes de `verify_base` evita malgastar /// pasadas sobre un `.swm` mal formado. pub fn verify_schema(&self) -> crate::Result<()> { if self.swm_version != 1 { return Err(crate::Error::Serde(format!( "swm_version no soportada: {} (soportada: 1)", self.swm_version ))); } if self.base.distro_version.is_empty() { return Err(crate::Error::Serde( "base.distro_version vacía".into(), )); } for (i, m) in self.mutations.iter().enumerate() { m.verify_schema().map_err(|e| { crate::Error::Serde(format!("mutations[{i}]: {e}")) })?; } Ok(()) } /// Compara la base del `.swm` con la del sistema local. Una pin del SWM **debe** estar /// presente en la base local con el mismo valor. Las pins extra de la base local (no /// declaradas por el SWM) no son problema — el SWM sólo declara lo que necesita. pub fn verify_base(&self, local: &BaseRef) -> BaseCompat { if self.base.distro_version != local.distro_version { return BaseCompat::DistroMismatch { local: local.distro_version.clone(), swm: self.base.distro_version.clone(), }; } let mut diffs = Vec::new(); for (name, swm_val) in &self.base.pins { match local.pins.get(name) { Some(local_val) if local_val == swm_val => {} Some(local_val) => diffs.push(PinDiff { name: name.clone(), local: Some(local_val.clone()), swm: swm_val.clone(), }), None => diffs.push(PinDiff { name: name.clone(), local: None, swm: swm_val.clone(), }), } } if diffs.is_empty() { BaseCompat::Ok } else { BaseCompat::PinMismatch(diffs) } } /// Construye un `.swm` de un único `source_patch` a partir de una [`crate::Recipe`] del /// corpus — la dirección **forward** que cierra la paquetería (Etapa F): una receta que el /// sistema YA sabe construir se vuelve un paquete distribuible y reproducible-desde-fuente. /// Es la inversa de `takana_build::swm_bridge` (que va `source_patch` → `Recipe` → build). /// /// `takana-core` no toca disco: el caller resuelve y **lee** los patches de la receta /// (relativos a `recipe.base_dir`) y pasa sus textos **en orden y uno por parche** en /// `patch_texts` — ver [`Mutation::SourcePatch::patches`] para por qué no van concatenados. El /// `expected_hash`, si se da, ancla "verificar, no confiar" (el receptor rehace y compara). pub fn from_recipe( recipe: &crate::Recipe, target_bin: impl Into, patch_texts: Vec, expected_hash: Option, distro_version: impl Into, ) -> crate::Result { use crate::recipe::SourceKind; // strip_components sólo viaja si difiere del default del lab (1) y es modo tarball. let strip_components = match recipe.source.kind()? { SourceKind::Tarball { .. } if recipe.source.strip_components != 1 => { Some(recipe.source.strip_components) } _ => None, }; let (repo, commit, tarball, sha256) = match recipe.source.kind()? { SourceKind::Git { repo, commit } => { (Some(repo.to_string()), Some(commit.to_string()), None, None) } SourceKind::Tarball { url, sha256 } => { (None, None, Some(url.to_string()), Some(sha256.to_string())) } // Un `.swm` es un manifiesto COMPARTIBLE: describe la mutación con punteros que el otro // lado pueda resolver (un commit, un sha256). El árbol de una receta derivada vive en // ESTE repo y no hay puntero que mandar, así que no se puede expresar todavía. Falla // diciéndolo en vez de emitir un manifiesto con el source vacío, que viajaría bien y // rompería del otro lado. SourceKind::Dir { dir } => { return Err(crate::Error::Recipe(format!( "`{}` usa source.dir ({dir}) y una receta derivada no se puede exportar a .swm: el manifiesto necesita un puntero resoluble desde fuera (commit o sha256)", recipe.name ))) } }; let build = SwmBuild { compiler: recipe.build.compiler.as_str().to_string(), target: recipe.build.target.clone(), link: recipe.build.link.as_str().to_string(), flags: recipe.build.flags.clone(), phases: recipe.build.phases.clone(), zig_version: recipe.build.zig_version.clone(), strip_debug: recipe.build.strip_debug, }; let swm = Swm { swm_version: 1, base: Base { distro_version: distro_version.into(), pins: BTreeMap::new(), }, mutations: vec![Mutation::SourcePatch { repo, commit, tarball, sha256, strip_components, patch: None, patches: patch_texts, patch_url: None, build, target_bin: target_bin.into(), expected_hash, deps: recipe.deps.clone(), evidence: recipe.evidence.clone(), slots: recipe.slots.clone(), }], signature: None, }; swm.verify_schema()?; Ok(swm) } } /// Resuelve el modo de origen de un `source_patch` (git **xor** tarball) desde sus campos /// opcionales, con la misma regla que [`crate::recipe::Source::kind`]. Centraliza la /// validación para que `verify_schema`, el lab y el export compartan una sola fuente de verdad. pub fn swm_source_kind<'a>( repo: Option<&'a str>, commit: Option<&'a str>, tarball: Option<&'a str>, sha256: Option<&'a str>, ) -> Result, String> { use crate::recipe::SourceKind; match (repo, commit, tarball, sha256) { (Some(repo), Some(commit), None, None) => Ok(SourceKind::Git { repo, commit }), (None, None, Some(url), Some(sha256)) => Ok(SourceKind::Tarball { url, sha256 }), (Some(_), Some(_), Some(_), _) | (Some(_), Some(_), _, Some(_)) => { Err("source_patch: usa repo+commit O tarball+sha256, no ambos".into()) } _ => Err("source_patch: faltan campos; necesito (repo+commit) o (tarball+sha256)".into()), } } impl Mutation { /// Sanity por mutación. Cada `type` tiene precondiciones distintas; la validación cruzada /// (p. ej. "el `target_bin` apunta a un dir gestionado") la decide quien aplica, no el /// schema. pub fn verify_schema(&self) -> Result<(), String> { use crate::recipe::SourceKind; match self { Mutation::SourcePatch { repo, commit, tarball, sha256, patch, patch_url, target_bin, evidence, .. } => { match swm_source_kind( repo.as_deref(), commit.as_deref(), tarball.as_deref(), sha256.as_deref(), )? { SourceKind::Git { repo, commit } => { if repo.is_empty() { return Err("source_patch: 'repo' vacío".into()); } if commit.is_empty() { return Err("source_patch: 'commit' vacío".into()); } } SourceKind::Tarball { url, sha256 } => { if url.is_empty() { return Err("source_patch: 'tarball' vacío".into()); } if sha256.is_empty() { return Err("source_patch: 'sha256' vacío".into()); } } // `swm_source_kind` sólo construye Git o Tarball a partir de los campos del // manifiesto: no hay forma de que un `.swm` traiga un `dir`. El brazo existe // para que el compilador siga vigilando este match si mañana lo trae. SourceKind::Dir { .. } => { return Err( "source_patch: un .swm no puede llevar un source por directorio".into(), ); } } if patch.is_some() && patch_url.is_some() { return Err( "source_patch: usa 'patch' (inline) O 'patch_url', no ambos".into(), ); } if !target_bin.starts_with('/') { return Err(format!( "source_patch: target_bin debe ser ruta absoluta, no '{target_bin}'" )); } // La evidencia (H1) viaja con el paquete: validamos su FORMA acá (cmd no vacío, // hash bien formado). La EJECUCIÓN la hace `takana swm-verify --evidence` (H1b). evidence.validate().map_err(|e| e.to_string())?; Ok(()) } Mutation::ConfigEdit { file, inline_diff } => { if !file.starts_with('/') { return Err(format!("config_edit: file debe ser absoluto, no '{file}'")); } if inline_diff.is_empty() { return Err("config_edit: inline_diff vacío".into()); } Ok(()) } Mutation::InitRule { action, service, command } => { if action.is_empty() || service.is_empty() || command.is_empty() { return Err("init_rule: action/service/command no pueden estar vacíos".into()); } Ok(()) } Mutation::FileDrop { path, content_hash, content_b64, content_url } => { if !path.starts_with('/') { return Err(format!("file_drop: path debe ser absoluto, no '{path}'")); } if content_hash.is_empty() { return Err("file_drop: content_hash vacío".into()); } if content_b64.is_none() && content_url.is_none() { return Err( "file_drop: hace falta 'content_b64' (inline) o 'content_url'".into(), ); } if content_b64.is_some() && content_url.is_some() { return Err( "file_drop: usa 'content_b64' O 'content_url', no ambos".into(), ); } Ok(()) } } } } #[cfg(test)] mod tests { use super::*; #[test] fn roundtrip_yaml() { let yaml = r#" swm_version: 1 base: distro_version: "2026-06-06" pins: grep: "a1b2c3d" mutations: - type: config_edit file: "/etc/network.conf" inline_diff: | - DHCP=yes + IP=192.168.1.100 "#; let swm = Swm::from_yaml(yaml).expect("parse"); assert_eq!(swm.swm_version, 1); assert_eq!(swm.mutations.len(), 1); let back = swm.to_yaml().expect("serialize"); assert!(back.contains("config_edit")); } fn base_v(distro: &str, pins: &[(&str, &str)]) -> Base { Base { distro_version: distro.into(), pins: pins.iter().map(|(k, v)| ((*k).into(), (*v).into())).collect(), } } fn baseref_v(distro: &str, pins: &[(&str, &str)]) -> BaseRef { BaseRef { distro_version: distro.into(), pins: pins.iter().map(|(k, v)| ((*k).into(), (*v).into())).collect(), } } #[test] fn verify_base_happy() { let swm = Swm { swm_version: 1, base: base_v("2026-06-06", &[("grep", "a1b2")]), mutations: vec![], signature: None, }; // Pins extra en local están bien. let local = baseref_v("2026-06-06", &[("grep", "a1b2"), ("musl", "deadbeef")]); assert_eq!(swm.verify_base(&local), BaseCompat::Ok); } #[test] fn verify_base_distro_mismatch() { let swm = Swm { swm_version: 1, base: base_v("2026-06-06", &[]), mutations: vec![], signature: None, }; let local = baseref_v("2026-06-07", &[]); assert_eq!( swm.verify_base(&local), BaseCompat::DistroMismatch { local: "2026-06-07".into(), swm: "2026-06-06".into(), } ); } #[test] fn verify_base_pin_mismatch_and_missing() { let swm = Swm { swm_version: 1, base: base_v("2026-06-06", &[("grep", "a1b2"), ("musl", "FFFF")]), mutations: vec![], signature: None, }; let local = baseref_v("2026-06-06", &[("grep", "OTRO")]); // musl no existe let got = swm.verify_base(&local); match got { BaseCompat::PinMismatch(diffs) => { assert_eq!(diffs.len(), 2); let grep = diffs.iter().find(|d| d.name == "grep").unwrap(); assert_eq!(grep.local.as_deref(), Some("OTRO")); assert_eq!(grep.swm, "a1b2"); let musl = diffs.iter().find(|d| d.name == "musl").unwrap(); assert!(musl.local.is_none()); } other => panic!("esperaba PinMismatch, obtuve {other:?}"), } } #[test] fn verify_schema_rejects_bad_version() { let swm = Swm { swm_version: 9, base: base_v("x", &[]), mutations: vec![], signature: None, }; let err = swm.verify_schema().unwrap_err().to_string(); assert!(err.contains("swm_version"), "{err}"); } #[test] fn verify_schema_rejects_relative_path() { let swm = Swm { swm_version: 1, base: base_v("x", &[]), mutations: vec![Mutation::ConfigEdit { file: "etc/network.conf".into(), inline_diff: "- a\n+ b\n".into(), }], signature: None, }; let err = swm.verify_schema().unwrap_err().to_string(); assert!(err.contains("absoluto"), "{err}"); } #[test] fn verify_schema_filedrop_needs_content_source() { let swm = Swm { swm_version: 1, base: base_v("x", &[]), mutations: vec![Mutation::FileDrop { path: "/var/lib/x".into(), content_hash: "b3:xxx".into(), content_b64: None, content_url: None, }], signature: None, }; let err = swm.verify_schema().unwrap_err().to_string(); assert!(err.contains("content_b64") || err.contains("content_url"), "{err}"); } #[test] fn verify_schema_filedrop_rejects_both_inline_and_url() { let swm = Swm { swm_version: 1, base: base_v("x", &[]), mutations: vec![Mutation::FileDrop { path: "/var/lib/x".into(), content_hash: "b3:xxx".into(), content_b64: Some("AA==".into()), content_url: Some("https://x".into()), }], signature: None, }; assert!(swm.verify_schema().is_err()); } #[test] fn verify_schema_source_patch_basic() { let swm = Swm { swm_version: 1, base: base_v("x", &[]), mutations: vec![Mutation::SourcePatch { repo: Some("git://x".into()), commit: Some("abc".into()), tarball: None, sha256: None, strip_components: None, patch: None, patches: Vec::new(), patch_url: None, build: SwmBuild { compiler: "zig-cc".into(), target: "x86_64-linux-musl".into(), link: "static".into(), flags: vec![], phases: Default::default(), zig_version: None, strip_debug: None, }, target_bin: "/bin/x".into(), expected_hash: None, deps: Default::default(), evidence: Default::default(), slots: Default::default(), }], signature: None, }; swm.verify_schema().unwrap(); } #[test] fn verify_schema_source_patch_tarball() { let swm = Swm { swm_version: 1, base: base_v("x", &[]), mutations: vec![Mutation::SourcePatch { repo: None, commit: None, tarball: Some("https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz".into()), sha256: Some("deadbeef".into()), strip_components: None, patch: None, patches: Vec::new(), patch_url: None, build: SwmBuild { compiler: "zig-cc".into(), target: "x86_64-linux-musl".into(), link: "static".into(), flags: vec![], phases: Default::default(), zig_version: None, strip_debug: None, }, target_bin: "/bin/grep".into(), expected_hash: None, deps: Default::default(), evidence: Default::default(), slots: Default::default(), }], signature: None, }; swm.verify_schema().unwrap(); } #[test] fn verify_schema_source_patch_rechaza_git_y_tarball_mezclados() { let m = Mutation::SourcePatch { repo: Some("git://x".into()), commit: Some("abc".into()), tarball: Some("https://x/a.tar.gz".into()), sha256: Some("deadbeef".into()), strip_components: None, patch: None, patches: Vec::new(), patch_url: None, build: SwmBuild { compiler: "zig-cc".into(), target: "x86_64-linux-musl".into(), link: "static".into(), flags: vec![], phases: Default::default(), zig_version: None, strip_debug: None, }, target_bin: "/bin/x".into(), expected_hash: None, deps: Default::default(), evidence: Default::default(), slots: Default::default(), }; let err = m.verify_schema().unwrap_err(); assert!(err.contains("no ambos"), "mensaje inesperado: {err}"); } #[test] fn from_recipe_git_preserva_build_phases_y_patch() { // Receta git con install custom, flags, zig_version → el paquete debe reproducir todo. let toml = r#" name = "ripgrep" version = "14.1.1" [source] repo = "https://github.com/BurntSushi/ripgrep" commit = "4649aa9700619f94cf9c66876e9549d83420e16c" [build] compiler = "zig-cc" target = "x86_64-linux-musl" link = "static" flags = ["--bin", "rg"] [build.phases] install = "cp target/release/rg /out/usr/bin/rg" "#; let recipe = crate::Recipe::from_toml(toml).unwrap(); let swm = Swm::from_recipe( &recipe, "/usr/bin/rg", vec!["PATCH-TEXT".to_string()], Some("b3:cafe".into()), "2026-06-21", ) .unwrap(); assert_eq!(swm.base.distro_version, "2026-06-21"); match &swm.mutations[0] { Mutation::SourcePatch { repo, commit, tarball, patch, build, target_bin, expected_hash, strip_components, .. } => { assert_eq!(repo.as_deref(), Some("https://github.com/BurntSushi/ripgrep")); assert_eq!(commit.as_deref(), Some("4649aa9700619f94cf9c66876e9549d83420e16c")); assert!(tarball.is_none()); assert_eq!(patch.as_deref(), Some("PATCH-TEXT")); assert_eq!(target_bin, "/usr/bin/rg"); assert_eq!(expected_hash.as_deref(), Some("b3:cafe")); assert!(strip_components.is_none(), "git no lleva strip_components"); assert_eq!(build.compiler, "zig-cc"); assert_eq!(build.link, "static"); assert_eq!(build.flags, vec!["--bin".to_string(), "rg".to_string()]); assert_eq!(build.phases.install.as_deref(), Some("cp target/release/rg /out/usr/bin/rg")); } other => panic!("esperaba SourcePatch, obtuve {other:?}"), } } #[test] fn from_recipe_tarball_lleva_zig_version_y_strip() { let toml = r#" name = "openssl" version = "3.5.4" [source] tarball = "https://example/openssl-3.5.4.tar.gz" sha256 = "deadbeef" strip_components = 2 [build] compiler = "zig-cc" zig_version = "0.13.0" "#; let recipe = crate::Recipe::from_toml(toml).unwrap(); let swm = Swm::from_recipe(&recipe, "/usr/bin/openssl", Vec::new(), None, "dev").unwrap(); match &swm.mutations[0] { Mutation::SourcePatch { tarball, sha256, strip_components, build, expected_hash, .. } => { assert_eq!(tarball.as_deref(), Some("https://example/openssl-3.5.4.tar.gz")); assert_eq!(sha256.as_deref(), Some("deadbeef")); assert_eq!(*strip_components, Some(2), "strip != 1 debe viajar"); assert_eq!(build.zig_version.as_deref(), Some("0.13.0")); assert!(expected_hash.is_none()); } other => panic!("esperaba SourcePatch, obtuve {other:?}"), } } /// `strip_debug` ENTRA EN `hash_inputs`, así que un `.swm` que no lo llevara hacía que el /// receptor sellara en OTRA dirección y `install --require-signed` abortara siempre. Medido el /// 2026-09-10 instalando `takana` desde su propio repo: declarado `b3:941d1857…`, obtenido /// `b3:2727050e…`. Afectaba a 40 recetas del corpus. Este test es el que faltaba. #[test] fn from_recipe_lleva_strip_debug_porque_es_entrada_de_hash() { let con = crate::Recipe::from_toml( "name = \"takana\"\nversion = \"0.0.1\"\n[source]\nrepo = \"ssh://x/y.git\"\ncommit = \"abc\"\n[build]\nstrip_debug = true\n", ) .unwrap(); let swm = Swm::from_recipe(&con, "/usr/bin/takana", Vec::new(), None, "dev").unwrap(); match &swm.mutations[0] { Mutation::SourcePatch { build, .. } => { assert_eq!(build.strip_debug, Some(true), "strip_debug tiene que viajar en el .swm"); } other => panic!("esperaba SourcePatch, obtuve {other:?}"), } // Y el control que hace que el test valga: sin el campo, no se inventa un `false`. let sin = crate::Recipe::from_toml( "name = \"tree\"\nversion = \"2.3.2\"\n[source]\nrepo = \"ssh://x/y.git\"\ncommit = \"abc\"\n[build]\n", ) .unwrap(); let swm2 = Swm::from_recipe(&sin, "/usr/bin/tree", Vec::new(), None, "dev").unwrap(); match &swm2.mutations[0] { Mutation::SourcePatch { build, .. } => { assert_eq!(build.strip_debug, None, "una receta sin strip_debug no debe ganar uno"); } other => panic!("esperaba SourcePatch, obtuve {other:?}"), } } #[test] fn verify_schema_source_patch_rechaza_sin_origen() { let m = Mutation::SourcePatch { repo: None, commit: None, tarball: None, sha256: None, strip_components: None, patch: None, patches: Vec::new(), patch_url: None, build: SwmBuild { compiler: "zig-cc".into(), target: "x86_64-linux-musl".into(), link: "static".into(), flags: vec![], phases: Default::default(), zig_version: None, strip_debug: None, }, target_bin: "/bin/x".into(), expected_hash: None, deps: Default::default(), evidence: Default::default(), slots: Default::default(), }; let err = m.verify_schema().unwrap_err(); assert!(err.contains("faltan campos"), "mensaje inesperado: {err}"); } #[test] fn from_recipe_carries_evidence() { // H1a: la evidencia de la receta viaja al .swm (source_patch), roundtripea por YAML, // y verify_schema la valida. let toml = r#" name = "grep" version = "3.11" [source] tarball = "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz" sha256 = "deadbeef" [build] [[evidence.checks]] kind = "cmd-exit" cmd = "grep --version" [[evidence.checks]] kind = "proptest" cmd = "cd /src && cargo test proptest_" expected_exit = 0 "#; let recipe = crate::Recipe::from_toml(toml).unwrap(); let swm = Swm::from_recipe(&recipe, "/usr/bin/grep", Vec::new(), None, "dev").unwrap(); // La evidencia llegó a la mutación. match &swm.mutations[0] { Mutation::SourcePatch { evidence, .. } => { assert_eq!(evidence.checks.len(), 2); assert_eq!(evidence.checks[0].kind, crate::recipe::EvidenceKind::CmdExit); assert_eq!(evidence.checks[1].kind, crate::recipe::EvidenceKind::Proptest); } other => panic!("esperaba SourcePatch, obtuve {other:?}"), } // Roundtrip YAML: el bloque evidence sobrevive. let yaml = swm.to_yaml().unwrap(); assert!(yaml.contains("evidence"), "el YAML debe traer evidence: {yaml}"); let back = Swm::from_yaml(&yaml).unwrap(); match &back.mutations[0] { Mutation::SourcePatch { evidence, .. } => assert_eq!(evidence.checks.len(), 2), other => panic!("esperaba SourcePatch, obtuve {other:?}"), } back.verify_schema().unwrap(); } #[test] fn verify_schema_rejects_evidence_with_empty_cmd() { let yaml = r#" swm_version: 1 base: distro_version: "dev" mutations: - type: source_patch tarball: "https://x/a.tar.gz" sha256: "deadbeef" build: {} target_bin: "/usr/bin/x" evidence: checks: - kind: cmd-exit cmd: " " "#; let swm = Swm::from_yaml(yaml).unwrap(); let err = swm.verify_schema().unwrap_err().to_string(); assert!(err.contains("vacío"), "mensaje inesperado: {err}"); } #[test] fn source_patch_without_evidence_omits_block() { // skip_serializing_if ⇒ un paquete sin evidencia no emite el bloque (compat hacia atrás). let toml = r#" name = "x" version = "0" [source] repo = "git://x" commit = "deadbeef" [build] "#; let recipe = crate::Recipe::from_toml(toml).unwrap(); let swm = Swm::from_recipe(&recipe, "/usr/bin/x", Vec::new(), None, "dev").unwrap(); let yaml = swm.to_yaml().unwrap(); assert!(!yaml.contains("evidence"), "no debería emitir evidence: {yaml}"); } }