tawasuyu

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…

Rust 45TOML 36Markdown 26Shell 7878 Sutichaykuna

⎇ 98f0b2d228ffebb06941dfc1f436167ab40d8f21 crates/hammer-core/src/swm.rs Rust · 565 siq'ikuna · 19.7 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(swm): source_patch modela tarballs además de git (Fase 4) 0f42c3810 · 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        #[serde(default, skip_serializing_if = "Option::is_none")]
87        patch: Option<String>,
88        #[serde(default, skip_serializing_if = "Option::is_none")]
89        patch_url: Option<String>,
90        build: SwmBuild,
91        target_bin: String,
92        #[serde(default, skip_serializing_if = "Option::is_none")]
93        expected_hash: Option<String>,
94    },
95    ConfigEdit {
96        file: String,
97        inline_diff: String,
98    },
99    InitRule {
100        action: String,
101        service: String,
102        command: String,
103    },
104    FileDrop {
105        path: String,
106        /// hash BLAKE3 del contenido, declarado para verificación.
107        content_hash: String,
108        /// Contenido base64 inline (RFC 4648, sin saltos de línea). Si está presente, el
109        /// receptor lo decodifica directamente; si no, debe usar `content_url`. Permite
110        /// `.swm`s autocontenidos sin red — útil para tests, snapshots offline, e
111        /// intercambio de pequeños binarios de datos (catálogos, listas) cuyo hash basta.
112        #[serde(default, skip_serializing_if = "Option::is_none")]
113        content_b64: Option<String>,
114        #[serde(default, skip_serializing_if = "Option::is_none")]
115        content_url: Option<String>,
116    },
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct SwmBuild {
121    #[serde(default = "default_compiler")]
122    pub compiler: String,
123    #[serde(default = "default_target")]
124    pub target: String,
125    #[serde(default = "default_link")]
126    pub link: String,
127    #[serde(default)]
128    pub flags: Vec<String>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct Signature {
133    pub by: String,
134    pub alg: String,
135    pub sig: String,
136}
137
138fn default_compiler() -> String {
139    "zig-cc".into()
140}
141fn default_target() -> String {
142    "x86_64-linux-musl".into()
143}
144fn default_link() -> String {
145    "static".into()
146}
147
148impl Swm {
149    pub fn from_yaml(s: &str) -> crate::Result<Swm> {
150        serde_yaml::from_str(s).map_err(|e| crate::Error::Serde(e.to_string()))
151    }
152
153    pub fn to_yaml(&self) -> crate::Result<String> {
154        serde_yaml::to_string(self).map_err(|e| crate::Error::Serde(e.to_string()))
155    }
156
157    /// Sanity checks **estructurales** del manifiesto. Lo que aquí se valida no depende del
158    /// sistema local — sólo del documento. Llamarlo antes de `verify_base` evita malgastar
159    /// pasadas sobre un `.swm` mal formado.
160    pub fn verify_schema(&self) -> crate::Result<()> {
161        if self.swm_version != 1 {
162            return Err(crate::Error::Serde(format!(
163                "swm_version no soportada: {} (soportada: 1)",
164                self.swm_version
165            )));
166        }
167        if self.base.distro_version.is_empty() {
168            return Err(crate::Error::Serde(
169                "base.distro_version vacía".into(),
170            ));
171        }
172        for (i, m) in self.mutations.iter().enumerate() {
173            m.verify_schema().map_err(|e| {
174                crate::Error::Serde(format!("mutations[{i}]: {e}"))
175            })?;
176        }
177        Ok(())
178    }
179
180    /// Compara la base del `.swm` con la del sistema local. Una pin del SWM **debe** estar
181    /// presente en la base local con el mismo valor. Las pins extra de la base local (no
182    /// declaradas por el SWM) no son problema — el SWM sólo declara lo que necesita.
183    pub fn verify_base(&self, local: &BaseRef) -> BaseCompat {
184        if self.base.distro_version != local.distro_version {
185            return BaseCompat::DistroMismatch {
186                local: local.distro_version.clone(),
187                swm: self.base.distro_version.clone(),
188            };
189        }
190        let mut diffs = Vec::new();
191        for (name, swm_val) in &self.base.pins {
192            match local.pins.get(name) {
193                Some(local_val) if local_val == swm_val => {}
194                Some(local_val) => diffs.push(PinDiff {
195                    name: name.clone(),
196                    local: Some(local_val.clone()),
197                    swm: swm_val.clone(),
198                }),
199                None => diffs.push(PinDiff {
200                    name: name.clone(),
201                    local: None,
202                    swm: swm_val.clone(),
203                }),
204            }
205        }
206        if diffs.is_empty() {
207            BaseCompat::Ok
208        } else {
209            BaseCompat::PinMismatch(diffs)
210        }
211    }
212}
213
214/// Resuelve el modo de origen de un `source_patch` (git **xor** tarball) desde sus campos
215/// opcionales, con la misma regla que [`crate::recipe::Source::kind`]. Centraliza la
216/// validación para que `verify_schema`, el lab y el export compartan una sola fuente de verdad.
217pub fn swm_source_kind<'a>(
218    repo: Option<&'a str>,
219    commit: Option<&'a str>,
220    tarball: Option<&'a str>,
221    sha256: Option<&'a str>,
222) -> Result<crate::recipe::SourceKind<'a>, String> {
223    use crate::recipe::SourceKind;
224    match (repo, commit, tarball, sha256) {
225        (Some(repo), Some(commit), None, None) => Ok(SourceKind::Git { repo, commit }),
226        (None, None, Some(url), Some(sha256)) => Ok(SourceKind::Tarball { url, sha256 }),
227        (Some(_), Some(_), Some(_), _) | (Some(_), Some(_), _, Some(_)) => {
228            Err("source_patch: usa repo+commit O tarball+sha256, no ambos".into())
229        }
230        _ => Err("source_patch: faltan campos; necesito (repo+commit) o (tarball+sha256)".into()),
231    }
232}
233
234impl Mutation {
235    /// Sanity por mutación. Cada `type` tiene precondiciones distintas; la validación cruzada
236    /// (p. ej. "el `target_bin` apunta a un dir gestionado") la decide quien aplica, no el
237    /// schema.
238    pub fn verify_schema(&self) -> Result<(), String> {
239        use crate::recipe::SourceKind;
240        match self {
241            Mutation::SourcePatch {
242                repo, commit, tarball, sha256, patch, patch_url, target_bin, ..
243            } => {
244                match swm_source_kind(
245                    repo.as_deref(),
246                    commit.as_deref(),
247                    tarball.as_deref(),
248                    sha256.as_deref(),
249                )? {
250                    SourceKind::Git { repo, commit } => {
251                        if repo.is_empty() {
252                            return Err("source_patch: 'repo' vacío".into());
253                        }
254                        if commit.is_empty() {
255                            return Err("source_patch: 'commit' vacío".into());
256                        }
257                    }
258                    SourceKind::Tarball { url, sha256 } => {
259                        if url.is_empty() {
260                            return Err("source_patch: 'tarball' vacío".into());
261                        }
262                        if sha256.is_empty() {
263                            return Err("source_patch: 'sha256' vacío".into());
264                        }
265                    }
266                }
267                if patch.is_some() && patch_url.is_some() {
268                    return Err(
269                        "source_patch: usa 'patch' (inline) O 'patch_url', no ambos".into(),
270                    );
271                }
272                if !target_bin.starts_with('/') {
273                    return Err(format!(
274                        "source_patch: target_bin debe ser ruta absoluta, no '{target_bin}'"
275                    ));
276                }
277                Ok(())
278            }
279            Mutation::ConfigEdit { file, inline_diff } => {
280                if !file.starts_with('/') {
281                    return Err(format!("config_edit: file debe ser absoluto, no '{file}'"));
282                }
283                if inline_diff.is_empty() {
284                    return Err("config_edit: inline_diff vacío".into());
285                }
286                Ok(())
287            }
288            Mutation::InitRule { action, service, command } => {
289                if action.is_empty() || service.is_empty() || command.is_empty() {
290                    return Err("init_rule: action/service/command no pueden estar vacíos".into());
291                }
292                Ok(())
293            }
294            Mutation::FileDrop { path, content_hash, content_b64, content_url } => {
295                if !path.starts_with('/') {
296                    return Err(format!("file_drop: path debe ser absoluto, no '{path}'"));
297                }
298                if content_hash.is_empty() {
299                    return Err("file_drop: content_hash vacío".into());
300                }
301                if content_b64.is_none() && content_url.is_none() {
302                    return Err(
303                        "file_drop: hace falta 'content_b64' (inline) o 'content_url'".into(),
304                    );
305                }
306                if content_b64.is_some() && content_url.is_some() {
307                    return Err(
308                        "file_drop: usa 'content_b64' O 'content_url', no ambos".into(),
309                    );
310                }
311                Ok(())
312            }
313        }
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn roundtrip_yaml() {
323        let yaml = r#"
324swm_version: 1
325base:
326  distro_version: "2026-06-06"
327  pins:
328    grep: "a1b2c3d"
329mutations:
330  - type: config_edit
331    file: "/etc/network.conf"
332    inline_diff: |
333      - DHCP=yes
334      + IP=192.168.1.100
335"#;
336        let swm = Swm::from_yaml(yaml).expect("parse");
337        assert_eq!(swm.swm_version, 1);
338        assert_eq!(swm.mutations.len(), 1);
339        let back = swm.to_yaml().expect("serialize");
340        assert!(back.contains("config_edit"));
341    }
342
343    fn base_v(distro: &str, pins: &[(&str, &str)]) -> Base {
344        Base {
345            distro_version: distro.into(),
346            pins: pins.iter().map(|(k, v)| ((*k).into(), (*v).into())).collect(),
347        }
348    }
349    fn baseref_v(distro: &str, pins: &[(&str, &str)]) -> BaseRef {
350        BaseRef {
351            distro_version: distro.into(),
352            pins: pins.iter().map(|(k, v)| ((*k).into(), (*v).into())).collect(),
353        }
354    }
355
356    #[test]
357    fn verify_base_happy() {
358        let swm = Swm {
359            swm_version: 1,
360            base: base_v("2026-06-06", &[("grep", "a1b2")]),
361            mutations: vec![],
362            signature: None,
363        };
364        // Pins extra en local están bien.
365        let local = baseref_v("2026-06-06", &[("grep", "a1b2"), ("musl", "deadbeef")]);
366        assert_eq!(swm.verify_base(&local), BaseCompat::Ok);
367    }
368
369    #[test]
370    fn verify_base_distro_mismatch() {
371        let swm = Swm {
372            swm_version: 1,
373            base: base_v("2026-06-06", &[]),
374            mutations: vec![],
375            signature: None,
376        };
377        let local = baseref_v("2026-06-07", &[]);
378        assert_eq!(
379            swm.verify_base(&local),
380            BaseCompat::DistroMismatch {
381                local: "2026-06-07".into(),
382                swm: "2026-06-06".into(),
383            }
384        );
385    }
386
387    #[test]
388    fn verify_base_pin_mismatch_and_missing() {
389        let swm = Swm {
390            swm_version: 1,
391            base: base_v("2026-06-06", &[("grep", "a1b2"), ("musl", "FFFF")]),
392            mutations: vec![],
393            signature: None,
394        };
395        let local = baseref_v("2026-06-06", &[("grep", "OTRO")]); // musl no existe
396        let got = swm.verify_base(&local);
397        match got {
398            BaseCompat::PinMismatch(diffs) => {
399                assert_eq!(diffs.len(), 2);
400                let grep = diffs.iter().find(|d| d.name == "grep").unwrap();
401                assert_eq!(grep.local.as_deref(), Some("OTRO"));
402                assert_eq!(grep.swm, "a1b2");
403                let musl = diffs.iter().find(|d| d.name == "musl").unwrap();
404                assert!(musl.local.is_none());
405            }
406            other => panic!("esperaba PinMismatch, obtuve {other:?}"),
407        }
408    }
409
410    #[test]
411    fn verify_schema_rejects_bad_version() {
412        let swm = Swm {
413            swm_version: 9,
414            base: base_v("x", &[]),
415            mutations: vec![],
416            signature: None,
417        };
418        let err = swm.verify_schema().unwrap_err().to_string();
419        assert!(err.contains("swm_version"), "{err}");
420    }
421
422    #[test]
423    fn verify_schema_rejects_relative_path() {
424        let swm = Swm {
425            swm_version: 1,
426            base: base_v("x", &[]),
427            mutations: vec![Mutation::ConfigEdit {
428                file: "etc/network.conf".into(),
429                inline_diff: "- a\n+ b\n".into(),
430            }],
431            signature: None,
432        };
433        let err = swm.verify_schema().unwrap_err().to_string();
434        assert!(err.contains("absoluto"), "{err}");
435    }
436
437    #[test]
438    fn verify_schema_filedrop_needs_content_source() {
439        let swm = Swm {
440            swm_version: 1,
441            base: base_v("x", &[]),
442            mutations: vec![Mutation::FileDrop {
443                path: "/var/lib/x".into(),
444                content_hash: "b3:xxx".into(),
445                content_b64: None,
446                content_url: None,
447            }],
448            signature: None,
449        };
450        let err = swm.verify_schema().unwrap_err().to_string();
451        assert!(err.contains("content_b64") || err.contains("content_url"), "{err}");
452    }
453
454    #[test]
455    fn verify_schema_filedrop_rejects_both_inline_and_url() {
456        let swm = Swm {
457            swm_version: 1,
458            base: base_v("x", &[]),
459            mutations: vec![Mutation::FileDrop {
460                path: "/var/lib/x".into(),
461                content_hash: "b3:xxx".into(),
462                content_b64: Some("AA==".into()),
463                content_url: Some("https://x".into()),
464            }],
465            signature: None,
466        };
467        assert!(swm.verify_schema().is_err());
468    }
469
470    #[test]
471    fn verify_schema_source_patch_basic() {
472        let swm = Swm {
473            swm_version: 1,
474            base: base_v("x", &[]),
475            mutations: vec![Mutation::SourcePatch {
476                repo: Some("git://x".into()),
477                commit: Some("abc".into()),
478                tarball: None,
479                sha256: None,
480                patch: None,
481                patch_url: None,
482                build: SwmBuild {
483                    compiler: "zig-cc".into(),
484                    target: "x86_64-linux-musl".into(),
485                    link: "static".into(),
486                    flags: vec![],
487                },
488                target_bin: "/bin/x".into(),
489                expected_hash: None,
490            }],
491            signature: None,
492        };
493        swm.verify_schema().unwrap();
494    }
495
496    #[test]
497    fn verify_schema_source_patch_tarball() {
498        let swm = Swm {
499            swm_version: 1,
500            base: base_v("x", &[]),
501            mutations: vec![Mutation::SourcePatch {
502                repo: None,
503                commit: None,
504                tarball: Some("https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz".into()),
505                sha256: Some("deadbeef".into()),
506                patch: None,
507                patch_url: None,
508                build: SwmBuild {
509                    compiler: "zig-cc".into(),
510                    target: "x86_64-linux-musl".into(),
511                    link: "static".into(),
512                    flags: vec![],
513                },
514                target_bin: "/bin/grep".into(),
515                expected_hash: None,
516            }],
517            signature: None,
518        };
519        swm.verify_schema().unwrap();
520    }
521
522    #[test]
523    fn verify_schema_source_patch_rechaza_git_y_tarball_mezclados() {
524        let m = Mutation::SourcePatch {
525            repo: Some("git://x".into()),
526            commit: Some("abc".into()),
527            tarball: Some("https://x/a.tar.gz".into()),
528            sha256: Some("deadbeef".into()),
529            patch: None,
530            patch_url: None,
531            build: SwmBuild {
532                compiler: "zig-cc".into(),
533                target: "x86_64-linux-musl".into(),
534                link: "static".into(),
535                flags: vec![],
536            },
537            target_bin: "/bin/x".into(),
538            expected_hash: None,
539        };
540        let err = m.verify_schema().unwrap_err();
541        assert!(err.contains("no ambos"), "mensaje inesperado: {err}");
542    }
543
544    #[test]
545    fn verify_schema_source_patch_rechaza_sin_origen() {
546        let m = Mutation::SourcePatch {
547            repo: None,
548            commit: None,
549            tarball: None,
550            sha256: None,
551            patch: None,
552            patch_url: None,
553            build: SwmBuild {
554                compiler: "zig-cc".into(),
555                target: "x86_64-linux-musl".into(),
556                link: "static".into(),
557                flags: vec![],
558            },
559            target_bin: "/bin/x".into(),
560            expected_hash: None,
561        };
562        let err = m.verify_schema().unwrap_err();
563        assert!(err.contains("faltan campos"), "mensaje inesperado: {err}");
564    }
565}