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