Fase 3: op del watcher (Create/Edit/Delete) derivada del diario

CLOSE_WRITE ya no se materializa siempre como Replace. classify_op deriva:
- Delete: el fd resuelve a " (deleted)" (sufijo que ahora recortamos del path
  en vez de colarlo al diario; content_hash queda None).
- Edit: el diario ya tenía un evento previo no-Delete para el path.
- Create: primera mutación observada, o resurrección tras un Delete.

Es la divergencia que el diario atestigua, no la verdad absoluta del FS. La
atribución plena vía FAN_REPORT_DFID_NAME queda diferida: nix 0.30 no parsea
los info-records de FID y el modo FID perdería el fd que hashea el contenido.

classify_op se extrae como función libre para probarla sin CAP_SYS_ADMIN; 4
tests unitarios nuevos. Roadmap Fase 3 actualizado.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-06-10 20:33:00 +00:00
co-authored by Claude Opus 4.8
parent 6c34484fbe
commit 303dfa2b58
2 changed files with 112 additions and 19 deletions
+104 -18
View File
@@ -8,9 +8,16 @@
//! * 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.
//! * Cada evento se materializa como `MutationEvent` con `source = External` y la `op`
//! se deriva (`classify_op`): si el fd resuelve a un path borrado (`" (deleted)"`) es
//! `Delete`; si el diario ya conocía ese path (con un evento previo no-`Delete`) es
//! `Edit`; en otro caso es `Create` (primera mutación que hammer observa sobre el path).
//! Nota: `FAN_CLOSE_WRITE` no distingue un `rename`-sobre-destino de una reescritura in
//! situ, ni reporta el nombre en eventos de directorio — la atribución plena de
//! `Create`/`Move`/`Delete` vía `FAN_REPORT_DFID_NAME` (con `open_by_handle_at` y
//! `CAP_DAC_READ_SEARCH`) queda para el track posterior; `nix` 0.30 aún no parsea los
//! info-records de FID, así que exigiría libc crudo y perdería el fd que hoy hashea el
//! contenido.
//!
//! 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.
@@ -133,7 +140,7 @@ impl Watcher {
for ev in events {
let pid = ev.pid() as u32;
let Some(fd) = ev.fd() else { continue };
let path = match path_of_fd(fd) {
let (path, deleted) = match path_of_fd(fd) {
Ok(p) => p,
Err(e) => {
tracing::warn!(error = %e, "watcher: no pude resolver path del fd, saltando");
@@ -148,18 +155,23 @@ impl Watcher {
continue;
}
// Hash del contenido tras la mutación: alimenta el de-dup idempotente y, más
// tarde, la correlación en `export`. Si no podemos leer el archivo (borrado,
// permiso), seguimos con `None` — el evento sigue siendo información válida.
let content_hash = match hammer_journal::hash_file(&path) {
Ok(h) => h,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "watcher: no pude hashear contenido");
None
// tarde, la correlación en `export`. Si el archivo se borró (o no podemos leerlo
// por permiso) seguimos con `None` — el evento sigue siendo información válida.
let content_hash = if deleted {
None
} else {
match hammer_journal::hash_file(&path) {
Ok(h) => h,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "watcher: no pude hashear contenido");
None
}
}
};
let op = self.classify_op(&path, deleted)?;
let me = MutationEvent {
ts: hammer_journal::now_rfc3339(),
op: MutationOp::Replace,
op,
path,
by: Actor {
source: Source::External,
@@ -186,6 +198,20 @@ impl Watcher {
Ok(recorded)
}
/// Deriva la `op` de un `CLOSE_WRITE` a partir del estado del fd y del diario:
///
/// * `deleted` (el fd resuelve a `" (deleted)"`) ⇒ `Delete`.
/// * el diario ya tiene un evento previo no-`Delete` para el path ⇒ `Edit` (el archivo
/// ya era conocido y se reescribió).
/// * en otro caso ⇒ `Create` — la primera mutación que hammer observa sobre el path, o
/// una resurrección tras un `Delete` previo.
///
/// No es una verdad absoluta del FS (un archivo preexistente al daemon, editado por
/// primera vez, se ve como `Create`); es la divergencia que el diario puede atestiguar.
fn classify_op(&self, path: &Path, deleted: bool) -> Result<MutationOp> {
classify_op(&self.journal, path, deleted)
}
fn overlay_targets(&self) -> Vec<PathBuf> {
let Some(root) = &self.overlay_state_root else {
return Vec::new();
@@ -204,21 +230,39 @@ impl Watcher {
}
/// 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> {
/// path destino del symlink y un flag `deleted`: `readlink` añade el sufijo `" (deleted)"`
/// cuando el archivo se desvinculó entre el evento y nuestra resolución. Lo recortamos para
/// que el path entre limpio al diario y propagamos el flag para clasificar la `op` como
/// `Delete`.
fn path_of_fd(fd: std::os::fd::BorrowedFd<'_>) -> std::io::Result<(PathBuf, bool)> {
use std::os::fd::AsRawFd;
use std::os::unix::ffi::OsStrExt;
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)
const SUFFIX: &[u8] = b" (deleted)";
let bytes = target.as_os_str().as_bytes();
if let Some(trimmed) = bytes.strip_suffix(SUFFIX) {
return Ok((PathBuf::from(std::ffi::OsStr::from_bytes(trimmed)), true));
}
Ok((target, false))
}
fn path_is_under_any(p: &Path, roots: &[PathBuf]) -> bool {
roots.iter().any(|r| p.starts_with(r))
}
/// Deriva la `op` de un `CLOSE_WRITE` (ver [`Watcher::classify_op`]). Libre de estado del
/// fanotify para poder probarla sin `CAP_SYS_ADMIN`.
fn classify_op(journal: &Journal, path: &Path, deleted: bool) -> Result<MutationOp> {
if deleted {
return Ok(MutationOp::Delete);
}
Ok(match journal.last_for_path(path)? {
Some(ev) if ev.op != MutationOp::Delete => MutationOp::Edit,
_ => MutationOp::Create,
})
}
/// 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()?;
@@ -255,6 +299,48 @@ mod tests {
assert!(!path_is_under_any(Path::new("/binx/y"), &roots));
}
fn me(op: MutationOp, path: &str, hash: Option<&str>) -> MutationEvent {
MutationEvent {
ts: hammer_journal::now_rfc3339(),
op,
path: PathBuf::from(path),
by: Actor { source: Source::External, pid: Some(1), uid: Some(0) },
content_hash: hash.map(str::to_string),
note: None,
}
}
#[test]
fn classify_op_deleted_is_delete() {
let d = tempfile::tempdir().unwrap();
let j = Journal::open(d.path()).unwrap();
assert_eq!(classify_op(&j, Path::new("/etc/x"), true).unwrap(), MutationOp::Delete);
}
#[test]
fn classify_op_unknown_path_is_create() {
let d = tempfile::tempdir().unwrap();
let j = Journal::open(d.path()).unwrap();
assert_eq!(classify_op(&j, Path::new("/bin/new"), false).unwrap(), MutationOp::Create);
}
#[test]
fn classify_op_known_path_is_edit() {
let d = tempfile::tempdir().unwrap();
let j = Journal::open(d.path()).unwrap();
j.record(&me(MutationOp::Create, "/etc/hosts", Some("b3:aaa"))).unwrap();
assert_eq!(classify_op(&j, Path::new("/etc/hosts"), false).unwrap(), MutationOp::Edit);
}
#[test]
fn classify_op_resurrection_after_delete_is_create() {
let d = tempfile::tempdir().unwrap();
let j = Journal::open(d.path()).unwrap();
j.record(&me(MutationOp::Create, "/etc/svc", Some("b3:aaa"))).unwrap();
j.record(&me(MutationOp::Delete, "/etc/svc", None)).unwrap();
assert_eq!(classify_op(&j, Path::new("/etc/svc"), false).unwrap(), MutationOp::Create);
}
#[test]
fn uid_of_self_matches() {
let me = std::process::id();
+8 -1
View File
@@ -54,7 +54,14 @@ pre-requisito de validación.
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).
- [x] Refinar la `op` del watcher. `watcher::classify_op(&journal, path, deleted)` deriva
`Delete` (el fd resuelve a `" (deleted)"`, que ahora recortamos del path en vez de
colarlo al diario), `Edit` (el diario ya tenía un evento previo no-`Delete` del path) o
`Create` (primera mutación observada, o resurrección tras un `Delete`). Es la divergencia
que el diario atestigua, no la verdad absoluta del FS. La atribución plena vía
`FAN_REPORT_DFID_NAME` (nombre en eventos de directorio, `rename`-sobre-destino,
`open_by_handle_at`+`CAP_DAC_READ_SEARCH`) queda para el track posterior: `nix` 0.30 no
parsea los info-records de FID y el modo FID perdería el fd que hoy hashea el contenido.
- [x] Hash del contenido tras la mutación (`content_hash`) y de-dup idempotente.
`hammer_journal::content_hash_of`/`hash_file` (blake3 plano, estilo `b3sum`, distinto del
`of_inputs` de artefactos). `Journal::record_dedup` omite el evento si deja el archivo