Skip to main content

air_sys_syscall/fs/
inotify.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! **inotify** — surveillance de changements de fichiers (couche 0, non
6//! privilégié, basé sur les chemins). Sous-module dédié `fs::inotify`, dans
7//! l'esprit FD-événementiel de `signalfd`/`timerfd`/`eventfd`.
8//!
9//! Référence normative : `docs/specs/layer-0/family-fs-inotify.md`.
10//!
11//! - **`CLOEXEC` par défaut** sur le FD ([`inotify_init`]).
12//! - **Décodage zéro-allocation emprunté** des `inotify_event` de taille variable
13//!   ([`InotifyEvents`], `air-sys-types::fs`) ; **zéro perte** (ADR-032) : tous les
14//!   événements complets sont rendus, une queue tronquée est *signalée*.
15//! - **Récursivité = couche 1** (`AirFileSystemWatcher`), pas ici. **`fanotify`
16//!   hors périmètre** (primitif privilégié distinct).
17
18use super::{errno_from_negative_syscall_ret, fd_to_u64, nr, read, syscall1, syscall2, syscall3};
19use air_sys_types::Errno;
20use air_sys_types::fd::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd};
21use air_sys_types::fs::{InotifyEventMask, InotifyEvents, InotifyFlags, WatchDescriptor};
22use core::ffi::CStr;
23
24/// Taille de buffer **recommandée** pour [`Inotify::read_events`] : un événement
25/// fait 16 octets d'entête + `name` (jusqu'à `NAME_MAX + 1`). 4096 octets
26/// accueillent confortablement un lot d'événements.
27pub const INOTIFY_RECOMMENDED_BUFFER_SIZE: usize = 4096;
28
29/// Instance inotify : possède un [`OwnedFd`], fermé au `Drop`.
30#[derive(Debug)]
31pub struct Inotify {
32    fd: OwnedFd,
33}
34
35/// Crée une instance inotify. **`IN_CLOEXEC` est posé par défaut** (tous les FD
36/// ouverts par Air ont `CLOEXEC`) ; `flags` ajoute par exemple
37/// [`InotifyFlags::NONBLOCK`].
38///
39/// # Errors
40///
41/// - [`Errno::EMFILE`]/[`Errno::ENFILE`] : limite de FD (processus / système) ou
42///   d'instances inotify (`max_user_instances`) atteinte.
43/// - [`Errno::ENOMEM`] : mémoire kernel insuffisante.
44pub fn inotify_init(flags: InotifyFlags) -> Result<Inotify, Errno> {
45    // `CLOEXEC` par défaut (jamais à la charge de l'appelant).
46    let raw_flags = (flags | InotifyFlags::CLOEXEC).bits();
47    // SAFETY: `inotify_init1` ne lit aucune mémoire utilisateur ; `raw_flags` est
48    // un jeu de drapeaux `IN_*` valides (NONBLOCK/CLOEXEC).
49    let ret = unsafe { syscall1(nr::INOTIFY_INIT1, u64::from(raw_flags.cast_unsigned())) };
50    if ret < 0 {
51        return Err(errno_from_negative_syscall_ret(ret));
52    }
53    let fd = i32::try_from(ret).map_err(|_| Errno::EINVAL)?;
54    // SAFETY: `inotify_init1` a retourné un FD frais et possédé (CLOEXEC posé).
55    Ok(Inotify {
56        fd: unsafe { OwnedFd::from_raw_fd(fd) },
57    })
58}
59
60impl Inotify {
61    /// FD emprunté (intégration dans une boucle d'événements — `poll`/io_uring).
62    #[must_use]
63    pub fn as_fd(&self) -> BorrowedFd<'_> {
64        self.fd.as_fd()
65    }
66
67    /// Consomme l'instance et rend l'[`OwnedFd`] (transfert d'ownership).
68    #[must_use]
69    pub fn into_fd(self) -> OwnedFd {
70        self.fd
71    }
72
73    /// Ajoute (ou met à jour, avec [`InotifyEventMask::MASK_ADD`]) un watch sur
74    /// `path` pour les événements `mask`. `path` : `&CStr` (octets terminés NUL,
75    /// convention couche 0).
76    ///
77    /// # Errors
78    ///
79    /// - [`Errno::ENOSPC`] : limite `max_user_watches` atteinte.
80    /// - [`Errno::ENOENT`] : `path` n'existe pas.
81    /// - [`Errno::EACCES`] : accès en lecture refusé sur `path`.
82    /// - [`Errno::ENOTDIR`] : [`InotifyEventMask::ONLYDIR`] posé et `path` n'est
83    ///   pas un répertoire.
84    pub fn add_watch(&self, path: &CStr, mask: InotifyEventMask) -> Result<WatchDescriptor, Errno> {
85        // SAFETY: `path` est un `CStr` terminé NUL, valide en lecture pour la
86        // durée de l'appel ; `fd` est le FD inotify possédé ; `inotify_add_watch`
87        // lit `path` (ne l'écrit pas) et `mask` est un scalaire.
88        let ret = unsafe {
89            syscall3(
90                nr::INOTIFY_ADD_WATCH,
91                fd_to_u64(self.fd.as_raw_fd()),
92                path.as_ptr() as u64,
93                u64::from(mask.bits()),
94            )
95        };
96        if ret < 0 {
97            return Err(errno_from_negative_syscall_ret(ret));
98        }
99        let wd = i32::try_from(ret).map_err(|_| Errno::EINVAL)?;
100        Ok(WatchDescriptor::from_raw(wd))
101    }
102
103    /// Retire le watch `wd`.
104    ///
105    /// # Errors
106    ///
107    /// - [`Errno::EINVAL`] : `wd` n'est pas un descripteur de watch valide.
108    pub fn remove_watch(&self, wd: WatchDescriptor) -> Result<(), Errno> {
109        // SAFETY: `fd` est le FD inotify possédé ; `inotify_rm_watch` ne touche
110        // aucune mémoire utilisateur (`wd` est un scalaire).
111        let ret = unsafe {
112            syscall2(
113                nr::INOTIFY_RM_WATCH,
114                fd_to_u64(self.fd.as_raw_fd()),
115                u64::from(wd.as_raw().cast_unsigned()),
116            )
117        };
118        if ret < 0 {
119            return Err(errno_from_negative_syscall_ret(ret));
120        }
121        Ok(())
122    }
123
124    /// Lit un lot d'événements **dans `buffer`** (fourni par l'appelant, zéro
125    /// allocation) et le décode. Le [`InotifyEvents`] retourné **emprunte**
126    /// `buffer` ; l'appelant l'itère puis peut consulter
127    /// [`InotifyEvents::truncated`] (ADR-032 : un événement coupé est signalé,
128    /// jamais avalé).
129    ///
130    /// # Errors
131    ///
132    /// - [`Errno::EAGAIN`] : FD [`InotifyFlags::NONBLOCK`] et aucun événement prêt.
133    /// - [`Errno::EINVAL`] : `buffer` trop petit pour le **prochain** événement
134    ///   (l'appelant agrandit — recommandé : [`INOTIFY_RECOMMENDED_BUFFER_SIZE`]).
135    pub fn read_events<'b>(&self, buffer: &'b mut [u8]) -> Result<InotifyEvents<'b>, Errno> {
136        let count = read(self.as_fd(), buffer)?;
137        // Vue immutable de durée `'b` sur les `count` octets remplis par le kernel.
138        let filled: &'b [u8] = buffer;
139        Ok(InotifyEvents::parse(filled.get(..count).unwrap_or(filled)))
140    }
141}
142
143// ─────────────────────────────────────────────────────────────────────────
144// Tests — intégration kernel réel (inotify non privilégié) ⇒ exécutés sur les
145// exécuteurs ; `#[cfg_attr(miri, ignore)]` (syscalls non modélisés par Miri).
146// Le décodeur pur, le property-based et le fuzz vivent dans `air-sys-types::fs`.
147// ─────────────────────────────────────────────────────────────────────────
148#[cfg(test)]
149mod tests {
150    use super::{INOTIFY_RECOMMENDED_BUFFER_SIZE, Inotify, inotify_init};
151    use air_sys_types::Errno;
152    use air_sys_types::fs::{InotifyEventMask, InotifyFlags};
153    use std::ffi::CString;
154    use std::io::Write as _;
155
156    /// Crée un répertoire temporaire unique pour le test.
157    fn temp_dir(tag: &str) -> std::path::PathBuf {
158        let dir = std::env::temp_dir().join(format!("air-inotify-{tag}-{}", std::process::id()));
159        let _ = std::fs::remove_dir_all(&dir);
160        std::fs::create_dir_all(&dir).expect("création répertoire temp");
161        dir
162    }
163
164    fn cpath(p: &std::path::Path) -> CString {
165        CString::new(p.as_os_str().as_encoded_bytes()).expect("chemin sans NUL")
166    }
167
168    /// Draine **tous** les événements en attente (NONBLOCK → `EAGAIN` à la fin),
169    /// en copiant ce qui est nécessaire (`name` possédé) pour franchir l'emprunt.
170    fn drain(inotify: &Inotify) -> Vec<(InotifyEventMask, Option<Vec<u8>>, u32)> {
171        let mut out = Vec::new();
172        let mut buffer = vec![0u8; INOTIFY_RECOMMENDED_BUFFER_SIZE];
173        // NONBLOCK : la lecture rend `EAGAIN` une fois la file vidée (jamais un
174        // `Ok` sans événement), ce qui termine la boucle — le bras `Err` est donc
175        // bien exécuté (et vérifie l'errno) plutôt que d'être un arm mort.
176        loop {
177            match inotify.read_events(&mut buffer) {
178                Ok(events) => {
179                    for ev in events {
180                        out.push((ev.mask, ev.name.map(<[u8]>::to_vec), ev.cookie));
181                    }
182                }
183                Err(e) => {
184                    assert_eq!(e, Errno::EAGAIN, "seul EAGAIN attendu (NONBLOCK)");
185                    break;
186                }
187            }
188        }
189        out
190    }
191
192    #[test]
193    #[cfg_attr(miri, ignore = "syscalls inotify non supportés par Miri")]
194    fn watch_create_modify_move_delete_round_trip() {
195        let dir = temp_dir("rt");
196        // NONBLOCK pour drainer jusqu'à EAGAIN sans bloquer.
197        let inotify = inotify_init(InotifyFlags::NONBLOCK).expect("inotify_init");
198        let wd = inotify
199            .add_watch(
200                &cpath(&dir),
201                InotifyEventMask::CREATE
202                    | InotifyEventMask::MODIFY
203                    | InotifyEventMask::MOVED_FROM
204                    | InotifyEventMask::MOVED_TO
205                    | InotifyEventMask::DELETE,
206            )
207            .expect("add_watch");
208
209        // CREATE + MODIFY "a".
210        let path_a = dir.join("a");
211        {
212            let mut f = std::fs::File::create(&path_a).expect("create a");
213            f.write_all(b"hello").expect("write a");
214        }
215        // MOVED_FROM "a" / MOVED_TO "b" (même cookie).
216        let path_b = dir.join("b");
217        std::fs::rename(&path_a, &path_b).expect("rename a→b");
218        // DELETE "b".
219        std::fs::remove_file(&path_b).expect("remove b");
220
221        let events = drain(&inotify);
222        // CREATE "a".
223        assert!(
224            events
225                .iter()
226                .any(|(m, n, _)| m.contains(InotifyEventMask::CREATE)
227                    && n.as_deref() == Some(&b"a"[..])),
228            "CREATE a manquant : {events:?}"
229        );
230        // MODIFY "a".
231        assert!(
232            events
233                .iter()
234                .any(|(m, n, _)| m.contains(InotifyEventMask::MODIFY)
235                    && n.as_deref() == Some(&b"a"[..])),
236            "MODIFY a manquant"
237        );
238        // MOVED_FROM "a" et MOVED_TO "b" corrélés par cookie (non nul).
239        let from = events
240            .iter()
241            .find(|(m, n, _)| {
242                m.contains(InotifyEventMask::MOVED_FROM) && n.as_deref() == Some(&b"a"[..])
243            })
244            .expect("MOVED_FROM a");
245        let to = events
246            .iter()
247            .find(|(m, n, _)| {
248                m.contains(InotifyEventMask::MOVED_TO) && n.as_deref() == Some(&b"b"[..])
249            })
250            .expect("MOVED_TO b");
251        assert_ne!(from.2, 0, "cookie non nul");
252        assert_eq!(from.2, to.2, "MOVED_FROM/MOVED_TO corrélés par cookie");
253        // DELETE "b".
254        assert!(
255            events
256                .iter()
257                .any(|(m, n, _)| m.contains(InotifyEventMask::DELETE)
258                    && n.as_deref() == Some(&b"b"[..])),
259            "DELETE b manquant"
260        );
261
262        // remove_watch puis IGNORED rendu.
263        inotify.remove_watch(wd).expect("remove_watch");
264        let after = drain(&inotify);
265        assert!(
266            after
267                .iter()
268                .any(|(m, _, _)| m.contains(InotifyEventMask::IGNORED)),
269            "IGNORED après remove_watch"
270        );
271
272        let _ = std::fs::remove_dir_all(&dir);
273    }
274
275    #[test]
276    #[cfg_attr(miri, ignore = "syscalls inotify non supportés par Miri")]
277    fn nonblock_read_with_no_events_is_eagain() {
278        let dir = temp_dir("eagain");
279        let inotify = inotify_init(InotifyFlags::NONBLOCK).expect("inotify_init");
280        inotify
281            .add_watch(&cpath(&dir), InotifyEventMask::CREATE)
282            .expect("add_watch");
283        let mut buffer = [0u8; 64];
284        // Aucun événement → EAGAIN (NONBLOCK).
285        assert!(matches!(
286            inotify.read_events(&mut buffer),
287            Err(Errno::EAGAIN)
288        ));
289        let _ = std::fs::remove_dir_all(&dir);
290    }
291
292    #[test]
293    #[cfg_attr(miri, ignore = "syscalls inotify non supportés par Miri")]
294    fn add_watch_on_missing_path_errors() {
295        let inotify = inotify_init(InotifyFlags::empty()).expect("inotify_init");
296        let missing = cpath(std::path::Path::new("/nonexistent/air-inotify/xyz"));
297        assert!(matches!(
298            inotify.add_watch(&missing, InotifyEventMask::CREATE),
299            Err(Errno::ENOENT)
300        ));
301    }
302
303    #[test]
304    #[cfg_attr(miri, ignore = "syscalls inotify non supportés par Miri")]
305    fn into_fd_and_as_fd_expose_the_descriptor() {
306        use air_sys_types::fd::AsRawFd as _;
307        let inotify = inotify_init(InotifyFlags::empty()).expect("inotify_init");
308        assert!(inotify.as_fd().as_raw_fd() >= 0);
309        let owned = inotify.into_fd();
310        assert!(owned.as_raw_fd() >= 0);
311        // `Debug` de l'instance (avant consommation) — couvre la dérive.
312        let other = inotify_init(InotifyFlags::empty()).expect("inotify_init 2");
313        assert!(format!("{other:?}").contains("Inotify"));
314    }
315
316    #[test]
317    #[cfg_attr(miri, ignore = "syscalls inotify non supportés par Miri")]
318    fn remove_invalid_watch_errors() {
319        let inotify = inotify_init(InotifyFlags::empty()).expect("inotify_init");
320        let bogus = air_sys_types::fs::WatchDescriptor::from_raw(424_242);
321        assert!(matches!(inotify.remove_watch(bogus), Err(Errno::EINVAL)));
322    }
323}