Fase 3 — diario de mutaciones (hammer-journal + fanotify + hammer journal)

Nuevo crate hammer-journal y watcher fanotify en hammerd. Cierra el contrato
del SDD 05 §3 ("toda mutación trazable/opaca registrada y consultable") y
liquida la deuda Fase 2 sobre el hook al diario en commit.

hammer-journal:
- MutationEvent (op, path, by, content_hash?, note?), MutationOp
  Create/Replace/Edit/Delete, Source enum tagged HammerHydrate /
  HammerCommit / External (distingue trazable de opaco para `export`).
- Journal: record append-only + sync_data, read_all/tail/follow (offset),
  resilencia a líneas malformadas (kernel-panic mid-write se ignora).
- RFC 3339 UTC sin chrono (Hinnant civil_from_days, válido para todo i64).
- 7 unit tests cubren append/read/tail/follow/malformed/serde/timestamp.

Watcher (hammerd):
- nix::sys::fanotify con FAN_CLASS_NOTIF + FAN_CLOSE_WRITE +
  FAN_EVENT_ON_CHILD sobre /bin /sbin /usr/bin /usr/sbin /lib /usr/lib /etc.
- Resuelve path por readlink(/proc/self/fd/<n>) — sin FAN_REPORT_DFID_NAME
  para Fase 3 v1; el refinamiento Create/Edit con metadata viene después.
- Filtro: hammer_overlay::status(state_root) → ignora paths bajo un overlay
  activo (el ruido del experimento no entra hasta el commit).
- Sin CAP_SYS_ADMIN, fanotify_init falla; hammerd lo reporta y sigue
  durmiendo (preparado para que Fase 5 monte el bus encima).
- 3 unit tests sobre defaults/filtros/uid lookup.

CLI:
- hammer journal [--dir] [--tail N] [--follow] [--format pretty|json].
- hammer commit registra cada archivo promocionado/removido vía
  commit_with_journal; --no-journal opt-out.

Hook overlay::commit:
- commit_with_journal(id, root, journal): para cada copied/removed emite un
  MutationEvent con Source::HammerCommit { overlay }. Test directo del hook
  sin overlayfs (lógica pura) más el e2e existente que ya cubre los mounts.

