//! 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 `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. 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 { ["/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 = std::result::Result; pub struct Watcher { fan: Fanotify, journal: Journal, watched: Vec, overlay_state_root: Option, /// Si está, cada `MutationEvent` registrado se re-emite al bus de agente como /// `Event::Modified` (SDD 07 §3). Sin bus, el watcher actúa como en Fase 3. events: Option, } 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). #[allow(dead_code)] pub fn start( dirs: &[PathBuf], journal: Journal, overlay_state_root: Option, ) -> Result { Self::start_with_events(dirs, journal, overlay_state_root, None) } /// Igual que `start`, pero conecta el watcher al bus de eventos del daemon. Cada /// mutación registrada se re-emite como `Event::Modified` a las conexiones abiertas. pub fn start_with_events( dirs: &[PathBuf], journal: Journal, overlay_state_root: Option, events: Option, ) -> Result { 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, events, }) } /// 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 { 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, deleted) = 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; } // Hash del contenido tras la mutación: alimenta el de-dup idempotente y, más // 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, path, by: Actor { source: Source::External, pid: Some(pid), uid: uid_of_pid(pid), }, content_hash, note: None, }; // De-dup: una reescritura que deja el archivo idéntico no ensucia el diario ni // despierta al bus. Sólo emitimos `Modified` cuando algo cambió de verdad. if !self.journal.record_dedup(&me)? { continue; } recorded += 1; if let Some(bus) = &self.events { bus.publish(&hammer_core::proto::Event::Modified { path: me.path.display().to_string(), op: me.op.as_str().to_string(), ts: me.ts.clone(), }); } } 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(); }; 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/`. Devuelve el /// 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)?; 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()?; 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)); } 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(); 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); } }