- 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).
443 lines
15 KiB
Rust
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();
|
|
}
|
|
}
|