> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
Fase 4 — provenance en `hammer export` (sidecar de receta en el store)
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.
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,25 +616,222 @@ 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<PathBuf, &hammer_journal::MutationEvent> =
- std::collections::BTreeMap::new();
- for ev in &filtered {
+ // 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
+ }
+ };
+
+ let (mutations, stats) = build_export_mutations(&filtered, store_opt.as_ref());
+
+ let swm = hammer_core::Swm {
+ swm_version: 1,
+ base,
+ mutations,
+ signature: None,
+ };
+ let yaml = swm.to_yaml()?;
+ print!("{yaml}");
+ eprintln!(
+ "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<hammer_core::Mutation>, ExportStats) {
+ use base64::{engine::general_purpose::STANDARD, Engine as _};
+ use std::collections::BTreeMap;
+
+ // Paso 1: dedup respetando Delete.
+ let mut last_by_path: BTreeMap<PathBuf, &hammer_journal::MutationEvent> = BTreeMap::new();
+ for ev in events {
if matches!(ev.op, hammer_journal::MutationOp::Delete) {
- last_by_path.remove(&ev.path); // borrado anula cualquier estado previo
+ last_by_path.remove(&ev.path);
continue;
}
last_by_path.insert(ev.path.clone(), ev);
}
- let mut mutations = Vec::new();
- let mut warnings = 0usize;
+ 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<path>). Los paths se ordenarán antes de
+ // emitir, para que el target_bin sea determinista (importante para tests).
+ let mut groups: BTreeMap<String, (hammer_core::Recipe, Vec<PathBuf>)> = BTreeMap::new();
+ let mut fallback: Vec<(PathBuf, &hammer_journal::MutationEvent)> = Vec::new();
+
+ let mut artifacts_seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
+ let mut artifacts_without_recipe: std::collections::BTreeSet<String> =
+ std::collections::BTreeSet::new();
+
for (path, ev) in &last_by_path {
- let bytes = match std::fs::read(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<hammer_core::Mutation> = 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<PathBuf> = 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) => {
- warnings += 1;
+ stats.warnings += 1;
eprintln!(
"warning: no pude leer {} para exportar (ev ts={}): {e}",
path.display(),
@@ -646,44 +842,46 @@ fn run_export(
};
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,
});
+ stats.file_drops += 1;
}
- 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(())
+ (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<String> {
+ 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 +
@@ -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<String> {
+ 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<Path>) -> crate::Result<Recipe> {
@@ -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<Option<Recipe>> {
+ 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<Option<Recipe>> {
+ 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