From de036d366605e338a6f604890433c11356101fb8 Mon Sep 17 00:00:00 2001 From: Sergio Date: Wed, 10 Jun 2026 16:49:09 +0000 Subject: [PATCH] =?UTF-8?q?Fase=204=20=E2=80=94=20provenance=20en=20`hamme?= =?UTF-8?q?r=20export`=20(sidecar=20de=20receta=20en=20el=20store)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hasta ahora `hammer export` emitía sólo `file_drop` con `content_b64`. Eso reproducía byte-a-byte pero perdía la receta: el receptor obtenía binarios opacos, sin manera de auditarlos ni de recompilarlos desde fuente. Esta fase cierra el hueco para los artefactos producidos por `hammer-build::build`. Mecanismo: - `Store` reserva `.hammer/recipe.toml` (`RECIPE_SIDECAR_REL`) dentro de cada artefacto. `hammer-build::build` lo escribe antes de sellar, así queda inmutable junto con el árbol. - `Store::recipe_for_dir` / `recipe_for_hash` lo leen de vuelta. Si el artefacto es viejo y no trae sidecar, devuelven `None` sin fallar. - `Recipe::to_toml` (nuevo) hace el roundtrip. Lógica del export (función pura `build_export_mutations`): 1. Aplica semánticas de Delete: invalida estados previos del mismo path. 2. Particiona los eventos sobrevivientes en (a) los que tienen `artifact_hash` con receta sidecar y (b) el resto. 3. Por cada grupo de (a) emite UN `source_patch` con repo+commit+build de la receta y `expected_hash = artifact_hash`. El `target_bin` es el primer path alfabético del grupo; el receptor hidrata el árbol completo al aplicar. 4. Patches de la receta se concatenan inline en el `source_patch.patch`. 5. Los eventos del bucket (b) caen al fallback `file_drop` con `content_b64 + content_hash` (orden alfabético). Limitaciones explícitas: - `SourcePatch` sólo modela `Source::Git`. Recetas con `tarball` se reportan por stderr y caen a file_drop. Extender el SWM para tarballs es trabajo aparte. - Si la receta tiene patches pero alguno no se puede leer, no se inlina; `expected_hash` sigue siendo el gate de integridad para detectar la divergencia. CLI: el subcomando `Export` ahora recibe `--store` para resolver artefactos. Defaults igual que antes (`/store`). Tests (12 nuevos): - hammer-core (5): `Recipe::to_toml` roundtrip; `Store::recipe_for_dir` sin/con sidecar; `Store::recipe_for_hash` por prefijo + hash inexistente. - hammer-cli (7): export sin store; semánticas de Delete; hydrate con sidecar emite source_patch agrupando dos archivos; hydrate sin sidecar cae a file_drop contando `missing_recipe`; mix trazable + external; path ilegible sólo warning. --- crates/hammer-build/src/lib.rs | 11 + crates/hammer-cli/src/main.rs | 517 +++++++++++++++++++++++++++---- crates/hammer-core/src/recipe.rs | 26 ++ crates/hammer-core/src/store.rs | 89 ++++++ docs/10-roadmap.md | 8 +- 5 files changed, 582 insertions(+), 69 deletions(-) diff --git a/crates/hammer-build/src/lib.rs b/crates/hammer-build/src/lib.rs index b9f6d8f5..ba171d57 100644 --- a/crates/hammer-build/src/lib.rs +++ b/crates/hammer-build/src/lib.rs @@ -91,6 +91,17 @@ pub fn build( )); } + // Sidecar de provenance: escribimos la receta serializada DENTRO de out_dir antes del + // seal, para que quede congelada en el árbol read-only del store. `hammer export` la + // lee de vuelta para emitir `source_patch` con la receta original en vez de un + // `file_drop` opaco. Ver `docs/04-overlay.md` §provenance (Fase 4). + let sidecar_path = out_dir.join(hammer_core::store::RECIPE_SIDECAR_REL); + if let Some(parent) = sidecar_path.parent() { + std::fs::create_dir_all(parent)?; + } + let toml_text = recipe.to_toml()?; + std::fs::write(&sidecar_path, toml_text.as_bytes())?; + let sealed = store.seal(&out_dir, &h, &recipe.name)?; tracing::info!(path = %sealed.display(), "sealed"); Ok(h) diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 08cb216e..297edcec 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -364,7 +364,7 @@ fn main() -> anyhow::Result<()> { run_swm_verify(&file, base_ref.as_deref())?; } Cmd::Export { base_ref, journal, since } => { - run_export(base_ref.as_deref(), &journal, since.as_deref())?; + run_export(base_ref.as_deref(), &journal, since.as_deref(), &cli.store)?; } Cmd::Ai { intent, @@ -576,24 +576,23 @@ fn run_apply( Ok(()) } -/// Exporta el diario actual como `.swm`. Estrategia honesta para Fase 4: +/// Exporta el diario actual como `.swm`. Provenance-aware: /// -/// - 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). +/// - Eventos cuyo `Source` apunta a un `artifact` con receta sidecar en el store +/// (`.hammer/recipe.toml`) se agrupan por `artifact_hash` y emiten **un único** +/// `source_patch` por grupo, con repo/commit/build de la receta original. El receptor +/// reconstruye el binario desde fuente — provenance real, no un binario opaco. +/// - Eventos sin provenance trazable (`Source::External`, builds viejos sin sidecar, +/// `HammerCommit` con `artifact: None`) caen al fallback `file_drop` con `content_b64` +/// + hash, reproduciendo byte-a-byte pero perdiendo la receta. Se cuenta como warning. +/// - Eventos `Delete` invalidan estados previos del mismo path y luego se ignoran (un +/// `.swm` describe el estado final por adición/edición). fn run_export( base_ref_path: Option<&std::path::Path>, journal_dir: &std::path::Path, since: Option<&str>, + store_root: &str, ) -> anyhow::Result<()> { - use base64::{engine::general_purpose::STANDARD, Engine as _}; use std::collections::BTreeMap; let j = hammer_journal::Journal::open(journal_dir)?; @@ -617,58 +616,19 @@ fn run_export( } }; - // 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; + // El store es opcional: si el directorio no existe, seguimos en modo "todo file_drop" + // (igual que el comportamiento pre-provenance). El receptor no nota la diferencia. + let store_opt = match hammer_core::Store::open(store_root) { + Ok(s) => Some(s), + Err(e) => { + eprintln!( + "warning: store {store_root} no accesible ({e}); export sin provenance" + ); + None } - 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 (mutations, stats) = build_export_mutations(&filtered, store_opt.as_ref()); let swm = hammer_core::Swm { swm_version: 1, @@ -679,13 +639,251 @@ fn run_export( let yaml = swm.to_yaml()?; print!("{yaml}"); eprintln!( - "export: {} mutación(es), {} warning(s)", - swm.mutations.len(), - warnings + "export: {} source_patch(es), {} file_drop(s), {} warning(s){}", + stats.source_patches, + stats.file_drops, + stats.warnings, + if stats.missing_recipe > 0 { + format!(" ({} artefacto(s) sin sidecar de receta)", stats.missing_recipe) + } else { + String::new() + }, ); Ok(()) } +#[derive(Debug, Default)] +struct ExportStats { + source_patches: usize, + file_drops: usize, + warnings: usize, + /// Cuántos artefactos referenciados por eventos no traían `.hammer/recipe.toml` + /// y por tanto cayeron a file_drop. Útil para que el humano sepa que parte del + /// export perdió provenance (típicamente builds anteriores a Fase 4). + missing_recipe: usize, +} + +/// Construye la lista de mutaciones del `.swm` exportado. Función pura sobre `events` + +/// el store: testeable sin abrir el journal real ni la CLI. +/// +/// Estrategia: +/// 1. Aplicamos las semánticas de Delete (invalida estados anteriores del mismo path) y +/// dedupeamos por path quedándonos con el último evento. +/// 2. Particionamos los eventos sobrevivientes en dos: los que tienen un `artifact_hash` +/// cuyo store contiene la receta sidecar (→ candidates para `source_patch` agrupado), +/// y el resto (→ `file_drop`). +/// 3. Por cada grupo `artifact_hash` con receta: emitimos UN `source_patch`. El +/// `target_bin` es el primer path del grupo en orden alfabético; el resto de paths +/// queda implícito (el receptor hidrata el artefacto completo). +/// 4. Por cada evento de fallback: leemos el contenido actual del disco, codificamos +/// base64 y emitimos `file_drop`. Si la lectura falla, contamos warning y omitimos. +fn build_export_mutations( + events: &[hammer_journal::MutationEvent], + store: Option<&hammer_core::Store>, +) -> (Vec, ExportStats) { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + use std::collections::BTreeMap; + + // Paso 1: dedup respetando Delete. + let mut last_by_path: BTreeMap = BTreeMap::new(); + for ev in events { + if matches!(ev.op, hammer_journal::MutationOp::Delete) { + last_by_path.remove(&ev.path); + continue; + } + last_by_path.insert(ev.path.clone(), ev); + } + + let mut stats = ExportStats::default(); + + // Paso 2: particionamos. Para que la asignación a `source_patch` requiera la receta, + // resolvemos el sidecar acá; si no está, el evento cae al fallback. + // + // Estructura: artifact_hash → (Recipe, Vec). Los paths se ordenarán antes de + // emitir, para que el target_bin sea determinista (importante para tests). + let mut groups: BTreeMap)> = BTreeMap::new(); + let mut fallback: Vec<(PathBuf, &hammer_journal::MutationEvent)> = Vec::new(); + + let mut artifacts_seen: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let mut artifacts_without_recipe: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + + for (path, ev) in &last_by_path { + let artifact = match &ev.by.source { + hammer_journal::Source::HammerHydrate { artifact } => Some(artifact.clone()), + hammer_journal::Source::HammerCommit { artifact: Some(a), .. } => Some(a.clone()), + _ => None, + }; + let Some(art) = artifact else { + fallback.push((path.clone(), *ev)); + continue; + }; + artifacts_seen.insert(art.clone()); + + // Si ya conseguimos la receta para este artifact_hash en una pasada anterior, + // reusamos sin tocar disco otra vez. + if let Some((_, paths)) = groups.get_mut(&art) { + paths.push(path.clone()); + continue; + } + if artifacts_without_recipe.contains(&art) { + fallback.push((path.clone(), *ev)); + continue; + } + + let recipe_opt = store.and_then(|s| s.recipe_for_hash(&art).ok().flatten()); + match recipe_opt { + Some(r) => { + groups.insert(art, (r, vec![path.clone()])); + } + None => { + artifacts_without_recipe.insert(art); + fallback.push((path.clone(), *ev)); + } + } + } + + stats.missing_recipe = artifacts_without_recipe.len(); + + let mut mutations: Vec = Vec::new(); + + // Paso 3: source_patches (ordenados por artifact_hash para estabilidad del output). + for (art_hash, (recipe, mut paths)) in groups { + paths.sort(); + let target_bin = paths + .first() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "/".into()); + + // Convertimos la Recipe a los campos que SourcePatch necesita. + let (repo, commit) = match recipe.source.kind() { + Ok(hammer_core::SourceKind::Git { repo, commit }) => { + (repo.to_string(), commit.to_string()) + } + Ok(hammer_core::SourceKind::Tarball { url, sha256 }) => { + // SourcePatch sólo modela el modo git. Para tarballs caemos al + // fallback file_drop por evento — no perdemos completeness, sólo + // provenance fina. El humano lo ve por stderr. + eprintln!( + "warning: artefacto {art_hash} proviene de tarball ({url} sha256={sha256}); \ + `source_patch` aún no modela tarballs, hago file_drop" + ); + for p in &paths { + if let Some(ev) = last_by_path.get(p) { + fallback.push((p.clone(), *ev)); + } + } + continue; + } + Err(_) => { + stats.warnings += 1; + eprintln!( + "warning: receta de {art_hash} tiene source inválido; cayendo a file_drop" + ); + for p in &paths { + if let Some(ev) = last_by_path.get(p) { + fallback.push((p.clone(), *ev)); + } + } + continue; + } + }; + + // Patches inline: si la receta tenía patches, los leemos y concatenamos. El + // hash del artefacto incluye su contenido (ver `Recipe::hash_inputs`), así que + // el receptor reconstruye byte-a-byte si y sólo si el patch coincide. + let patch_inline = collect_patches_inline(&recipe); + + if paths.len() > 1 { + eprintln!( + " source_patch {} cubre {} archivo(s); target_bin={} (resto se hidrata al aplicar)", + art_hash, + paths.len(), + target_bin + ); + } + + mutations.push(hammer_core::Mutation::SourcePatch { + repo, + commit, + patch: patch_inline, + patch_url: None, + build: hammer_core::SwmBuild { + compiler: recipe.build.compiler.as_str().to_string(), + target: recipe.build.target.clone(), + link: recipe.build.link.as_str().to_string(), + flags: recipe.build.flags.clone(), + }, + target_bin, + expected_hash: Some(art_hash), + }); + stats.source_patches += 1; + } + + // Paso 4: file_drops. Dedupeamos por path porque algunos paths pueden haber rebotado + // del grupo de tarball-fallback al fallback general además del path original. + let mut seen_paths: std::collections::BTreeSet = std::collections::BTreeSet::new(); + fallback.sort_by(|(a, _), (b, _)| a.cmp(b)); + for (path, ev) in fallback { + if !seen_paths.insert(path.clone()) { + continue; + } + let bytes = match std::fs::read(&path) { + Ok(b) => b, + Err(e) => { + stats.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); + 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, + }); + stats.file_drops += 1; + } + + (mutations, stats) +} + +/// Concatena los contenidos de todos los patches declarados por la receta en un único +/// blob (separado por una línea con `--- /dev/null` falsa). Si la receta no tiene +/// patches, devuelve `None`. Si algún patch no se puede leer, devuelve `None` y deja un +/// warning en stderr — el receptor reconstruirá sin patches y verificará con +/// `expected_hash` que el binario coincide, así que el error es detectable. +fn collect_patches_inline(recipe: &hammer_core::Recipe) -> Option { + if recipe.source.patches.is_empty() { + return None; + } + let mut all = String::new(); + for p in &recipe.source.patches { + let path = if std::path::Path::new(p).is_absolute() { + std::path::PathBuf::from(p) + } else { + recipe.base_dir.join(p) + }; + match std::fs::read_to_string(&path) { + Ok(text) => all.push_str(&text), + Err(e) => { + eprintln!( + "warning: no pude leer patch {} para inline: {e}", + path.display() + ); + return None; + } + } + } + Some(all) +} + /// Ejecuta el bucle agéntico de Fase 6 vía `hammer-agent`. Acepta una intención NL + /// catálogo YAML que mapea intentos exactos a `.swm`s pre-armados. La salida es un /// `Proposal` legible en stdout + el siguiente paso recomendado al humano. @@ -948,3 +1146,190 @@ fn run_ctl(line: &str, fifo: &std::path::Path) -> anyhow::Result<()> { } Ok(()) } + +#[cfg(test)] +mod export_tests { + use super::*; + use hammer_journal::{Actor, MutationEvent, MutationOp, Source}; + + fn write_recipe_sidecar(art_dir: &std::path::Path, name: &str, commit: &str) { + std::fs::create_dir_all(art_dir.join(".hammer")).unwrap(); + let toml = format!( + r#" +name = "{name}" +version = "0.1" +[source] +repo = "git://example/{name}.git" +commit = "{commit}" +[build] +compiler = "zig-cc" +target = "x86_64-linux-musl" +link = "static" +flags = ["--enable-foo"] +"# + ); + std::fs::write(art_dir.join(hammer_core::store::RECIPE_SIDECAR_REL), toml).unwrap(); + } + + fn ev_hydrate(path: &str, artifact: &str) -> MutationEvent { + MutationEvent { + ts: "2026-06-10T00:00:00Z".into(), + op: MutationOp::Create, + path: PathBuf::from(path), + by: Actor { + source: Source::HammerHydrate { artifact: artifact.into() }, + pid: None, + uid: None, + }, + content_hash: None, + note: None, + } + } + + fn ev_external(path: &str) -> MutationEvent { + MutationEvent { + ts: "2026-06-10T00:00:01Z".into(), + op: MutationOp::Edit, + path: PathBuf::from(path), + by: Actor { source: Source::External, pid: Some(42), uid: Some(1000) }, + content_hash: None, + note: None, + } + } + + #[test] + fn export_without_store_falls_back_to_file_drops() { + let d = tempfile::tempdir().unwrap(); + let target = d.path().join("etc/hosts"); + std::fs::create_dir_all(target.parent().unwrap()).unwrap(); + std::fs::write(&target, b"127.0.0.1 lo\n").unwrap(); + + let events = vec![ev_external(target.to_str().unwrap())]; + let (mutations, stats) = build_export_mutations(&events, None); + assert_eq!(stats.source_patches, 0); + assert_eq!(stats.file_drops, 1); + assert!(matches!(mutations[0], hammer_core::Mutation::FileDrop { .. })); + } + + #[test] + fn delete_event_invalidates_previous_state() { + let d = tempfile::tempdir().unwrap(); + let target = d.path().join("a"); + std::fs::write(&target, b"x").unwrap(); + + let mut create = ev_external(target.to_str().unwrap()); + create.op = MutationOp::Create; + let mut delete = ev_external(target.to_str().unwrap()); + delete.op = MutationOp::Delete; + let events = vec![create, delete]; + let (mutations, stats) = build_export_mutations(&events, None); + assert!(mutations.is_empty()); + assert_eq!(stats.file_drops, 0); + } + + #[test] + fn hydrate_with_recipe_sidecar_emits_source_patch() { + let d = tempfile::tempdir().unwrap(); + let store_root = d.path().join("store"); + let store = hammer_core::Store::open(&store_root).unwrap(); + let art_dir = store_root.join("cafe1234-grep"); + write_recipe_sidecar(&art_dir, "grep", "deadbeef"); + + // Dos archivos del MISMO artifact_hash ⇒ deben colapsar en UN source_patch. + let file_a = d.path().join("usr/bin/grep"); + let file_b = d.path().join("usr/lib/grep-helper.so"); + std::fs::create_dir_all(file_a.parent().unwrap()).unwrap(); + std::fs::create_dir_all(file_b.parent().unwrap()).unwrap(); + std::fs::write(&file_a, b"x").unwrap(); + std::fs::write(&file_b, b"y").unwrap(); + let events = vec![ + ev_hydrate(file_a.to_str().unwrap(), "b3:cafe1234"), + ev_hydrate(file_b.to_str().unwrap(), "b3:cafe1234"), + ]; + + let (mutations, stats) = build_export_mutations(&events, Some(&store)); + assert_eq!(stats.source_patches, 1, "{:?}", stats); + assert_eq!(stats.file_drops, 0); + assert_eq!(stats.missing_recipe, 0); + match &mutations[0] { + hammer_core::Mutation::SourcePatch { + repo, + commit, + build, + target_bin, + expected_hash, + .. + } => { + assert_eq!(repo, "git://example/grep.git"); + assert_eq!(commit, "deadbeef"); + assert_eq!(build.compiler, "zig-cc"); + assert_eq!(build.link, "static"); + assert_eq!(build.flags, vec!["--enable-foo".to_string()]); + // target_bin es el primer path alfabético del grupo. + let tb = std::path::PathBuf::from(target_bin); + assert!(tb.ends_with("usr/bin/grep"), "{target_bin}"); + assert_eq!(expected_hash.as_deref(), Some("b3:cafe1234")); + } + other => panic!("esperaba SourcePatch, llegó {other:?}"), + } + } + + #[test] + fn hydrate_without_recipe_falls_back_to_file_drop() { + let d = tempfile::tempdir().unwrap(); + let store_root = d.path().join("store"); + let store = hammer_core::Store::open(&store_root).unwrap(); + // Creamos el artefacto SIN sidecar. + std::fs::create_dir_all(store_root.join("badbeef-grep")).unwrap(); + + let target = d.path().join("usr/bin/grep"); + std::fs::create_dir_all(target.parent().unwrap()).unwrap(); + std::fs::write(&target, b"binary").unwrap(); + let events = vec![ev_hydrate(target.to_str().unwrap(), "b3:badbeef")]; + + let (mutations, stats) = build_export_mutations(&events, Some(&store)); + assert_eq!(stats.source_patches, 0); + assert_eq!(stats.file_drops, 1); + assert_eq!(stats.missing_recipe, 1, "debe contar el artefacto sin sidecar"); + assert!(matches!(mutations[0], hammer_core::Mutation::FileDrop { .. })); + } + + #[test] + fn mixed_traceable_and_external_emit_both_kinds() { + let d = tempfile::tempdir().unwrap(); + let store_root = d.path().join("store"); + let store = hammer_core::Store::open(&store_root).unwrap(); + let art_dir = store_root.join("aaaa1111-grep"); + write_recipe_sidecar(&art_dir, "grep", "feedface"); + + let traced = d.path().join("usr/bin/grep"); + std::fs::create_dir_all(traced.parent().unwrap()).unwrap(); + std::fs::write(&traced, b"x").unwrap(); + let edited = d.path().join("etc/grep.conf"); + std::fs::create_dir_all(edited.parent().unwrap()).unwrap(); + std::fs::write(&edited, b"OPTS=\n").unwrap(); + + let events = vec![ + ev_hydrate(traced.to_str().unwrap(), "b3:aaaa1111"), + ev_external(edited.to_str().unwrap()), + ]; + let (mutations, stats) = build_export_mutations(&events, Some(&store)); + assert_eq!(stats.source_patches, 1); + assert_eq!(stats.file_drops, 1); + // Orden: source_patches primero (BTreeMap por hash), luego file_drops. + assert!(matches!(mutations[0], hammer_core::Mutation::SourcePatch { .. })); + assert!(matches!(mutations[1], hammer_core::Mutation::FileDrop { .. })); + } + + #[test] + fn unreadable_path_in_fallback_only_warns() { + // ev_external apuntando a un path inexistente: el evento se cuenta como warning + // y queda fuera del .swm. La función no falla. + let events = vec![ev_external("/no/existe/seguramente/x")]; + let (mutations, stats) = build_export_mutations(&events, None); + assert_eq!(stats.warnings, 1); + assert_eq!(stats.file_drops, 0); + assert!(mutations.is_empty()); + } + +} diff --git a/crates/hammer-core/src/recipe.rs b/crates/hammer-core/src/recipe.rs index cced4da2..9cf6c7c5 100644 --- a/crates/hammer-core/src/recipe.rs +++ b/crates/hammer-core/src/recipe.rs @@ -171,6 +171,12 @@ impl Recipe { toml::from_str(s).map_err(|e| crate::Error::Recipe(e.to_string())) } + /// Serializa la receta a TOML. Útil para el sidecar de provenance que `hammer-build` + /// escribe junto al artefacto en el store. `base_dir` no se serializa (es transient). + pub fn to_toml(&self) -> crate::Result { + toml::to_string(self).map_err(|e| crate::Error::Recipe(e.to_string())) + } + /// Carga una receta desde el disco. `base_dir` se fija al directorio que contiene el /// archivo, de modo que `source.patches` resuelve relativo a ahí (estilo Cargo). pub fn load_from_path(path: impl AsRef) -> crate::Result { @@ -266,6 +272,26 @@ build = ["pcre2"] assert_eq!(r.deps.build, vec!["pcre2".to_string()]); } + #[test] + fn to_toml_roundtrips_via_from_toml() { + let r1 = Recipe::from_toml(SAMPLE).unwrap(); + let serialized = r1.to_toml().expect("to_toml"); + let r2 = Recipe::from_toml(&serialized).expect("from_toml(roundtrip)"); + assert_eq!(r2.name, r1.name); + assert_eq!(r2.version, r1.version); + assert_eq!(r2.build.compiler, r1.build.compiler); + assert_eq!(r2.build.link, r1.build.link); + assert_eq!(r2.build.flags, r1.build.flags); + assert_eq!(r2.deps.build, r1.deps.build); + // El kind del source también roundtripea. + match (r1.source.kind().unwrap(), r2.source.kind().unwrap()) { + (SourceKind::Git { commit: c1, .. }, SourceKind::Git { commit: c2, .. }) => { + assert_eq!(c1, c2); + } + _ => panic!("source kind cambió en el roundtrip"), + } + } + #[test] fn parse_tarball_source() { let s = r#" diff --git a/crates/hammer-core/src/store.rs b/crates/hammer-core/src/store.rs index 629b25e9..71908511 100644 --- a/crates/hammer-core/src/store.rs +++ b/crates/hammer-core/src/store.rs @@ -6,6 +6,12 @@ use std::path::{Path, PathBuf}; use crate::hash::ArtifactHash; +use crate::recipe::Recipe; + +/// Ruta relativa, dentro de cada artefacto sellado, donde el lab guarda la receta original +/// como sidecar de provenance. Estable: el receptor de un `.swm` exportado puede asumir +/// este path para reconstruir la receta de un artefacto cualquiera. +pub const RECIPE_SIDECAR_REL: &str = ".hammer/recipe.toml"; pub struct Store { root: PathBuf, @@ -71,6 +77,28 @@ impl Store { } } + /// Lee la receta sidecar (`.hammer/recipe.toml`) dentro de un artefacto sellado, si + /// existe. Devuelve `None` para artefactos viejos sin sidecar (builds anteriores a la + /// introducción del provenance) o cuando el sidecar fue eliminado a mano. Es + /// best-effort: nunca falla por ausencia, sólo por sidecar corrupto. + pub fn recipe_for_dir(&self, artifact_dir: &Path) -> crate::Result> { + let p = artifact_dir.join(RECIPE_SIDECAR_REL); + if !p.is_file() { + return Ok(None); + } + let text = std::fs::read_to_string(&p)?; + Recipe::from_toml(&text).map(Some) + } + + /// Como `recipe_for_dir` pero resuelve el artefacto por hash (con o sin `b3:`). + pub fn recipe_for_hash(&self, hex_or_prefixed: &str) -> crate::Result> { + let dir = match self.find_by_hash(hex_or_prefixed) { + Ok(d) => d, + Err(_) => return Ok(None), + }; + self.recipe_for_dir(&dir) + } + /// Sella el árbol de salida de un build en el store bajo su hash. /// /// - Si el destino ya existe (caché), devuelve la ruta sin tocar `out_dir`. @@ -193,6 +221,67 @@ mod tests { assert!(err.contains("aaa111-foo") && err.contains("aaa222-bar"), "{err}"); } + #[test] + fn recipe_for_dir_returns_none_without_sidecar() { + let store_dir = tempfile::tempdir().unwrap(); + let store = Store::open(store_dir.path()).unwrap(); + let art = store_dir.path().join("aaa-foo"); + std::fs::create_dir_all(&art).unwrap(); + let r = store.recipe_for_dir(&art).unwrap(); + assert!(r.is_none()); + } + + #[test] + fn recipe_for_dir_reads_sidecar() { + let store_dir = tempfile::tempdir().unwrap(); + let store = Store::open(store_dir.path()).unwrap(); + let art = store_dir.path().join("aaa-foo"); + std::fs::create_dir_all(art.join(".hammer")).unwrap(); + std::fs::write( + art.join(RECIPE_SIDECAR_REL), + r#" +name = "grep" +version = "3.12" +[source] +repo = "git://example/grep.git" +commit = "deadbeef" +[build] +compiler = "zig-cc" +target = "x86_64-linux-musl" +link = "static" +flags = [] +"#, + ) + .unwrap(); + let r = store.recipe_for_dir(&art).unwrap().expect("Some"); + assert_eq!(r.name, "grep"); + assert_eq!(r.version, "3.12"); + } + + #[test] + fn recipe_for_hash_resolves_by_prefix_then_reads() { + let store_dir = tempfile::tempdir().unwrap(); + let store = Store::open(store_dir.path()).unwrap(); + let art = store.root().join("cafe1234-demo"); + std::fs::create_dir_all(art.join(".hammer")).unwrap(); + std::fs::write( + art.join(RECIPE_SIDECAR_REL), + r#" +name = "demo" +version = "0.1" +[source] +tarball = "https://x/y.tar.gz" +sha256 = "abc123" +[build] +"#, + ) + .unwrap(); + let r = store.recipe_for_hash("cafe").unwrap().expect("Some"); + assert_eq!(r.name, "demo"); + // Hash inexistente: None (no error). + assert!(store.recipe_for_hash("ffffff").unwrap().is_none()); + } + #[test] fn seal_is_idempotent_when_dst_exists() { let store_dir = tempfile::tempdir().unwrap(); diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md index fbeb5a9e..876877ec 100644 --- a/docs/10-roadmap.md +++ b/docs/10-roadmap.md @@ -67,9 +67,11 @@ pre-requisito de validació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. +- [x] Provenance en `export`: mapa artefacto→receta vía sidecar `.hammer/recipe.toml` que + `hammer-build::build` escribe dentro del artefacto antes de sellar. `hammer export` + agrupa eventos por `artifact_hash` y emite UN `source_patch` por grupo cuya receta + sea recuperable; lo demás cae al fallback `file_drop`. Source `git` modelado; + `tarball` cae a file_drop con warning (pendiente extender SourcePatch). - [ ] 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