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