From 09d50f9e60c9f523e99aff3d6c8dcf2e5b3cd1a5 Mon Sep 17 00:00:00 2001 From: Sergio Date: Tue, 9 Jun 2026 15:19:33 +0000 Subject: [PATCH] =?UTF-8?q?Fase=204=20=E2=80=94=20formato=20.swm:=20verify?= =?UTF-8?q?,=20apply=20(config=5Fedit/file=5Fdrop/source=5Fpatch)=20y=20ex?= =?UTF-8?q?port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- Cargo.lock | 9 + Cargo.toml | 1 + crates/hammer-build/src/lib.rs | 2 + crates/hammer-build/src/swm_bridge.rs | 260 +++++++++++++++ crates/hammer-cli/Cargo.toml | 5 + crates/hammer-cli/src/main.rs | 349 +++++++++++++++++++- crates/hammer-cli/tests/swm_roundtrip.rs | 163 ++++++++++ crates/hammer-core/Cargo.toml | 1 + crates/hammer-core/src/apply.rs | 388 +++++++++++++++++++++++ crates/hammer-core/src/lib.rs | 3 +- crates/hammer-core/src/swm.rs | 325 ++++++++++++++++++- docs/06-swm-format.md | 19 +- docs/10-roadmap.md | 19 +- 13 files changed, 1529 insertions(+), 15 deletions(-) create mode 100644 crates/hammer-build/src/swm_bridge.rs create mode 100644 crates/hammer-cli/tests/swm_roundtrip.rs create mode 100644 crates/hammer-core/src/apply.rs diff --git a/Cargo.lock b/Cargo.lock index cba81b11..dd418425 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,6 +79,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.13.0" @@ -296,12 +302,14 @@ name = "hammer-cli" version = "0.0.1" dependencies = [ "anyhow", + "base64", "clap", "hammer-build", "hammer-core", "hammer-journal", "hammer-overlay", "serde_json", + "tempfile", "tracing", "tracing-subscriber", ] @@ -311,6 +319,7 @@ name = "hammer-core" version = "0.0.1" dependencies = [ "anyhow", + "base64", "blake3", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index dc1a8497..a0b89b1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ toml = "0.8" blake3 = "1" sha2 = "0.10" hex = "0.4" +base64 = "0.22" nix = { version = "0.30", default-features = false, features = ["fanotify", "fs", "user"] } tempfile = "3" clap = { version = "4", features = ["derive"] } diff --git a/crates/hammer-build/src/lib.rs b/crates/hammer-build/src/lib.rs index 51933b90..b9f6d8f5 100644 --- a/crates/hammer-build/src/lib.rs +++ b/crates/hammer-build/src/lib.rs @@ -9,10 +9,12 @@ pub mod config; pub mod fetch; pub mod hydrate; pub mod sandbox; +pub mod swm_bridge; pub use config::BuildConfig; pub use hydrate::{hydrate as run_hydrate, HydrateReport, HydratedFile}; pub use sandbox::Sandbox; +pub use swm_bridge::build_source_patch; /// Calcula el `ArtifactHash` de una receta, resolviendo recursivamente sus deps de build. /// Ver `docs/02-build-lab.md` §2 y §5. diff --git a/crates/hammer-build/src/swm_bridge.rs b/crates/hammer-build/src/swm_bridge.rs new file mode 100644 index 00000000..82a891fc --- /dev/null +++ b/crates/hammer-build/src/swm_bridge.rs @@ -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 { + 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 = 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 +/// (`-`), 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, + base_dir: &Path, +) -> hammer_core::Result { + 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 { + 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 { + 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}"); + } +} diff --git a/crates/hammer-cli/Cargo.toml b/crates/hammer-cli/Cargo.toml index 32c1dc57..ecf9b44b 100644 --- a/crates/hammer-cli/Cargo.toml +++ b/crates/hammer-cli/Cargo.toml @@ -17,7 +17,12 @@ hammer-build.workspace = true hammer-overlay.workspace = true hammer-journal.workspace = true serde_json.workspace = true +base64.workspace = true anyhow.workspace = true clap.workspace = true tracing.workspace = true tracing-subscriber.workspace = true + +[dev-dependencies] +tempfile.workspace = true +base64.workspace = true diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index e09c7257..46777d4a 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -91,12 +91,47 @@ enum Cmd { #[arg(long, default_value = "pretty")] format: String, }, - /// [Fase 4] Aplica un manifiesto .swm (reproduce y lo deja en un overlay). - Apply { file: String }, - /// [Fase 4] Exporta el delta del sistema como manifiesto .swm. - Export { + /// [Fase 4] Aplica un manifiesto .swm. Por defecto abre un overlay sobre el FHS y aplica + /// las mutaciones ahí; con `--prefix DIR` opera directamente en `DIR` sin overlay + /// (útil para tests offline o staging en otro filesystem). + 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)] - base: Option, + prefix: Option, + /// 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, + /// Raíz del overlay (sólo en modo overlay). + #[arg(long)] + state_root: Option, + }, + /// [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, + }, + /// [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, + /// 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, }, /// [Fase 5] Envía un comando al init (proxy a /run/init.control). Ctl { line: String }, @@ -251,11 +286,21 @@ fn main() -> anyhow::Result<()> { } } } - Cmd::Apply { file } => { - println!("[fase 4 pendiente] apply {file} — ver docs/06-swm-format.md"); + Cmd::Apply { file, prefix, skip_source_patch, base_ref, state_root } => { + run_apply( + &cli.store, + &file, + prefix.as_deref(), + skip_source_patch, + base_ref.as_deref(), + state_root.as_deref(), + )?; } - Cmd::Export { base } => { - println!("[fase 4 pendiente] export base={base:?} — ver docs/05-journal.md"); + Cmd::SwmVerify { file, base_ref } => { + 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 } => { println!("[fase 5 pendiente] ctl {line:?} — ver docs/07-agent-bus.md"); @@ -263,3 +308,289 @@ fn main() -> anyhow::Result<()> { } Ok(()) } + +/// Lee y deserializa un `.swm` desde disco; falla con mensaje útil si no parsea. +fn load_swm(file: &str) -> anyhow::Result { + 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> { + 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 = 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 = + 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(()) +} diff --git a/crates/hammer-cli/tests/swm_roundtrip.rs b/crates/hammer-cli/tests/swm_roundtrip.rs new file mode 100644 index 00000000..bbac6b68 --- /dev/null +++ b/crates/hammer-cli/tests/swm_roundtrip.rs @@ -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) -> 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"); +} diff --git a/crates/hammer-core/Cargo.toml b/crates/hammer-core/Cargo.toml index fb2e8801..e8f17d2f 100644 --- a/crates/hammer-core/Cargo.toml +++ b/crates/hammer-core/Cargo.toml @@ -15,6 +15,7 @@ serde_json.workspace = true serde_yaml.workspace = true toml.workspace = true blake3.workspace = true +base64.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/hammer-core/src/apply.rs b/crates/hammer-core/src/apply.rs new file mode 100644 index 00000000..b3d1b177 --- /dev/null +++ b/crates/hammer-core/src/apply.rs @@ -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 = std::result::Result; + +/// 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, + add: Vec, +} + +fn parse_hunks(inline_diff: &str) -> ApplyResult> { + 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 { + // 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")); + } +} diff --git a/crates/hammer-core/src/lib.rs b/crates/hammer-core/src/lib.rs index 1e9cf3b5..46f1cca1 100644 --- a/crates/hammer-core/src/lib.rs +++ b/crates/hammer-core/src/lib.rs @@ -4,6 +4,7 @@ //! contratos están definidos; la lógica pesada (sandbox, fanotify, bus) vive en los otros //! crates y se irá rellenando por fase. +pub mod apply; pub mod hash; pub mod recipe; pub mod store; @@ -12,7 +13,7 @@ pub mod swm; pub use hash::ArtifactHash; pub use recipe::{Compiler, LinkMode, Phases, Recipe, Source, SourceKind}; 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. #[derive(Debug, thiserror::Error)] diff --git a/crates/hammer-core/src/swm.rs b/crates/hammer-core/src/swm.rs index bd6abb68..da39ef41 100644 --- a/crates/hammer-core/src/swm.rs +++ b/crates/hammer-core/src/swm.rs @@ -1,7 +1,10 @@ //! 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). +//! 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}; @@ -18,7 +21,49 @@ pub struct Swm { pub struct Base { pub distro_version: String, #[serde(default)] - pub pins: std::collections::BTreeMap, + pub pins: BTreeMap, +} + +/// 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, +} + +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), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PinDiff { + pub name: String, + pub local: Option, + pub swm: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -49,6 +94,12 @@ pub enum Mutation { 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, #[serde(default, skip_serializing_if = "Option::is_none")] content_url: Option, }, @@ -91,6 +142,125 @@ impl Swm { pub fn to_yaml(&self) -> crate::Result { 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)] @@ -118,4 +288,155 @@ mutations: 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(); + } } diff --git a/docs/06-swm-format.md b/docs/06-swm-format.md index 32ca690c..48fd192d 100644 --- a/docs/06-swm-format.md +++ b/docs/06-swm-format.md @@ -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 | | `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 viajar. El `build` lleva el **compilador por mutación** (no global). @@ -110,4 +117,14 @@ impl Swm { } ``` -CLI: `hammer apply ` · `hammer export … > out.swm` · `hammer swm verify `. +CLI: + +- `hammer apply ` — 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 ` — 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). diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md index f0d46ab8..85d88f67 100644 --- a/docs/10-roadmap.md +++ b/docs/10-roadmap.md @@ -57,9 +57,24 @@ pre-requisito de validación. - [ ] 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. -### Fase 4 — Formato y flujo `.swm` -- [ ] `Swm` (de/serialización YAML), `hammer export`, `hammer apply` (con overlay), `verify`. +### Fase 4 — Formato y flujo `.swm` ▶ *en progreso* +- [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. + ✅ 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 - [ ] `/run/init.control` (FIFO humano) + `/run/agent.sock` (JSON-líneas, `SO_PEERCRED`).