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…

TOML 1182Shell 87Rust 63Markdown 541592 Definiciones

⎇ 09d50f9e60c9f523e99aff3d6c8dcf2e5b3cd1a5 crates/hammer-core/src/swm.rs Rust · 442 líneas · 14.8 KB Historia Quién tocó qué crudo

Fase 4 — formato .swm: verify, apply (config_edit/file_drop/source_patch) y export 09d50f9e6 · 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        repo: String,
74        commit: String,
75        #[serde(default, skip_serializing_if = "Option::is_none")]
76        patch: Option<String>,
77        #[serde(default, skip_serializing_if = "Option::is_none")]
78        patch_url: Option<String>,
79        build: SwmBuild,
80        target_bin: String,
81        #[serde(default, skip_serializing_if = "Option::is_none")]
82        expected_hash: Option<String>,
83    },
84    ConfigEdit {
85        file: String,
86        inline_diff: String,
87    },
88    InitRule {
89        action: String,
90        service: String,
91        command: String,
92    },
93    FileDrop {
94        path: String,
95        /// hash BLAKE3 del contenido, declarado para verificación.
96        content_hash: String,
97        /// Contenido base64 inline (RFC 4648, sin saltos de línea). Si está presente, el
98        /// receptor lo decodifica directamente; si no, debe usar `content_url`. Permite
99        /// `.swm`s autocontenidos sin red — útil para tests, snapshots offline, e
100        /// intercambio de pequeños binarios de datos (catálogos, listas) cuyo hash basta.
101        #[serde(default, skip_serializing_if = "Option::is_none")]
102        content_b64: Option<String>,
103        #[serde(default, skip_serializing_if = "Option::is_none")]
104        content_url: Option<String>,
105    },
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct SwmBuild {
110    #[serde(default = "default_compiler")]
111    pub compiler: String,
112    #[serde(default = "default_target")]
113    pub target: String,
114    #[serde(default = "default_link")]
115    pub link: String,
116    #[serde(default)]
117    pub flags: Vec<String>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct Signature {
122    pub by: String,
123    pub alg: String,
124    pub sig: String,
125}
126
127fn default_compiler() -> String {
128    "zig-cc".into()
129}
130fn default_target() -> String {
131    "x86_64-linux-musl".into()
132}
133fn default_link() -> String {
134    "static".into()
135}
136
137impl Swm {
138    pub fn from_yaml(s: &str) -> crate::Result<Swm> {
139        serde_yaml::from_str(s).map_err(|e| crate::Error::Serde(e.to_string()))
140    }
141
142    pub fn to_yaml(&self) -> crate::Result<String> {
143        serde_yaml::to_string(self).map_err(|e| crate::Error::Serde(e.to_string()))
144    }
145
146    /// Sanity checks **estructurales** del manifiesto. Lo que aquí se valida no depende del
147    /// sistema local — sólo del documento. Llamarlo antes de `verify_base` evita malgastar
148    /// pasadas sobre un `.swm` mal formado.
149    pub fn verify_schema(&self) -> crate::Result<()> {
150        if self.swm_version != 1 {
151            return Err(crate::Error::Serde(format!(
152                "swm_version no soportada: {} (soportada: 1)",
153                self.swm_version
154            )));
155        }
156        if self.base.distro_version.is_empty() {
157            return Err(crate::Error::Serde(
158                "base.distro_version vacía".into(),
159            ));
160        }
161        for (i, m) in self.mutations.iter().enumerate() {
162            m.verify_schema().map_err(|e| {
163                crate::Error::Serde(format!("mutations[{i}]: {e}"))
164            })?;
165        }
166        Ok(())
167    }
168
169    /// Compara la base del `.swm` con la del sistema local. Una pin del SWM **debe** estar
170    /// presente en la base local con el mismo valor. Las pins extra de la base local (no
171    /// declaradas por el SWM) no son problema — el SWM sólo declara lo que necesita.
172    pub fn verify_base(&self, local: &BaseRef) -> BaseCompat {
173        if self.base.distro_version != local.distro_version {
174            return BaseCompat::DistroMismatch {
175                local: local.distro_version.clone(),
176                swm: self.base.distro_version.clone(),
177            };
178        }
179        let mut diffs = Vec::new();
180        for (name, swm_val) in &self.base.pins {
181            match local.pins.get(name) {
182                Some(local_val) if local_val == swm_val => {}
183                Some(local_val) => diffs.push(PinDiff {
184                    name: name.clone(),
185                    local: Some(local_val.clone()),
186                    swm: swm_val.clone(),
187                }),
188                None => diffs.push(PinDiff {
189                    name: name.clone(),
190                    local: None,
191                    swm: swm_val.clone(),
192                }),
193            }
194        }
195        if diffs.is_empty() {
196            BaseCompat::Ok
197        } else {
198            BaseCompat::PinMismatch(diffs)
199        }
200    }
201}
202
203impl Mutation {
204    /// Sanity por mutación. Cada `type` tiene precondiciones distintas; la validación cruzada
205    /// (p. ej. "el `target_bin` apunta a un dir gestionado") la decide quien aplica, no el
206    /// schema.
207    pub fn verify_schema(&self) -> Result<(), String> {
208        match self {
209            Mutation::SourcePatch { repo, commit, patch, patch_url, target_bin, .. } => {
210                if repo.is_empty() {
211                    return Err("source_patch: 'repo' vacío".into());
212                }
213                if commit.is_empty() {
214                    return Err("source_patch: 'commit' vacío".into());
215                }
216                if patch.is_some() && patch_url.is_some() {
217                    return Err(
218                        "source_patch: usa 'patch' (inline) O 'patch_url', no ambos".into(),
219                    );
220                }
221                if !target_bin.starts_with('/') {
222                    return Err(format!(
223                        "source_patch: target_bin debe ser ruta absoluta, no '{target_bin}'"
224                    ));
225                }
226                Ok(())
227            }
228            Mutation::ConfigEdit { file, inline_diff } => {
229                if !file.starts_with('/') {
230                    return Err(format!("config_edit: file debe ser absoluto, no '{file}'"));
231                }
232                if inline_diff.is_empty() {
233                    return Err("config_edit: inline_diff vacío".into());
234                }
235                Ok(())
236            }
237            Mutation::InitRule { action, service, command } => {
238                if action.is_empty() || service.is_empty() || command.is_empty() {
239                    return Err("init_rule: action/service/command no pueden estar vacíos".into());
240                }
241                Ok(())
242            }
243            Mutation::FileDrop { path, content_hash, content_b64, content_url } => {
244                if !path.starts_with('/') {
245                    return Err(format!("file_drop: path debe ser absoluto, no '{path}'"));
246                }
247                if content_hash.is_empty() {
248                    return Err("file_drop: content_hash vacío".into());
249                }
250                if content_b64.is_none() && content_url.is_none() {
251                    return Err(
252                        "file_drop: hace falta 'content_b64' (inline) o 'content_url'".into(),
253                    );
254                }
255                if content_b64.is_some() && content_url.is_some() {
256                    return Err(
257                        "file_drop: usa 'content_b64' O 'content_url', no ambos".into(),
258                    );
259                }
260                Ok(())
261            }
262        }
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn roundtrip_yaml() {
272        let yaml = r#"
273swm_version: 1
274base:
275  distro_version: "2026-06-06"
276  pins:
277    grep: "a1b2c3d"
278mutations:
279  - type: config_edit
280    file: "/etc/network.conf"
281    inline_diff: |
282      - DHCP=yes
283      + IP=192.168.1.100
284"#;
285        let swm = Swm::from_yaml(yaml).expect("parse");
286        assert_eq!(swm.swm_version, 1);
287        assert_eq!(swm.mutations.len(), 1);
288        let back = swm.to_yaml().expect("serialize");
289        assert!(back.contains("config_edit"));
290    }
291
292    fn base_v(distro: &str, pins: &[(&str, &str)]) -> Base {
293        Base {
294            distro_version: distro.into(),
295            pins: pins.iter().map(|(k, v)| ((*k).into(), (*v).into())).collect(),
296        }
297    }
298    fn baseref_v(distro: &str, pins: &[(&str, &str)]) -> BaseRef {
299        BaseRef {
300            distro_version: distro.into(),
301            pins: pins.iter().map(|(k, v)| ((*k).into(), (*v).into())).collect(),
302        }
303    }
304
305    #[test]
306    fn verify_base_happy() {
307        let swm = Swm {
308            swm_version: 1,
309            base: base_v("2026-06-06", &[("grep", "a1b2")]),
310            mutations: vec![],
311            signature: None,
312        };
313        // Pins extra en local están bien.
314        let local = baseref_v("2026-06-06", &[("grep", "a1b2"), ("musl", "deadbeef")]);
315        assert_eq!(swm.verify_base(&local), BaseCompat::Ok);
316    }
317
318    #[test]
319    fn verify_base_distro_mismatch() {
320        let swm = Swm {
321            swm_version: 1,
322            base: base_v("2026-06-06", &[]),
323            mutations: vec![],
324            signature: None,
325        };
326        let local = baseref_v("2026-06-07", &[]);
327        assert_eq!(
328            swm.verify_base(&local),
329            BaseCompat::DistroMismatch {
330                local: "2026-06-07".into(),
331                swm: "2026-06-06".into(),
332            }
333        );
334    }
335
336    #[test]
337    fn verify_base_pin_mismatch_and_missing() {
338        let swm = Swm {
339            swm_version: 1,
340            base: base_v("2026-06-06", &[("grep", "a1b2"), ("musl", "FFFF")]),
341            mutations: vec![],
342            signature: None,
343        };
344        let local = baseref_v("2026-06-06", &[("grep", "OTRO")]); // musl no existe
345        let got = swm.verify_base(&local);
346        match got {
347            BaseCompat::PinMismatch(diffs) => {
348                assert_eq!(diffs.len(), 2);
349                let grep = diffs.iter().find(|d| d.name == "grep").unwrap();
350                assert_eq!(grep.local.as_deref(), Some("OTRO"));
351                assert_eq!(grep.swm, "a1b2");
352                let musl = diffs.iter().find(|d| d.name == "musl").unwrap();
353                assert!(musl.local.is_none());
354            }
355            other => panic!("esperaba PinMismatch, obtuve {other:?}"),
356        }
357    }
358
359    #[test]
360    fn verify_schema_rejects_bad_version() {
361        let swm = Swm {
362            swm_version: 9,
363            base: base_v("x", &[]),
364            mutations: vec![],
365            signature: None,
366        };
367        let err = swm.verify_schema().unwrap_err().to_string();
368        assert!(err.contains("swm_version"), "{err}");
369    }
370
371    #[test]
372    fn verify_schema_rejects_relative_path() {
373        let swm = Swm {
374            swm_version: 1,
375            base: base_v("x", &[]),
376            mutations: vec![Mutation::ConfigEdit {
377                file: "etc/network.conf".into(),
378                inline_diff: "- a\n+ b\n".into(),
379            }],
380            signature: None,
381        };
382        let err = swm.verify_schema().unwrap_err().to_string();
383        assert!(err.contains("absoluto"), "{err}");
384    }
385
386    #[test]
387    fn verify_schema_filedrop_needs_content_source() {
388        let swm = Swm {
389            swm_version: 1,
390            base: base_v("x", &[]),
391            mutations: vec![Mutation::FileDrop {
392                path: "/var/lib/x".into(),
393                content_hash: "b3:xxx".into(),
394                content_b64: None,
395                content_url: None,
396            }],
397            signature: None,
398        };
399        let err = swm.verify_schema().unwrap_err().to_string();
400        assert!(err.contains("content_b64") || err.contains("content_url"), "{err}");
401    }
402
403    #[test]
404    fn verify_schema_filedrop_rejects_both_inline_and_url() {
405        let swm = Swm {
406            swm_version: 1,
407            base: base_v("x", &[]),
408            mutations: vec![Mutation::FileDrop {
409                path: "/var/lib/x".into(),
410                content_hash: "b3:xxx".into(),
411                content_b64: Some("AA==".into()),
412                content_url: Some("https://x".into()),
413            }],
414            signature: None,
415        };
416        assert!(swm.verify_schema().is_err());
417    }
418
419    #[test]
420    fn verify_schema_source_patch_basic() {
421        let swm = Swm {
422            swm_version: 1,
423            base: base_v("x", &[]),
424            mutations: vec![Mutation::SourcePatch {
425                repo: "git://x".into(),
426                commit: "abc".into(),
427                patch: None,
428                patch_url: None,
429                build: SwmBuild {
430                    compiler: "zig-cc".into(),
431                    target: "x86_64-linux-musl".into(),
432                    link: "static".into(),
433                    flags: vec![],
434                },
435                target_bin: "/bin/x".into(),
436                expected_hash: None,
437            }],
438            signature: None,
439        };
440        swm.verify_schema().unwrap();
441    }
442}