57 tests workspace, 0 fallos.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-06-09 14:43:07 +00:00
co-authored by Claude Opus 4.7
parent fe1601e669
commit a87b16dd85
12 changed files with 911 additions and 26 deletions
Generated
+41
View File
@@ -124,6 +124,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.6.1" version = "4.6.1"
@@ -293,7 +299,9 @@ dependencies = [
"clap", "clap",
"hammer-build", "hammer-build",
"hammer-core", "hammer-core",
"hammer-journal",
"hammer-overlay", "hammer-overlay",
"serde_json",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
] ]
@@ -312,12 +320,27 @@ dependencies = [
"toml", "toml",
] ]
[[package]]
name = "hammer-journal"
version = "0.0.1"
dependencies = [
"anyhow",
"hammer-core",
"serde",
"serde_json",
"tempfile",
"thiserror",
"tracing",
]
[[package]] [[package]]
name = "hammer-overlay" name = "hammer-overlay"
version = "0.0.1" version = "0.0.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"hammer-core", "hammer-core",
"hammer-journal",
"libc",
"serde", "serde",
"serde_json", "serde_json",
"tempfile", "tempfile",
@@ -333,6 +356,12 @@ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
"hammer-core", "hammer-core",
"hammer-journal",
"hammer-overlay",
"libc",
"nix",
"tempfile",
"thiserror",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
] ]
@@ -439,6 +468,18 @@ version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "nix"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
+3
View File
@@ -4,6 +4,7 @@ members = [
"crates/hammer-core", "crates/hammer-core",
"crates/hammer-build", "crates/hammer-build",
"crates/hammer-overlay", "crates/hammer-overlay",
"crates/hammer-journal",
"crates/hammer-cli", "crates/hammer-cli",
"crates/hammerd", "crates/hammerd",
] ]
@@ -19,6 +20,7 @@ repository = "https://gitea.gioser.net/sergio/hammer"
hammer-core = { path = "crates/hammer-core" } hammer-core = { path = "crates/hammer-core" }
hammer-build = { path = "crates/hammer-build" } hammer-build = { path = "crates/hammer-build" }
hammer-overlay = { path = "crates/hammer-overlay" } hammer-overlay = { path = "crates/hammer-overlay" }
hammer-journal = { path = "crates/hammer-journal" }
# shared third-party deps, pinned at the workspace level # shared third-party deps, pinned at the workspace level
anyhow = "1" anyhow = "1"
@@ -30,6 +32,7 @@ toml = "0.8"
blake3 = "1" blake3 = "1"
sha2 = "0.10" sha2 = "0.10"
hex = "0.4" hex = "0.4"
nix = { version = "0.30", default-features = false, features = ["fanotify", "fs", "user"] }
tempfile = "3" tempfile = "3"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
tracing = "0.1" tracing = "0.1"
+2
View File
@@ -15,6 +15,8 @@ path = "src/main.rs"
hammer-core.workspace = true hammer-core.workspace = true
hammer-build.workspace = true hammer-build.workspace = true
hammer-overlay.workspace = true hammer-overlay.workspace = true
hammer-journal.workspace = true
serde_json.workspace = true
anyhow.workspace = true anyhow.workspace = true
clap.workspace = true clap.workspace = true
tracing.workspace = true tracing.workspace = true
+86 -8
View File
@@ -52,11 +52,18 @@ enum Cmd {
#[arg(long)] #[arg(long)]
state_root: Option<PathBuf>, state_root: Option<PathBuf>,
}, },
/// [Fase 2] Fusiona un overlay al FHS real y desmonta. Sin diario aún (Fase 3). /// [Fase 2+3] Fusiona un overlay al FHS real y desmonta. Por defecto registra cada
/// archivo promocionado en el diario; usa `--no-journal` para saltarse el registro.
Commit { Commit {
id: String, id: String,
#[arg(long)] #[arg(long)]
state_root: Option<PathBuf>, state_root: Option<PathBuf>,
/// Directorio del diario donde anotar la promoción.
#[arg(long, default_value = "/var/lib/hammer/journal")]
journal: PathBuf,
/// Saltarse el registro en el diario (útil para experimentos efímeros).
#[arg(long)]
no_journal: bool,
}, },
/// [Fase 2] Desmonta un overlay y borra su upper. El sistema vuelve al estado base. /// [Fase 2] Desmonta un overlay y borra su upper. El sistema vuelve al estado base.
Discard { Discard {
@@ -70,7 +77,20 @@ enum Cmd {
state_root: Option<PathBuf>, state_root: Option<PathBuf>,
}, },
/// [Fase 3] Ver/seguir el diario de mutaciones. /// [Fase 3] Ver/seguir el diario de mutaciones.
Journal, Journal {
/// Directorio del diario (mismo que `hammerd --journal`).
#[arg(long, default_value = "/var/lib/hammer/journal")]
dir: PathBuf,
/// Muestra los últimos N eventos. Default: 20.
#[arg(long, short, default_value = "20")]
tail: usize,
/// Sigue el archivo: imprime nuevos eventos según aparecen.
#[arg(long, short)]
follow: bool,
/// Formato. `pretty` (humano, default) o `json` (raw passthrough).
#[arg(long, default_value = "pretty")]
format: String,
},
/// [Fase 4] Aplica un manifiesto .swm (reproduce y lo deja en un overlay). /// [Fase 4] Aplica un manifiesto .swm (reproduce y lo deja en un overlay).
Apply { file: String }, Apply { file: String },
/// [Fase 4] Exporta el delta del sistema como manifiesto .swm. /// [Fase 4] Exporta el delta del sistema como manifiesto .swm.
@@ -82,6 +102,39 @@ enum Cmd {
Ctl { line: String }, Ctl { line: String },
} }
fn print_event(ev: &hammer_journal::MutationEvent, format: &str) {
match format {
"json" => {
// Re-serializa. No imprimimos la línea original (no la tenemos a mano), pero el
// formato es estable porque MutationEvent es la fuente de verdad del schema.
match serde_json::to_string(ev) {
Ok(s) => println!("{s}"),
Err(e) => eprintln!("journal: error serializando: {e}"),
}
}
_ => {
let actor = match &ev.by.source {
hammer_journal::Source::HammerHydrate { artifact } => format!("hydrate {artifact}"),
hammer_journal::Source::HammerCommit { overlay, artifact } => match artifact {
Some(a) => format!("commit {overlay} ({a})"),
None => format!("commit {overlay}"),
},
hammer_journal::Source::External => {
let pid = ev.by.pid.map(|p| format!("pid={p}")).unwrap_or_default();
let uid = ev.by.uid.map(|u| format!("uid={u}")).unwrap_or_default();
format!("external {pid} {uid}").trim().to_string()
}
};
println!(
"{ts} {op:<7} {path}{actor}",
ts = ev.ts,
op = ev.op.as_str(),
path = ev.path.display(),
);
}
}
}
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_writer(std::io::stderr) .with_writer(std::io::stderr)
@@ -134,14 +187,21 @@ fn main() -> anyhow::Result<()> {
let id = hammer_overlay::try_overlay(&targets, &root)?; let id = hammer_overlay::try_overlay(&targets, &root)?;
println!("{id}"); println!("{id}");
} }
Cmd::Commit { id, state_root } => { Cmd::Commit { id, state_root, journal, no_journal } => {
let root = state_root let root = state_root
.unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT)); .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
let report = hammer_overlay::commit(&hammer_overlay::OverlayId(id), &root)?; let id = hammer_overlay::OverlayId(id);
let report = if no_journal {
hammer_overlay::commit(&id, &root)?
} else {
let j = hammer_journal::Journal::open(&journal)?;
hammer_overlay::commit_with_journal(&id, &root, &j)?
};
println!( println!(
"commit OK — {} archivo(s) promocionados, {} eliminado(s)", "commit OK — {} archivo(s) promocionados, {} eliminado(s){}",
report.copied.len(), report.copied.len(),
report.removed.len() report.removed.len(),
if no_journal { "" } else { " (anotado en el diario)" }
); );
} }
Cmd::Discard { id, state_root } => { Cmd::Discard { id, state_root } => {
@@ -170,8 +230,26 @@ fn main() -> anyhow::Result<()> {
} }
} }
} }
Cmd::Journal => { Cmd::Journal { dir, tail, follow, format } => {
println!("[fase 3 pendiente] diario — ver docs/05-journal.md"); let j = hammer_journal::Journal::open(&dir)?;
let initial = j.tail(tail)?;
for ev in &initial {
print_event(ev, &format);
}
if follow {
use std::io::Write;
let _ = std::io::stdout().flush();
let mut cursor = j.end_offset()?;
loop {
std::thread::sleep(std::time::Duration::from_millis(500));
let (news, new_cursor) = j.read_from_offset(cursor)?;
cursor = new_cursor;
for ev in &news {
print_event(ev, &format);
}
let _ = std::io::stdout().flush();
}
}
} }
Cmd::Apply { file } => { Cmd::Apply { file } => {
println!("[fase 4 pendiente] apply {file} — ver docs/06-swm-format.md"); println!("[fase 4 pendiente] apply {file} — ver docs/06-swm-format.md");
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "hammer-journal"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
description = "El diario de mutaciones de hammer: append-only JSON-líneas sobre eventos del FHS."
[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
+370
View File
@@ -0,0 +1,370 @@
//! El diario de mutaciones. Ver `docs/05-journal.md`.
//!
//! Formato: archivo único de **JSON-líneas append-only** (`mutations.jsonl`). Cada línea es
//! un `MutationEvent` autónomo; `cat` / `grep` / `jq` los procesan sin parseo extra.
//!
//! Esta crate es **lógica pura**: nada de fanotify ni de mounts. El watcher real vive en
//! `hammerd` y empuja eventos aquí vía `Journal::record`. El hook desde
//! `hammer_overlay::commit` también empuja por aquí.
//!
//! Diseño rationale (SDD 05):
//! - El diario no bloquea acciones; sólo observa. Pero también acepta eventos "trazables"
//! (de un `hydrate` o `commit`) que llegan con `actor.source = HammerHydrate { artifact }`.
//! - Las "mutaciones opacas" (pisada manual sin rastro de artefacto) llegan con
//! `actor.source = External { pid, uid }`. Eso permite que `hammer export` distinga
//! replays reproducibles de cambios que sólo se pueden compartir como warnings.
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
/// Operación sobre el FHS. Ver SDD 05 §2 — la lista evolucionará con los eventos que el
/// watcher fanotify aprenda a distinguir.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MutationOp {
/// Archivo nuevo donde antes no había nada.
Create,
/// Pisado: el archivo existía y fue sustituido (mismo path, distinto contenido o inode).
Replace,
/// Edición en sitio: el archivo cambió sin que cambiara el path (típico de configs en /etc).
Edit,
/// Borrado.
Delete,
}
impl MutationOp {
pub fn as_str(&self) -> &'static str {
match self {
MutationOp::Create => "create",
MutationOp::Replace => "replace",
MutationOp::Edit => "edit",
MutationOp::Delete => "delete",
}
}
}
/// Quién originó la mutación. Distinguir trazable vs opaca es lo que más tarde permite
/// que `hammer export` produzca un `.swm` reproducible (artefactos) más una lista de
/// warnings (pisadas manuales).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum Source {
/// Vino de un `hammer hydrate <artifact>` — la mutación es replay-able a partir del hash.
HammerHydrate { artifact: String },
/// Vino de un `hammer commit <overlay>` — origen indirecto: el `actor` fue el overlay,
/// y `artifact` es opcional (puede no haber, si la promoción fue de un edit manual).
HammerCommit {
overlay: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
artifact: Option<String>,
},
/// Pisada manual fuera del flujo de hammer. La grabamos sin juzgar — el SDD insiste en
/// que es información, no error.
External,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Actor {
pub source: Source,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pid: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uid: Option<u32>,
}
/// Un evento atómico — una línea del diario.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MutationEvent {
/// RFC 3339 UTC. El watcher fija el timestamp en el momento de la observación.
pub ts: String,
pub op: MutationOp,
pub path: PathBuf,
pub by: Actor,
/// Opcional: hash blake3 del contenido del archivo tras la mutación. Permite a `export`
/// detectar mutaciones idempotentes (mismo contenido tras N replaces → un sólo evento).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_hash: Option<String>,
/// Nota libre (humana). Útil para que la IA explique su intención.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
/// Handle del diario: una ruta a `mutations.jsonl`. Las operaciones son thread-safe en el
/// sentido de que cada `record` abre + append + cierra; suficiente para el throughput
/// esperado (cambios humanos, no log-de-tracing).
#[derive(Debug, Clone)]
pub struct Journal {
pub dir: PathBuf,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("json: {0}")]
Json(#[from] serde_json::Error),
#[error("journal: {0}")]
Other(String),
}
pub type Result<T> = std::result::Result<T, Error>;
impl Journal {
/// Abre (creando el directorio si hace falta) un diario bajo `dir`. El archivo
/// `mutations.jsonl` se crea en el primer `record`.
pub fn open(dir: impl Into<PathBuf>) -> Result<Self> {
let dir = dir.into();
std::fs::create_dir_all(&dir)?;
Ok(Self { dir })
}
pub fn file_path(&self) -> PathBuf {
self.dir.join("mutations.jsonl")
}
/// Append-only. Una línea JSON + `\n`. Maneja la creación del archivo si no existe.
pub fn record(&self, ev: &MutationEvent) -> Result<()> {
let line = serde_json::to_string(ev)?;
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(self.file_path())?;
f.write_all(line.as_bytes())?;
f.write_all(b"\n")?;
f.sync_data()?;
Ok(())
}
/// Lee todos los eventos en orden de aparición. Líneas vacías o malformadas se loguean
/// y se saltan — el diario es append-only pero queremos resilencia a truncados de
/// emergencia (kernel-panic en medio de una escritura).
pub fn read_all(&self) -> Result<Vec<MutationEvent>> {
let path = self.file_path();
if !path.is_file() {
return Ok(Vec::new());
}
let f = std::fs::File::open(&path)?;
let rdr = BufReader::new(f);
let mut out = Vec::new();
for (i, line) in rdr.lines().enumerate() {
let line = line?;
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<MutationEvent>(&line) {
Ok(ev) => out.push(ev),
Err(e) => {
tracing::warn!(line = i + 1, error = %e, "journal: línea malformada, saltando");
}
}
}
Ok(out)
}
/// Lee los últimos `n` eventos sin cargar todo el archivo. Implementación naïve por
/// ahora (delega a `read_all` y trunca); cuando el archivo crezca añadiremos un índice
/// por bloques.
pub fn tail(&self, n: usize) -> Result<Vec<MutationEvent>> {
let mut all = self.read_all()?;
if all.len() > n {
all.drain(..(all.len() - n));
}
Ok(all)
}
/// Devuelve el offset en bytes del fin de archivo actual. Útil para implementar un
/// "follow" en CLI: tras un read inicial, hacemos polling desde este offset.
pub fn end_offset(&self) -> Result<u64> {
let path = self.file_path();
if !path.is_file() {
return Ok(0);
}
let m = std::fs::metadata(&path)?;
Ok(m.len())
}
/// Lee eventos desde un offset en bytes hasta el final actual. Devuelve los eventos y el
/// nuevo offset (para usar en la siguiente iteración del follow).
pub fn read_from_offset(&self, offset: u64) -> Result<(Vec<MutationEvent>, u64)> {
let path = self.file_path();
if !path.is_file() {
return Ok((Vec::new(), 0));
}
let mut f = std::fs::File::open(&path)?;
f.seek(SeekFrom::Start(offset))?;
let mut buf = String::new();
f.read_to_string(&mut buf)?;
let new_offset = offset + buf.len() as u64;
let mut out = Vec::new();
for line in buf.lines() {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<MutationEvent>(line) {
Ok(ev) => out.push(ev),
Err(e) => tracing::warn!(error = %e, "journal: línea malformada en tail"),
}
}
Ok((out, new_offset))
}
}
/// Construye un timestamp RFC 3339 UTC del instante actual usando std-only (sin chrono).
pub fn now_rfc3339() -> String {
use std::time::SystemTime;
let d = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
let secs = d.as_secs() as i64;
let nanos = d.subsec_nanos();
let (year, month, day, hour, minute, second) = unix_to_ymdhms(secs);
format!(
"{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{:09}Z",
nanos
)
}
/// Conversión epoch → (year, month, day, hour, minute, second) UTC. Algoritmo de Howard
/// Hinnant (`civil_from_days`), válido para todo rango representable por i64.
fn unix_to_ymdhms(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
let day_secs = 86_400i64;
let mut days = secs.div_euclid(day_secs);
let time_of_day = secs.rem_euclid(day_secs) as u32;
let hour = time_of_day / 3600;
let minute = (time_of_day / 60) % 60;
let second = time_of_day % 60;
days += 719_468; // shift al epoch del algoritmo (0000-03-01)
let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
let doe = (days - era * 146_097) as u64; // [0, 146_096]
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
let mp = (5 * doy + 2) / 153; // [0, 11]
let day = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
let year = (y + if month <= 2 { 1 } else { 0 }) as i32;
(year, month, day, hour, minute, second)
}
#[cfg(test)]
mod tests {
use super::*;
fn ev(op: MutationOp, path: &str) -> MutationEvent {
MutationEvent {
ts: now_rfc3339(),
op,
path: PathBuf::from(path),
by: Actor {
source: Source::External,
pid: Some(1234),
uid: Some(1000),
},
content_hash: None,
note: None,
}
}
#[test]
fn record_and_read_roundtrip() {
let d = tempfile::tempdir().unwrap();
let j = Journal::open(d.path()).unwrap();
j.record(&ev(MutationOp::Replace, "/bin/grep")).unwrap();
j.record(&ev(MutationOp::Edit, "/etc/hosts")).unwrap();
let read = j.read_all().unwrap();
assert_eq!(read.len(), 2);
assert_eq!(read[0].op, MutationOp::Replace);
assert_eq!(read[0].path, PathBuf::from("/bin/grep"));
assert_eq!(read[1].op, MutationOp::Edit);
}
#[test]
fn empty_journal_reads_empty() {
let d = tempfile::tempdir().unwrap();
let j = Journal::open(d.path()).unwrap();
assert!(j.read_all().unwrap().is_empty());
assert_eq!(j.end_offset().unwrap(), 0);
}
#[test]
fn tail_returns_last_n() {
let d = tempfile::tempdir().unwrap();
let j = Journal::open(d.path()).unwrap();
for i in 0..5 {
j.record(&ev(MutationOp::Create, &format!("/etc/{i}"))).unwrap();
}
let last = j.tail(2).unwrap();
assert_eq!(last.len(), 2);
assert_eq!(last[0].path, PathBuf::from("/etc/3"));
assert_eq!(last[1].path, PathBuf::from("/etc/4"));
}
#[test]
fn follow_via_offset_yields_new_events_only() {
let d = tempfile::tempdir().unwrap();
let j = Journal::open(d.path()).unwrap();
j.record(&ev(MutationOp::Create, "/etc/a")).unwrap();
let cursor = j.end_offset().unwrap();
j.record(&ev(MutationOp::Create, "/etc/b")).unwrap();
j.record(&ev(MutationOp::Create, "/etc/c")).unwrap();
let (news, new_cursor) = j.read_from_offset(cursor).unwrap();
assert_eq!(news.len(), 2);
assert_eq!(news[0].path, PathBuf::from("/etc/b"));
assert_eq!(news[1].path, PathBuf::from("/etc/c"));
assert_eq!(new_cursor, j.end_offset().unwrap());
// Una segunda llamada desde el nuevo cursor devuelve vacío.
let (more, _) = j.read_from_offset(new_cursor).unwrap();
assert!(more.is_empty());
}
#[test]
fn malformed_line_is_skipped_not_fatal() {
let d = tempfile::tempdir().unwrap();
let j = Journal::open(d.path()).unwrap();
j.record(&ev(MutationOp::Create, "/etc/a")).unwrap();
// Plantamos basura en medio (kernel panic mid-write).
let mut f = std::fs::OpenOptions::new().append(true).open(j.file_path()).unwrap();
f.write_all(b"{esto no es JSON valido\n").unwrap();
f.write_all(b"\n").unwrap(); // línea vacía
drop(f);
j.record(&ev(MutationOp::Create, "/etc/b")).unwrap();
let read = j.read_all().unwrap();
assert_eq!(read.len(), 2, "líneas válidas se preservan");
}
#[test]
fn source_serializes_with_tag() {
let e = ev(MutationOp::Replace, "/bin/grep");
let j = serde_json::to_string(&e).unwrap();
assert!(j.contains(r#""kind":"external""#), "got {j}");
let mut e2 = e.clone();
e2.by.source = Source::HammerHydrate {
artifact: "b3:abc".into(),
};
let j2 = serde_json::to_string(&e2).unwrap();
assert!(j2.contains(r#""kind":"hammer-hydrate""#), "got {j2}");
assert!(j2.contains(r#""artifact":"b3:abc""#), "got {j2}");
}
#[test]
fn rfc3339_format_basics() {
let ts = now_rfc3339();
// Verifica forma `YYYY-MM-DDThh:mm:ss.nnnnnnnnnZ` minimal.
assert!(ts.len() >= 29);
assert_eq!(&ts[4..5], "-");
assert_eq!(&ts[7..8], "-");
assert_eq!(&ts[10..11], "T");
assert_eq!(&ts[13..14], ":");
assert_eq!(&ts[16..17], ":");
assert!(ts.ends_with('Z'));
// Año razonable (2026 actualmente; este test sólo verifica que no salimos del rango
// 1970..3000).
let year: i32 = ts[..4].parse().unwrap();
assert!((1970..3000).contains(&year), "year = {year}");
}
}
+2
View File
@@ -9,11 +9,13 @@ description = "El overlay de experimentación de hammer: try/commit/discard/stat
[dependencies] [dependencies]
hammer-core.workspace = true hammer-core.workspace = true
hammer-journal.workspace = true
anyhow.workspace = true anyhow.workspace = true
thiserror.workspace = true thiserror.workspace = true
tracing.workspace = true tracing.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
libc = "0.2"
[dev-dependencies] [dev-dependencies]
tempfile.workspace = true tempfile.workspace = true
+101 -4
View File
@@ -218,12 +218,30 @@ pub fn discard(id: &OverlayId, state_root: &Path) -> Result<()> {
Ok(()) Ok(())
} }
/// Fusiona los `upper`s al FHS y desmonta. Cero diario por ahora — Fase 3 lo añade. /// Fusiona los `upper`s al FHS y desmonta. Sin diario.
/// ///
/// Estrategia: por cada mount, `rsync -a --delete-after` desde el upperdir al target. /// Para registrar cada promoción en el diario (SDD 04 §2 + SDD 05), usa
/// Antes del rsync desmontamos el overlay; si no, rsync estaría leyendo de la vista /// [`commit_with_journal`].
/// "merged" en lugar del upper puro (y promocionaríamos todo, no sólo los cambios).
pub fn commit(id: &OverlayId, state_root: &Path) -> Result<CommitReport> { pub fn commit(id: &OverlayId, state_root: &Path) -> Result<CommitReport> {
commit_inner(id, state_root, None)
}
/// Como [`commit`] pero registra cada archivo promocionado/eliminado como
/// `MutationEvent` con `source = HammerCommit { overlay }`. El SDD insiste en que el ruido
/// del experimento no entra al diario hasta el commit — esto es el momento exacto.
pub fn commit_with_journal(
id: &OverlayId,
state_root: &Path,
journal: &hammer_journal::Journal,
) -> Result<CommitReport> {
commit_inner(id, state_root, Some(journal))
}
fn commit_inner(
id: &OverlayId,
state_root: &Path,
journal: Option<&hammer_journal::Journal>,
) -> Result<CommitReport> {
let base = state_root.join(id.as_str()); let base = state_root.join(id.as_str());
let state_file = base.join("state.json"); let state_file = base.join("state.json");
if !state_file.is_file() { if !state_file.is_file() {
@@ -246,10 +264,57 @@ pub fn commit(id: &OverlayId, state_root: &Path) -> Result<CommitReport> {
for m in &state.mounts { for m in &state.mounts {
promote_upper_to_lower(&m.upper, &m.target, &mut report)?; promote_upper_to_lower(&m.upper, &m.target, &mut report)?;
} }
if let Some(j) = journal {
record_commit_to_journal(j, id, &report);
}
std::fs::remove_dir_all(&base)?; std::fs::remove_dir_all(&base)?;
Ok(report) Ok(report)
} }
fn record_commit_to_journal(
j: &hammer_journal::Journal,
id: &OverlayId,
report: &CommitReport,
) {
use hammer_journal::{Actor, MutationEvent, MutationOp, Source};
let actor = Actor {
source: Source::HammerCommit {
overlay: id.as_str().to_string(),
artifact: None,
},
pid: Some(std::process::id()),
uid: Some(unsafe { libc::getuid() }),
};
let ts = hammer_journal::now_rfc3339();
for p in &report.copied {
let ev = MutationEvent {
ts: ts.clone(),
op: MutationOp::Replace,
path: p.clone(),
by: actor.clone(),
content_hash: None,
note: None,
};
if let Err(e) = j.record(&ev) {
tracing::warn!(error = %e, path = %p.display(), "journal: fallo al registrar commit");
}
}
for p in &report.removed {
let ev = MutationEvent {
ts: ts.clone(),
op: MutationOp::Delete,
path: p.clone(),
by: actor.clone(),
content_hash: None,
note: None,
};
if let Err(e) = j.record(&ev) {
tracing::warn!(error = %e, path = %p.display(), "journal: fallo al registrar remove");
}
}
}
fn do_mount(lower: &Path, upper: &Path, work: &Path) -> Result<()> { fn do_mount(lower: &Path, upper: &Path, work: &Path) -> Result<()> {
// overlayfs requiere `,` como separador. Si alguno de los paths contiene una coma, // overlayfs requiere `,` como separador. Si alguno de los paths contiene una coma,
// overlayfs sólo soporta escaparla a través de opciones específicas — rechazamos en // overlayfs sólo soporta escaparla a través de opciones específicas — rechazamos en
@@ -453,4 +518,36 @@ mod tests {
let err = discard(&OverlayId("nope".into()), d.path()).unwrap_err().to_string(); let err = discard(&OverlayId("nope".into()), d.path()).unwrap_err().to_string();
assert!(err.contains("no encuentro overlay"), "msg = {err}"); assert!(err.contains("no encuentro overlay"), "msg = {err}");
} }
#[test]
fn journal_hook_records_copied_and_removed() {
// No tocamos mount real: ejercitamos directamente la función privada que el commit
// delega para anotar al diario. Eso valida el contrato sin necesidad de overlayfs.
let d = tempfile::tempdir().unwrap();
let journal = hammer_journal::Journal::open(d.path().join("journal")).unwrap();
let report = CommitReport {
copied: vec![PathBuf::from("/etc/hosts"), PathBuf::from("/bin/grep")],
removed: vec![PathBuf::from("/bin/obsoleto")],
};
super::record_commit_to_journal(&journal, &OverlayId("1234-1".into()), &report);
let events = journal.read_all().unwrap();
assert_eq!(events.len(), 3, "2 copied + 1 removed = 3 eventos");
// Los promocionados van como Replace, los removidos como Delete.
let ops_paths: Vec<_> = events
.iter()
.map(|e| (e.op, e.path.display().to_string()))
.collect();
assert!(ops_paths.contains(&(hammer_journal::MutationOp::Replace, "/etc/hosts".into())));
assert!(ops_paths.contains(&(hammer_journal::MutationOp::Replace, "/bin/grep".into())));
assert!(ops_paths.contains(&(hammer_journal::MutationOp::Delete, "/bin/obsoleto".into())));
// Todos comparten el mismo source con overlay id.
for e in &events {
match &e.by.source {
hammer_journal::Source::HammerCommit { overlay, .. } => {
assert_eq!(overlay, "1234-1");
}
other => panic!("source inesperado: {other:?}"),
}
}
}
} }
+8
View File
@@ -13,7 +13,15 @@ path = "src/main.rs"
[dependencies] [dependencies]
hammer-core.workspace = true hammer-core.workspace = true
hammer-journal.workspace = true
hammer-overlay.workspace = true
anyhow.workspace = true anyhow.workspace = true
clap.workspace = true clap.workspace = true
thiserror.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
nix.workspace = true
libc = "0.2"
[dev-dependencies]
tempfile.workspace = true
+38 -8
View File
@@ -1,12 +1,18 @@
//! `hammerd` — daemon de hammer. Dos responsabilidades: //! `hammerd` — daemon de hammer. Dos responsabilidades:
//! 1. Bus de agente: /run/agent.sock (JSON-líneas, SO_PEERCRED). Ver `docs/07-agent-bus.md`. //! 1. Diario de mutaciones: fanotify sobre /bin,/sbin,/lib,/etc → `hammer-journal`.
//! 2. Diario de mutaciones: fanotify sobre /bin,/sbin,/lib,/etc. Ver `docs/05-journal.md`. //! 2. Bus de agente: /run/agent.sock (JSON-líneas, SO_PEERCRED). Ver `docs/07-agent-bus.md`.
//! //!
//! Esqueleto de arranque. Las dos subsistemas (Fases 3 y 5) se implementarán por separado; //! Fase 3 implementa (1). Fase 5 añadirá (2). Si el daemon arranca sin CAP_SYS_ADMIN, el
//! aquí queda el binario navegable y el cableado básico de logging/argumentos. //! watcher falla en init y el daemon sigue corriendo sin él (warn claro). Esto es
//! deliberado para que puedas correr `hammerd` en dev sin sudo y aún así probar la parte
//! del bus.
use std::path::PathBuf;
use clap::Parser; use clap::Parser;
mod watcher;
#[derive(Parser)] #[derive(Parser)]
#[command(name = "hammerd", version, about = "Daemon de hammer: bus de agente + diario.")] #[command(name = "hammerd", version, about = "Daemon de hammer: bus de agente + diario.")]
struct Args { struct Args {
@@ -19,6 +25,13 @@ struct Args {
/// Directorio del diario de mutaciones. /// Directorio del diario de mutaciones.
#[arg(long, default_value = "/var/lib/hammer/journal")] #[arg(long, default_value = "/var/lib/hammer/journal")]
journal: String, journal: String,
/// Directorio raíz de overlays (para que el watcher pueda filtrar eventos bajo overlays
/// activos). Default coincide con `hammer try`.
#[arg(long, default_value = hammer_overlay::DEFAULT_STATE_ROOT)]
overlay_state_root: String,
/// Directorios extra a vigilar; si vacío, usa los defaults del FHS (SDD 05 §2).
#[arg(long = "watch")]
extra_watch: Vec<PathBuf>,
} }
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
@@ -35,11 +48,28 @@ fn main() -> anyhow::Result<()> {
agent_sock = %args.agent_sock, agent_sock = %args.agent_sock,
init_control = %args.init_control, init_control = %args.init_control,
journal = %args.journal, journal = %args.journal,
"hammerd: arranque (esqueleto)" "hammerd: arranque"
); );
// TODO(fase-3): iniciar el watcher fanotify → diario. let journal = hammer_journal::Journal::open(&args.journal)?;
// TODO(fase-5): servir el bus de agente en agent_sock (JSON-líneas, SO_PEERCRED). let mut dirs = watcher::default_watch_dirs();
tracing::warn!("subsistemas pendientes: diario (Fase 3) y bus de agente (Fase 5)"); dirs.extend(args.extra_watch);
let overlay_root = PathBuf::from(&args.overlay_state_root);
match watcher::Watcher::start(&dirs, journal, Some(overlay_root)) {
Ok(w) => {
tracing::info!("watcher fanotify activo");
w.run_forever()?;
}
Err(e) => {
tracing::warn!(
error = %e,
"watcher fanotify NO arrancó; el daemon sigue (sin diario en este run)"
);
// TODO(fase-5): aquí entra el bus de agente. De momento sólo dormimos para que
// el daemon no salga.
std::thread::park();
}
}
Ok(()) Ok(())
} }
+229
View File
@@ -0,0 +1,229 @@
//! Watcher de mutaciones del FHS. Usa `fanotify` para observar `CLOSE_WRITE` (alguien
//! escribió en un archivo y lo cerró) sobre los directorios del SDD 05 §2.
//!
//! Diseño Fase 3 v1:
//!
//! * Marcamos como `FAN_MARK_ADD | FAN_MARK_ONLYDIR` cada directorio vigilado. La cobertura
//! por defecto (ver `default_watch_dirs`) cubre el FHS clásico de userland.
//! * Filtro mínimo: ignoramos eventos cuya ruta resuelta cae dentro de un overlay activo
//! (consultamos `hammer_overlay::status` en cada batch — para Fase 3 el coste es
//! despreciable; cuando los overlays se vuelvan numerosos cachearemos los paths).
//! * Cada evento se materializa como `MutationEvent` con `op = Replace` y `source = External`.
//! El refinamiento (Create/Edit por contenido, hashing de contenido, atribución a un
//! `hydrate` reciente) llega después.
//!
//! Privilegios: `fanotify_init` requiere `CAP_SYS_ADMIN`. Si no lo tenemos, fallamos rápido
//! con un mensaje claro y el daemon sigue arrancando los otros subsistemas.
use std::ffi::OsString;
use std::os::fd::AsFd;
use std::os::unix::ffi::OsStringExt;
use std::path::{Path, PathBuf};
use hammer_journal::{Actor, Journal, MutationEvent, MutationOp, Source};
use nix::fcntl::AT_FDCWD;
use nix::sys::fanotify::{EventFFlags, Fanotify, InitFlags, MarkFlags, MaskFlags};
/// Directorios del FHS que vigilamos por defecto (SDD 05 §2).
pub fn default_watch_dirs() -> Vec<PathBuf> {
["/bin", "/sbin", "/usr/bin", "/usr/sbin", "/lib", "/usr/lib", "/etc"]
.into_iter()
.map(PathBuf::from)
.collect()
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("fanotify_init falló (¿CAP_SYS_ADMIN?): {0}")]
Init(nix::errno::Errno),
#[error("fanotify_mark falló sobre {path}: {err}")]
Mark { path: PathBuf, err: nix::errno::Errno },
#[error("read_events: {0}")]
Read(nix::errno::Errno),
#[error("journal: {0}")]
Journal(#[from] hammer_journal::Error),
#[error("io: {0}")]
Io(#[from] std::io::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
pub struct Watcher {
fan: Fanotify,
journal: Journal,
watched: Vec<PathBuf>,
overlay_state_root: Option<PathBuf>,
}
impl Watcher {
/// Inicializa el watcher y registra los `dirs` como vigilados.
///
/// `overlay_state_root`, si está presente, se usa para filtrar eventos que ocurren
/// dentro de un overlay activo (no entran al diario hasta el `commit` del overlay).
pub fn start(
dirs: &[PathBuf],
journal: Journal,
overlay_state_root: Option<PathBuf>,
) -> Result<Self> {
let fan = Fanotify::init(
InitFlags::FAN_CLASS_NOTIF | InitFlags::FAN_CLOEXEC,
EventFFlags::O_RDONLY | EventFFlags::O_CLOEXEC,
)
.map_err(Error::Init)?;
for dir in dirs {
if !dir.is_dir() {
tracing::warn!(path = %dir.display(), "watcher: skip — no es directorio");
continue;
}
fan.mark(
MarkFlags::FAN_MARK_ADD | MarkFlags::FAN_MARK_ONLYDIR,
MaskFlags::FAN_CLOSE_WRITE | MaskFlags::FAN_EVENT_ON_CHILD,
AT_FDCWD,
Some(dir.as_path()),
)
.map_err(|err| Error::Mark { path: dir.clone(), err })?;
tracing::info!(path = %dir.display(), "watcher: vigilando");
}
Ok(Self {
fan,
journal,
watched: dirs.to_vec(),
overlay_state_root,
})
}
/// Devuelve un fd que se puede usar con `poll`/`epoll` para esperar eventos sin bloquear.
/// Útil cuando integremos esto con el bus de agente (Fase 5).
#[allow(dead_code)]
pub fn fd(&self) -> std::os::fd::BorrowedFd<'_> {
self.fan.as_fd()
}
/// Loop bloqueante. Vuelve sólo si `read_events` falla irrecuperablemente. Se espera
/// invocarlo en su propio thread.
pub fn run_forever(&self) -> Result<()> {
loop {
self.poll_once()?;
}
}
/// Una iteración del loop. Útil para tests.
pub fn poll_once(&self) -> Result<usize> {
let events = self.fan.read_events().map_err(Error::Read)?;
let overlay_targets = self.overlay_targets();
let mut recorded = 0;
for ev in events {
let pid = ev.pid() as u32;
let Some(fd) = ev.fd() else { continue };
let path = match path_of_fd(fd) {
Ok(p) => p,
Err(e) => {
tracing::warn!(error = %e, "watcher: no pude resolver path del fd, saltando");
continue;
}
};
if !path_is_under_any(&path, &self.watched) {
continue;
}
if path_is_under_any(&path, &overlay_targets) {
tracing::debug!(path = %path.display(), "watcher: ignoro evento bajo overlay activo");
continue;
}
let me = MutationEvent {
ts: hammer_journal::now_rfc3339(),
op: MutationOp::Replace,
path,
by: Actor {
source: Source::External,
pid: Some(pid),
uid: uid_of_pid(pid),
},
content_hash: None,
note: None,
};
self.journal.record(&me)?;
recorded += 1;
}
Ok(recorded)
}
fn overlay_targets(&self) -> Vec<PathBuf> {
let Some(root) = &self.overlay_state_root else {
return Vec::new();
};
match hammer_overlay::status(root) {
Ok(overlays) => overlays
.into_iter()
.flat_map(|o| o.mounts.into_iter().map(|m| m.target))
.collect(),
Err(e) => {
tracing::warn!(error = %e, "watcher: status() falló, no filtro overlays");
Vec::new()
}
}
}
}
/// Resuelve el path real apuntado por un fd procediendo por `/proc/self/fd/<n>`. Devuelve el
/// path destino del symlink.
fn path_of_fd(fd: std::os::fd::BorrowedFd<'_>) -> std::io::Result<PathBuf> {
use std::os::fd::AsRawFd;
let link = format!("/proc/self/fd/{}", fd.as_raw_fd());
let target = std::fs::read_link(&link)?;
// readlink puede devolver paths suffix " (deleted)" si el archivo fue eliminado entre el
// evento y nuestra resolución. Los preservamos como están — el journal verá el evento
// pero el caller puede filtrarlos si quiere.
Ok(target)
}
fn path_is_under_any(p: &Path, roots: &[PathBuf]) -> bool {
roots.iter().any(|r| p.starts_with(r))
}
/// Lee `/proc/<pid>/status` y extrae el UID real. Devuelve `None` si el proceso desapareció.
fn uid_of_pid(pid: u32) -> Option<u32> {
let bytes = std::fs::read(format!("/proc/{pid}/status")).ok()?;
let text = OsString::from_vec(bytes);
let s = text.to_string_lossy();
for line in s.lines() {
if let Some(rest) = line.strip_prefix("Uid:") {
let first = rest.split_whitespace().next()?;
return first.parse().ok();
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_cover_fhs_userland() {
let d = default_watch_dirs();
assert!(d.iter().any(|p| p == Path::new("/bin")));
assert!(d.iter().any(|p| p == Path::new("/etc")));
assert!(d.iter().any(|p| p == Path::new("/usr/lib")));
assert!(!d.iter().any(|p| p == Path::new("/tmp")));
}
#[test]
fn path_filter_under_any() {
let roots = vec![PathBuf::from("/bin"), PathBuf::from("/etc")];
assert!(path_is_under_any(Path::new("/bin/grep"), &roots));
assert!(path_is_under_any(Path::new("/etc/passwd"), &roots));
assert!(!path_is_under_any(Path::new("/tmp/x"), &roots));
assert!(!path_is_under_any(Path::new("/binx/y"), &roots));
}
#[test]
fn uid_of_self_matches() {
let me = std::process::id();
let uid = uid_of_pid(me).expect("self uid");
// En entornos normales, comparable con getuid del proceso.
let expected = unsafe { libc::getuid() };
assert_eq!(uid, expected);
}
}
+12 -6
View File
@@ -37,19 +37,25 @@ Alpine vía `bwrap`** — confirma el corazón "fábrica funcional → FHS mutab
substrato Alpine. La VM/LXC dedicada queda como ejercicio de empaque, no como substrato Alpine. La VM/LXC dedicada queda como ejercicio de empaque, no como
pre-requisito de validación. pre-requisito de validación.
### Fase 2 — Overlay de experimentación ▶ *en progreso* ### Fase 2 — Overlay de experimentación
- [x] Crate `hammer-overlay`: `try` / `commit` / `discard` / `status` sobre `overlayfs`. - [x] Crate `hammer-overlay`: `try` / `commit` / `discard` / `status` sobre `overlayfs`.
- [x] Manifiesto persistente en `<state_root>/<id>/state.json`; `status` enumera. - [x] Manifiesto persistente en `<state_root>/<id>/state.json`; `status` enumera.
- [x] `commit` promociona archivos y procesa whiteouts (char dev 0/0) como remove. - [x] `commit` promociona archivos y procesa whiteouts (char dev 0/0) como remove.
- [x] Subcomandos del CLI: `hammer try [targets…]`, `commit <id>`, `discard <id>`, `status`. - [x] Subcomandos del CLI: `hammer try [targets…]`, `commit <id>`, `discard <id>`, `status`.
- [x] Tests E2E con bwrap+user-ns, gateados en `HAMMER_OVERLAY_TESTS=1` (kernel-dependiente). - [x] Tests E2E con bwrap+user-ns, gateados en `HAMMER_OVERLAY_TESTS=1` (kernel-dependiente).
- [ ] Hook al diario en `commit` (queda para Fase 3). - [x] Hook al diario en `commit` (Fase 3 lo añadió vía `commit_with_journal`).
- [ ] Anidación real de overlays (un solo overlay activo por target hoy). - [ ] Anidación real de overlays (un solo overlay activo por target hoy).
### Fase 3 — Diario de mutaciones ### Fase 3 — Diario de mutaciones ▶ *en progreso*
- [ ] `hammerd` con `fanotify` sobre `/bin`,`/sbin`,`/lib`,`/etc`. - [x] Crate `hammer-journal`: `MutationEvent`, `Source` (HammerHydrate/HammerCommit/External),
- [ ] Log JSON-líneas append-only; `hammer journal`. append/read/tail/follow JSON-líneas. RFC 3339 sin chrono.
- **Hecho cuando:** toda mutación trazable/opaca queda registrada y consultable. - [x] `hammerd` con `fanotify` (FAN_CLOSE_WRITE) sobre `/bin`,`/sbin`,`/usr/bin`,`/usr/sbin`,
`/lib`,`/usr/lib`,`/etc`. Filtra eventos bajo overlays activos. Fallback graceful sin
CAP_SYS_ADMIN.
- [x] `hammer journal [--tail N] [--follow] [--format pretty|json]`.
- [x] `hammer commit` registra cada archivo promocionado vía `commit_with_journal`.
- [ ] Refinar op del watcher (Create vs Replace vs Edit con FAN_REPORT_DFID_NAME).
- [ ] Hash del contenido tras la mutación (`content_hash`) y de-dup idempotente.
### Fase 4 — Formato y flujo `.swm` ### Fase 4 — Formato y flujo `.swm`
- [ ] `Swm` (de/serialización YAML), `hammer export`, `hammer apply` (con overlay), `verify`. - [ ] `Swm` (de/serialización YAML), `hammer export`, `hammer apply` (con overlay), `verify`.