Files
takana/crates/hammer-core/src/swm.rs
T
Sergio 09d50f9e60 Fase 4 — formato .swm: verify, apply (config_edit/file_drop/source_patch) y export
- hammer-core::swm: verify_schema (invariantes por mutación) + verify_base con
  BaseRef/BaseCompat/PinDiff (distro_version + pins). FileDrop.content_b64 para
  .swm autocontenidos.
- hammer-core::apply: primitivas puras apply_config_edit (hunks -/+ con búsqueda
  exacta de bloque, ambiguo => error), apply_file_drop (base64 + verify BLAKE3),
  rebase_path para tests con prefix.
- hammer-build::swm_bridge: Mutation::SourcePatch -> Recipe sintética + patch
  inline materializado + build, con comparación opcional contra expected_hash.
- hammer-cli: nuevos subcomandos apply [--prefix --base-ref --skip-source-patch
  --state-root], swm-verify, export --journal --base-ref --since.
- Tests: 18 unit nuevos en hammer-core (apply + verify), 5 en swm_bridge,
  4 e2e en hammer-cli (roundtrip yaml -> apply bajo prefix reproduce los archivos).
- Roadmap y SDD 06 actualizados con lo cerrado y lo pendiente (URLs remotos,
  provenance en export vía mapa artefacto->receta, firma ed25519).
2026-06-09 15:19:33 +00:00

443 lines
15 KiB
Rust

