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…
⎇ 98f0b2d228ffebb06941dfc1f436167ab40d8f21
crates/hammer-core/src/swm.rs
Historia
Quién tocó qué
crudo
feat(swm): source_patch modela tarballs además de git (Fase 4)
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}