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…
⎇ 09d50f9e60c9f523e99aff3d6c8dcf2e5b3cd1a5
crates/hammer-core/src/swm.rs
Historia
Quién tocó qué
crudo
Fase 4 — formato .swm: verify, apply (config_edit/file_drop/source_patch) y export
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}