//! El manifiesto `.swm` (Software Mutación). Ver `docs/06-swm-format.md`.
//!
//! Es la unidad de intercambio: receta de transformación sobre fuente pública + ediciones de
//! config. NUNCA transporta binarios cocidos (salvo `FileDrop` con hash declarado y, opcional,
//! contenido inline base64 para `.swm`s autocontenidos en tests/escenarios offline).
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Swm {
pub swm_version: u32,
pub base: Base,
pub mutations: Vec<Mutation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<Signature>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Base {
pub distro_version: String,
#[serde(default)]
pub pins: BTreeMap<String, String>,
}
/// Referencia a la base **local** — lo que el sistema receptor tiene cocido. Se compara
/// contra `Swm::base` en `verify_base`. La construye el receptor leyendo, p. ej.,
/// `/etc/hammer/base.json`; en tests la fabricamos a mano.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BaseRef {
pub distro_version: String,
#[serde(default)]
pub pins: BTreeMap<String, String>,
}
impl From<&Base> for BaseRef {
fn from(b: &Base) -> Self {
BaseRef {
distro_version: b.distro_version.clone(),
pins: b.pins.clone(),
}
}
}
/// Resultado de `verify_base`. `Ok` ⇒ las pins del SWM coinciden con las locales (al menos
/// para las llaves que el SWM declara) y la `distro_version` es idéntica. Cualquier
/// discrepancia se materializa como variante con detalle: `apply` puede decidir abortar,
/// el humano puede leer qué difiere.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BaseCompat {
/// La base local satisface todas las pins declaradas por el SWM.
Ok,
/// La `distro_version` no coincide.
DistroMismatch { local: String, swm: String },
/// Una o más pins difieren entre la base local y el SWM. Cada entrada es
/// `(pin_name, local_value, swm_value)`; un `local_value` ausente significa que la
/// llave no existe en la base local.
PinMismatch(Vec<PinDiff>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PinDiff {
pub name: String,
pub local: Option<String>,
pub swm: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Mutation {
SourcePatch {
repo: String,
commit: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
patch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
patch_url: Option<String>,
build: SwmBuild,
target_bin: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
expected_hash: Option<String>,
},
ConfigEdit {
file: String,
inline_diff: String,
},
InitRule {
action: String,
service: String,
command: String,
},
FileDrop {
path: String,
/// hash BLAKE3 del contenido, declarado para verificación.
content_hash: String,
/// Contenido base64 inline (RFC 4648, sin saltos de línea). Si está presente, el
/// receptor lo decodifica directamente; si no, debe usar `content_url`. Permite
/// `.swm`s autocontenidos sin red — útil para tests, snapshots offline, e
/// intercambio de pequeños binarios de datos (catálogos, listas) cuyo hash basta.
#[serde(default, skip_serializing_if = "Option::is_none")]
content_b64: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
content_url: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SwmBuild {
#[serde(default = "default_compiler")]
pub compiler: String,
#[serde(default = "default_target")]
pub target: String,
#[serde(default = "default_link")]
pub link: String,
#[serde(default)]
pub flags: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Signature {
pub by: String,
pub alg: String,
pub sig: String,
}
fn default_compiler() -> String {
"zig-cc".into()
}
fn default_target() -> String {
"x86_64-linux-musl".into()
}
fn default_link() -> String {
"static".into()
}
impl Swm {
pub fn from_yaml(s: &str) -> crate::Result<Swm> {
serde_yaml::from_str(s).map_err(|e| crate::Error::Serde(e.to_string()))
}
pub fn to_yaml(&self) -> crate::Result<String> {
serde_yaml::to_string(self).map_err(|e| crate::Error::Serde(e.to_string()))
}
/// Sanity checks **estructurales** del manifiesto. Lo que aquí se valida no depende del
/// sistema local — sólo del documento. Llamarlo antes de `verify_base` evita malgastar
/// pasadas sobre un `.swm` mal formado.
pub fn verify_schema(&self) -> crate::Result<()> {
if self.swm_version != 1 {
return Err(crate::Error::Serde(format!(
"swm_version no soportada: {} (soportada: 1)",
self.swm_version
)));
}
if self.base.distro_version.is_empty() {
return Err(crate::Error::Serde(
"base.distro_version vacía".into(),
));
}
for (i, m) in self.mutations.iter().enumerate() {
m.verify_schema().map_err(|e| {
crate::Error::Serde(format!("mutations[{i}]: {e}"))
})?;
}
Ok(())
}
/// Compara la base del `.swm` con la del sistema local. Una pin del SWM **debe** estar
/// presente en la base local con el mismo valor. Las pins extra de la base local (no
/// declaradas por el SWM) no son problema — el SWM sólo declara lo que necesita.
pub fn verify_base(&self, local: &BaseRef) -> BaseCompat {
if self.base.distro_version != local.distro_version {
return BaseCompat::DistroMismatch {
local: local.distro_version.clone(),
swm: self.base.distro_version.clone(),
};
}
let mut diffs = Vec::new();
for (name, swm_val) in &self.base.pins {
match local.pins.get(name) {
Some(local_val) if local_val == swm_val => {}
Some(local_val) => diffs.push(PinDiff {
name: name.clone(),
local: Some(local_val.clone()),
swm: swm_val.clone(),
}),
None => diffs.push(PinDiff {
name: name.clone(),
local: None,
swm: swm_val.clone(),
}),
}
}
if diffs.is_empty() {
BaseCompat::Ok
} else {
BaseCompat::PinMismatch(diffs)
}
}
}
impl Mutation {
/// Sanity por mutación. Cada `type` tiene precondiciones distintas; la validación cruzada
/// (p. ej. "el `target_bin` apunta a un dir gestionado") la decide quien aplica, no el
/// schema.
pub fn verify_schema(&self) -> Result<(), String> {
match self {
Mutation::SourcePatch { repo, commit, patch, patch_url, target_bin, .. } => {
if repo.is_empty() {
return Err("source_patch: 'repo' vacío".into());
}
if commit.is_empty() {
return Err("source_patch: 'commit' vacío".into());
}
if patch.is_some() && patch_url.is_some() {
return Err(
"source_patch: usa 'patch' (inline) O 'patch_url', no ambos".into(),
);
}
if !target_bin.starts_with('/') {
return Err(format!(
"source_patch: target_bin debe ser ruta absoluta, no '{target_bin}'"
));
}
Ok(())
}
Mutation::ConfigEdit { file, inline_diff } => {
if !file.starts_with('/') {
return Err(format!("config_edit: file debe ser absoluto, no '{file}'"));
}
if inline_diff.is_empty() {
return Err("config_edit: inline_diff vacío".into());
}
Ok(())
}
Mutation::InitRule { action, service, command } => {
if action.is_empty() || service.is_empty() || command.is_empty() {
return Err("init_rule: action/service/command no pueden estar vacíos".into());
}
Ok(())
}
Mutation::FileDrop { path, content_hash, content_b64, content_url } => {
if !path.starts_with('/') {
return Err(format!("file_drop: path debe ser absoluto, no '{path}'"));
}
if content_hash.is_empty() {
return Err("file_drop: content_hash vacío".into());
}
if content_b64.is_none() && content_url.is_none() {
return Err(
"file_drop: hace falta 'content_b64' (inline) o 'content_url'".into(),
);
}
if content_b64.is_some() && content_url.is_some() {
return Err(
"file_drop: usa 'content_b64' O 'content_url', no ambos".into(),
);
}
Ok(())
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_yaml() {
let yaml = r#"
swm_version: 1
base:
distro_version: "2026-06-06"
pins:
grep: "a1b2c3d"
mutations:
- type: config_edit
file: "/etc/network.conf"
inline_diff: |
- DHCP=yes
+ IP=192.168.1.100
"#;
let swm = Swm::from_yaml(yaml).expect("parse");
assert_eq!(swm.swm_version, 1);
assert_eq!(swm.mutations.len(), 1);
let back = swm.to_yaml().expect("serialize");
assert!(back.contains("config_edit"));
}
fn base_v(distro: &str, pins: &[(&str, &str)]) -> Base {
Base {
distro_version: distro.into(),
pins: pins.iter().map(|(k, v)| ((*k).into(), (*v).into())).collect(),
}
}
fn baseref_v(distro: &str, pins: &[(&str, &str)]) -> BaseRef {
BaseRef {
distro_version: distro.into(),
pins: pins.iter().map(|(k, v)| ((*k).into(), (*v).into())).collect(),
}
}
#[test]
fn verify_base_happy() {
let swm = Swm {
swm_version: 1,
base: base_v("2026-06-06", &[("grep", "a1b2")]),
mutations: vec![],
signature: None,
};
// Pins extra en local están bien.
let local = baseref_v("2026-06-06", &[("grep", "a1b2"), ("musl", "deadbeef")]);
assert_eq!(swm.verify_base(&local), BaseCompat::Ok);
}
#[test]
fn verify_base_distro_mismatch() {
let swm = Swm {
swm_version: 1,
base: base_v("2026-06-06", &[]),
mutations: vec![],
signature: None,
};
let local = baseref_v("2026-06-07", &[]);
assert_eq!(
swm.verify_base(&local),
BaseCompat::DistroMismatch {
local: "2026-06-07".into(),
swm: "2026-06-06".into(),
}
);
}
#[test]
fn verify_base_pin_mismatch_and_missing() {
let swm = Swm {
swm_version: 1,
base: base_v("2026-06-06", &[("grep", "a1b2"), ("musl", "FFFF")]),
mutations: vec![],
signature: None,
};
let local = baseref_v("2026-06-06", &[("grep", "OTRO")]); // musl no existe
let got = swm.verify_base(&local);
match got {
BaseCompat::PinMismatch(diffs) => {
assert_eq!(diffs.len(), 2);
let grep = diffs.iter().find(|d| d.name == "grep").unwrap();
assert_eq!(grep.local.as_deref(), Some("OTRO"));
assert_eq!(grep.swm, "a1b2");
let musl = diffs.iter().find(|d| d.name == "musl").unwrap();
assert!(musl.local.is_none());
}
other => panic!("esperaba PinMismatch, obtuve {other:?}"),
}
}
#[test]
fn verify_schema_rejects_bad_version() {
let swm = Swm {
swm_version: 9,
base: base_v("x", &[]),
mutations: vec![],
signature: None,
};
let err = swm.verify_schema().unwrap_err().to_string();
assert!(err.contains("swm_version"), "{err}");
}
#[test]
fn verify_schema_rejects_relative_path() {
let swm = Swm {
swm_version: 1,
base: base_v("x", &[]),
mutations: vec![Mutation::ConfigEdit {
file: "etc/network.conf".into(),
inline_diff: "- a\n+ b\n".into(),
}],
signature: None,
};
let err = swm.verify_schema().unwrap_err().to_string();
assert!(err.contains("absoluto"), "{err}");
}
#[test]
fn verify_schema_filedrop_needs_content_source() {
let swm = Swm {
swm_version: 1,
base: base_v("x", &[]),
mutations: vec![Mutation::FileDrop {
path: "/var/lib/x".into(),
content_hash: "b3:xxx".into(),
content_b64: None,
content_url: None,
}],
signature: None,
};
let err = swm.verify_schema().unwrap_err().to_string();
assert!(err.contains("content_b64") || err.contains("content_url"), "{err}");
}
#[test]
fn verify_schema_filedrop_rejects_both_inline_and_url() {
let swm = Swm {
swm_version: 1,
base: base_v("x", &[]),
mutations: vec![Mutation::FileDrop {
path: "/var/lib/x".into(),
content_hash: "b3:xxx".into(),
content_b64: Some("AA==".into()),
content_url: Some("https://x".into()),
}],
signature: None,
};
assert!(swm.verify_schema().is_err());
}
#[test]
fn verify_schema_source_patch_basic() {
let swm = Swm {
swm_version: 1,
base: base_v("x", &[]),
mutations: vec![Mutation::SourcePatch {
repo: "git://x".into(),
commit: "abc".into(),
patch: None,
patch_url: None,
build: SwmBuild {
compiler: "zig-cc".into(),
target: "x86_64-linux-musl".into(),
link: "static".into(),
flags: vec![],
},
target_bin: "/bin/x".into(),
expected_hash: None,
}],
signature: None,
};
swm.verify_schema().unwrap();
}
}