//! 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 `op = Replace` y `source = External`. //! El refinamiento (Create/Edit por contenido, hashing de contenido, atribución a un //! `hydrate` reciente) llega después. //! //! 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 = 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; } let me = MutationEvent { ts: hammer_journal::now_rfc3339(), op: MutationOp::Replace, path, by: Actor { source: Source::External, pid: Some(pid), uid: uid_of_pid(pid), }, content_hash: None, note: None, }; self.journal.record(&me)?; recorded += 1; if let Some(bus) = &self.events { bus.publish(&crate::proto::Event::Modified { path: me.path.display().to_string(), op: me.op.as_str().to_string(), ts: me.ts.clone(), }); } } Ok(recorded) } 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. fn path_of_fd(fd: std::os::fd::BorrowedFd<'_>) -> std::io::Result { use std::os::fd::AsRawFd; 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) } fn path_is_under_any(p: &Path, roots: &[PathBuf]) -> bool { roots.iter().any(|r| p.starts_with(r)) } /// 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)); } #[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); } }