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.
This commit is contained in:
Sergio
2026-06-10 16:49:09 +00:00
parent 5986200539
commit de036d3666
5 changed files with 582 additions and 69 deletions
+11
View File
@@ -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)
+451 -66
View File
@@ -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<PathBuf, &hammer_journal::MutationEvent> =
std::collections::BTreeMap::new();
for ev in &filtered {
if matches!(ev.op, hammer_journal::MutationOp::Delete) {
last_by_path.remove(&ev.path); // borrado anula cualquier estado previo
continue;
// 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<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);
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<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 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) => {
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<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 +
/// 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());
}
}
+26
View File
@@ -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#"
+89
View File
@@ -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();
+5 -3
View File
@@ -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