Layer 0 spec — fs family, inotify sub-module (file monitoring)
Technical specification — Version 1.0 (target kernel: Linux 6.12 LTS). Extension
of the fs family.
Position
This document specifies the inotify primitive of layer 0, a missing piece
identified while specifying air-filesystem (layer 1): AirFileSystemWatcher needs
it and no inotify wrapper existed. It is a small extension of the fs
family (sub-module air-sys-syscall::fs::inotify), in the
FD-event-driven spirit of signalfd/timerfd/eventfd and the uevent socket of device.
Emergence (doc-first method). This gap was revealed by the specification of layer 1 — exactly the expected mechanism: consuming layer 0 surfaces its holes. This extension must be implemented before the watchers of
air-filesystem(coordinated PR).
Scope. inotify (file change notification, unprivileged,
path-based). Out of scope: fanotify (monitoring
at the scale of a mount/FS + access decisions, privileged) — a distinct primitive, to
be produced later for a security/audit service (layer 5), not required here.
Cross-cutting characteristics (consistent with layer 0):
- CLOEXEC by default on the inotify FD.
- Decoding = mirror of the kernel format: reading the FD yields a stream of
struct inotify_eventof variable size (anamefield follows); it is decoded via a borrowed, zero-allocation iterator (same approach as the uevent parser ofdevice, and theSignalFdInfoprecedent). 2 bis. Zero-loss (ADR-032): all events from a read buffer are yielded; a truncated event (buffer too short) is signaled, not swallowed. - No generic ioctl (ADR-021 c.3) — inotify has its dedicated syscalls.
name= bytes (&[u8]), no presumed UTF-8 (Principle 3).
Types (pure → air-sys-types::fs)
#![allow(unused)]
fn main() {
/// Watch descriptor (returned by `inotify_add_watch`). Typed newtype
/// (never a raw `i32`) — ADR-029.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct WatchDescriptor(i32);
bitflags! {
/// Event / option mask (`IN_*`). Kernel names preserved (ADR-029).
pub struct InotifyEventMask: u32 {
const ACCESS = 0x0000_0001;
const MODIFY = 0x0000_0002;
const ATTRIB = 0x0000_0004;
const CLOSE_WRITE = 0x0000_0008;
const CLOSE_NOWRITE = 0x0000_0010;
const OPEN = 0x0000_0020;
const MOVED_FROM = 0x0000_0040;
const MOVED_TO = 0x0000_0080;
const CREATE = 0x0000_0100;
const DELETE = 0x0000_0200;
const DELETE_SELF = 0x0000_0400;
const MOVE_SELF = 0x0000_0800;
// read bits (set by the kernel in the event):
const UNMOUNT = 0x0000_2000;
const Q_OVERFLOW = 0x0000_4000;
const IGNORED = 0x0000_8000;
const ISDIR = 0x4000_0000;
// watch-add options:
const ONLYDIR = 0x0100_0000;
const DONT_FOLLOW = 0x0200_0000;
const EXCL_UNLINK = 0x0400_0000;
const MASK_ADD = 0x2000_0000;
const ONESHOT = 0x8000_0000;
}
}
bitflags! {
pub struct InotifyFlags: i32 {
const NONBLOCK = 0x800; // IN_NONBLOCK (= O_NONBLOCK)
const CLOEXEC = 0x80000; // IN_CLOEXEC (= O_CLOEXEC, set by default)
}
}
}
RAII + wrappers (air-sys-syscall::fs::inotify)
#![allow(unused)]
fn main() {
/// inotify instance: owns an `OwnedFd`, closed on `Drop`.
pub struct Inotify { /* OwnedFd */ }
pub fn inotify_init(flags: InotifyFlags) -> Result<Inotify, Errno>;
impl Inotify {
pub fn as_fd(&self) -> BorrowedFd<'_>;
pub fn into_fd(self) -> OwnedFd;
/// Adds (or updates) a watch on `path` for the `mask` events.
/// `path`: `&CStr` (NUL-terminated bytes, layer 0 convention).
pub fn add_watch(&self, path: &CStr, mask: InotifyEventMask)
-> Result<WatchDescriptor, Errno>;
/// Removes a watch.
pub fn remove_watch(&self, wd: WatchDescriptor) -> Result<(), Errno>;
/// Reads a batch of events **into `buffer`** and decodes it without allocation.
/// The returned `InotifyEvents` **borrows** `buffer`.
pub fn read_events<'b>(&self, buffer: &'b mut [u8])
-> Result<InotifyEvents<'b>, Errno>;
}
}
Underlying syscalls. inotify_init1 (x86_64 no.294, ARM64 no.26),
inotify_add_watch (x86_64 no.254, ARM64 no.27), inotify_rm_watch (x86_64 no.255,
ARM64 no.28), read for the events. IN_CLOEXEC set by default. Available
since Linux 2.6.27 (inotify_init1).
Preconditions / errors. add_watch: ENOSPC (limit
max_user_watches), ENOENT (path), EACCES. read_events: EAGAIN
(NONBLOCK with no event), EINVAL (buffer too small for the next
event — the caller enlarges it). remove_watch: EINVAL (invalid wd).
Buffer size recommendation. One event = 16 bytes of header + name
(up to NAME_MAX+1). Recommend ≥ 4096 bytes; indicative constant
INOTIFY_RECOMMENDED_BUFFER_SIZE = 4096.
The decoded stream: InotifyEvents / InotifyEvent
#![allow(unused)]
fn main() {
/// Borrowed view over a buffer filled by `read`: a sequence of variable-size
/// events. Zero-alloc iterator.
pub struct InotifyEvents<'b> { /* cursor over &'b [u8] */ }
impl<'b> Iterator for InotifyEvents<'b> {
type Item = InotifyEvent<'b>;
fn next(&mut self) -> Option<Self::Item>;
}
pub struct InotifyEvent<'b> {
pub wd: WatchDescriptor,
pub mask: InotifyEventMask,
pub cookie: u32, // correlates MOVED_FROM / MOVED_TO
pub name: Option<&'b [u8]>, // name (bytes) if present, without the padding NUL
}
}
Decoding (ABI mirror). Each record: wd:i32, mask:u32,
cookie:u32, len:u32, then len bytes of name (NUL-padded). The decoder
advances by 16 + len at each step, without copying; name is sliced into the
caller’s buffer (padding NUL removed). Slicing via get() (never
panicking indexing). A final truncated record (buffer cut mid-way)
→ the iterator stops cleanly and read_events can signal it (consistent with
ADR-032: a half event is not swallowed).
Non-recursive (usage note). inotify watches one directory per watch, not its sub-directories. Recursive monitoring (adding one watch per sub-directory, handling new folders via
CREATE|ISDIR) is logic → it isAirFileSystemWatcher(layer 1) that carries it, not layer 0.
Summary
| Function | Role |
|---|---|
inotify_init | creates the instance (RAII Inotify, CLOEXEC) |
Inotify::add_watch / remove_watch | adds/removes a watch on a path |
Inotify::read_events | reads + decodes (borrowed zero-alloc iterator) |
Inotify::as_fd / into_fd | event loop integration / ownership transfer |
Total: ~5 public functions. Types added to air-sys-types::fs:
WatchDescriptor, InotifyEventMask, InotifyFlags, InotifyEvents<'b>,
InotifyEvent<'b> (≈ 5).
Tests
- Integration (real kernel):
inotify_init→add_watchon a temporary directory → create/modify/move/delete a file →read_eventsyields the expected events (CREATE,MODIFY,MOVED_FROM/MOVED_TOcorrelated bycookie,DELETE), with the rightname;remove_watch;Q_OVERFLOWunder a burst. - Pure decoder: synthetic buffers (multi-event, with/without
name, final truncated record) — zero panic, zero OOB, zero alloc (test allocator that panics onalloc). - Property-based (proptest): for any
&[u8], the iterator terminates and never panics. - Fuzzing (cargo-fuzz): the decoder ingests external data (kernel
buffer) → mandatory fuzz harness on
InotifyEvents::parse(Principle 3). - Coverage 100 % lines + branches; resource errors
(
ENOSPC/EMFILE) not triggerable in CI →COVERAGE-EXCEPTIONS.md(STRUCTURAL category).
Core decisions
- inotify as a sub-module of
fs(fs::inotify), not a separate family — it is filesystem by nature, and small. - Borrowed zero-alloc decoding of the variable-size
inotify_event(uevent/SignalFdInfoprecedent); zero-loss (ADR-032). fanotifyout of scope — a distinct, privileged primitive, for a later security/audit service (layer 5).- Recursion = layer 1 (
AirFileSystemWatcher), not layer 0.
Document license: MPL 2.0
Status: Technical specification of the air-sys-syscall::fs::inotify sub-module
(layer 0), target kernel 6.12 LTS. Extension of the fs family.