moq_native/watch.rs
1//! Watch on-disk TLS certificates, keys, and root CAs for rotation.
2
3use std::path::{Path, PathBuf};
4
5use notify::Watcher;
6use tokio::sync::mpsc;
7
8/// Watches a set of files and resolves whenever something changes in their
9/// directories.
10///
11/// Reacting to the filesystem (rather than a SIGHUP/SIGUSR1) is what lets
12/// cert-manager, Kubernetes secret mounts, and `mv`-into-place rotate files with
13/// no extra signalling: they rewrite the file and the watcher fires.
14///
15/// Watches each file's *parent directory*, not the file itself. Editors,
16/// cert-manager, and K8s secret mounts replace files by atomic rename or symlink
17/// swap, which changes the inode (and, for the K8s `..data` symlink, fires on the
18/// directory without ever naming the file), so a watch set directly on the path
19/// would be missed.
20pub struct FileWatcher {
21 // Holds the OS watcher alive; dropping it stops events.
22 _watcher: notify::RecommendedWatcher,
23 events: mpsc::Receiver<()>,
24}
25
26impl FileWatcher {
27 /// Start watching the parent directories of `paths`. Errors if the OS watcher
28 /// can't be created or a directory can't be watched (e.g. the inotify
29 /// instance/watch limit is hit). `notify` already falls back to a built-in
30 /// poll watcher on platforms without a native backend, so there's no manual
31 /// polling here.
32 pub fn new(paths: &[PathBuf]) -> notify::Result<Self> {
33 // A capacity-1 channel of unit wakeups coalesces the burst of raw events
34 // notify emits per change (and any unrelated churn in the directory): a
35 // full buffer already has a pending wakeup, so extra sends are dropped.
36 let (tx, rx) = mpsc::channel(1);
37 let watcher = callback(paths, move || {
38 let _ = tx.try_send(());
39 })?;
40
41 Ok(Self {
42 _watcher: watcher,
43 events: rx,
44 })
45 }
46
47 /// Resolve once the OS reports activity in a watched directory. The caller
48 /// reloads on return; reloads are idempotent, so the coarse "something
49 /// changed" granularity at worst costs an occasional redundant reload.
50 pub async fn changed(&mut self) {
51 // The sender lives inside `_watcher`, which we hold for `&mut self`, so the
52 // channel can't be closed here.
53 self.events
54 .recv()
55 .await
56 .expect("file watcher channel closed unexpectedly");
57 }
58}
59
60/// Watch `paths` and invoke `changed` after a write or atomic replacement.
61///
62/// The callback runs on notify's worker thread, so it must finish promptly.
63pub(crate) fn callback(
64 paths: &[PathBuf],
65 mut changed: impl FnMut() + Send + 'static,
66) -> notify::Result<notify::RecommendedWatcher> {
67 let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
68 let reload = match res {
69 Ok(event) => is_reload_trigger(&event.kind),
70 // A watcher error (e.g. inotify queue overflow) may mean we missed a
71 // real change, so reload to be safe.
72 Err(_) => true,
73 };
74 if reload {
75 changed();
76 }
77 })?;
78
79 // Watch each distinct parent directory once. A bare filename like
80 // `cert.pem` has an empty-string parent (`Some("")`, not `None`), which the
81 // OS watcher rejects with "No path was found", so map that to the current
82 // directory.
83 let mut dirs: Vec<&Path> = paths
84 .iter()
85 .filter_map(|p| p.parent())
86 .map(|p| if p.as_os_str().is_empty() { Path::new(".") } else { p })
87 .collect();
88 dirs.sort_unstable();
89 dirs.dedup();
90 for dir in dirs {
91 watcher.watch(dir, notify::RecursiveMode::NonRecursive)?;
92 }
93
94 Ok(watcher)
95}
96
97/// Whether a raw notify event reflects a real change that should trigger a reload.
98///
99/// The reload path opens and reads the watched files, and notify's inotify backend
100/// reports IN_OPEN/IN_ACCESS for those reads. Treating them as changes makes a reload
101/// re-trigger itself in a tight loop (a ~400/sec storm that starved TLS handshakes in
102/// production), so we react only to events that can mean new cert bytes: a create, a
103/// modify/rename, or a finished write (IN_CLOSE_WRITE).
104fn is_reload_trigger(kind: ¬ify::EventKind) -> bool {
105 use notify::EventKind;
106 use notify::event::{AccessKind, AccessMode};
107 match kind {
108 // A finished write is the only access event that signals new content. The
109 // reload opens and reads these files itself (and other processes may read them
110 // too), so open/read/close-without-write must be ignored or it loops forever.
111 EventKind::Access(AccessKind::Close(AccessMode::Write)) => true,
112 EventKind::Access(_) => false,
113 // Rotations arrive as a create or a modify/rename: cert-manager and
114 // mv-into-place rename over the file, the K8s `..data` symlink swap fires a
115 // directory rename, and in-place rewrites modify the data.
116 EventKind::Create(_) | EventKind::Modify(_) => true,
117 // A bare removal leaves nothing to load (wait for the replacement's create),
118 // and Any/Other are unclassified noise we don't act on.
119 _ => false,
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 // A bare filename has a `Some("")` parent; watching "" is rejected by the OS
128 // watcher, so `new` must fall back to the current directory rather than error.
129 #[test]
130 fn bare_filename_watches_current_dir() {
131 FileWatcher::new(&[PathBuf::from("cert.pem"), PathBuf::from("key.pem")])
132 .expect("bare filenames should watch the current directory");
133 }
134
135 // The reload reads its own files; reads and bare removals must not re-trigger it.
136 #[test]
137 fn ignored_events_do_not_trigger_reload() {
138 use notify::EventKind;
139 use notify::event::{AccessKind, AccessMode, RemoveKind};
140 assert!(!is_reload_trigger(&EventKind::Access(AccessKind::Read)));
141 assert!(!is_reload_trigger(&EventKind::Access(AccessKind::Open(
142 AccessMode::Read
143 ))));
144 assert!(!is_reload_trigger(&EventKind::Access(AccessKind::Open(
145 AccessMode::Any
146 ))));
147 assert!(!is_reload_trigger(&EventKind::Access(AccessKind::Close(
148 AccessMode::Read
149 ))));
150 assert!(!is_reload_trigger(&EventKind::Remove(RemoveKind::Any)));
151 }
152
153 // A finished write, a create, and a modify/rename are real rotations.
154 #[test]
155 fn writes_and_rotations_trigger_reload() {
156 use notify::EventKind;
157 use notify::event::{AccessKind, AccessMode, CreateKind, ModifyKind};
158 assert!(is_reload_trigger(&EventKind::Access(AccessKind::Close(
159 AccessMode::Write
160 ))));
161 assert!(is_reload_trigger(&EventKind::Create(CreateKind::Any)));
162 assert!(is_reload_trigger(&EventKind::Modify(ModifyKind::Any)));
163 }
164}