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>
This commit is contained in:
@@ -6,6 +6,7 @@ members = [
|
|||||||
"crates/hammer-bootstrap",
|
"crates/hammer-bootstrap",
|
||||||
"crates/hammer-overlay",
|
"crates/hammer-overlay",
|
||||||
"crates/hammer-journal",
|
"crates/hammer-journal",
|
||||||
|
"crates/hammer-mirror",
|
||||||
"crates/hammer-agent",
|
"crates/hammer-agent",
|
||||||
"crates/hammer-cli",
|
"crates/hammer-cli",
|
||||||
"crates/hammerd",
|
"crates/hammerd",
|
||||||
@@ -25,6 +26,7 @@ hammer-build = { path = "crates/hammer-build" }
|
|||||||
hammer-bootstrap = { path = "crates/hammer-bootstrap" }
|
hammer-bootstrap = { path = "crates/hammer-bootstrap" }
|
||||||
hammer-overlay = { path = "crates/hammer-overlay" }
|
hammer-overlay = { path = "crates/hammer-overlay" }
|
||||||
hammer-journal = { path = "crates/hammer-journal" }
|
hammer-journal = { path = "crates/hammer-journal" }
|
||||||
|
hammer-mirror = { path = "crates/hammer-mirror" }
|
||||||
hammer-agent = { path = "crates/hammer-agent" }
|
hammer-agent = { path = "crates/hammer-agent" }
|
||||||
|
|
||||||
# shared third-party deps, pinned at the workspace level
|
# shared third-party deps, pinned at the workspace level
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ llm-claude = ["hammer-agent/llm-claude"]
|
|||||||
hammer-core.workspace = true
|
hammer-core.workspace = true
|
||||||
hammer-build.workspace = true
|
hammer-build.workspace = true
|
||||||
hammer-bootstrap.workspace = true
|
hammer-bootstrap.workspace = true
|
||||||
|
hammer-mirror.workspace = true
|
||||||
hammer-overlay.workspace = true
|
hammer-overlay.workspace = true
|
||||||
hammer-journal.workspace = true
|
hammer-journal.workspace = true
|
||||||
hammer-agent.workspace = true
|
hammer-agent.workspace = true
|
||||||
|
|||||||
@@ -239,6 +239,31 @@ enum Cmd {
|
|||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
sub: BootstrapCmd,
|
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)]
|
#[derive(Subcommand)]
|
||||||
@@ -742,10 +767,61 @@ fn main() -> anyhow::Result<()> {
|
|||||||
println!("{}", serde_json::to_string_pretty(&manifest)?);
|
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(())
|
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.
|
/// Lee y deserializa un `.swm` desde disco; falla con mensaje útil si no parsea.
|
||||||
fn load_swm(file: &str) -> anyhow::Result<hammer_core::Swm> {
|
fn load_swm(file: &str) -> anyhow::Result<hammer_core::Swm> {
|
||||||
let yaml = std::fs::read_to_string(file)
|
let yaml = std::fs::read_to_string(file)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
un *product rootfs* lean es refinamiento.
|
||||||
- **E2 — instalador a disco físico:** un `hammer install <device>` que particiona un disco real y
|
- **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).
|
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
|
- **E3 — mirror del store: ✅ primer corte** (crate `hammer-mirror` + CLI `hammer mirror push|pull|status`).
|
||||||
hash ES la dirección, así que un mirror es un CAS replicado + resolución por hash.
|
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 +
|
- **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.
|
el journal (D), con rollback al árbol anterior.
|
||||||
- **E5 — ISO/medio de arranque:** medio live para correr el instalador (xorriso/grub-mkrescue;
|
- **E5 — ISO/medio de arranque:** medio live para correr el instalador (xorriso/grub-mkrescue;
|
||||||
|
|||||||
Reference in New Issue
Block a user