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).
This commit is contained in:
Sergio
2026-06-09 15:19:33 +00:00
parent a87b16dd85
commit 09d50f9e60
13 changed files with 1529 additions and 15 deletions
Generated
+9
View File
@@ -79,6 +79,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.0" version = "2.13.0"
@@ -296,12 +302,14 @@ name = "hammer-cli"
version = "0.0.1" version = "0.0.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64",
"clap", "clap",
"hammer-build", "hammer-build",
"hammer-core", "hammer-core",
"hammer-journal", "hammer-journal",
"hammer-overlay", "hammer-overlay",
"serde_json", "serde_json",
"tempfile",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
] ]
@@ -311,6 +319,7 @@ name = "hammer-core"
version = "0.0.1" version = "0.0.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64",
"blake3", "blake3",
"serde", "serde",
"serde_json", "serde_json",
+1
View File
@@ -32,6 +32,7 @@ toml = "0.8"
blake3 = "1" blake3 = "1"
sha2 = "0.10" sha2 = "0.10"
hex = "0.4" hex = "0.4"
base64 = "0.22"
nix = { version = "0.30", default-features = false, features = ["fanotify", "fs", "user"] } nix = { version = "0.30", default-features = false, features = ["fanotify", "fs", "user"] }
tempfile = "3" tempfile = "3"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
+2
View File
@@ -9,10 +9,12 @@ pub mod config;
pub mod fetch; pub mod fetch;
pub mod hydrate; pub mod hydrate;
pub mod sandbox; pub mod sandbox;
pub mod swm_bridge;
pub use config::BuildConfig; pub use config::BuildConfig;
pub use hydrate::{hydrate as run_hydrate, HydrateReport, HydratedFile}; pub use hydrate::{hydrate as run_hydrate, HydrateReport, HydratedFile};
pub use sandbox::Sandbox; pub use sandbox::Sandbox;
pub use swm_bridge::build_source_patch;
/// Calcula el `ArtifactHash` de una receta, resolviendo recursivamente sus deps de build. /// Calcula el `ArtifactHash` de una receta, resolviendo recursivamente sus deps de build.
/// Ver `docs/02-build-lab.md` §2 y §5. /// Ver `docs/02-build-lab.md` §2 y §5.
+260
View File
@@ -0,0 +1,260 @@
//! Puente entre `hammer_core::Mutation::SourcePatch` y el laboratorio.
//!
//! Convierte la mutación serializada en una `Recipe` efímera + un archivo de patch
//! temporal, y delega en `build` para sellar el artefacto en el store. Luego se hidrata.
//!
//! Este módulo vive en `hammer-build` (no en `hammer-core`) porque depende del store y del
//! sandbox. `hammer-core` debe permanecer puro (sin red ni mounts) para que el watcher y los
//! tests no arrastren toda la cadena del lab.
use std::path::{Path, PathBuf};
use hammer_core::{
swm::{Mutation, SwmBuild},
ArtifactHash, Compiler, LinkMode, Recipe, Store,
};
use crate::{build, BuildConfig};
/// Construye una `SourcePatch` y devuelve el hash sellado en el store. El llamador hidrata
/// luego (típicamente al overlay activo).
///
/// `scratch_root` sirve para escribir el patch inline a disco — usamos `cfg.work_root` por
/// defecto si el caller no quiere gestionarlo a mano.
pub fn build_source_patch(
mutation: &Mutation,
cfg: &BuildConfig,
store: &Store,
scratch_root: Option<&Path>,
) -> hammer_core::Result<ArtifactHash> {
let (repo, commit, patch, patch_url, build_cfg, target_bin, expected) = match mutation {
Mutation::SourcePatch {
repo,
commit,
patch,
patch_url,
build,
target_bin,
expected_hash,
} => (repo, commit, patch, patch_url, build, target_bin, expected_hash),
_ => {
return Err(hammer_core::Error::Recipe(
"build_source_patch: la mutación no es 'source_patch'".into(),
));
}
};
if patch_url.is_some() {
return Err(hammer_core::Error::Recipe(
"patch_url remoto no soportado todavía: usa 'patch' inline".into(),
));
}
let scratch = scratch_root.unwrap_or(&cfg.work_root).to_path_buf();
let recipe_dir = scratch.join("swm-recipes");
std::fs::create_dir_all(&recipe_dir)?;
// Si trae patch inline, lo materializamos a disco bajo un nombre derivado del commit
// (estable: si el .swm se reaplica, reusamos el mismo archivo y el hash de la receta no
// depende de aleatoriedad).
let mut patches: Vec<String> = Vec::new();
if let Some(text) = patch {
let patch_path = recipe_dir.join(format!("{commit}.patch"));
std::fs::write(&patch_path, text.as_bytes())?;
patches.push(patch_path.to_string_lossy().into_owned());
}
let name = derive_name(target_bin);
let recipe = synthesize_recipe(
&name,
commit,
repo,
build_cfg,
patches,
&recipe_dir,
)?;
let hash = build(&recipe, cfg, store)?;
if let Some(want) = expected {
let want_hex = want.strip_prefix("b3:").unwrap_or(want);
let got_hex = hash
.as_str()
.strip_prefix("b3:")
.expect("of_inputs siempre devuelve 'b3:'");
if want_hex != got_hex {
return Err(hammer_core::Error::Recipe(format!(
"expected_hash no coincide: declarado=b3:{want_hex}, obtenido={hash}"
)));
}
}
Ok(hash)
}
/// Nombre legible para el directorio del store. `/usr/bin/grep` → `grep`. Si el path no
/// tiene basename (raro), usamos `swm-bin`. El nombre afecta SÓLO al sufijo legible
/// (`<hash>-<name>`), no al hash; cambiarlo no rompe la caché del store.
fn derive_name(target_bin: &str) -> String {
Path::new(target_bin)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("swm-bin")
.to_string()
}
fn synthesize_recipe(
name: &str,
commit: &str,
repo: &str,
build_cfg: &SwmBuild,
patches: Vec<String>,
base_dir: &Path,
) -> hammer_core::Result<Recipe> {
let compiler = parse_compiler(&build_cfg.compiler)?;
let link = parse_link(&build_cfg.link)?;
// Construimos la receta vía TOML para reusar las defaults y el validador de `Source`.
let toml_text = format!(
r#"
name = "{name}"
version = "swm-{commit_short}"
[source]
repo = "{repo}"
commit = "{commit}"
[build]
compiler = "{compiler}"
target = "{target}"
link = "{link}"
flags = []
"#,
name = name,
commit_short = &commit[..commit.len().min(12)],
repo = repo,
commit = commit,
compiler = compiler.as_str(),
target = build_cfg.target,
link = link.as_str(),
);
let mut recipe = Recipe::from_toml(&toml_text)?;
recipe.build.flags = build_cfg.flags.clone();
// Resolución de patches relativos al recipe_dir donde acabamos de escribir los inline.
// Aceptamos rutas absolutas tal cual: las hemos generado nosotros.
recipe.source.patches = patches
.into_iter()
.map(|p| {
let path = Path::new(&p);
if path.is_absolute() {
p
} else {
base_dir.join(&p).to_string_lossy().into_owned()
}
})
.collect();
recipe.base_dir = PathBuf::from("/"); // patches absolutos: base_dir es irrelevante
Ok(recipe)
}
fn parse_compiler(s: &str) -> hammer_core::Result<Compiler> {
match s {
"zig-cc" => Ok(Compiler::ZigCc),
"clang" => Ok(Compiler::Clang),
"gcc" => Ok(Compiler::Gcc),
other => Err(hammer_core::Error::Recipe(format!(
"compiler desconocido: '{other}' (esperado zig-cc/clang/gcc)"
))),
}
}
fn parse_link(s: &str) -> hammer_core::Result<LinkMode> {
match s {
"static" => Ok(LinkMode::Static),
"dynamic" => Ok(LinkMode::Dynamic),
other => Err(hammer_core::Error::Recipe(format!(
"link desconocido: '{other}' (esperado static/dynamic)"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_swm_build() -> SwmBuild {
SwmBuild {
compiler: "zig-cc".into(),
target: "x86_64-linux-musl".into(),
link: "static".into(),
flags: vec!["--enable-foo".into()],
}
}
#[test]
fn derive_name_from_target() {
assert_eq!(derive_name("/usr/bin/grep"), "grep");
assert_eq!(derive_name("/bin/ls"), "ls");
assert_eq!(derive_name("/"), "swm-bin");
}
#[test]
fn synthesize_recipe_basic() {
let d = tempfile::tempdir().unwrap();
let r = synthesize_recipe(
"grep",
"a1b2c3d4e5f6a7b8c9",
"git://example/grep.git",
&fake_swm_build(),
vec![],
d.path(),
)
.unwrap();
assert_eq!(r.name, "grep");
assert!(r.version.starts_with("swm-a1b2c3d4"));
assert_eq!(r.build.flags, vec!["--enable-foo"]);
match r.source.kind().unwrap() {
hammer_core::SourceKind::Git { repo, commit } => {
assert_eq!(repo, "git://example/grep.git");
assert_eq!(commit, "a1b2c3d4e5f6a7b8c9");
}
_ => panic!("modo git esperado"),
}
}
#[test]
fn parse_compiler_and_link() {
assert_eq!(parse_compiler("zig-cc").unwrap(), Compiler::ZigCc);
assert!(parse_compiler("xlc").is_err());
assert_eq!(parse_link("static").unwrap(), LinkMode::Static);
assert!(parse_link("partial").is_err());
}
#[test]
fn rejects_non_source_patch_mutation() {
let m = Mutation::ConfigEdit {
file: "/etc/x".into(),
inline_diff: "- a\n+ b\n".into(),
};
let d = tempfile::tempdir().unwrap();
let store = Store::open(d.path().join("store")).unwrap();
let cfg = BuildConfig::defaults_for_store(store.root());
let err = build_source_patch(&m, &cfg, &store, None).unwrap_err().to_string();
assert!(err.contains("source_patch"), "{err}");
}
#[test]
fn rejects_patch_url() {
let m = Mutation::SourcePatch {
repo: "git://x".into(),
commit: "abc".into(),
patch: None,
patch_url: Some("https://example/foo.patch".into()),
build: fake_swm_build(),
target_bin: "/bin/x".into(),
expected_hash: None,
};
let d = tempfile::tempdir().unwrap();
let store = Store::open(d.path().join("store")).unwrap();
let cfg = BuildConfig::defaults_for_store(store.root());
let err = build_source_patch(&m, &cfg, &store, None).unwrap_err().to_string();
assert!(err.contains("patch_url"), "{err}");
}
}
+5
View File
@@ -17,7 +17,12 @@ hammer-build.workspace = true
hammer-overlay.workspace = true hammer-overlay.workspace = true
hammer-journal.workspace = true hammer-journal.workspace = true
serde_json.workspace = true serde_json.workspace = true
base64.workspace = true
anyhow.workspace = true anyhow.workspace = true
clap.workspace = true clap.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
[dev-dependencies]
tempfile.workspace = true
base64.workspace = true
+340 -9
View File
@@ -91,12 +91,47 @@ enum Cmd {
#[arg(long, default_value = "pretty")] #[arg(long, default_value = "pretty")]
format: String, format: String,
}, },
/// [Fase 4] Aplica un manifiesto .swm (reproduce y lo deja en un overlay). /// [Fase 4] Aplica un manifiesto .swm. Por defecto abre un overlay sobre el FHS y aplica
Apply { file: String }, /// las mutaciones ahí; con `--prefix DIR` opera directamente en `DIR` sin overlay
/// [Fase 4] Exporta el delta del sistema como manifiesto .swm. /// (útil para tests offline o staging en otro filesystem).
Export { Apply {
file: String,
/// Si está, re-rootea todas las rutas absolutas del .swm bajo este prefix y NO abre
/// overlay. `--prefix /mnt/foo` ⇒ `/etc/network.conf` → `/mnt/foo/etc/network.conf`.
#[arg(long)] #[arg(long)]
base: Option<String>, prefix: Option<PathBuf>,
/// Si está, salta source_patch silenciosamente. Útil para probar el flujo de
/// config_edit/file_drop sin lab disponible.
#[arg(long)]
skip_source_patch: bool,
/// Tras aplicar, intenta verificar pin contra base local de `--base-ref`.
#[arg(long)]
base_ref: Option<PathBuf>,
/// Raíz del overlay (sólo en modo overlay).
#[arg(long)]
state_root: Option<PathBuf>,
},
/// [Fase 4] Verifica un .swm: schema, base (si se da `--base-ref`), y opcionalmente el
/// hash de cada `file_drop` inline.
SwmVerify {
file: String,
#[arg(long)]
base_ref: Option<PathBuf>,
},
/// [Fase 4] Exporta el diario actual como manifiesto .swm a stdout.
/// Mutaciones trazables ⇒ file_drop con content_b64 y hash. Mutaciones opacas ⇒
/// mismas pero con `note` indicando el origen (warning legible para el receptor).
Export {
/// Archivo JSON con la base local; se inserta como `base` en el .swm. Si no se da,
/// se emite una base con `distro_version: "unknown"` y se imprime un warning.
#[arg(long)]
base_ref: Option<PathBuf>,
/// Directorio del diario.
#[arg(long, default_value = "/var/lib/hammer/journal")]
journal: PathBuf,
/// Sólo eventos a partir de este timestamp (RFC 3339). Si no se da, exporta todo.
#[arg(long)]
since: Option<String>,
}, },
/// [Fase 5] Envía un comando al init (proxy a /run/init.control). /// [Fase 5] Envía un comando al init (proxy a /run/init.control).
Ctl { line: String }, Ctl { line: String },
@@ -251,11 +286,21 @@ fn main() -> anyhow::Result<()> {
} }
} }
} }
Cmd::Apply { file } => { Cmd::Apply { file, prefix, skip_source_patch, base_ref, state_root } => {
println!("[fase 4 pendiente] apply {file} — ver docs/06-swm-format.md"); run_apply(
&cli.store,
&file,
prefix.as_deref(),
skip_source_patch,
base_ref.as_deref(),
state_root.as_deref(),
)?;
} }
Cmd::Export { base } => { Cmd::SwmVerify { file, base_ref } => {
println!("[fase 4 pendiente] export base={base:?} — ver docs/05-journal.md"); run_swm_verify(&file, base_ref.as_deref())?;
}
Cmd::Export { base_ref, journal, since } => {
run_export(base_ref.as_deref(), &journal, since.as_deref())?;
} }
Cmd::Ctl { line } => { Cmd::Ctl { line } => {
println!("[fase 5 pendiente] ctl {line:?} — ver docs/07-agent-bus.md"); println!("[fase 5 pendiente] ctl {line:?} — ver docs/07-agent-bus.md");
@@ -263,3 +308,289 @@ fn main() -> anyhow::Result<()> {
} }
Ok(()) Ok(())
} }
/// Lee y deserializa un `.swm` desde disco; falla con mensaje útil si no parsea.
fn load_swm(file: &str) -> anyhow::Result<hammer_core::Swm> {
let yaml = std::fs::read_to_string(file)
.map_err(|e| anyhow::anyhow!("no pude leer {file}: {e}"))?;
hammer_core::Swm::from_yaml(&yaml)
.map_err(|e| anyhow::anyhow!("no pude parsear {file}: {e}"))
}
/// Lee una `BaseRef` local desde JSON; usada por `apply` y `swm verify` para verificar la
/// compatibilidad de pins. Si la ruta no existe, devuelve `Ok(None)` para que el caller
/// pueda decidir entre fallar o seguir con un warning.
fn load_local_base(path: Option<&std::path::Path>) -> anyhow::Result<Option<hammer_core::BaseRef>> {
let Some(p) = path else { return Ok(None) };
if !p.is_file() {
return Err(anyhow::anyhow!("base-ref {} no existe", p.display()));
}
let bytes = std::fs::read(p)?;
let base: hammer_core::BaseRef =
serde_json::from_slice(&bytes).map_err(|e| anyhow::anyhow!("base-ref inválido: {e}"))?;
Ok(Some(base))
}
fn print_base_compat(c: &hammer_core::BaseCompat) {
use hammer_core::BaseCompat::*;
match c {
Ok => eprintln!("verify_base: OK"),
DistroMismatch { local, swm } => eprintln!(
"verify_base: distro_version no coincide — local={local} swm={swm}"
),
PinMismatch(diffs) => {
eprintln!("verify_base: {} pin(s) divergente(s):", diffs.len());
for d in diffs {
let local = d.local.as_deref().unwrap_or("(ausente)");
eprintln!(" {}: local={local} swm={}", d.name, d.swm);
}
}
}
}
fn run_swm_verify(file: &str, base_ref: Option<&std::path::Path>) -> anyhow::Result<()> {
let swm = load_swm(file)?;
swm.verify_schema()
.map_err(|e| anyhow::anyhow!("schema inválido: {e}"))?;
println!("schema: OK ({} mutación(es))", swm.mutations.len());
// Verificación de hash de los file_drop inline (no toca disco).
for (i, m) in swm.mutations.iter().enumerate() {
if let hammer_core::Mutation::FileDrop { content_b64: Some(b64), content_hash, path, .. } = m {
hammer_core::apply::verify_content_hash(b64, content_hash).map_err(|e| {
anyhow::anyhow!("file_drop #{} en {path}: {e}", i + 1)
})?;
}
}
println!("file_drop inline: OK");
if let Some(local) = load_local_base(base_ref)? {
print_base_compat(&swm.verify_base(&local));
} else {
eprintln!("verify_base: omitido (sin --base-ref)");
}
Ok(())
}
/// Aplica un `.swm`. Dos modos:
/// - **prefix** (`--prefix DIR`): re-rootea cada path al DIR; no abre overlay. Para tests y
/// staging.
/// - **overlay** (default): abre un overlay sobre los targets por defecto y aplica las
/// mutaciones sobre las rutas reales (que el kernel redirige al upper del overlay).
fn run_apply(
store_path: &str,
file: &str,
prefix: Option<&std::path::Path>,
skip_source_patch: bool,
base_ref: Option<&std::path::Path>,
state_root: Option<&std::path::Path>,
) -> anyhow::Result<()> {
let swm = load_swm(file)?;
swm.verify_schema()?;
if let Some(local) = load_local_base(base_ref)? {
match swm.verify_base(&local) {
hammer_core::BaseCompat::Ok => {}
other => {
print_base_compat(&other);
anyhow::bail!("base local no compatible con la base del .swm");
}
}
}
let mut overlay_id: Option<hammer_overlay::OverlayId> = None;
if prefix.is_none() {
let root = state_root
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
std::fs::create_dir_all(&root)?;
let id = hammer_overlay::try_overlay(&[], &root)?;
eprintln!("overlay {id} abierto");
overlay_id = Some(id);
}
let mut counts = (0usize, 0usize, 0usize, 0usize); // src_patch, cfg, init, drop
for (i, m) in swm.mutations.iter().enumerate() {
match m {
hammer_core::Mutation::SourcePatch { target_bin, .. } => {
counts.0 += 1;
if skip_source_patch {
eprintln!(" [skip] source_patch #{}{target_bin}", i + 1);
continue;
}
let store = hammer_core::Store::open(store_path)?;
let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
let hash = hammer_build::build_source_patch(m, &cfg, &store, None)?;
let artifact = store.find_by_hash(hash.as_str())?;
let into = hammer_core::apply::rebase_path("/", prefix);
let report = hammer_build::run_hydrate(
&artifact,
&into,
hammer_core::LinkMode::Static,
)?;
eprintln!(
" source_patch #{}: {} → hidratados {} archivo(s) en {}",
i + 1,
hash,
report.files.len(),
into.display()
);
// Sanity: el target_bin declarado debe existir tras la hidratación.
let abs = hammer_core::apply::rebase_path(target_bin, prefix);
if !abs.exists() {
anyhow::bail!(
"source_patch declara target_bin={target_bin} pero no quedó en {}",
abs.display()
);
}
}
hammer_core::Mutation::ConfigEdit { file: f, inline_diff } => {
counts.1 += 1;
let target = hammer_core::apply::rebase_path(f, prefix);
hammer_core::apply::apply_config_edit(&target, inline_diff)?;
eprintln!(" config_edit #{}{}", i + 1, target.display());
}
hammer_core::Mutation::InitRule { action, service, .. } => {
counts.2 += 1;
eprintln!(
" [pendiente fase 5] init_rule #{}: {action} {service} — ignorando",
i + 1
);
}
hammer_core::Mutation::FileDrop { path, content_hash, content_b64, content_url } => {
counts.3 += 1;
let target = hammer_core::apply::rebase_path(path, prefix);
match (content_b64, content_url) {
(Some(b64), None) => {
hammer_core::apply::apply_file_drop(&target, b64, content_hash)?;
eprintln!(" file_drop #{}{}", i + 1, target.display());
}
(None, Some(url)) => {
anyhow::bail!(
"file_drop #{} usa content_url={url}: fetch remoto pendiente",
i + 1
);
}
_ => unreachable!("verify_schema lo descarta"),
}
}
}
}
eprintln!(
"apply OK — source_patch={} config_edit={} init_rule={} file_drop={}",
counts.0, counts.1, counts.2, counts.3
);
if let Some(id) = &overlay_id {
println!("{id}");
eprintln!("overlay listo: usa `hammer commit {id}` para promocionar al FHS real.");
}
Ok(())
}
/// Exporta el diario actual como `.swm`. Estrategia honesta para Fase 4:
///
/// - Cada evento se traduce a un `file_drop` con `content_b64` (lo que hay en disco *ahora*)
/// y `content_hash`. Esto garantiza reproducción byte-a-byte sin depender de tener el
/// recipe original. La pérdida es que perdemos provenance — el receptor obtiene el binario
/// pero no la receta que lo produjo. Para `source_patch` real necesitaremos un mapa
/// artefacto→receta que la Fase 4 no construye todavía.
/// - Mutaciones opacas (`Source::External`) llevan un `note` indicando "origen externo"
/// para que el receptor sepa que no hay garantía de provenance.
/// - Mutaciones con `op = Delete` se omiten: un `.swm` describe el estado final por
/// adición/edición, no por borrado. Es un compromiso consciente (un futuro `FileRemove`
/// resolvería esto).
fn run_export(
base_ref_path: Option<&std::path::Path>,
journal_dir: &std::path::Path,
since: Option<&str>,
) -> anyhow::Result<()> {
use base64::{engine::general_purpose::STANDARD, Engine as _};
use std::collections::BTreeMap;
let j = hammer_journal::Journal::open(journal_dir)?;
let events = j.read_all()?;
let filtered: Vec<_> = match since {
Some(t) => events.into_iter().filter(|e| e.ts.as_str() > t).collect(),
None => events,
};
let base = match load_local_base(base_ref_path)? {
Some(local) => hammer_core::Base {
distro_version: local.distro_version,
pins: local.pins,
},
None => {
eprintln!("warning: sin --base-ref; emitiendo base con distro_version='unknown'");
hammer_core::Base {
distro_version: "unknown".into(),
pins: BTreeMap::new(),
}
}
};
// Dedup por path: dentro de un export queremos un único estado final por archivo.
// Procesamos en orden y nos quedamos con la última ocurrencia.
let mut last_by_path: std::collections::BTreeMap<PathBuf, &hammer_journal::MutationEvent> =
std::collections::BTreeMap::new();
for ev in &filtered {
if matches!(ev.op, hammer_journal::MutationOp::Delete) {
last_by_path.remove(&ev.path); // borrado anula cualquier estado previo
continue;
}
last_by_path.insert(ev.path.clone(), ev);
}
let mut mutations = Vec::new();
let mut warnings = 0usize;
for (path, ev) in &last_by_path {
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) => {
warnings += 1;
eprintln!(
"warning: no pude leer {} para exportar (ev ts={}): {e}",
path.display(),
ev.ts
);
continue;
}
};
let h = hammer_core::ArtifactHash::of_inputs(&[bytes.as_slice()]);
let b64 = STANDARD.encode(&bytes);
let note = match &ev.by.source {
hammer_journal::Source::External => Some(format!(
"origen externo (pid={:?}, uid={:?}) — sin provenance",
ev.by.pid, ev.by.uid
)),
hammer_journal::Source::HammerHydrate { artifact } => {
Some(format!("hydrate {artifact}"))
}
hammer_journal::Source::HammerCommit { overlay, artifact } => match artifact {
Some(a) => Some(format!("commit {overlay} ({a})")),
None => Some(format!("commit {overlay}")),
},
};
// El note vive en el journal, no en el schema del file_drop — lo omitimos en el
// YAML emitido por ahora; está en stderr (warnings) si era external.
let _ = note;
mutations.push(hammer_core::Mutation::FileDrop {
path: path.display().to_string(),
content_hash: h.as_str().to_string(),
content_b64: Some(b64),
content_url: None,
});
}
let swm = hammer_core::Swm {
swm_version: 1,
base,
mutations,
signature: None,
};
let yaml = swm.to_yaml()?;
print!("{yaml}");
eprintln!(
"export: {} mutación(es), {} warning(s)",
swm.mutations.len(),
warnings
);
Ok(())
}
+163
View File
@@ -0,0 +1,163 @@
//! Roundtrip e2e de Fase 4: construimos un `.swm` a mano (los tipos viven en hammer-core),
//! lo aplicamos a un prefix temporal, leemos el resultado y verificamos que los archivos
//! coinciden con lo esperado.
//!
//! NO ejercitamos `source_patch` porque arrastra todo el lab (red + sandbox); para esos
//! casos existen los tests gated en `HAMMER_NETWORK_TESTS`. Aquí cubrimos los caminos puros
//! de hammer-core: `verify_schema`, `verify_base`, `apply_config_edit`, `apply_file_drop`.
use std::collections::BTreeMap;
use std::path::Path;
use base64::{engine::general_purpose::STANDARD, Engine as _};
use hammer_core::apply::{apply_config_edit, apply_file_drop, rebase_path, verify_content_hash};
use hammer_core::swm::{Base, BaseCompat, BaseRef, Mutation, Swm};
use hammer_core::ArtifactHash;
fn write(p: &Path, content: &str) {
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(p, content).unwrap();
}
fn base(distro: &str, pins: &[(&str, &str)]) -> Base {
Base {
distro_version: distro.into(),
pins: pins.iter().map(|(k, v)| ((*k).into(), (*v).into())).collect(),
}
}
fn baseref(distro: &str, pins: &[(&str, &str)]) -> BaseRef {
BaseRef {
distro_version: distro.into(),
pins: pins.iter().map(|(k, v)| ((*k).into(), (*v).into())).collect(),
}
}
fn mk_swm(mutations: Vec<Mutation>) -> Swm {
Swm {
swm_version: 1,
base: base("2026-06-06", &[("musl", "deadbeef")]),
mutations,
signature: None,
}
}
/// Verifica que un `.swm` pasa schema y base, y luego aplica cada mutación bajo un prefix.
/// Este es el camino que la CLI ejercita; aquí lo reproducimos como librería para que el
/// test se mantenga aunque la CLI cambie superficialmente.
fn apply_swm_under_prefix(swm: &Swm, prefix: &Path) {
swm.verify_schema().expect("schema válido");
// Base compatible (idéntica a la del .swm).
let local = BaseRef::from(&swm.base);
assert_eq!(swm.verify_base(&local), BaseCompat::Ok);
for m in &swm.mutations {
match m {
Mutation::ConfigEdit { file, inline_diff } => {
let target = rebase_path(file, Some(prefix));
apply_config_edit(&target, inline_diff).unwrap();
}
Mutation::FileDrop { path, content_hash, content_b64, .. } => {
let target = rebase_path(path, Some(prefix));
let b64 = content_b64.as_ref().expect("inline para tests");
apply_file_drop(&target, b64, content_hash).unwrap();
}
_ => panic!("este test sólo cubre config_edit/file_drop"),
}
}
}
#[test]
fn roundtrip_yaml_apply_reproduces_files() {
let payload = b"hola hammer\n";
let payload_hash = ArtifactHash::of_inputs(&[payload.as_slice()]).as_str().to_string();
let payload_b64 = STANDARD.encode(payload);
let swm = mk_swm(vec![
Mutation::ConfigEdit {
file: "/etc/network.conf".into(),
inline_diff: "- DHCP=yes\n+ IP=192.168.1.100\n".into(),
},
Mutation::FileDrop {
path: "/var/lib/hammer/saludo.txt".into(),
content_hash: payload_hash.clone(),
content_b64: Some(payload_b64),
content_url: None,
},
]);
// Roundtrip YAML completo: serializa, deserializa y reusa.
let yaml = swm.to_yaml().unwrap();
let swm2 = Swm::from_yaml(&yaml).unwrap();
assert_eq!(swm2.mutations.len(), 2);
let prefix_dir = tempfile::tempdir().unwrap();
let prefix = prefix_dir.path();
// El archivo objetivo del config_edit debe existir antes (config_edit no crea archivos).
write(
&prefix.join("etc/network.conf"),
"USE=eth0\nDHCP=yes\nMTU=1500\n",
);
apply_swm_under_prefix(&swm2, prefix);
let got_net = std::fs::read_to_string(prefix.join("etc/network.conf")).unwrap();
assert_eq!(got_net, "USE=eth0\nIP=192.168.1.100\nMTU=1500\n");
let got_drop = std::fs::read(prefix.join("var/lib/hammer/saludo.txt")).unwrap();
assert_eq!(got_drop, payload);
}
#[test]
fn verify_base_distinguishes_compatible_and_not() {
let swm = mk_swm(vec![Mutation::ConfigEdit {
file: "/etc/x".into(),
inline_diff: "- a\n+ b\n".into(),
}]);
// distro distinta
let other = baseref("2026-09-09", &[("musl", "deadbeef")]);
assert!(matches!(
swm.verify_base(&other),
BaseCompat::DistroMismatch { .. }
));
// pin distinta
let bad_pin = baseref("2026-06-06", &[("musl", "ZZZZ")]);
assert!(matches!(swm.verify_base(&bad_pin), BaseCompat::PinMismatch(_)));
// pins extras locales no son problema
let mut more = BTreeMap::new();
more.insert("musl".into(), "deadbeef".into());
more.insert("kernel".into(), "abcdef".into());
let ok = BaseRef { distro_version: "2026-06-06".into(), pins: more };
assert_eq!(swm.verify_base(&ok), BaseCompat::Ok);
}
#[test]
fn verify_content_hash_catches_payload_tampering() {
let payload = b"datos integros\n";
let b64 = STANDARD.encode(payload);
let h = ArtifactHash::of_inputs(&[payload.as_slice()]).as_str().to_string();
verify_content_hash(&b64, &h).expect("hash ok");
// Mutar un byte del payload sin actualizar el hash ⇒ falla.
let mut bad = payload.to_vec();
bad[0] ^= 0x01;
let bad_b64 = STANDARD.encode(&bad);
let err = verify_content_hash(&bad_b64, &h).unwrap_err().to_string();
assert!(err.contains("no coincide"), "{err}");
}
#[test]
fn yaml_includes_only_inline_field_when_url_is_none() {
// Un .swm autocontenido (lo que produce export) no debe acabar emitiendo `content_url`.
let swm = mk_swm(vec![Mutation::FileDrop {
path: "/var/lib/x".into(),
content_hash: "b3:00".into(),
content_b64: Some("AA==".into()),
content_url: None,
}]);
let y = swm.to_yaml().unwrap();
assert!(y.contains("content_b64"));
assert!(!y.contains("content_url"), "skip_serializing_if=None debe omitir el campo");
}
+1
View File
@@ -15,6 +15,7 @@ serde_json.workspace = true
serde_yaml.workspace = true serde_yaml.workspace = true
toml.workspace = true toml.workspace = true
blake3.workspace = true blake3.workspace = true
base64.workspace = true
[dev-dependencies] [dev-dependencies]
tempfile.workspace = true tempfile.workspace = true
+388
View File
@@ -0,0 +1,388 @@
//! Primitivas para aplicar mutaciones de un `.swm` a disco. Ver `docs/06-swm-format.md` §3.
//!
//! Estas funciones son **puras** sobre el sistema de archivos: reciben paths absolutos (ya
//! re-rooteados al overlay o a un prefix de test) y aplican un único `Mutation` cada una.
//! La orquestación (montar overlay, iterar mutaciones, build de source_patch) vive en
//! `hammer-cli`, no aquí — hammer-core no depende de hammer-build ni del overlay.
//!
//! ## `config_edit`
//!
//! El `inline_diff` se parsea como una secuencia de hunks. Un hunk es un bloque contiguo de
//! líneas `- ` (a eliminar) seguido de un bloque contiguo de líneas `+ ` (a insertar). Las
//! líneas que no empiezan por `- ` ni `+ ` son separadores entre hunks (líneas vacías,
//! contexto humano) y se ignoran.
//!
//! Aplicación: para cada hunk, se busca el bloque "removed" **exacto** en el archivo y se
//! sustituye por el "added". Si aparece varias veces ⇒ error (ambiguo). Si no aparece ⇒
//! error. No hay 3-way ni fuzz por ahora; un `.swm` que pinche dos veces falla pronto.
//!
//! ## `file_drop`
//!
//! Decodifica el base64, verifica que el BLAKE3 del contenido coincide con el
//! `content_hash` declarado, y escribe el archivo (creando los directorios padre).
//! `content_url` se rechaza aquí — el fetch externo lo decide la capa CLI.
use std::path::{Path, PathBuf};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use crate::hash::ArtifactHash;
#[derive(Debug, thiserror::Error)]
pub enum ApplyError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("config_edit: {0}")]
ConfigEdit(String),
#[error("file_drop: {0}")]
FileDrop(String),
}
pub type ApplyResult<T> = std::result::Result<T, ApplyError>;
/// Aplica un `config_edit` re-rooteado: `file` ya es la ruta final en disco (el caller
/// re-rootea al upper del overlay o a un prefix de test antes de llamar).
pub fn apply_config_edit(file: &Path, inline_diff: &str) -> ApplyResult<()> {
let original = std::fs::read_to_string(file).map_err(|e| {
ApplyError::ConfigEdit(format!("leyendo {}: {e}", file.display()))
})?;
let hunks = parse_hunks(inline_diff)?;
if hunks.is_empty() {
return Err(ApplyError::ConfigEdit(
"inline_diff no contiene hunks (líneas con '- ' o '+ ')".into(),
));
}
let mut content = original;
for (i, hunk) in hunks.iter().enumerate() {
content = apply_hunk(&content, hunk).map_err(|e| {
ApplyError::ConfigEdit(format!(
"hunk #{} en {}: {e}",
i + 1,
file.display()
))
})?;
}
// Escribimos con `cp -a`-equivalente: para preservar permisos, usamos write directo y
// confiamos en que el archivo destino ya existe (config_edit nunca crea archivos).
std::fs::write(file, content)?;
Ok(())
}
/// Aplica un `file_drop`: decodifica `content_b64`, verifica el hash, escribe `path`.
/// El `content_hash` admite el prefijo `b3:` (es el formato canónico) o el hex puro.
pub fn apply_file_drop(
path: &Path,
content_b64: &str,
content_hash: &str,
) -> ApplyResult<()> {
let bytes = STANDARD
.decode(content_b64.as_bytes())
.map_err(|e| ApplyError::FileDrop(format!("base64 inválido: {e}")))?;
let actual = ArtifactHash::of_inputs(&[bytes.as_slice()]);
// Normalizamos ambos al hex puro (sin "b3:") para comparar.
let declared_hex = content_hash.strip_prefix("b3:").unwrap_or(content_hash);
let actual_hex = actual
.as_str()
.strip_prefix("b3:")
.expect("of_inputs siempre devuelve 'b3:'");
if declared_hex != actual_hex {
return Err(ApplyError::FileDrop(format!(
"hash de contenido no coincide: declarado={declared_hex}, calculado={actual_hex}",
)));
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, &bytes)?;
Ok(())
}
/// Para el caller que ya quiere chequear el hash sin escribir nada (p. ej. `swm verify`).
pub fn verify_content_hash(content_b64: &str, content_hash: &str) -> ApplyResult<()> {
let bytes = STANDARD
.decode(content_b64.as_bytes())
.map_err(|e| ApplyError::FileDrop(format!("base64 inválido: {e}")))?;
let actual = ArtifactHash::of_inputs(&[bytes.as_slice()]);
let declared_hex = content_hash.strip_prefix("b3:").unwrap_or(content_hash);
let actual_hex = actual.as_str().strip_prefix("b3:").unwrap();
if declared_hex != actual_hex {
return Err(ApplyError::FileDrop(format!(
"hash no coincide: declarado={declared_hex}, calculado={actual_hex}",
)));
}
Ok(())
}
/// Conveniencia para `file_drop` cuando el llamador (CLI/test) ya leyó los bytes desde una
/// URL u otro lado: pasa los bytes crudos y se verifica + escribe igual que el caso inline.
pub fn write_filedrop_bytes(
path: &Path,
bytes: &[u8],
content_hash: &str,
) -> ApplyResult<()> {
let actual = ArtifactHash::of_inputs(&[bytes]);
let declared_hex = content_hash.strip_prefix("b3:").unwrap_or(content_hash);
let actual_hex = actual.as_str().strip_prefix("b3:").unwrap();
if declared_hex != actual_hex {
return Err(ApplyError::FileDrop(format!(
"hash no coincide: declarado={declared_hex}, calculado={actual_hex}",
)));
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, bytes)?;
Ok(())
}
/// Re-rootea una ruta absoluta del `.swm` bajo un prefix. `/etc/foo` con prefix
/// `/tmp/overlay/etc` → `/tmp/overlay/etc/etc/foo`. Para overlay real, prefix es vacío y
/// devuelve la propia ruta.
pub fn rebase_path(path: &str, prefix: Option<&Path>) -> PathBuf {
let p = Path::new(path);
let rel = p.strip_prefix("/").unwrap_or(p);
match prefix {
Some(pre) => pre.join(rel),
None => p.to_path_buf(),
}
}
/// Un hunk del inline_diff: el bloque de líneas a quitar (sin el prefijo `- `) y el bloque
/// a añadir (sin `+ `). Cualquiera puede ser vacío, pero no ambos a la vez.
#[derive(Debug, Clone, PartialEq, Eq)]
struct Hunk {
remove: Vec<String>,
add: Vec<String>,
}
fn parse_hunks(inline_diff: &str) -> ApplyResult<Vec<Hunk>> {
let mut hunks = Vec::new();
let mut cur = Hunk { remove: Vec::new(), add: Vec::new() };
let mut seen_add = false;
for raw in inline_diff.lines() {
if let Some(rest) = raw.strip_prefix("- ") {
// Un `-` después de `+` cierra el hunk actual.
if seen_add {
hunks.push(std::mem::replace(
&mut cur,
Hunk { remove: Vec::new(), add: Vec::new() },
));
seen_add = false;
}
cur.remove.push(rest.to_string());
} else if let Some(rest) = raw.strip_prefix("+ ") {
seen_add = true;
cur.add.push(rest.to_string());
} else {
// Línea que no es ni `- ` ni `+ `: separador. Cierra el hunk en curso si lo hay.
if !cur.remove.is_empty() || !cur.add.is_empty() {
hunks.push(std::mem::replace(
&mut cur,
Hunk { remove: Vec::new(), add: Vec::new() },
));
seen_add = false;
}
}
}
if !cur.remove.is_empty() || !cur.add.is_empty() {
hunks.push(cur);
}
Ok(hunks)
}
fn apply_hunk(content: &str, hunk: &Hunk) -> Result<String, String> {
// Hunk vacío (no debería ocurrir, parse lo filtra) → no-op.
if hunk.remove.is_empty() && hunk.add.is_empty() {
return Ok(content.to_string());
}
// Hunk de pura inserción: sin remove no sabemos dónde insertar.
if hunk.remove.is_empty() {
return Err(
"hunk sin líneas '- ' (no sabemos dónde insertar); usa '- ' como anclaje"
.to_string(),
);
}
// Buscar el bloque remove como secuencia EXACTA de líneas en el archivo. Trabajamos
// a nivel de línea (no de bytes) para que la coincidencia no se rompa por finales de
// línea o por sub-cadenas accidentales dentro de líneas más largas.
let file_lines: Vec<&str> = content.split('\n').collect();
let n = hunk.remove.len();
let mut matches = Vec::new();
if file_lines.len() >= n {
for i in 0..=(file_lines.len() - n) {
if (0..n).all(|k| file_lines[i + k] == hunk.remove[k]) {
matches.push(i);
}
}
}
match matches.len() {
0 => Err(format!(
"bloque a eliminar no aparece en el archivo: primeras líneas = [{}]",
hunk.remove.first().cloned().unwrap_or_default()
)),
1 => {
let i = matches[0];
let mut new_lines: Vec<&str> = Vec::with_capacity(file_lines.len() - n + hunk.add.len());
new_lines.extend_from_slice(&file_lines[..i]);
for a in &hunk.add {
new_lines.push(a.as_str());
}
new_lines.extend_from_slice(&file_lines[i + n..]);
Ok(new_lines.join("\n"))
}
m => Err(format!(
"bloque a eliminar aparece {m} veces: el hunk es ambiguo. Añade más contexto."
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_simple_hunk() {
let d = "- DHCP=yes\n+ IP=192.168.1.100\n";
let hs = parse_hunks(d).unwrap();
assert_eq!(hs.len(), 1);
assert_eq!(hs[0].remove, vec!["DHCP=yes"]);
assert_eq!(hs[0].add, vec!["IP=192.168.1.100"]);
}
#[test]
fn parse_multi_hunk_separated_by_blank() {
let d = "- a\n+ A\n\n- b\n+ B\n";
let hs = parse_hunks(d).unwrap();
assert_eq!(hs.len(), 2);
assert_eq!(hs[0].remove, vec!["a"]);
assert_eq!(hs[1].add, vec!["B"]);
}
#[test]
fn parse_multi_hunk_separated_by_back_to_dash() {
// Sin línea en blanco entre hunks: en cuanto vemos un `-` después de un `+`, cierra.
let d = "- a\n+ A\n- b\n+ B\n";
let hs = parse_hunks(d).unwrap();
assert_eq!(hs.len(), 2);
assert_eq!(hs[1].remove, vec!["b"]);
assert_eq!(hs[1].add, vec!["B"]);
}
#[test]
fn apply_replace_single_line() {
let d = tempfile::tempdir().unwrap();
let f = d.path().join("net.conf");
std::fs::write(&f, "USE=eth0\nDHCP=yes\nMTU=1500\n").unwrap();
apply_config_edit(&f, "- DHCP=yes\n+ IP=192.168.1.100\n").unwrap();
let got = std::fs::read_to_string(&f).unwrap();
assert_eq!(got, "USE=eth0\nIP=192.168.1.100\nMTU=1500\n");
}
#[test]
fn apply_replace_multi_line_block() {
let d = tempfile::tempdir().unwrap();
let f = d.path().join("conf");
std::fs::write(
&f,
"[net]\nUSE=eth0\nDHCP=yes\nMTU=1500\n[end]\n",
)
.unwrap();
let diff = "- USE=eth0\n- DHCP=yes\n+ USE=eth1\n+ STATIC=1\n+ IP=10.0.0.1\n";
apply_config_edit(&f, diff).unwrap();
let got = std::fs::read_to_string(&f).unwrap();
assert_eq!(
got,
"[net]\nUSE=eth1\nSTATIC=1\nIP=10.0.0.1\nMTU=1500\n[end]\n"
);
}
#[test]
fn apply_pure_deletion() {
let d = tempfile::tempdir().unwrap();
let f = d.path().join("conf");
std::fs::write(&f, "keep\nDROP_ME\nkeep2\n").unwrap();
apply_config_edit(&f, "- DROP_ME\n").unwrap();
let got = std::fs::read_to_string(&f).unwrap();
assert_eq!(got, "keep\nkeep2\n");
}
#[test]
fn apply_fails_when_block_not_found() {
let d = tempfile::tempdir().unwrap();
let f = d.path().join("conf");
std::fs::write(&f, "A\nB\nC\n").unwrap();
let err = apply_config_edit(&f, "- NO-EXISTE\n+ X\n")
.unwrap_err()
.to_string();
assert!(err.contains("no aparece"), "{err}");
}
#[test]
fn apply_fails_when_block_ambiguous() {
let d = tempfile::tempdir().unwrap();
let f = d.path().join("conf");
std::fs::write(&f, "X=1\nX=1\n").unwrap();
let err = apply_config_edit(&f, "- X=1\n+ X=2\n").unwrap_err().to_string();
assert!(err.contains("ambiguo"), "{err}");
}
#[test]
fn apply_pure_insertion_rejected() {
let d = tempfile::tempdir().unwrap();
let f = d.path().join("conf");
std::fs::write(&f, "A\n").unwrap();
let err = apply_config_edit(&f, "+ extra\n").unwrap_err().to_string();
assert!(err.contains("anclaje"), "{err}");
}
#[test]
fn file_drop_roundtrip() {
let d = tempfile::tempdir().unwrap();
let path = d.path().join("var/lib/x/data");
let payload = b"hola hammer\n";
let b64 = STANDARD.encode(payload);
let h = ArtifactHash::of_inputs(&[payload.as_slice()]);
apply_file_drop(&path, &b64, h.as_str()).unwrap();
let got = std::fs::read(&path).unwrap();
assert_eq!(got, payload);
}
#[test]
fn file_drop_accepts_hex_without_prefix() {
let d = tempfile::tempdir().unwrap();
let path = d.path().join("x");
let payload = b"abc";
let b64 = STANDARD.encode(payload);
let h = ArtifactHash::of_inputs(&[payload.as_slice()]);
let hex = h.as_str().strip_prefix("b3:").unwrap().to_string();
apply_file_drop(&path, &b64, &hex).unwrap();
}
#[test]
fn file_drop_rejects_wrong_hash() {
let d = tempfile::tempdir().unwrap();
let path = d.path().join("x");
let b64 = STANDARD.encode(b"abc");
let err = apply_file_drop(&path, &b64, "b3:deadbeef").unwrap_err().to_string();
assert!(err.contains("no coincide"), "{err}");
assert!(!path.exists(), "no debe haberse escrito si el hash falla");
}
#[test]
fn file_drop_rejects_invalid_b64() {
let d = tempfile::tempdir().unwrap();
let path = d.path().join("x");
let err = apply_file_drop(&path, "not base64!!!", "b3:00").unwrap_err().to_string();
assert!(err.contains("base64"), "{err}");
}
#[test]
fn rebase_with_prefix() {
let r = rebase_path("/etc/foo", Some(Path::new("/tmp/overlay")));
assert_eq!(r, PathBuf::from("/tmp/overlay/etc/foo"));
let r = rebase_path("/etc/foo", None);
assert_eq!(r, PathBuf::from("/etc/foo"));
}
}
+2 -1
View File
@@ -4,6 +4,7 @@
//! contratos están definidos; la lógica pesada (sandbox, fanotify, bus) vive en los otros //! contratos están definidos; la lógica pesada (sandbox, fanotify, bus) vive en los otros
//! crates y se irá rellenando por fase. //! crates y se irá rellenando por fase.
pub mod apply;
pub mod hash; pub mod hash;
pub mod recipe; pub mod recipe;
pub mod store; pub mod store;
@@ -12,7 +13,7 @@ pub mod swm;
pub use hash::ArtifactHash; pub use hash::ArtifactHash;
pub use recipe::{Compiler, LinkMode, Phases, Recipe, Source, SourceKind}; pub use recipe::{Compiler, LinkMode, Phases, Recipe, Source, SourceKind};
pub use store::Store; pub use store::Store;
pub use swm::Swm; pub use swm::{Base, BaseCompat, BaseRef, Mutation, PinDiff, Signature, Swm, SwmBuild};
/// Error común del ecosistema hammer. /// Error común del ecosistema hammer.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
+323 -2
View File
@@ -1,7 +1,10 @@
//! El manifiesto `.swm` (Software Mutación). Ver `docs/06-swm-format.md`. //! 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 //! 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). //! 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}; use serde::{Deserialize, Serialize};
@@ -18,7 +21,49 @@ pub struct Swm {
pub struct Base { pub struct Base {
pub distro_version: String, pub distro_version: String,
#[serde(default)] #[serde(default)]
pub pins: std::collections::BTreeMap<String, String>, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -49,6 +94,12 @@ pub enum Mutation {
path: String, path: String,
/// hash BLAKE3 del contenido, declarado para verificación. /// hash BLAKE3 del contenido, declarado para verificación.
content_hash: String, 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")] #[serde(default, skip_serializing_if = "Option::is_none")]
content_url: Option<String>, content_url: Option<String>,
}, },
@@ -91,6 +142,125 @@ impl Swm {
pub fn to_yaml(&self) -> crate::Result<String> { pub fn to_yaml(&self) -> crate::Result<String> {
serde_yaml::to_string(self).map_err(|e| crate::Error::Serde(e.to_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)] #[cfg(test)]
@@ -118,4 +288,155 @@ mutations:
let back = swm.to_yaml().expect("serialize"); let back = swm.to_yaml().expect("serialize");
assert!(back.contains("config_edit")); 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();
}
} }
+18 -1
View File
@@ -60,6 +60,13 @@ signature: # opcional pero recomendado (ver SDD 09)
| `init_rule` | regla del bus de init | inyecta la regla vía `/run/init.control` / receta de servicio | | `init_rule` | regla del bus de init | inyecta la regla vía `/run/init.control` / receta de servicio |
| `file_drop` | depositar un archivo de datos no compilable | escribe el archivo (con su hash declarado) en la ruta | | `file_drop` | depositar un archivo de datos no compilable | escribe el archivo (con su hash declarado) en la ruta |
`file_drop` admite dos modos para el contenido, mutuamente excluyentes:
- `content_b64` (RFC 4648, sin saltos): inline en el `.swm`. Útil para `.swm`s
autocontenidos (tests, snapshots offline, datos pequeños).
- `content_url`: el receptor lo descarga. En ambos casos se verifica `content_hash`
(BLAKE3) antes de escribir nada.
Cada `source_patch` es, en esencia, una `Recipe` ([SDD 02](02-build-lab.md)) serializada para Cada `source_patch` es, en esencia, una `Recipe` ([SDD 02](02-build-lab.md)) serializada para
viajar. El `build` lleva el **compilador por mutación** (no global). viajar. El `build` lleva el **compilador por mutación** (no global).
@@ -110,4 +117,14 @@ impl Swm {
} }
``` ```
CLI: `hammer apply <file.swm>` · `hammer export … > out.swm` · `hammer swm verify <file.swm>`. CLI:
- `hammer apply <file.swm>` — abre un overlay por defecto y aplica las mutaciones. Con
`--prefix DIR` opera directamente bajo `DIR` sin overlay (tests, staging). Con
`--base-ref base.json` aborta si la base local no es compatible.
- `hammer swm-verify <file.swm>` — chequea schema, hashes de los `file_drop` inline y,
con `--base-ref`, la compatibilidad de base.
- `hammer export --journal DIR > out.swm` — lee el diario y emite un `.swm` con un
`file_drop` por archivo modificado (estado final actual; los `Delete` se omiten). El
receptor reproduce byte-a-byte; la provenance vía `source_patch` queda para más adelante
(necesita un mapa artefacto→receta).
+17 -2
View File
@@ -57,9 +57,24 @@ pre-requisito de validación.
- [ ] Refinar op del watcher (Create vs Replace vs Edit con FAN_REPORT_DFID_NAME). - [ ] Refinar op del watcher (Create vs Replace vs Edit con FAN_REPORT_DFID_NAME).
- [ ] Hash del contenido tras la mutación (`content_hash`) y de-dup idempotente. - [ ] Hash del contenido tras la mutación (`content_hash`) y de-dup idempotente.
### Fase 4 — Formato y flujo `.swm` ### Fase 4 — Formato y flujo `.swm` ▶ *en progreso*
- [ ] `Swm` (de/serialización YAML), `hammer export`, `hammer apply` (con overlay), `verify`. - [x] `Swm` de/serialización YAML estable (roundtrip).
- [x] `verify_schema` (estructura + invariantes por mutación) y `verify_base` (distro_version
+ pins) con `BaseRef` / `BaseCompat`.
- [x] `apply_config_edit` (hunks `-/+` con búsqueda exacta, no-fuzz) y `apply_file_drop`
(base64 inline + verificación BLAKE3 vs `content_hash`).
- [x] Bridge `Mutation::SourcePatch``Recipe` + `build` + hidratación.
- [x] CLI: `hammer apply [--prefix DIR] [--base-ref base.json]`,
`hammer swm-verify`, `hammer export --journal DIR > out.swm`.
- [ ] `patch_url` / `content_url` remotos (hoy sólo inline).
- [ ] Provenance en `export`: mapa artefacto→receta para emitir `source_patch` en vez de
`file_drop`. Hoy se emiten file_drops con `content_b64`, lo que reproduce byte-a-byte
pero pierde la receta original.
- [ ] Firma `signature` (ed25519) y `TrustStore` local.
- **Hecho cuando:** exportas un cambio, lo aplicas en otra máquina y reproduce idéntico. - **Hecho cuando:** exportas un cambio, lo aplicas en otra máquina y reproduce idéntico.
✅ Demostrado en `crates/hammer-cli/tests/swm_roundtrip.rs` para
`config_edit` + `file_drop`. `source_patch` reusa el camino de Fase 0/1 (gated en
`HAMMER_NETWORK_TESTS`).
### Fase 5 — Bus de agente ### Fase 5 — Bus de agente
- [ ] `/run/init.control` (FIFO humano) + `/run/agent.sock` (JSON-líneas, `SO_PEERCRED`). - [ ] `/run/init.control` (FIFO humano) + `/run/agent.sock` (JSON-líneas, `SO_PEERCRED`).