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

⎇ 9fe1e010e21199c12896ff156f85884a83f04ce6 crates/takana-core/src/swm.rs Rust · 937 líneas · 37.4 KB Historia Quién tocó qué crudo

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