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:
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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#"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user