From 303dfa2b58dbdfb6b648a241ac6841f1a3542885 Mon Sep 17 00:00:00 2001 From: Sergio Date: Wed, 10 Jun 2026 20:33:00 +0000 Subject: [PATCH] Fase 3: op del watcher (Create/Edit/Delete) derivada del diario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/hammerd/src/watcher.rs | 122 +++++++++++++++++++++++++++++----- docs/10-roadmap.md | 9 ++- 2 files changed, 112 insertions(+), 19 deletions(-) diff --git a/crates/hammerd/src/watcher.rs b/crates/hammerd/src/watcher.rs index d1ae1361..da5e29ff 100644 --- a/crates/hammerd/src/watcher.rs +++ b/crates/hammerd/src/watcher.rs @@ -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 { + classify_op(&self.journal, path, deleted) + } + fn overlay_targets(&self) -> Vec { 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/`. Devuelve el -/// path destino del symlink. -fn path_of_fd(fd: std::os::fd::BorrowedFd<'_>) -> std::io::Result { +/// 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 { + 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//status` y extrae el UID real. Devuelve `None` si el proceso desapareció. fn uid_of_pid(pid: u32) -> Option { 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(); diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md index 76ea8932..c6088b9f 100644 --- a/docs/10-roadmap.md +++ b/docs/10-roadmap.md @@ -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