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…
⎇ f41022d81f87b38135f1faca5821e31ceaf7ee90
crates/hammerd/src/watcher.rs
History
Who touched what
raw
Fase 5 — bus de agente (/run/agent.sock) + FIFO de control humano
1//! Watcher de mutaciones del FHS. Usa `fanotify` para observar `CLOSE_WRITE` (alguien
2//! escribió en un archivo y lo cerró) sobre los directorios del SDD 05 §2.
3//!
4//! Diseño Fase 3 v1:
5//!
6//! * Marcamos como `FAN_MARK_ADD | FAN_MARK_ONLYDIR` cada directorio vigilado. La cobertura
7//! por defecto (ver `default_watch_dirs`) cubre el FHS clásico de userland.
8//! * Filtro mínimo: ignoramos eventos cuya ruta resuelta cae dentro de un overlay activo
9//! (consultamos `hammer_overlay::status` en cada batch — para Fase 3 el coste es
10//! despreciable; cuando los overlays se vuelvan numerosos cachearemos los paths).
11//! * Cada evento se materializa como `MutationEvent` con `op = Replace` y `source = External`.
12//! El refinamiento (Create/Edit por contenido, hashing de contenido, atribución a un
13//! `hydrate` reciente) llega después.
14//!
15//! Privilegios: `fanotify_init` requiere `CAP_SYS_ADMIN`. Si no lo tenemos, fallamos rápido
16//! con un mensaje claro y el daemon sigue arrancando los otros subsistemas.
17
18use std::ffi::OsString;
19use std::os::fd::AsFd;
20use std::os::unix::ffi::OsStringExt;
21use std::path::{Path, PathBuf};
22
23use hammer_journal::{Actor, Journal, MutationEvent, MutationOp, Source};
24use nix::fcntl::AT_FDCWD;
25use nix::sys::fanotify::{EventFFlags, Fanotify, InitFlags, MarkFlags, MaskFlags};
26
27/// Directorios del FHS que vigilamos por defecto (SDD 05 §2).
28pub fn default_watch_dirs() -> Vec<PathBuf> {
29 ["/bin", "/sbin", "/usr/bin", "/usr/sbin", "/lib", "/usr/lib", "/etc"]
30 .into_iter()
31 .map(PathBuf::from)
32 .collect()
33}
34
35#[derive(Debug, thiserror::Error)]
36pub enum Error {
37 #[error("fanotify_init falló (¿CAP_SYS_ADMIN?): {0}")]
38 Init(nix::errno::Errno),
39 #[error("fanotify_mark falló sobre {path}: {err}")]
40 Mark { path: PathBuf, err: nix::errno::Errno },
41 #[error("read_events: {0}")]
42 Read(nix::errno::Errno),
43 #[error("journal: {0}")]
44 Journal(#[from] hammer_journal::Error),
45 #[error("io: {0}")]
46 Io(#[from] std::io::Error),
47}
48
49pub type Result<T> = std::result::Result<T, Error>;
50
51pub struct Watcher {
52 fan: Fanotify,
53 journal: Journal,
54 watched: Vec<PathBuf>,
55 overlay_state_root: Option<PathBuf>,
56 /// Si está, cada `MutationEvent` registrado se re-emite al bus de agente como
57 /// `Event::Modified` (SDD 07 §3). Sin bus, el watcher actúa como en Fase 3.
58 events: Option<crate::events::EventBus>,
59}
60
61impl Watcher {
62 /// Inicializa el watcher y registra los `dirs` como vigilados.
63 ///
64 /// `overlay_state_root`, si está presente, se usa para filtrar eventos que ocurren
65 /// dentro de un overlay activo (no entran al diario hasta el `commit` del overlay).
66 #[allow(dead_code)]
67 pub fn start(
68 dirs: &[PathBuf],
69 journal: Journal,
70 overlay_state_root: Option<PathBuf>,
71 ) -> Result<Self> {
72 Self::start_with_events(dirs, journal, overlay_state_root, None)
73 }
74
75 /// Igual que `start`, pero conecta el watcher al bus de eventos del daemon. Cada
76 /// mutación registrada se re-emite como `Event::Modified` a las conexiones abiertas.
77 pub fn start_with_events(
78 dirs: &[PathBuf],
79 journal: Journal,
80 overlay_state_root: Option<PathBuf>,
81 events: Option<crate::events::EventBus>,
82 ) -> Result<Self> {
83 let fan = Fanotify::init(
84 InitFlags::FAN_CLASS_NOTIF | InitFlags::FAN_CLOEXEC,
85 EventFFlags::O_RDONLY | EventFFlags::O_CLOEXEC,
86 )
87 .map_err(Error::Init)?;
88
89 for dir in dirs {
90 if !dir.is_dir() {
91 tracing::warn!(path = %dir.display(), "watcher: skip — no es directorio");
92 continue;
93 }
94 fan.mark(
95 MarkFlags::FAN_MARK_ADD | MarkFlags::FAN_MARK_ONLYDIR,
96 MaskFlags::FAN_CLOSE_WRITE | MaskFlags::FAN_EVENT_ON_CHILD,
97 AT_FDCWD,
98 Some(dir.as_path()),
99 )
100 .map_err(|err| Error::Mark { path: dir.clone(), err })?;
101 tracing::info!(path = %dir.display(), "watcher: vigilando");
102 }
103
104 Ok(Self {
105 fan,
106 journal,
107 watched: dirs.to_vec(),
108 overlay_state_root,
109 events,
110 })
111 }
112
113 /// Devuelve un fd que se puede usar con `poll`/`epoll` para esperar eventos sin bloquear.
114 /// Útil cuando integremos esto con el bus de agente (Fase 5).
115 #[allow(dead_code)]
116 pub fn fd(&self) -> std::os::fd::BorrowedFd<'_> {
117 self.fan.as_fd()
118 }
119
120 /// Loop bloqueante. Vuelve sólo si `read_events` falla irrecuperablemente. Se espera
121 /// invocarlo en su propio thread.
122 pub fn run_forever(&self) -> Result<()> {
123 loop {
124 self.poll_once()?;
125 }
126 }
127
128 /// Una iteración del loop. Útil para tests.
129 pub fn poll_once(&self) -> Result<usize> {
130 let events = self.fan.read_events().map_err(Error::Read)?;
131 let overlay_targets = self.overlay_targets();
132 let mut recorded = 0;
133 for ev in events {
134 let pid = ev.pid() as u32;
135 let Some(fd) = ev.fd() else { continue };
136 let path = match path_of_fd(fd) {
137 Ok(p) => p,
138 Err(e) => {
139 tracing::warn!(error = %e, "watcher: no pude resolver path del fd, saltando");
140 continue;
141 }
142 };
143 if !path_is_under_any(&path, &self.watched) {
144 continue;
145 }
146 if path_is_under_any(&path, &overlay_targets) {
147 tracing::debug!(path = %path.display(), "watcher: ignoro evento bajo overlay activo");
148 continue;
149 }
150 let me = MutationEvent {
151 ts: hammer_journal::now_rfc3339(),
152 op: MutationOp::Replace,
153 path,
154 by: Actor {
155 source: Source::External,
156 pid: Some(pid),
157 uid: uid_of_pid(pid),
158 },
159 content_hash: None,
160 note: None,
161 };
162 self.journal.record(&me)?;
163 recorded += 1;
164 if let Some(bus) = &self.events {
165 bus.publish(&crate::proto::Event::Modified {
166 path: me.path.display().to_string(),
167 op: me.op.as_str().to_string(),
168 ts: me.ts.clone(),
169 });
170 }
171 }
172 Ok(recorded)
173 }
174
175 fn overlay_targets(&self) -> Vec<PathBuf> {
176 let Some(root) = &self.overlay_state_root else {
177 return Vec::new();
178 };
179 match hammer_overlay::status(root) {
180 Ok(overlays) => overlays
181 .into_iter()
182 .flat_map(|o| o.mounts.into_iter().map(|m| m.target))
183 .collect(),
184 Err(e) => {
185 tracing::warn!(error = %e, "watcher: status() falló, no filtro overlays");
186 Vec::new()
187 }
188 }
189 }
190}
191
192/// Resuelve el path real apuntado por un fd procediendo por `/proc/self/fd/<n>`. Devuelve el
193/// path destino del symlink.
194fn path_of_fd(fd: std::os::fd::BorrowedFd<'_>) -> std::io::Result<PathBuf> {
195 use std::os::fd::AsRawFd;
196 let link = format!("/proc/self/fd/{}", fd.as_raw_fd());
197 let target = std::fs::read_link(&link)?;
198 // readlink puede devolver paths suffix " (deleted)" si el archivo fue eliminado entre el
199 // evento y nuestra resolución. Los preservamos como están — el journal verá el evento
200 // pero el caller puede filtrarlos si quiere.
201 Ok(target)
202}
203
204fn path_is_under_any(p: &Path, roots: &[PathBuf]) -> bool {
205 roots.iter().any(|r| p.starts_with(r))
206}
207
208/// Lee `/proc/<pid>/status` y extrae el UID real. Devuelve `None` si el proceso desapareció.
209fn uid_of_pid(pid: u32) -> Option<u32> {
210 let bytes = std::fs::read(format!("/proc/{pid}/status")).ok()?;
211 let text = OsString::from_vec(bytes);
212 let s = text.to_string_lossy();
213 for line in s.lines() {
214 if let Some(rest) = line.strip_prefix("Uid:") {
215 let first = rest.split_whitespace().next()?;
216 return first.parse().ok();
217 }
218 }
219 None
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 #[test]
227 fn defaults_cover_fhs_userland() {
228 let d = default_watch_dirs();
229 assert!(d.iter().any(|p| p == Path::new("/bin")));
230 assert!(d.iter().any(|p| p == Path::new("/etc")));
231 assert!(d.iter().any(|p| p == Path::new("/usr/lib")));
232 assert!(!d.iter().any(|p| p == Path::new("/tmp")));
233 }
234
235 #[test]
236 fn path_filter_under_any() {
237 let roots = vec![PathBuf::from("/bin"), PathBuf::from("/etc")];
238 assert!(path_is_under_any(Path::new("/bin/grep"), &roots));
239 assert!(path_is_under_any(Path::new("/etc/passwd"), &roots));
240 assert!(!path_is_under_any(Path::new("/tmp/x"), &roots));
241 assert!(!path_is_under_any(Path::new("/binx/y"), &roots));
242 }
243
244 #[test]
245 fn uid_of_self_matches() {
246 let me = std::process::id();
247 let uid = uid_of_pid(me).expect("self uid");
248 // En entornos normales, comparable con getuid del proceso.
249 let expected = unsafe { libc::getuid() };
250 assert_eq!(uid, expected);
251 }
252}