agentguard_policy/
watcher.rs

1//! Hot-reload watcher for policy files.
2//!
3//! Wraps the `notify` crate to provide a debounced stream of
4//! filesystem events under a watched directory. The caller drains
5//! events via [`PolicyWatcher::events`] and decides what to do (typically
6//! call `PolicyStore::load_policies` to re-read the disk state).
7//!
8//! # Debouncing
9//!
10//! A single editor save can produce 3-5 raw events
11//! (modify → close-write → chmod → ...). The watcher coalesces events
12//! that arrive within `debounce` of each other into a single batch.
13//! The kind is collapsed to the dominant kind for the batch (Create
14//! wins, then Write, then Remove, then Other).
15//!
16//! # Errors
17//!
18//! `watch()` returns an error if the underlying watcher cannot register the
19//! store (for example, because it does not exist or is unreadable). Runtime
20//! errors delivered by `notify` are available through
21//! [`PolicyWatcher::take_errors`]; an empty `events()` result is normal and
22//! does not indicate watcher failure.
23
24use std::path::{Path, PathBuf};
25use std::sync::{Arc, Mutex};
26use std::time::{Duration, Instant};
27
28use notify::{Config, Event, EventKind, PollWatcher, RecursiveMode, Watcher};
29
30/// A debounced filesystem event for a watched policy directory.
31#[derive(Debug, Clone)]
32pub struct WatchEvent {
33    /// A bounded diagnostic sample of paths changed in this debounce window.
34    /// This is not a complete change set; consumers must reload the complete
35    /// policy store rather than update state based only on these paths.
36    pub paths: Vec<PathBuf>,
37    /// What kind of change occurred. See [`WatchEventKind`].
38    pub kind: WatchEventKind,
39}
40
41/// High-level event kind. Maps roughly to `notify::EventKind` but
42/// collapses the noisy variants (e.g. `Modify::Metadata` vs
43/// `Modify::Data`) into one actionable case.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum WatchEventKind {
46    /// A file was created.
47    Create,
48    /// A file was written to (data changed).
49    Write,
50    /// A file was removed.
51    Remove,
52    /// Any other change (metadata, rename, etc.).
53    Other,
54}
55
56const MAX_PENDING_WATCH_PATHS: usize = 128;
57const MAX_PENDING_WATCH_ERRORS: usize = 16;
58const MAX_WATCH_ERROR_BYTES: usize = 512;
59
60impl From<&EventKind> for WatchEventKind {
61    fn from(k: &EventKind) -> Self {
62        match k {
63            EventKind::Create(_) => Self::Create,
64            EventKind::Modify(_) => Self::Write,
65            EventKind::Remove(_) => Self::Remove,
66            _ => Self::Other,
67        }
68    }
69}
70
71/// A debounced filesystem watcher.
72///
73/// The watcher runs a background thread that calls `notify`'s
74/// recommended backend. Event paths and errors are coalesced into bounded
75/// shared state; drain with [`PolicyWatcher::events`].
76pub struct PolicyWatcher {
77    _inner: PollWatcher,
78    pending: Arc<Mutex<PendingSignals>>,
79    debounce: Duration,
80    last_emit: Option<Instant>,
81}
82
83struct PendingSignals {
84    changed: bool,
85    paths: Vec<PathBuf>,
86    kind: WatchEventKind,
87    pending_errors: Vec<String>,
88    last_error: Option<String>,
89}
90
91impl Default for PendingSignals {
92    fn default() -> Self {
93        Self {
94            changed: false,
95            paths: Vec::new(),
96            kind: WatchEventKind::Other,
97            pending_errors: Vec::new(),
98            last_error: None,
99        }
100    }
101}
102
103fn queue_new_error(errors: &mut Vec<String>, last_error: &mut Option<String>, mut error: String) {
104    if error.len() > MAX_WATCH_ERROR_BYTES {
105        let mut end = MAX_WATCH_ERROR_BYTES;
106        while !error.is_char_boundary(end) {
107            end -= 1;
108        }
109        error.truncate(end);
110    }
111    if last_error.as_ref() != Some(&error) {
112        if errors.len() < MAX_PENDING_WATCH_ERRORS {
113            errors.push(error.clone());
114        }
115        *last_error = Some(error);
116    }
117}
118
119fn promote_kind(current: WatchEventKind, incoming: WatchEventKind) -> WatchEventKind {
120    match (current, incoming) {
121        (WatchEventKind::Create, _) | (_, WatchEventKind::Create) => WatchEventKind::Create,
122        (WatchEventKind::Write, _) | (_, WatchEventKind::Write) => WatchEventKind::Write,
123        (WatchEventKind::Remove, _) | (_, WatchEventKind::Remove) => WatchEventKind::Remove,
124        _ => WatchEventKind::Other,
125    }
126}
127
128impl PendingSignals {
129    fn record_event(&mut self, paths: impl IntoIterator<Item = PathBuf>, kind: WatchEventKind) {
130        self.changed = true;
131        self.last_error = None;
132        self.kind = promote_kind(self.kind, kind);
133        for path in paths {
134            if self.paths.len() == MAX_PENDING_WATCH_PATHS {
135                break;
136            }
137            self.paths.push(path);
138        }
139    }
140
141    fn record_error(&mut self, error: String) {
142        queue_new_error(&mut self.pending_errors, &mut self.last_error, error);
143    }
144}
145
146impl PolicyWatcher {
147    /// Drain any pending events. Returns an empty vec if no events
148    /// are ready (i.e. nothing changed since the last call or the
149    /// debounce window hasn't elapsed).
150    pub fn events(&mut self) -> Vec<WatchEvent> {
151        let now = Instant::now();
152        let ready = match self.last_emit {
153            None => true,
154            Some(t) => now.duration_since(t) >= self.debounce,
155        };
156        if !ready {
157            return Vec::new();
158        }
159        let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner());
160        if !pending.changed {
161            return Vec::new();
162        }
163        self.last_emit = Some(now);
164        pending.changed = false;
165        let paths = std::mem::take(&mut pending.paths);
166        let kind = std::mem::replace(&mut pending.kind, WatchEventKind::Other);
167        vec![WatchEvent { paths, kind }]
168    }
169
170    /// Drain newly observed runtime watcher errors. Identical errors are
171    /// reported once until a successful filesystem event is observed.
172    pub fn take_errors(&mut self) -> Vec<String> {
173        let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner());
174        std::mem::take(&mut pending.pending_errors)
175    }
176
177    /// Stop watching. Idempotent. Drops the background thread and
178    /// any buffered events.
179    pub fn stop(self) {
180        // RecommendedWatcher::drop joins the background thread.
181        drop(self);
182    }
183}
184
185/// Watch a policy store for changes to Cedar files anywhere under `policies/`
186/// and to `schema.cedarschema`. The returned watcher is debounced: events that
187/// arrive within `debounce` of each other are coalesced into a single batch.
188pub fn watch<P: AsRef<Path>>(dir: P, debounce: Duration) -> std::io::Result<PolicyWatcher> {
189    let dir = std::fs::canonicalize(dir.as_ref())?;
190    let policies_dir = dir.join("policies");
191    let schema_path = dir.join("schema.cedarschema");
192    let pending = Arc::new(Mutex::new(PendingSignals::default()));
193    let callback_pending = Arc::clone(&pending);
194    let mut inner = PollWatcher::new(
195        move |res: notify::Result<Event>| {
196            // Collapse each notify::Event into (paths, kind). The kind
197            // is the dominant kind for the event — multiple kinds in one
198            // batch are coalesced at the consumer via `pending_kind`.
199            match res {
200                Ok(ev) => {
201                    let kind = WatchEventKind::from(&ev.kind);
202                    // Keep file matching aligned with `PolicyStore`'s
203                    // recursive Cedar load semantics.
204                    let paths = ev.paths.into_iter().filter(|path| {
205                        path == &schema_path
206                            || (path
207                                .parent()
208                                .is_some_and(|parent| parent.starts_with(&policies_dir))
209                                && path.extension().is_some_and(|ext| ext == "cedar"))
210                    });
211                    let mut bounded_paths = Vec::new();
212                    for path in paths {
213                        if bounded_paths.len() == MAX_PENDING_WATCH_PATHS {
214                            break;
215                        }
216                        bounded_paths.push(path);
217                    }
218                    if !bounded_paths.is_empty() {
219                        callback_pending
220                            .lock()
221                            .unwrap_or_else(|e| e.into_inner())
222                            .record_event(bounded_paths, kind);
223                    }
224                }
225                Err(error) => {
226                    callback_pending
227                        .lock()
228                        .unwrap_or_else(|e| e.into_inner())
229                        .record_error(error.to_string());
230                }
231            }
232        },
233        Config::default()
234            .with_poll_interval(Duration::from_millis(100))
235            .with_compare_contents(true),
236    )
237    .map_err(|e| std::io::Error::other(format!("notify watcher: {e}")))?;
238    inner
239        .watch(&dir, RecursiveMode::Recursive)
240        .map_err(|e| std::io::Error::other(format!("notify watch: {e}")))?;
241    Ok(PolicyWatcher {
242        _inner: inner,
243        pending,
244        debounce,
245        last_emit: None,
246    })
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use std::fs;
253    use std::io::Write;
254    use tempfile::tempdir;
255
256    /// Store policies are nested one level below the root. They must still
257    /// trigger a reload event.
258    #[test]
259    fn watcher_emits_event_for_nested_policy_create() {
260        let dir = tempdir().unwrap();
261        fs::create_dir(dir.path().join("policies")).unwrap();
262        let mut w = watch(dir.path(), Duration::from_millis(50)).unwrap();
263        // Give the watcher a moment to register.
264        std::thread::sleep(Duration::from_millis(50));
265        let path = dir.path().join("policies/test.cedar");
266        {
267            let mut f = fs::File::create(&path).unwrap();
268            f.write_all(b"permit (principal, action, resource);\n")
269                .unwrap();
270        }
271        // Filesystem notification delivery is asynchronous and varies by
272        // backend. Poll for a bounded period instead of relying on a fixed
273        // sleep that flakes under load or on slower CI runners.
274        let deadline = std::time::Instant::now() + Duration::from_secs(2);
275        let events = loop {
276            let events = w.events();
277            if !events.is_empty() || std::time::Instant::now() >= deadline {
278                break events;
279            }
280            std::thread::sleep(Duration::from_millis(25));
281        };
282        assert!(!events.is_empty(), "expected at least one event");
283        let paths: Vec<_> = events.iter().flat_map(|e| e.paths.iter()).collect();
284        assert!(
285            paths.iter().any(|p| p.ends_with("policies/test.cedar")),
286            "expected policies/test.cedar in events, got: {paths:?}"
287        );
288    }
289
290    #[test]
291    fn watcher_emits_event_for_policy_in_nested_subdirectory() {
292        let dir = tempdir().unwrap();
293        let policy_dir = dir.path().join("policies/team");
294        fs::create_dir_all(&policy_dir).unwrap();
295        let mut watcher = watch(dir.path(), Duration::from_millis(50)).unwrap();
296        std::thread::sleep(Duration::from_millis(50));
297        let path = policy_dir.join("read.cedar");
298        fs::write(&path, "permit(principal, action, resource);").unwrap();
299
300        let deadline = std::time::Instant::now() + Duration::from_secs(2);
301        let events = loop {
302            let events = watcher.events();
303            if !events.is_empty() || std::time::Instant::now() >= deadline {
304                break events;
305            }
306            std::thread::sleep(Duration::from_millis(25));
307        };
308        assert!(
309            events
310                .iter()
311                .flat_map(|event| &event.paths)
312                .any(|changed| changed.ends_with("policies/team/read.cedar")),
313            "expected nested policy path {path:?} in events: {events:?}"
314        );
315    }
316
317    #[test]
318    fn watcher_emits_event_for_schema_change() {
319        let dir = tempdir().unwrap();
320        fs::create_dir(dir.path().join("policies")).unwrap();
321        let mut watcher = watch(dir.path(), Duration::from_millis(50)).unwrap();
322        std::thread::sleep(Duration::from_millis(50));
323        fs::write(dir.path().join("schema.cedarschema"), "namespace test {}").unwrap();
324
325        let deadline = std::time::Instant::now() + Duration::from_secs(2);
326        let events = loop {
327            let events = watcher.events();
328            if !events.is_empty() || std::time::Instant::now() >= deadline {
329                break events;
330            }
331            std::thread::sleep(Duration::from_millis(25));
332        };
333        assert!(
334            events
335                .iter()
336                .flat_map(|event| &event.paths)
337                .any(|path| path.ends_with("schema.cedarschema")),
338            "expected schema.cedarschema in events: {events:?}"
339        );
340    }
341
342    #[cfg(unix)]
343    #[test]
344    fn watcher_detects_atomic_projected_volume_update() {
345        use std::os::unix::fs::symlink;
346
347        let dir = tempdir().unwrap();
348        let first = dir.path().join("..data-1");
349        let second = dir.path().join("..data-2");
350        fs::create_dir_all(first.join("policies")).unwrap();
351        fs::create_dir_all(second.join("policies")).unwrap();
352        fs::write(
353            first.join("policies/allow.cedar"),
354            "permit(principal, action, resource);",
355        )
356        .unwrap();
357        fs::write(
358            second.join("policies/allow.cedar"),
359            "forbid(principal, action, resource);",
360        )
361        .unwrap();
362        fs::create_dir(dir.path().join("policies")).unwrap();
363        symlink(&first, dir.path().join("..data")).unwrap();
364        symlink(
365            "../..data/policies/allow.cedar",
366            dir.path().join("policies/allow.cedar"),
367        )
368        .unwrap();
369
370        let mut watcher = watch(dir.path(), Duration::from_millis(50)).unwrap();
371        std::thread::sleep(Duration::from_millis(150));
372        let next_link = dir.path().join("..data-next");
373        symlink(&second, &next_link).unwrap();
374        fs::rename(&next_link, dir.path().join("..data")).unwrap();
375
376        let deadline = std::time::Instant::now() + Duration::from_secs(2);
377        let events = loop {
378            let events = watcher.events();
379            if !events.is_empty() || std::time::Instant::now() >= deadline {
380                break events;
381            }
382            std::thread::sleep(Duration::from_millis(25));
383        };
384        assert!(
385            events
386                .iter()
387                .flat_map(|event| &event.paths)
388                .any(|path| { path.ends_with("policies/allow.cedar") }),
389            "expected atomic projection update to report policies/allow.cedar: {events:?}"
390        );
391    }
392
393    #[test]
394    fn repeated_runtime_watcher_errors_are_deduplicated_until_recovery() {
395        let mut errors = Vec::new();
396        let mut last_error = None;
397        queue_new_error(&mut errors, &mut last_error, "watch failed".to_owned());
398        queue_new_error(&mut errors, &mut last_error, "watch failed".to_owned());
399        assert_eq!(errors, ["watch failed"]);
400
401        last_error = None; // A valid filesystem event clears the error state.
402        queue_new_error(&mut errors, &mut last_error, "watch failed".to_owned());
403        assert_eq!(errors, ["watch failed", "watch failed"]);
404    }
405
406    #[test]
407    fn pending_watcher_signals_coalesce_and_stay_bounded() {
408        let mut pending = PendingSignals::default();
409        let paths = (0..MAX_PENDING_WATCH_PATHS + 50)
410            .map(|index| PathBuf::from(format!("policies/{index}.cedar")));
411        pending.record_event(paths, WatchEventKind::Write);
412        pending.record_event(
413            [PathBuf::from("policies/important.cedar")],
414            WatchEventKind::Create,
415        );
416
417        assert!(pending.changed);
418        assert_eq!(pending.paths.len(), MAX_PENDING_WATCH_PATHS);
419        assert_eq!(pending.kind, WatchEventKind::Create);
420
421        for index in 0..MAX_PENDING_WATCH_ERRORS + 10 {
422            pending.record_error(format!("watch error {index}: {}", "x".repeat(1024)));
423        }
424        assert_eq!(pending.pending_errors.len(), MAX_PENDING_WATCH_ERRORS);
425        assert!(pending
426            .pending_errors
427            .iter()
428            .all(|error| error.len() <= MAX_WATCH_ERROR_BYTES));
429    }
430
431    /// The watcher stops cleanly when dropped (no panics, no leaked
432    /// thread).
433    #[test]
434    fn watcher_stop_is_idempotent() {
435        let dir = tempdir().unwrap();
436        let w = watch(dir.path(), Duration::from_millis(10)).unwrap();
437        w.stop();
438    }
439}