> 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…
hammer-mirror: mirror del store content-addressed (Etapa E3, primer corte)
Nuevo crate hammer-mirror + CLI `hammer mirror push|pull|status <remote>`. El /store es un
CAS (dir `<hash>-<name>`, el hash ES la dirección); un mirror = ese CAS replicado entre
máquinas + resolución por hash. La integridad se ancla en of_tree (content-hash BLAKE3 del
árbol): el receptor RECOMPUTA of_tree sobre lo copiado y exige que case el del índice ANTES
de sellar (Store::seal, atómico + read-only en un staging del mismo FS) ⇒ una transferencia
corrupta/manipulada se RECHAZA, no se instala.
- index(store): enumera dirs `<64hex>-<name>` (filtra bootstrap.json/.bootstrap-tmp), of_tree
cada uno. diff(src,dst): faltantes en cada lado + conflictos (mismo dir, otro contenido).
- sync(src,dst): copia los que faltan (verificados), idempotente, no sobrescribe conflictos.
push = sync(local,remoto); pull = sync(remoto,local).
- Transporte = filesystem (el remoto es una ruta a otro store, p.ej. sshfs); SSH/red queda
como envoltorio ortogonal posterior. Endurecimiento: anclar of_tree a una raíz firmada
(bootstrap.json / atestación D) vs un origen plenamente malicioso.
5 tests unitarios (forma de dir, índice, sync idempotente, rechazo de transferencia corrupta,
conflicto-no-sobrescribe) + validado E2E con el CLI sobre artefactos reales (push/pull/status,
idempotencia, bytes idénticos, tamper→conflicto, conflicto no sobrescrito exit≠0).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
diff --git a/Cargo.toml b/Cargo.toml
index d1f99bc3..5f6f8ccb 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,6 +6,7 @@ members = [
"crates/hammer-bootstrap",
"crates/hammer-overlay",
"crates/hammer-journal",
+ "crates/hammer-mirror",
"crates/hammer-agent",
"crates/hammer-cli",
"crates/hammerd",
@@ -25,6 +26,7 @@ hammer-build = { path = "crates/hammer-build" }
hammer-bootstrap = { path = "crates/hammer-bootstrap" }
hammer-overlay = { path = "crates/hammer-overlay" }
hammer-journal = { path = "crates/hammer-journal" }
+hammer-mirror = { path = "crates/hammer-mirror" }
hammer-agent = { path = "crates/hammer-agent" }
# shared third-party deps, pinned at the workspace level
diff --git a/crates/hammer-cli/Cargo.toml b/crates/hammer-cli/Cargo.toml
index af7aa368..ca789a34 100644
--- a/crates/hammer-cli/Cargo.toml
+++ b/crates/hammer-cli/Cargo.toml
@@ -21,6 +21,7 @@ llm-claude = ["hammer-agent/llm-claude"]
hammer-core.workspace = true
hammer-build.workspace = true
hammer-bootstrap.workspace = true
+hammer-mirror.workspace = true
hammer-overlay.workspace = true
hammer-journal.workspace = true
hammer-agent.workspace = true
diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index c0fed1ba..ebf14d3a 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -239,6 +239,31 @@ enum Cmd {
#[command(subcommand)]
sub: BootstrapCmd,
},
+ /// [Etapa E3] Mirror del store content-addressed: replica artefactos por hash entre máquinas,
+ /// verificando `of_tree` (BLAKE3) en recepción. `--store` es el store local.
+ Mirror {
+ #[command(subcommand)]
+ sub: MirrorCmd,
+ },
+}
+
+#[derive(Subcommand)]
+enum MirrorCmd {
+ /// Replica al store REMOTO los artefactos que le faltan (verificados antes de sellar).
+ Push {
+ /// Ruta del store remoto destino (puede ser un montaje de red/sshfs).
+ remote: String,
+ },
+ /// Trae del store REMOTO los artefactos que le faltan al local (verificados antes de sellar).
+ Pull {
+ /// Ruta del store remoto origen.
+ remote: String,
+ },
+ /// Muestra el diff entre el store local y uno remoto (qué falta en cada lado, conflictos). No copia.
+ Status {
+ /// Ruta del store remoto a comparar.
+ remote: String,
+ },
}
#[derive(Subcommand)]
@@ -742,10 +767,61 @@ fn main() -> anyhow::Result<()> {
println!("{}", serde_json::to_string_pretty(&manifest)?);
}
},
+ Cmd::Mirror { sub } => {
+ let local = std::path::PathBuf::from(&cli.store);
+ match sub {
+ MirrorCmd::Push { remote } => {
+ let remote = std::path::PathBuf::from(&remote);
+ let r = hammer_mirror::sync(&local, &remote)?;
+ print_sync("push", &r);
+ if !r.ok() {
+ anyhow::bail!("mirror push incompleto: {} fallo(s), {} conflicto(s)", r.failed.len(), r.conflicts.len());
+ }
+ }
+ MirrorCmd::Pull { remote } => {
+ let remote = std::path::PathBuf::from(&remote);
+ let r = hammer_mirror::sync(&remote, &local)?;
+ print_sync("pull", &r);
+ if !r.ok() {
+ anyhow::bail!("mirror pull incompleto: {} fallo(s), {} conflicto(s)", r.failed.len(), r.conflicts.len());
+ }
+ }
+ MirrorCmd::Status { remote } => {
+ let remote = std::path::PathBuf::from(&remote);
+ let (missing_remote, missing_local, conflicts) = hammer_mirror::diff(&local, &remote)?;
+ println!("faltan en el remoto (push los lleva): {}", missing_remote.len());
+ for a in &missing_remote { println!(" → {}", a.dir); }
+ println!("faltan en el local (pull los trae): {}", missing_local.len());
+ for a in &missing_local { println!(" ← {}", a.dir); }
+ println!("conflictos (mismo hash, otro contenido): {}", conflicts.len());
+ for d in &conflicts { println!(" ✗ {d}"); }
+ }
+ }
+ }
}
Ok(())
}
+/// Imprime el reporte de un `mirror push|pull`.
+fn print_sync(verbo: &str, r: &hammer_mirror::SyncReport) {
+ for d in &r.copied {
+ println!(" ✓ {d}");
+ }
+ for (d, why) in &r.failed {
+ println!(" ✗ {d}: {why}");
+ }
+ for d in &r.conflicts {
+ println!(" ⚠ conflicto {d} (no sobrescrito)");
+ }
+ println!(
+ "mirror {verbo}: {} copiados, {} ya presentes, {} conflictos, {} fallos",
+ r.copied.len(),
+ r.skipped_present,
+ r.conflicts.len(),
+ r.failed.len()
+ );
+}
+
/// Lee y deserializa un `.swm` desde disco; falla con mensaje útil si no parsea.
fn load_swm(file: &str) -> anyhow::Result<hammer_core::Swm> {
let yaml = std::fs::read_to_string(file)
diff --git a/crates/hammer-mirror/Cargo.toml b/crates/hammer-mirror/Cargo.toml
new file mode 100644
index 00000000..b017236e
--- /dev/null
+++ b/crates/hammer-mirror/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "hammer-mirror"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+repository.workspace = true
+description = "Mirror del store content-addressed de hammer (E3): replica artefactos por hash, verifica of_tree en recepción."
+
+[dependencies]
+hammer-core.workspace = true
+anyhow.workspace = true
+thiserror.workspace = true
+tracing.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+
+[dev-dependencies]
+tempfile.workspace = true
diff --git a/crates/hammer-mirror/src/lib.rs b/crates/hammer-mirror/src/lib.rs
new file mode 100644
index 00000000..4416e289
--- /dev/null
+++ b/crates/hammer-mirror/src/lib.rs
@@ -0,0 +1,364 @@
+//! **E3 — mirror del store content-addressed** ([SDD 13 §E3](../../docs/13-release-engineering.md)).
+//!
+//! El `/store` de hammer es un CAS: cada artefacto vive en un directorio `<hash>-<name>` y *el hash es
+//! la dirección*. Un mirror, entonces, es ese CAS **replicado** entre dos máquinas + **resolución por
+//! hash**. Esta capa replica los artefactos que le faltan al destino y **ancla la integridad en
+//! `of_tree`** (el content-hash BLAKE3 del árbol, [`ArtifactHash::of_tree`]): el receptor **recomputa**
+//! `of_tree` sobre lo que copió y **exige** que case el del índice **antes** de sellar. Una transferencia
+//! corrupta (o un origen que sirvió bytes que no casan el hash anunciado) se **rechaza**, no se instala.
+//!
+//! El transporte de esta primera entrega es **sistema de ficheros**: el "remoto" es una ruta a otro
+//! store (que puede ser un montaje de red, sshfs, etc.). El transporte sobre SSH/red —que el producto ya
+//! expone vía sshd— es un envoltorio ortogonal posterior; la lógica de CAS (índice, diff, copia
+//! verificada, sellado atómico) vive aquí y es agnóstica del medio.
+//!
+//! **Modelo de confianza:** la verificación `of_tree` detecta corrupción de transporte y que el origen
+//! sirva un árbol que no case el hash que anuncia. Anclar ese hash a una raíz de confianza firmada
+//! (el log de transparencia `bootstrap.json` / la atestación de Etapa D) es la capa de endurecimiento
+//! posterior: hoy el invariante CAS que se *fuerza* es "el contenido recibido HASHEA a lo que el índice
+//! dice".
+
+use std::path::Path;
+
+use hammer_core::{ArtifactHash, Store};
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ #[error("io: {0}")]
+ Io(#[from] std::io::Error),
+ #[error("core: {0}")]
+ Core(#[from] hammer_core::Error),
+ #[error(
+ "integridad: el artefacto '{dir}' recibido hashea a {got} pero el índice anuncia {want} — \
+ transferencia corrupta o manipulada; NO se selló"
+ )]
+ Verify { dir: String, want: String, got: String },
+ #[error("mirror: {0}")]
+ Other(String),
+}
+
+pub type Result<T> = std::result::Result<T, Error>;
+
+/// Una entrada del índice del store: el directorio del artefacto (`<hash>-<name>`) y su **content-hash**
+/// (`of_tree`, `b3:…`). El `dir` es la dirección (input-addressed) y `content` la huella de los bytes:
+/// dos artefactos con el mismo `dir` deben tener el mismo `content` (coherencia del CAS).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ArtifactRef {
+ pub dir: String,
+ pub content: String,
+}
+
+/// Índice de un store: la lista de sus artefactos con su content-hash, ordenada por `dir` (estable).
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct StoreIndex {
+ pub artifacts: Vec<ArtifactRef>,
+}
+
+impl StoreIndex {
+ /// Busca un artefacto por su `dir`.
+ pub fn get(&self, dir: &str) -> Option<&ArtifactRef> {
+ self.artifacts.iter().find(|a| a.dir == dir)
+ }
+}
+
+/// ¿El nombre de directorio tiene forma de artefacto del store? = 64 hex + `-` + `<name>` no vacío. Así
+/// se ignoran `bootstrap.json`, `.bootstrap-tmp`, índices y otros ficheros del root del store.
+fn is_artifact_dir(name: &str) -> bool {
+ let bytes = name.as_bytes();
+ bytes.len() > 65
+ && bytes[64] == b'-'
+ && bytes[..64].iter().all(|b| b.is_ascii_hexdigit())
+}
+
+/// Parte `<64hex>-<name>` en `(ArtifactHash, name)`. El `name` puede contener `-` (p.ej.
+/// `product-attested-rootfs`); sólo el primer `-`, en la posición 64, separa el hash.
+fn parse_dir(dir: &str) -> Option<(ArtifactHash, String)> {
+ if !is_artifact_dir(dir) {
+ return None;
+ }
+ Some((ArtifactHash::from_hex(&dir[..64]), dir[65..].to_string()))
+}
+
+/// Construye el índice de un store: para cada directorio-artefacto, su `of_tree`. Un store inexistente
+/// se trata como vacío (el destino aún no creado en un primer `pull`). Determinista (ordenado por `dir`).
+pub fn index(store_root: &Path) -> Result<StoreIndex> {
+ let mut artifacts = Vec::new();
+ if store_root.is_dir() {
+ for entry in std::fs::read_dir(store_root)? {
+ let entry = entry?;
+ let name = entry.file_name().to_string_lossy().into_owned();
+ if !is_artifact_dir(&name) || !entry.path().is_dir() {
+ continue;
+ }
+ let content = ArtifactHash::of_tree(&entry.path())?.as_str().to_string();
+ artifacts.push(ArtifactRef { dir: name, content });
+ }
+ }
+ artifacts.sort_by(|a, b| a.dir.cmp(&b.dir));
+ Ok(StoreIndex { artifacts })
+}
+
+/// Diferencia entre dos stores: `(faltan_en_dst, faltan_en_src, conflictos)`. Un **conflicto** es un
+/// artefacto presente en ambos con el MISMO `dir` pero DISTINTO `content` (misma dirección, otros bytes:
+/// rotura de la coherencia del CAS — no-reproducibilidad o corrupción). No copia nada.
+pub fn diff(
+ src_root: &Path,
+ dst_root: &Path,
+) -> Result<(Vec<ArtifactRef>, Vec<ArtifactRef>, Vec<String>)> {
+ let si = index(src_root)?;
+ let di = index(dst_root)?;
+ let mut missing_in_dst = Vec::new();
+ let mut conflicts = Vec::new();
+ for a in &si.artifacts {
+ match di.get(&a.dir) {
+ None => missing_in_dst.push(a.clone()),
+ Some(d) if d.content != a.content => conflicts.push(a.dir.clone()),
+ Some(_) => {}
+ }
+ }
+ let missing_in_src = di
+ .artifacts
+ .iter()
+ .filter(|d| si.get(&d.dir).is_none())
+ .cloned()
+ .collect();
+ Ok((missing_in_dst, missing_in_src, conflicts))
+}
+
+/// Reporte de un [`sync`]: qué se copió, cuántos ya estaban, conflictos y fallos de verificación.
+#[derive(Debug, Clone, Default)]
+pub struct SyncReport {
+ /// `dir`s copiados+verificados+sellados en el destino.
+ pub copied: Vec<String>,
+ /// Ya presentes con el mismo content-hash (idempotencia).
+ pub skipped_present: usize,
+ /// Presentes en ambos con content-hash distinto (CAS incoherente; NO se sobrescriben).
+ pub conflicts: Vec<String>,
+ /// `(dir, motivo)` de los que fallaron la verificación de integridad (no se sellaron).
+ pub failed: Vec<(String, String)>,
+}
+
+impl SyncReport {
+ /// Éxito = nada falló la verificación y no hubo conflictos.
+ pub fn ok(&self) -> bool {
+ self.failed.is_empty() && self.conflicts.is_empty()
+ }
+}
+
+/// Copia el árbol de un artefacto a un staging bajo el destino, **recomputa `of_tree`**, exige que case
+/// `expected_content` y sólo entonces lo **sella** en `dst_store` (rename atómico + read-only, vía
+/// [`Store::seal`]). El staging vive bajo el root del destino ⇒ mismo filesystem ⇒ el rename del seal es
+/// atómico; ante un hash que no casa, se borra el staging y NO se instala nada.
+fn install_verified(
+ src_dir: &Path,
+ dst_store: &Store,
+ h: &ArtifactHash,
+ name: &str,
+ expected_content: &str,
+) -> Result<()> {
+ let dir_label = format!("{}-{name}", h.as_str().trim_start_matches("b3:"));
+ let staging = dst_store
+ .root()
+ .join(".mirror-tmp")
+ .join(format!("{dir_label}.incoming"));
+ let _ = std::fs::remove_dir_all(&staging);
+ if let Some(parent) = staging.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+
+ let result = (|| -> Result<()> {
+ copy_tree(src_dir, &staging)?;
+ let got = ArtifactHash::of_tree(&staging)?;
+ if got.as_str() != expected_content {
+ return Err(Error::Verify {
+ dir: dir_label.clone(),
+ want: expected_content.to_string(),
+ got: got.as_str().to_string(),
+ });
+ }
+ dst_store.seal(&staging, h, name)?;
+ Ok(())
+ })();
+ let _ = std::fs::remove_dir_all(&staging);
+ result
+}
+
+/// **Sincroniza** `src`→`dst`: copia a `dst` cada artefacto que le falta (verificando `of_tree` antes de
+/// sellar), salta los ya presentes con el mismo contenido y reporta conflictos (mismo `dir`, otro
+/// contenido). Idempotente: re-correr sobre un destino ya al día copia cero. Es el motor de `push`
+/// (`sync(local, remoto)`) y `pull` (`sync(remoto, local)`).
+pub fn sync(src_root: &Path, dst_root: &Path) -> Result<SyncReport> {
+ let si = index(src_root)?;
+ let di = index(dst_root)?;
+ let dst_store = Store::open(dst_root)?;
+ let mut report = SyncReport::default();
+
+ for a in &si.artifacts {
+ match di.get(&a.dir) {
+ Some(d) if d.content == a.content => {
+ report.skipped_present += 1;
+ continue;
+ }
+ Some(_) => {
+ report.conflicts.push(a.dir.clone());
+ continue;
+ }
+ None => {}
+ }
+ let (h, name) = match parse_dir(&a.dir) {
+ Some(p) => p,
+ None => continue, // index() ya filtra, defensivo
+ };
+ match install_verified(&src_root.join(&a.dir), &dst_store, &h, &name, &a.content) {
+ Ok(()) => {
+ tracing::info!(dir = %a.dir, "mirror: artefacto replicado y verificado");
+ report.copied.push(a.dir.clone());
+ }
+ Err(e) => {
+ tracing::warn!(dir = %a.dir, error = %e, "mirror: fallo de replicación");
+ report.failed.push((a.dir.clone(), e.to_string()));
+ }
+ }
+ }
+ Ok(report)
+}
+
+/// Copia recursiva `src`→`dst` preservando symlinks (target literal) y modos (incl. bit de ejecución,
+/// que `of_tree` mira). El destino se asume inexistente (staging recién limpiado).
+fn copy_tree(src: &Path, dst: &Path) -> Result<()> {
+ let meta = std::fs::symlink_metadata(src)?;
+ let ft = meta.file_type();
+ if ft.is_symlink() {
+ let target = std::fs::read_link(src)?;
+ std::os::unix::fs::symlink(target, dst)?;
+ } else if ft.is_dir() {
+ std::fs::create_dir_all(dst)?;
+ for entry in std::fs::read_dir(src)? {
+ let entry = entry?;
+ copy_tree(&entry.path(), &dst.join(entry.file_name()))?;
+ }
+ } else {
+ std::fs::copy(src, dst)?;
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Sella un artefacto sintético `<hex>-<name>` en `root` con los ficheros que pinte `build`.
+ fn seal(root: &Path, hex: &str, name: &str, build: impl Fn(&Path)) -> String {
+ let store = Store::open(root).unwrap();
+ let h = ArtifactHash::from_hex(hex);
+ let staging = root.join(".bootstrap-tmp").join(h.store_dir_name(name));
+ let _ = std::fs::remove_dir_all(&staging);
+ std::fs::create_dir_all(&staging).unwrap();
+ build(&staging);
+ store.seal(&staging, &h, name).unwrap();
+ h.store_dir_name(name)
+ }
+
+ fn h64(prefix: &str) -> String {
+ format!("{prefix}{}", "0".repeat(64 - prefix.len()))
+ }
+
+ #[test]
+ fn artifact_dir_shape_filters_non_artifacts() {
+ assert!(is_artifact_dir(&format!("{}-busybox", h64("ab"))));
+ assert!(is_artifact_dir(&format!("{}-product-attested-rootfs", h64("ff"))));
+ assert!(!is_artifact_dir("bootstrap.json"));
+ assert!(!is_artifact_dir(".bootstrap-tmp"));
+ assert!(!is_artifact_dir(&format!("{}-", h64("ab"))), "sin name");
+ assert!(!is_artifact_dir(&format!("{}xy-z", h64("ab"))), "65 chars no-hex antes del -");
+ // parse_dir respeta los '-' del name.
+ let (hh, name) = parse_dir(&format!("{}-find-utils-xargs", h64("cd"))).unwrap();
+ assert_eq!(name, "find-utils-xargs");
+ assert!(hh.as_str().starts_with("b3:"));
+ }
+
+ #[test]
+ fn index_lists_artifacts_with_content_hash_and_skips_junk() {
+ let tmp = tempfile::tempdir().unwrap();
+ let root = tmp.path();
+ seal(root, &h64("aa"), "busybox", |w| {
+ std::fs::write(w.join("bin"), b"\x7fELF").unwrap();
+ });
+ // ruido del root del store que NO debe entrar al índice.
+ std::fs::write(root.join("bootstrap.json"), b"{}").unwrap();
+ std::fs::create_dir_all(root.join(".bootstrap-tmp")).unwrap();
+
+ let idx = index(root).unwrap();
+ assert_eq!(idx.artifacts.len(), 1, "sólo el artefacto, no bootstrap.json ni .bootstrap-tmp");
+ assert!(idx.artifacts[0].dir.ends_with("-busybox"));
+ assert!(idx.artifacts[0].content.starts_with("b3:"));
+ // content == of_tree real del dir.
+ let ot = ArtifactHash::of_tree(&root.join(&idx.artifacts[0].dir)).unwrap();
+ assert_eq!(idx.artifacts[0].content, ot.as_str());
+ }
+
+ #[test]
+ fn sync_replicates_missing_and_is_idempotent() {
+ let src = tempfile::tempdir().unwrap();
+ let dst = tempfile::tempdir().unwrap();
+ seal(src.path(), &h64("aa"), "busybox", |w| {
+ std::fs::write(w.join("bin"), b"\x7fELF-bb").unwrap();
+ std::os::unix::fs::symlink("bin", w.join("sh")).unwrap();
+ });
+ seal(src.path(), &h64("bb"), "uutils", |w| {
+ std::fs::create_dir_all(w.join("usr/bin")).unwrap();
+ std::fs::write(w.join("usr/bin/coreutils"), b"\x7fELF-uu").unwrap();
+ });
+
+ let r = sync(src.path(), dst.path()).unwrap();
+ assert!(r.ok());
+ assert_eq!(r.copied.len(), 2);
+ // el destino quedó con los mismos artefactos y mismos content-hash.
+ let (md, ms, conf) = diff(src.path(), dst.path()).unwrap();
+ assert!(md.is_empty() && conf.is_empty(), "destino al día");
+ assert!(ms.is_empty(), "el destino no tiene de más");
+ // los bytes (y el symlink) viajaron.
+ let bbdir = dst.path().join(format!("{}-busybox", h64("aa")));
+ assert_eq!(std::fs::read(bbdir.join("bin")).unwrap(), b"\x7fELF-bb");
+ assert_eq!(std::fs::read_link(bbdir.join("sh")).unwrap(), std::path::PathBuf::from("bin"));
+
+ // 2ª corrida: idempotente, copia 0.
+ let r2 = sync(src.path(), dst.path()).unwrap();
+ assert!(r2.copied.is_empty() && r2.skipped_present == 2);
+ assert!(!dst.path().join(".mirror-tmp").join(format!("{}-busybox.incoming", h64("aa"))).exists(),
+ "el staging se limpió");
+ }
+
+ #[test]
+ fn sync_rejects_corrupted_transfer_without_sealing() {
+ // El guardrail de integridad: si lo que llega NO hashea a lo que el índice anuncia, se rechaza.
+ let dst = tempfile::tempdir().unwrap();
+ let src_dir = tempfile::tempdir().unwrap();
+ std::fs::write(src_dir.path().join("bin"), b"bytes-reales").unwrap();
+ let store = Store::open(dst.path()).unwrap();
+ let h = ArtifactHash::from_hex(&h64("cc"));
+ // expected_content MENTIROSO (no es el of_tree real del src) ⇒ debe fallar Verify y no sellar.
+ let err = install_verified(src_dir.path(), &store, &h, "evil", "b3:deadbeef").unwrap_err();
+ assert!(matches!(err, Error::Verify { .. }), "esperaba Verify, vino {err}");
+ assert!(!store.has(&h, "evil"), "un artefacto que no verifica NO se sella");
+ assert!(!dst.path().join(".mirror-tmp").join(format!("{}-evil.incoming", h64("cc"))).exists(),
+ "staging limpiado tras el rechazo");
+ }
+
+ #[test]
+ fn sync_flags_conflicts_and_does_not_overwrite() {
+ // Mismo dir en src y dst pero contenido distinto = conflicto (CAS incoherente). No se pisa.
+ let src = tempfile::tempdir().unwrap();
+ let dst = tempfile::tempdir().unwrap();
+ seal(src.path(), &h64("dd"), "x", |w| std::fs::write(w.join("f"), b"SRC").unwrap());
+ seal(dst.path(), &h64("dd"), "x", |w| std::fs::write(w.join("f"), b"DST-distinto").unwrap());
+
+ let r = sync(src.path(), dst.path()).unwrap();
+ assert!(!r.ok(), "hay conflicto ⇒ no-ok");
+ assert_eq!(r.conflicts.len(), 1);
+ assert!(r.copied.is_empty());
+ // el destino conserva SUS bytes (no se sobrescribió).
+ assert_eq!(std::fs::read(dst.path().join(format!("{}-x", h64("dd"))).join("f")).unwrap(), b"DST-distinto");
+ }
+}
diff --git a/docs/13-release-engineering.md b/docs/13-release-engineering.md
index 1f3e44fa..14fd397d 100644
--- a/docs/13-release-engineering.md
+++ b/docs/13-release-engineering.md
@@ -52,8 +52,17 @@ kernel en `/boot`, los módulos GRUB en `/boot/grub/i386-pc` y los pasos `grub-m
un *product rootfs* lean es refinamiento.
- **E2 — instalador a disco físico:** un `hammer install <device>` que particiona un disco real y
vuelca las tres particiones + GRUB (la misma lógica, apuntando a `/dev/sdX` en vez de un fichero).
-- **E3 — mirror del store:** servir/replicar `/store` content-addressed (BLAKE3) entre máquinas; el
- hash ES la dirección, así que un mirror es un CAS replicado + resolución por hash.
+- **E3 — mirror del store: ✅ primer corte** (crate `hammer-mirror` + CLI `hammer mirror push|pull|status`).
+ Replica `/store` content-addressed (BLAKE3) entre máquinas; el hash ES la dirección, así que un mirror es
+ un CAS replicado + resolución por hash. La integridad se ancla en `of_tree` (content-hash del árbol): el
+ receptor **recomputa** `of_tree` sobre lo recibido y exige que case el del índice **antes** de sellar
+ (`Store::seal` atómico + read-only) — una transferencia corrupta/manipulada se **rechaza**, no se instala.
+ `push`/`pull` son `sync(src,dst)` (idempotente, salta presentes, reporta conflictos = mismo hash con otro
+ contenido). Transporte de esta entrega = **sistema de ficheros** (el remoto es una ruta a otro store, p.ej.
+ un montaje sshfs); el transporte sobre SSH/red que el producto ya expone es un envoltorio posterior.
+ Endurecimiento pendiente: anclar el `of_tree` a una raíz firmada (log de transparencia `bootstrap.json` /
+ atestación de Etapa D) para defenderse de un origen plenamente malicioso (hoy el invariante forzado es
+ "el contenido recibido hashea a lo que el índice anuncia", que ataja corrupción de transporte).
- **E4 — upgrades:** aplicar un nuevo árbol Stage 1 sin reinstalar — atómico vía el modelo overlay +
el journal (D), con rollback al árbol anterior.
- **E5 — ISO/medio de arranque:** medio live para correr el instalador (xorriso/grub-mkrescue;