tawasuyu

sergio/takana

> 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…

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>

303dfa2b58dbdfb6b648a241ac6841f1a3542885 · por Sergio · (hace 4 meses) · padre 6c34484fb · ver el árbol en este commit

Delta semántico · 10

crates/hammerd/src/watcher.rs

2 archivos · +112 −19

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<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();
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