gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1574Shell 204Rust 111Markdown 993763 Definiciones

⎇ 54fa4a941c1650de708229862bfd9a748d073663 crates/hammer-core/src/swm.rs Rust · 865 líneas · 33.0 KB Historia Quién tocó qué crudo

hammer: `source.dir` — el modo de fuente que faltaba para las recetas DERIVADAS 54fa4a941 · Sergio ·

1//! El manifiesto `.swm` (Software Mutación). Ver `docs/06-swm-format.md`.
2//!
3//! Es la unidad de intercambio: receta de transformación sobre fuente pública + ediciones de
4//! config. NUNCA transporta binarios cocidos (salvo `FileDrop` con hash declarado y, opcional,
5//! contenido inline base64 para `.swm`s autocontenidos en tests/escenarios offline).
6
7use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Swm {
13    pub swm_version: u32,
14    pub base: Base,
15    pub mutations: Vec<Mutation>,
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub signature: Option<Signature>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct Base {
22    pub distro_version: String,
23    #[serde(default)]
24    pub pins: BTreeMap<String, String>,
25}
26
27/// Referencia a la base **local** — lo que el sistema receptor tiene cocido. Se compara
28/// contra `Swm::base` en `verify_base`. La construye el receptor leyendo, p. ej.,
29/// `/etc/hammer/base.json`; en tests la fabricamos a mano.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct BaseRef {
32    pub distro_version: String,
33    #[serde(default)]
34    pub pins: BTreeMap<String, String>,
35}
36
37impl From<&Base> for BaseRef {
38    fn from(b: &Base) -> Self {
39        BaseRef {
40            distro_version: b.distro_version.clone(),
41            pins: b.pins.clone(),
42        }
43    }
44}
45
46/// Resultado de `verify_base`. `Ok` ⇒ las pins del SWM coinciden con las locales (al menos
47/// para las llaves que el SWM declara) y la `distro_version` es idéntica. Cualquier
48/// discrepancia se materializa como variante con detalle: `apply` puede decidir abortar,
49/// el humano puede leer qué difiere.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum BaseCompat {
52    /// La base local satisface todas las pins declaradas por el SWM.
53    Ok,
54    /// La `distro_version` no coincide.
55    DistroMismatch { local: String, swm: String },
56    /// Una o más pins difieren entre la base local y el SWM. Cada entrada es
57    /// `(pin_name, local_value, swm_value)`; un `local_value` ausente significa que la
58    /// llave no existe en la base local.
59    PinMismatch(Vec<PinDiff>),
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct PinDiff {
64    pub name: String,
65    pub local: Option<String>,
66    pub swm: String,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(tag = "type", rename_all = "snake_case")]
71pub enum Mutation {
72    SourcePatch {
73        // Origen git (modo histórico) **o** tarball — exactamente uno, igual que
74        // [`crate::recipe::Source`]. Antes sólo se modelaba git y los tarballs caían a
75        // `file_drop`; ahora `hammer export` los reconstruye como source_patch. Los campos
76        // son `Option` (con `serde(default)`) para que los `.swm` git existentes —que sólo
77        // traen `repo`+`commit`— sigan parseando sin cambios.
78        #[serde(default, skip_serializing_if = "Option::is_none")]
79        repo: Option<String>,
80        #[serde(default, skip_serializing_if = "Option::is_none")]
81        commit: Option<String>,
82        #[serde(default, skip_serializing_if = "Option::is_none")]
83        tarball: Option<String>,
84        #[serde(default, skip_serializing_if = "Option::is_none")]
85        sha256: Option<String>,
86        /// Componentes a recortar al extraer un tarball (modo tarball). `None` ⇒ el default
87        /// del lab (1, un único top-level `proyecto-version/`). Sólo se emite si difiere.
88        #[serde(default, skip_serializing_if = "Option::is_none")]
89        strip_components: Option<usize>,
90        #[serde(default, skip_serializing_if = "Option::is_none")]
91        patch: Option<String>,
92        #[serde(default, skip_serializing_if = "Option::is_none")]
93        patch_url: Option<String>,
94        build: SwmBuild,
95        target_bin: String,
96        #[serde(default, skip_serializing_if = "Option::is_none")]
97        expected_hash: Option<String>,
98        /// Dependencias del paquete por NOMBRE (las de la receta original). Las build-deps deben
99        /// estar presentes como paquetes en el repo del receptor: `install` reconstruye con ellas
100        /// un catálogo de recetas para que el lab las materialice en el sandbox al reproducir.
101        /// Vacío ⇒ paquete autocontenido (el caso común, binarios estáticos sin deps).
102        #[serde(default, skip_serializing_if = "crate::recipe::Deps::is_empty")]
103        deps: crate::recipe::Deps,
104        /// Evidencia de comportamiento que acompaña al paquete (H1 — proof-carrying recipes). El
105        /// receptor puede correr `hammer swm-verify --evidence` para reproducir cada check en el
106        /// sandbox antes de aceptar la mutación. Vacío ⇒ paquete sin evidencia (sólo reproducibilidad).
107        #[serde(default, skip_serializing_if = "crate::recipe::Evidence::is_empty")]
108        evidence: crate::recipe::Evidence,
109        /// Superficies del sistema que el paquete reclama/requiere (H4b — configs compatibles, SDD
110        /// 15 §H4). El receptor las evalúa contra su estado instalado (`crate::compat`) antes de
111        /// hidratar: un requisito que no resuelve aborta, un reclamo que pisa otra config pide
112        /// elección. Vacío ⇒ paquete que no declara topología (no participa del gate).
113        #[serde(default, skip_serializing_if = "crate::recipe::Slots::is_empty")]
114        slots: crate::recipe::Slots,
115    },
116    ConfigEdit {
117        file: String,
118        inline_diff: String,
119    },
120    InitRule {
121        action: String,
122        service: String,
123        command: String,
124    },
125    FileDrop {
126        path: String,
127        /// hash BLAKE3 del contenido, declarado para verificación.
128        content_hash: String,
129        /// Contenido base64 inline (RFC 4648, sin saltos de línea). Si está presente, el
130        /// receptor lo decodifica directamente; si no, debe usar `content_url`. Permite
131        /// `.swm`s autocontenidos sin red — útil para tests, snapshots offline, e
132        /// intercambio de pequeños binarios de datos (catálogos, listas) cuyo hash basta.
133        #[serde(default, skip_serializing_if = "Option::is_none")]
134        content_b64: Option<String>,
135        #[serde(default, skip_serializing_if = "Option::is_none")]
136        content_url: Option<String>,
137    },
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct SwmBuild {
142    #[serde(default = "default_compiler")]
143    pub compiler: String,
144    #[serde(default = "default_target")]
145    pub target: String,
146    #[serde(default = "default_link")]
147    pub link: String,
148    #[serde(default)]
149    pub flags: Vec<String>,
150    /// Overrides explícitos de las fases del build (configure/compile/install). Sin esto un
151    /// `source_patch` sólo reproduciría builds heurísticos; con esto un paquete que viene de
152    /// una `Recipe` con install custom (p. ej. los multicall coreutils/findutils) se rehace
153    /// fiel. Vacío ⇒ heurística del lab, idéntico a una receta sin `[build.phases]`.
154    #[serde(default, skip_serializing_if = "crate::recipe::Phases::is_empty")]
155    pub phases: crate::recipe::Phases,
156    /// Versión de zig por-paquete (escotilla a la regresión de zig 0.14; ver `Build::zig_version`).
157    /// `None` ⇒ el zig por defecto del receptor.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub zig_version: Option<String>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct Signature {
164    pub by: String,
165    pub alg: String,
166    pub sig: String,
167}
168
169fn default_compiler() -> String {
170    "zig-cc".into()
171}
172fn default_target() -> String {
173    "x86_64-linux-musl".into()
174}
175fn default_link() -> String {
176    "static".into()
177}
178
179impl Swm {
180    pub fn from_yaml(s: &str) -> crate::Result<Swm> {
181        serde_yaml::from_str(s).map_err(|e| crate::Error::Serde(e.to_string()))
182    }
183
184    pub fn to_yaml(&self) -> crate::Result<String> {
185        serde_yaml::to_string(self).map_err(|e| crate::Error::Serde(e.to_string()))
186    }
187
188    /// Sanity checks **estructurales** del manifiesto. Lo que aquí se valida no depende del
189    /// sistema local — sólo del documento. Llamarlo antes de `verify_base` evita malgastar
190    /// pasadas sobre un `.swm` mal formado.
191    pub fn verify_schema(&self) -> crate::Result<()> {
192        if self.swm_version != 1 {
193            return Err(crate::Error::Serde(format!(
194                "swm_version no soportada: {} (soportada: 1)",
195                self.swm_version
196            )));
197        }
198        if self.base.distro_version.is_empty() {
199            return Err(crate::Error::Serde(
200                "base.distro_version vacía".into(),
201            ));
202        }
203        for (i, m) in self.mutations.iter().enumerate() {
204            m.verify_schema().map_err(|e| {
205                crate::Error::Serde(format!("mutations[{i}]: {e}"))
206            })?;
207        }
208        Ok(())
209    }
210
211    /// Compara la base del `.swm` con la del sistema local. Una pin del SWM **debe** estar
212    /// presente en la base local con el mismo valor. Las pins extra de la base local (no
213    /// declaradas por el SWM) no son problema — el SWM sólo declara lo que necesita.
214    pub fn verify_base(&self, local: &BaseRef) -> BaseCompat {
215        if self.base.distro_version != local.distro_version {
216            return BaseCompat::DistroMismatch {
217                local: local.distro_version.clone(),
218                swm: self.base.distro_version.clone(),
219            };
220        }
221        let mut diffs = Vec::new();
222        for (name, swm_val) in &self.base.pins {
223            match local.pins.get(name) {
224                Some(local_val) if local_val == swm_val => {}
225                Some(local_val) => diffs.push(PinDiff {
226                    name: name.clone(),
227                    local: Some(local_val.clone()),
228                    swm: swm_val.clone(),
229                }),
230                None => diffs.push(PinDiff {
231                    name: name.clone(),
232                    local: None,
233                    swm: swm_val.clone(),
234                }),
235            }
236        }
237        if diffs.is_empty() {
238            BaseCompat::Ok
239        } else {
240            BaseCompat::PinMismatch(diffs)
241        }
242    }
243
244    /// Construye un `.swm` de un único `source_patch` a partir de una [`crate::Recipe`] del
245    /// corpus — la dirección **forward** que cierra la paquetería (Etapa F): una receta que el
246    /// sistema YA sabe construir se vuelve un paquete distribuible y reproducible-desde-fuente.
247    /// Es la inversa de `hammer_build::swm_bridge` (que va `source_patch` → `Recipe` → build).
248    ///
249    /// `hammer-core` no toca disco: el caller resuelve y **lee** los patches de la receta
250    /// (relativos a `recipe.base_dir`) y pasa su texto ya concatenado en `patch_text`. El
251    /// `expected_hash`, si se da, ancla "verificar, no confiar" (el receptor rehace y compara).
252    pub fn from_recipe(
253        recipe: &crate::Recipe,
254        target_bin: impl Into<String>,
255        patch_text: Option<String>,
256        expected_hash: Option<String>,
257        distro_version: impl Into<String>,
258    ) -> crate::Result<Swm> {
259        use crate::recipe::SourceKind;
260
261        // strip_components sólo viaja si difiere del default del lab (1) y es modo tarball.
262        let strip_components = match recipe.source.kind()? {
263            SourceKind::Tarball { .. } if recipe.source.strip_components != 1 => {
264                Some(recipe.source.strip_components)
265            }
266            _ => None,
267        };
268        let (repo, commit, tarball, sha256) = match recipe.source.kind()? {
269            SourceKind::Git { repo, commit } => {
270                (Some(repo.to_string()), Some(commit.to_string()), None, None)
271            }
272            SourceKind::Tarball { url, sha256 } => {
273                (None, None, Some(url.to_string()), Some(sha256.to_string()))
274            }
275            // Un `.swm` es un manifiesto COMPARTIBLE: describe la mutación con punteros que el otro
276            // lado pueda resolver (un commit, un sha256). El árbol de una receta derivada vive en
277            // ESTE repo y no hay puntero que mandar, así que no se puede expresar todavía. Falla
278            // diciéndolo en vez de emitir un manifiesto con el source vacío, que viajaría bien y
279            // rompería del otro lado.
280            SourceKind::Dir { dir } => {
281                return Err(crate::Error::Recipe(format!(
282                    "`{}` 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)",
283                    recipe.name
284                )))
285            }
286        };
287
288        let build = SwmBuild {
289            compiler: recipe.build.compiler.as_str().to_string(),
290            target: recipe.build.target.clone(),
291            link: recipe.build.link.as_str().to_string(),
292            flags: recipe.build.flags.clone(),
293            phases: recipe.build.phases.clone(),
294            zig_version: recipe.build.zig_version.clone(),
295        };
296
297        let swm = Swm {
298            swm_version: 1,
299            base: Base {
300                distro_version: distro_version.into(),
301                pins: BTreeMap::new(),
302            },
303            mutations: vec![Mutation::SourcePatch {
304                repo,
305                commit,
306                tarball,
307                sha256,
308                strip_components,
309                patch: patch_text,
310                patch_url: None,
311                build,
312                target_bin: target_bin.into(),
313                expected_hash,
314                deps: recipe.deps.clone(),
315                evidence: recipe.evidence.clone(),
316                slots: recipe.slots.clone(),
317            }],
318            signature: None,
319        };
320        swm.verify_schema()?;
321        Ok(swm)
322    }
323}
324
325/// Resuelve el modo de origen de un `source_patch` (git **xor** tarball) desde sus campos
326/// opcionales, con la misma regla que [`crate::recipe::Source::kind`]. Centraliza la
327/// validación para que `verify_schema`, el lab y el export compartan una sola fuente de verdad.
328pub fn swm_source_kind<'a>(
329    repo: Option<&'a str>,
330    commit: Option<&'a str>,
331    tarball: Option<&'a str>,
332    sha256: Option<&'a str>,
333) -> Result<crate::recipe::SourceKind<'a>, String> {
334    use crate::recipe::SourceKind;
335    match (repo, commit, tarball, sha256) {
336        (Some(repo), Some(commit), None, None) => Ok(SourceKind::Git { repo, commit }),
337        (None, None, Some(url), Some(sha256)) => Ok(SourceKind::Tarball { url, sha256 }),
338        (Some(_), Some(_), Some(_), _) | (Some(_), Some(_), _, Some(_)) => {
339            Err("source_patch: usa repo+commit O tarball+sha256, no ambos".into())
340        }
341        _ => Err("source_patch: faltan campos; necesito (repo+commit) o (tarball+sha256)".into()),
342    }
343}
344
345impl Mutation {
346    /// Sanity por mutación. Cada `type` tiene precondiciones distintas; la validación cruzada
347    /// (p. ej. "el `target_bin` apunta a un dir gestionado") la decide quien aplica, no el
348    /// schema.
349    pub fn verify_schema(&self) -> Result<(), String> {
350        use crate::recipe::SourceKind;
351        match self {
352            Mutation::SourcePatch {
353                repo, commit, tarball, sha256, patch, patch_url, target_bin, evidence, ..
354            } => {
355                match swm_source_kind(
356                    repo.as_deref(),
357                    commit.as_deref(),
358                    tarball.as_deref(),
359                    sha256.as_deref(),
360                )? {
361                    SourceKind::Git { repo, commit } => {
362                        if repo.is_empty() {
363                            return Err("source_patch: 'repo' vacío".into());
364                        }
365                        if commit.is_empty() {
366                            return Err("source_patch: 'commit' vacío".into());
367                        }
368                    }
369                    SourceKind::Tarball { url, sha256 } => {
370                        if url.is_empty() {
371                            return Err("source_patch: 'tarball' vacío".into());
372                        }
373                        if sha256.is_empty() {
374                            return Err("source_patch: 'sha256' vacío".into());
375                        }
376                    }
377                    // `swm_source_kind` sólo construye Git o Tarball a partir de los campos del
378                    // manifiesto: no hay forma de que un `.swm` traiga un `dir`. El brazo existe
379                    // para que el compilador siga vigilando este match si mañana lo trae.
380                    SourceKind::Dir { .. } => {
381                        return Err(
382                            "source_patch: un .swm no puede llevar un source por directorio".into(),
383                        );
384                    }
385                }
386                if patch.is_some() && patch_url.is_some() {
387                    return Err(
388                        "source_patch: usa 'patch' (inline) O 'patch_url', no ambos".into(),
389                    );
390                }
391                if !target_bin.starts_with('/') {
392                    return Err(format!(
393                        "source_patch: target_bin debe ser ruta absoluta, no '{target_bin}'"
394                    ));
395                }
396                // La evidencia (H1) viaja con el paquete: validamos su FORMA acá (cmd no vacío,
397                // hash bien formado). La EJECUCIÓN la hace `hammer swm-verify --evidence` (H1b).
398                evidence.validate().map_err(|e| e.to_string())?;
399                Ok(())
400            }
401            Mutation::ConfigEdit { file, inline_diff } => {
402                if !file.starts_with('/') {
403                    return Err(format!("config_edit: file debe ser absoluto, no '{file}'"));
404                }
405                if inline_diff.is_empty() {
406                    return Err("config_edit: inline_diff vacío".into());
407                }
408                Ok(())
409            }
410            Mutation::InitRule { action, service, command } => {
411                if action.is_empty() || service.is_empty() || command.is_empty() {
412                    return Err("init_rule: action/service/command no pueden estar vacíos".into());
413                }
414                Ok(())
415            }
416            Mutation::FileDrop { path, content_hash, content_b64, content_url } => {
417                if !path.starts_with('/') {
418                    return Err(format!("file_drop: path debe ser absoluto, no '{path}'"));
419                }
420                if content_hash.is_empty() {
421                    return Err("file_drop: content_hash vacío".into());
422                }
423                if content_b64.is_none() && content_url.is_none() {
424                    return Err(
425                        "file_drop: hace falta 'content_b64' (inline) o 'content_url'".into(),
426                    );
427                }
428                if content_b64.is_some() && content_url.is_some() {
429                    return Err(
430                        "file_drop: usa 'content_b64' O 'content_url', no ambos".into(),
431                    );
432                }
433                Ok(())
434            }
435        }
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn roundtrip_yaml() {
445        let yaml = r#"
446swm_version: 1
447base:
448  distro_version: "2026-06-06"
449  pins:
450    grep: "a1b2c3d"
451mutations:
452  - type: config_edit
453    file: "/etc/network.conf"
454    inline_diff: |
455      - DHCP=yes
456      + IP=192.168.1.100
457"#;
458        let swm = Swm::from_yaml(yaml).expect("parse");
459        assert_eq!(swm.swm_version, 1);
460        assert_eq!(swm.mutations.len(), 1);
461        let back = swm.to_yaml().expect("serialize");
462        assert!(back.contains("config_edit"));
463    }
464
465    fn base_v(distro: &str, pins: &[(&str, &str)]) -> Base {
466        Base {
467            distro_version: distro.into(),
468            pins: pins.iter().map(|(k, v)| ((*k).into(), (*v).into())).collect(),
469        }
470    }
471    fn baseref_v(distro: &str, pins: &[(&str, &str)]) -> BaseRef {
472        BaseRef {
473            distro_version: distro.into(),
474            pins: pins.iter().map(|(k, v)| ((*k).into(), (*v).into())).collect(),
475        }
476    }
477
478    #[test]
479    fn verify_base_happy() {
480        let swm = Swm {
481            swm_version: 1,
482            base: base_v("2026-06-06", &[("grep", "a1b2")]),
483            mutations: vec![],
484            signature: None,
485        };
486        // Pins extra en local están bien.
487        let local = baseref_v("2026-06-06", &[("grep", "a1b2"), ("musl", "deadbeef")]);
488        assert_eq!(swm.verify_base(&local), BaseCompat::Ok);
489    }
490
491    #[test]
492    fn verify_base_distro_mismatch() {
493        let swm = Swm {
494            swm_version: 1,
495            base: base_v("2026-06-06", &[]),
496            mutations: vec![],
497            signature: None,
498        };
499        let local = baseref_v("2026-06-07", &[]);
500        assert_eq!(
501            swm.verify_base(&local),
502            BaseCompat::DistroMismatch {
503                local: "2026-06-07".into(),
504                swm: "2026-06-06".into(),
505            }
506        );
507    }
508
509    #[test]
510    fn verify_base_pin_mismatch_and_missing() {
511        let swm = Swm {
512            swm_version: 1,
513            base: base_v("2026-06-06", &[("grep", "a1b2"), ("musl", "FFFF")]),
514            mutations: vec![],
515            signature: None,
516        };
517        let local = baseref_v("2026-06-06", &[("grep", "OTRO")]); // musl no existe
518        let got = swm.verify_base(&local);
519        match got {
520            BaseCompat::PinMismatch(diffs) => {
521                assert_eq!(diffs.len(), 2);
522                let grep = diffs.iter().find(|d| d.name == "grep").unwrap();
523                assert_eq!(grep.local.as_deref(), Some("OTRO"));
524                assert_eq!(grep.swm, "a1b2");
525                let musl = diffs.iter().find(|d| d.name == "musl").unwrap();
526                assert!(musl.local.is_none());
527            }
528            other => panic!("esperaba PinMismatch, obtuve {other:?}"),
529        }
530    }
531
532    #[test]
533    fn verify_schema_rejects_bad_version() {
534        let swm = Swm {
535            swm_version: 9,
536            base: base_v("x", &[]),
537            mutations: vec![],
538            signature: None,
539        };
540        let err = swm.verify_schema().unwrap_err().to_string();
541        assert!(err.contains("swm_version"), "{err}");
542    }
543
544    #[test]
545    fn verify_schema_rejects_relative_path() {
546        let swm = Swm {
547            swm_version: 1,
548            base: base_v("x", &[]),
549            mutations: vec![Mutation::ConfigEdit {
550                file: "etc/network.conf".into(),
551                inline_diff: "- a\n+ b\n".into(),
552            }],
553            signature: None,
554        };
555        let err = swm.verify_schema().unwrap_err().to_string();
556        assert!(err.contains("absoluto"), "{err}");
557    }
558
559    #[test]
560    fn verify_schema_filedrop_needs_content_source() {
561        let swm = Swm {
562            swm_version: 1,
563            base: base_v("x", &[]),
564            mutations: vec![Mutation::FileDrop {
565                path: "/var/lib/x".into(),
566                content_hash: "b3:xxx".into(),
567                content_b64: None,
568                content_url: None,
569            }],
570            signature: None,
571        };
572        let err = swm.verify_schema().unwrap_err().to_string();
573        assert!(err.contains("content_b64") || err.contains("content_url"), "{err}");
574    }
575
576    #[test]
577    fn verify_schema_filedrop_rejects_both_inline_and_url() {
578        let swm = Swm {
579            swm_version: 1,
580            base: base_v("x", &[]),
581            mutations: vec![Mutation::FileDrop {
582                path: "/var/lib/x".into(),
583                content_hash: "b3:xxx".into(),
584                content_b64: Some("AA==".into()),
585                content_url: Some("https://x".into()),
586            }],
587            signature: None,
588        };
589        assert!(swm.verify_schema().is_err());
590    }
591
592    #[test]
593    fn verify_schema_source_patch_basic() {
594        let swm = Swm {
595            swm_version: 1,
596            base: base_v("x", &[]),
597            mutations: vec![Mutation::SourcePatch {
598                repo: Some("git://x".into()),
599                commit: Some("abc".into()),
600                tarball: None,
601                sha256: None,
602                strip_components: None,
603                patch: None,
604                patch_url: None,
605                build: SwmBuild {
606                    compiler: "zig-cc".into(),
607                    target: "x86_64-linux-musl".into(),
608                    link: "static".into(),
609                    flags: vec![],
610                    phases: Default::default(),
611                    zig_version: None,
612                },
613                target_bin: "/bin/x".into(),
614                expected_hash: None,
615                deps: Default::default(),
616                evidence: Default::default(),
617                slots: Default::default(),
618            }],
619            signature: None,
620        };
621        swm.verify_schema().unwrap();
622    }
623
624    #[test]
625    fn verify_schema_source_patch_tarball() {
626        let swm = Swm {
627            swm_version: 1,
628            base: base_v("x", &[]),
629            mutations: vec![Mutation::SourcePatch {
630                repo: None,
631                commit: None,
632                tarball: Some("https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz".into()),
633                sha256: Some("deadbeef".into()),
634                strip_components: None,
635                patch: None,
636                patch_url: None,
637                build: SwmBuild {
638                    compiler: "zig-cc".into(),
639                    target: "x86_64-linux-musl".into(),
640                    link: "static".into(),
641                    flags: vec![],
642                    phases: Default::default(),
643                    zig_version: None,
644                },
645                target_bin: "/bin/grep".into(),
646                expected_hash: None,
647                deps: Default::default(),
648                evidence: Default::default(),
649                slots: Default::default(),
650            }],
651            signature: None,
652        };
653        swm.verify_schema().unwrap();
654    }
655
656    #[test]
657    fn verify_schema_source_patch_rechaza_git_y_tarball_mezclados() {
658        let m = Mutation::SourcePatch {
659            repo: Some("git://x".into()),
660            commit: Some("abc".into()),
661            tarball: Some("https://x/a.tar.gz".into()),
662            sha256: Some("deadbeef".into()),
663            strip_components: None,
664            patch: None,
665            patch_url: None,
666            build: SwmBuild {
667                compiler: "zig-cc".into(),
668                target: "x86_64-linux-musl".into(),
669                link: "static".into(),
670                flags: vec![],
671                phases: Default::default(),
672                zig_version: None,
673            },
674            target_bin: "/bin/x".into(),
675            expected_hash: None,
676            deps: Default::default(),
677            evidence: Default::default(),
678                slots: Default::default(),
679        };
680        let err = m.verify_schema().unwrap_err();
681        assert!(err.contains("no ambos"), "mensaje inesperado: {err}");
682    }
683
684    #[test]
685    fn from_recipe_git_preserva_build_phases_y_patch() {
686        // Receta git con install custom, flags, zig_version → el paquete debe reproducir todo.
687        let toml = r#"
688name = "ripgrep"
689version = "14.1.1"
690[source]
691repo   = "https://github.com/BurntSushi/ripgrep"
692commit = "4649aa9700619f94cf9c66876e9549d83420e16c"
693[build]
694compiler = "zig-cc"
695target   = "x86_64-linux-musl"
696link     = "static"
697flags    = ["--bin", "rg"]
698[build.phases]
699install = "cp target/release/rg /out/usr/bin/rg"
700"#;
701        let recipe = crate::Recipe::from_toml(toml).unwrap();
702        let swm = Swm::from_recipe(
703            &recipe,
704            "/usr/bin/rg",
705            Some("PATCH-TEXT".into()),
706            Some("b3:cafe".into()),
707            "2026-06-21",
708        )
709        .unwrap();
710        assert_eq!(swm.base.distro_version, "2026-06-21");
711        match &swm.mutations[0] {
712            Mutation::SourcePatch {
713                repo, commit, tarball, patch, build, target_bin, expected_hash, strip_components, ..
714            } => {
715                assert_eq!(repo.as_deref(), Some("https://github.com/BurntSushi/ripgrep"));
716                assert_eq!(commit.as_deref(), Some("4649aa9700619f94cf9c66876e9549d83420e16c"));
717                assert!(tarball.is_none());
718                assert_eq!(patch.as_deref(), Some("PATCH-TEXT"));
719                assert_eq!(target_bin, "/usr/bin/rg");
720                assert_eq!(expected_hash.as_deref(), Some("b3:cafe"));
721                assert!(strip_components.is_none(), "git no lleva strip_components");
722                assert_eq!(build.compiler, "zig-cc");
723                assert_eq!(build.link, "static");
724                assert_eq!(build.flags, vec!["--bin".to_string(), "rg".to_string()]);
725                assert_eq!(build.phases.install.as_deref(), Some("cp target/release/rg /out/usr/bin/rg"));
726            }
727            other => panic!("esperaba SourcePatch, obtuve {other:?}"),
728        }
729    }
730
731    #[test]
732    fn from_recipe_tarball_lleva_zig_version_y_strip() {
733        let toml = r#"
734name = "openssl"
735version = "3.5.4"
736[source]
737tarball = "https://example/openssl-3.5.4.tar.gz"
738sha256  = "deadbeef"
739strip_components = 2
740[build]
741compiler = "zig-cc"
742zig_version = "0.13.0"
743"#;
744        let recipe = crate::Recipe::from_toml(toml).unwrap();
745        let swm = Swm::from_recipe(&recipe, "/usr/bin/openssl", None, None, "dev").unwrap();
746        match &swm.mutations[0] {
747            Mutation::SourcePatch { tarball, sha256, strip_components, build, expected_hash, .. } => {
748                assert_eq!(tarball.as_deref(), Some("https://example/openssl-3.5.4.tar.gz"));
749                assert_eq!(sha256.as_deref(), Some("deadbeef"));
750                assert_eq!(*strip_components, Some(2), "strip != 1 debe viajar");
751                assert_eq!(build.zig_version.as_deref(), Some("0.13.0"));
752                assert!(expected_hash.is_none());
753            }
754            other => panic!("esperaba SourcePatch, obtuve {other:?}"),
755        }
756    }
757
758    #[test]
759    fn verify_schema_source_patch_rechaza_sin_origen() {
760        let m = Mutation::SourcePatch {
761            repo: None,
762            commit: None,
763            tarball: None,
764            sha256: None,
765            strip_components: None,
766            patch: None,
767            patch_url: None,
768            build: SwmBuild {
769                compiler: "zig-cc".into(),
770                target: "x86_64-linux-musl".into(),
771                link: "static".into(),
772                flags: vec![],
773                phases: Default::default(),
774                zig_version: None,
775            },
776            target_bin: "/bin/x".into(),
777            expected_hash: None,
778            deps: Default::default(),
779            evidence: Default::default(),
780                slots: Default::default(),
781        };
782        let err = m.verify_schema().unwrap_err();
783        assert!(err.contains("faltan campos"), "mensaje inesperado: {err}");
784    }
785
786    #[test]
787    fn from_recipe_carries_evidence() {
788        // H1a: la evidencia de la receta viaja al .swm (source_patch), roundtripea por YAML,
789        // y verify_schema la valida.
790        let toml = r#"
791name = "grep"
792version = "3.11"
793[source]
794tarball = "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz"
795sha256  = "deadbeef"
796[build]
797[[evidence.checks]]
798kind = "cmd-exit"
799cmd  = "grep --version"
800[[evidence.checks]]
801kind = "proptest"
802cmd  = "cd /src && cargo test proptest_"
803expected_exit = 0
804"#;
805        let recipe = crate::Recipe::from_toml(toml).unwrap();
806        let swm = Swm::from_recipe(&recipe, "/usr/bin/grep", None, None, "dev").unwrap();
807        // La evidencia llegó a la mutación.
808        match &swm.mutations[0] {
809            Mutation::SourcePatch { evidence, .. } => {
810                assert_eq!(evidence.checks.len(), 2);
811                assert_eq!(evidence.checks[0].kind, crate::recipe::EvidenceKind::CmdExit);
812                assert_eq!(evidence.checks[1].kind, crate::recipe::EvidenceKind::Proptest);
813            }
814            other => panic!("esperaba SourcePatch, obtuve {other:?}"),
815        }
816        // Roundtrip YAML: el bloque evidence sobrevive.
817        let yaml = swm.to_yaml().unwrap();
818        assert!(yaml.contains("evidence"), "el YAML debe traer evidence: {yaml}");
819        let back = Swm::from_yaml(&yaml).unwrap();
820        match &back.mutations[0] {
821            Mutation::SourcePatch { evidence, .. } => assert_eq!(evidence.checks.len(), 2),
822            other => panic!("esperaba SourcePatch, obtuve {other:?}"),
823        }
824        back.verify_schema().unwrap();
825    }
826
827    #[test]
828    fn verify_schema_rejects_evidence_with_empty_cmd() {
829        let yaml = r#"
830swm_version: 1
831base:
832  distro_version: "dev"
833mutations:
834  - type: source_patch
835    tarball: "https://x/a.tar.gz"
836    sha256: "deadbeef"
837    build: {}
838    target_bin: "/usr/bin/x"
839    evidence:
840      checks:
841        - kind: cmd-exit
842          cmd: "   "
843"#;
844        let swm = Swm::from_yaml(yaml).unwrap();
845        let err = swm.verify_schema().unwrap_err().to_string();
846        assert!(err.contains("vacío"), "mensaje inesperado: {err}");
847    }
848
849    #[test]
850    fn source_patch_without_evidence_omits_block() {
851        // skip_serializing_if ⇒ un paquete sin evidencia no emite el bloque (compat hacia atrás).
852        let toml = r#"
853name = "x"
854version = "0"
855[source]
856repo = "git://x"
857commit = "deadbeef"
858[build]
859"#;
860        let recipe = crate::Recipe::from_toml(toml).unwrap();
861        let swm = Swm::from_recipe(&recipe, "/usr/bin/x", None, None, "dev").unwrap();
862        let yaml = swm.to_yaml().unwrap();
863        assert!(!yaml.contains("evidence"), "no debería emitir evidence: {yaml}");
864    }
865}