Spec couche 1 — air-filesystem (opérations filesystem haut niveau)
Spécification technique — Version 1.0. Couche 1 « Primitives système ».
Position et méthode
air-filesystem fournit les opérations filesystem applicatives que les couches
supérieures consomment : écriture atomique, chemins canoniques et confinement,
répertoires temporaires auto-nettoyés, copie efficace, surveillance (watchers),
recherche bornée. Elle s’appuie sur air-base-lib (AirPath, AirString,
AirError/AirResult) et sur la famille fs de la couche 0
(air-sys-syscall::fs).
API Rust d’abord (ABI C différée, ADR-012/027). Méthode doc-d’abord : contrat validé avant implémentation.
Décision de couche :
air-filesystemest synchrone. Les opérations exposées sont bloquantes, bâties sur les syscalls synchrones de la couche 0 (openat2,read/write,fsync,rename,copy_file_range,getdents64…). L’I/O fichier asynchrone (io_uring + ordonnancement) relève de l’event loop unifié de la couche 2 (air-event/runtime), pas de la couche 1.air-filesystemreste la brique synchrone fiable sur laquelle la couche 2 construira l’asynchrone.
Première vraie consommation de la couche 0 (journal de dette doc). Comme pour
air-base-libservices, c’est ici qu’on consomme massivement la famillefs. Chaque fois que l’implémentation devra descendre dans le code source d’un wrapperfsfaute de doc suffisante, on le consigne — signal de dette doc couche 0 à amender.
Dépendances assumées (règle des 80 %, ADR-024)
air-base-lib(types, erreurs, chemins) etair-sys-syscall::fs(couche 0). Pas de dépendance vers une couche ≥ 2.regex: seulement pour la recherche par expression régulière (§7), derrière une featuresearch-regex(audit 80 % à mener ; la recherche par glob est hand-rolled, sans dépendance). À documenter dansDEPENDENCIES.md.
Section 1 — Chemins canoniques & confinement
Reprend la canonicalisation explicitement différée du cœur
air-base-lib(oùAirPathne fait que du lexical pur). Ici, résolution réelle sur disque (donc syscalls) → c’est bien la place d’air-filesystem.
#![allow(unused)]
fn main() {
pub struct AirFileSystem;
impl AirFileSystem {
/// Résout un chemin en chemin **absolu canonique** : suit les symlinks,
/// élimine `.`/`..`, vérifie l'existence. Consomme `openat2` (couche 0).
/// # Errors `AirError` (NotFound, PermissionDenied, symlink loop → `ELOOP`).
pub fn canonicalize(path: &AirPath) -> AirResult<AirPath>;
/// Résout `path` **en restant confiné sous `root`** : aucune composante ni
/// symlink ne peut s'échapper de `root` (anti-« path traversal »). S'appuie
/// sur `openat2` + `RESOLVE_BENEATH`/`RESOLVE_NO_MAGICLINKS` (couche 0).
/// # Errors `AirError { PermissionDenied }` (`EXDEV`/`ELOOP`) si évasion tentée.
pub fn resolve_within(root: &AirPath, path: &AirPath) -> AirResult<AirPath>;
/// Le chemin existe-t-il ? (sans suivre le dernier symlink si `follow=false`).
pub fn exists(path: &AirPath, follow_symlinks: bool) -> bool;
}
}
Décision (sécurité). resolve_within est de première classe : le
confinement filesystem (entitlements d’application, ADR-010) en dépendra. On
utilise les flags kernel RESOLVE_* d’openat2 (sûreté par construction)
plutôt qu’une vérification lexicale a posteriori (contournable par symlink).
Section 2 — Opérations atomiques
#![allow(unused)]
fn main() {
impl AirFileSystem {
/// Écriture **atomique** : écrit `data` dans un fichier temporaire du même
/// répertoire, `fsync`, puis `rename` sur `path` (remplacement atomique).
/// Aucune corruption en cas de crash : `path` contient l'ancien **ou** le
/// nouveau contenu intégral, jamais un état partiel.
/// # Errors `AirError` (avec le chemin en contexte).
pub fn write_atomic(path: &AirPath, data: &[u8]) -> AirResult<()>;
/// Lecture intégrale d'un fichier en mémoire (taille bornée par `max_len`
/// pour éviter une explosion mémoire ; `None` = pas de borne).
/// # Errors `AirError { InvalidData }` si le fichier dépasse `max_len`.
pub fn read_to_bytes(path: &AirPath, max_len: Option<usize>) -> AirResult<Vec<u8>>;
}
}
Décision (zéro perte, ADR-032). read_to_bytes ne tronque jamais
silencieusement : dépasser max_len est une erreur explicite, pas une lecture
partielle muette. Le pattern tmpfile + fsync + rename consomme openat2
(O_TMPFILE ou fichier nommé), write, fsync, rename (couche 0).
Section 3 — Répertoires temporaires (AirTempDir)
#![allow(unused)]
fn main() {
/// Répertoire temporaire **auto-nettoyé** : son `Drop` supprime récursivement le
/// répertoire et son contenu (RAII).
pub struct AirTempDir { /* AirPath + garde de nettoyage */ }
impl AirTempDir {
/// Crée un répertoire temporaire unique (sous `$TMPDIR`/`/tmp`), permissions 0700.
/// # Errors `AirError`.
pub fn new() -> AirResult<Self>;
/// Crée sous un parent donné (utile pour rester sur le même FS qu'une cible).
pub fn new_in(parent: &AirPath) -> AirResult<Self>;
pub fn path(&self) -> &AirPath;
/// Désactive le nettoyage auto et rend le chemin (l'appelant en hérite).
pub fn into_path(self) -> AirPath;
}
}
Décision (sûreté du Drop). Le nettoyage récursif au Drop ne panique
jamais (un échec de suppression est au pire logué/ignoré — un Drop qui panique
est interdit). Création via mkdir (couche 0) + unicité (suffixe aléatoire via
getrandom, couche 0). Suppression récursive via getdents64 + unlinkat.
Section 4 — Copie efficace
#![allow(unused)]
fn main() {
impl AirFileSystem {
/// Copie `src` → `dst`. Utilise `copy_file_range` (zero-copy kernel) sur les
/// FS qui le supportent, avec **repli automatique** `read`/`write` sinon.
/// Préserve le contenu intégral ; le mode de `dst` suit `options`.
/// # Errors `AirError` (avec src/dst en contexte).
pub fn copy(src: &AirPath, dst: &AirPath, options: AirCopyOptions) -> AirResult<u64>; // octets copiés
/// Copie récursive d'une arborescence (bornée en profondeur pour éviter les
/// boucles de symlinks).
pub fn copy_tree(src: &AirPath, dst: &AirPath, options: AirCopyOptions) -> AirResult<u64>;
}
#[derive(Debug, Clone, Default)]
pub struct AirCopyOptions {
pub overwrite: bool, // sinon `AlreadyExists`
pub follow_symlinks: bool,
pub preserve_mode: bool,
}
}
Décision. copy_file_range est disponible en couche 0 (famille fs) ; le
repli read/write couvre les FS/cas où il échoue (EXDEV, ENOSYS). Le repli
est transparent mais documenté (pas de magie cachée — Principe 7).
Section 5 — Watchers (AirFileSystemWatcher)
#![allow(unused)]
fn main() {
/// Surveille des chemins et émet des événements de changement (création,
/// modification, suppression, déplacement), avec filtrage et **debouncing**.
pub struct AirFileSystemWatcher { /* fd inotify + état de debounce */ }
#[derive(Debug, Clone)]
pub struct AirFileSystemEvent {
pub path: AirPath,
pub kind: AirFileSystemEventKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AirFileSystemEventKind { Created, Modified, Removed, MovedFrom, MovedTo, Overflow, Other }
impl AirFileSystemWatcher {
/// # Errors `AirError`.
pub fn new() -> AirResult<Self>;
/// Surveille un chemin (récursif optionnel). Retourne un identifiant de watch.
pub fn watch(&mut self, path: &AirPath, recursive: bool) -> AirResult<WatchId>;
pub fn unwatch(&mut self, id: WatchId) -> AirResult<()>;
/// Bloque jusqu'au(x) prochain(s) événement(s), avec timeout optionnel.
/// Le **debouncing** coalesce les rafales (`AirDuration` de fenêtre).
pub fn next_events(&mut self, timeout: Option<AirDuration>) -> AirResult<Vec<AirFileSystemEvent>>;
/// Le FD sous-jacent, pour intégration dans une event loop (couche 2).
pub fn as_fd(&self) -> BorrowedFd<'_>;
}
}
✅ inotify EST disponible en couche 0 (note « à ajouter » périmée). La couche 0 expose
air-sys-syscall::fs::inotify(inotify_init,Inotify::{add_watch, remove_watch, read_events, as_fd, into_fd}) et les typesair-sys-types::fs::{InotifyFlags, InotifyEventMask, WatchDescriptor, InotifyEvent, InotifyEvents}(cf.docs/specs/layer-0/family-fs-inotify.md). Le décodage desstruct inotify_event(données kernel) est déjà fait et fuzzé en couche 0 (itérateur zéro-copieInotifyEvents, bornesget(), zéro-perte ADR-032 viatruncated()).AirFileSystemWatcherconsomme donc desInotifyEventdécodés — il ne re-parse pas d’octets bruts. La logique de couche 1 (filtrage, debouncing, récursivité, coalescence, corrélationMOVED_FROM/MOVED_TOparcookie, mapping versAirFileSystemEvent) vit ici.Conséquence : §5 est DANS le périmètre v1, NON différé. Débordement de la file kernel (
IN_Q_OVERFLOW) → variante dédiéeAirFileSystemEventKind::Overflow(événements perdus → signal de re-scan, zéro-perte ADR-032), pasOther.
Décision (intégration event loop). as_fd() expose le FD inotify : la couche
2 pourra l’enregistrer dans son event loop io_uring plutôt que d’appeler
next_events en bloquant — air-filesystem fournit les deux usages (bloquant simple +
FD intégrable).
Section 6 — Recherche bornée
#![allow(unused)]
fn main() {
impl AirFileSystem {
/// Liste les entrées d'un répertoire (un niveau). Consomme `getdents64`.
pub fn read_dir(path: &AirPath) -> AirResult<Vec<AirDirEntry>>;
/// Recherche par **glob** (`*`, `?`, `[...]`, `**`), parcours **borné**
/// (profondeur max, nombre d'entrées max) pour éviter les explosions.
pub fn glob(root: &AirPath, pattern: &str, limits: AirSearchLimits)
-> AirResult<Vec<AirPath>>;
/// Recherche par **expression régulière** sur les noms (feature `search-regex`).
#[cfg(feature = "search-regex")]
pub fn find_regex(root: &AirPath, regex: &str, limits: AirSearchLimits)
-> AirResult<Vec<AirPath>>;
}
#[derive(Debug, Clone)]
pub struct AirSearchLimits { pub max_depth: u32, pub max_entries: usize }
#[derive(Debug, Clone)]
pub struct AirDirEntry { pub name: AirPath, pub file_type: AirFileType }
}
Décision (bornage obligatoire — Principe 4/5). Toute recherche est bornée
(max_depth, max_entries) : pas de parcours non borné qui exploserait mémoire/
temps sur une grande arborescence ou une boucle de symlinks. Le glob est
implémenté sans dépendance ; la regex est optionnelle (feature, dépendance
regex auditée).
Section 7 — Handle I/O fichier générique (AirFile)
Là où les sections 1–6 exposent des opérations path-level, la section 7 expose
un handle de fichier persistant possédant un descripteur (OwnedFd, RAII) —
le pendant fichier des handles I/O que air-socket/air-process exposent déjà.
Il débloque l’expression d’open(2)/read(2)/write(2) d’une future libc Air
(audit face-libc P0 item 1 ; audit face-PAL §4 item 1) : la couche 1 ne doit jamais
sauter en couche 0 côté consommateur.
#![allow(unused)]
fn main() {
/// Origine d'un déplacement `lseek` (newtype couche 1, jamais de `whence` brut).
pub enum AirSeekFrom { Start(u64), Current(i64), End(i64) }
pub struct AirFile { /* OwnedFd (RAII) */ }
impl AirFile {
pub fn open(path: &AirPath, flags: OpenFlags, mode: Mode) -> AirResult<Self>;
pub fn open_at(directory: BorrowedFd<'_>, path: &AirPath,
flags: OpenFlags, mode: Mode) -> AirResult<Self>;
pub fn from_owned_fd(fd: OwnedFd) -> Self; // interop
pub fn into_owned_fd(self) -> OwnedFd; // cède la propriété
pub fn read(&self, buffer: &mut [u8]) -> AirResult<usize>; // read(2)
pub fn write(&self, buffer: &[u8]) -> AirResult<usize>; // write(2)
pub fn read_at(&self, buffer: &mut [u8], offset: u64) -> AirResult<usize>; // pread
pub fn write_at(&self, buffer: &[u8], offset: u64) -> AirResult<usize>; // pwrite
pub fn seek(&self, position: AirSeekFrom) -> AirResult<u64>; // lseek
pub fn set_length(&self, length: u64) -> AirResult<()>; // ftruncate
pub fn metadata(&self) -> AirResult<AirFileMetadata>; // statx (AT_EMPTY_PATH)
pub fn duplicate(&self) -> AirResult<Self>; // F_DUPFD_CLOEXEC
pub fn status_flags(&self) -> AirResult<StatusFlags>; // F_GETFL
pub fn set_status_flags(&self, flags: StatusFlags) -> AirResult<()>; // F_SETFL
pub fn descriptor_flags(&self) -> AirResult<FdFlags>; // F_GETFD
pub fn set_descriptor_flags(&self, flags: FdFlags) -> AirResult<()>; // F_SETFD
pub fn set_nonblocking(&self, nonblocking: bool) -> AirResult<()>; // helper
pub fn set_close_on_exec(&self, close_on_exec: bool) -> AirResult<()>;// helper
pub fn close(self) -> AirResult<()>; // close(2) — récupère l'erreur (sinon Drop)
}
/// Métadonnées (vue couche 1 stable au-dessus de `statx`).
pub struct AirFileMetadata { /* type, mode, size, nlink, uid/gid, a/m/c-time, btime? */ }
}
Décisions (fidélité kernel — ADR-021).
- Tier fidèle : un appel = un syscall.
read/writepeuvent être partiels (short read/write) ; l’appelant boucle.EINTRest remonté tel quel (jamais de retry automatique — convention 2), pour qu’une libc servantread(2)/write(2)puisse l’observer. Un helper « lecture intégrale avec retry » relève d’un autre chantier. - Newtypes typés, pas d’entier magique. Le positionnement passe par
AirSeekFrom(jamais(offset, whence)brut) ; les drapeaux par les bitflagsOpenFlags/StatusFlags/FdFlagsvia fonctions dédiées (F_GETFL/F_SETFL/F_GETFD/F_SETFD), jamais unfcntl(op, arg)générique (convention 3). - Arithmétique défensive (Principe 2/4). Les offsets
u64du handle sont validés vers l’off_tsigné du noyau pari64::try_from(jamaisas) : une position/longueur >i64::MAXest rejetée en amont (InvalidInput), pas laissée au noyau. - RAII,
Dropnon-paniquant. Le FD ferme auDropd’OwnedFd(best-effort, erreur ignorée) ;close(self)permet de récupérer l’erreur (ex.EIONFS).AirFileimplémenteAsFd(interop + ancreopen_at). createdenOption(ADR-032). Le birth-time (STATX_BTIME) n’est pas garanti par tous les FS :Nonesignale honnêtement l’indisponibilité plutôt que d’inventer une valeur.
Note fuzz. AirFile n’introduit aucune surface de parsing d’octets
externes (contrairement au matcher de glob, fuzzé) : c’est un wrapper mince de
syscalls dont la seule logique pure (AirSeekFrom → whence, statx →
AirFileMetadata) prend une entrée structurée (enum/struct), couverte par
tests unitaires + property-based. Aucune cible cargo-fuzz dédiée n’est donc
ajoutée (une cible faisant de l’I/O réelle serait non déterministe) ; le
round-trip write_at/read_at est couvert en property-based.
Récapitulatif air-filesystem
| Domaine | API principale | Couche 0 consommée |
|---|---|---|
| Chemins canoniques / confinement | canonicalize, resolve_within, exists | openat2 (RESOLVE_*), readlink |
| Atomique | write_atomic, read_to_bytes | openat2, write, fsync, rename |
| Temp dirs | AirTempDir (RAII) | mkdir, getrandom, getdents64, unlinkat |
| Copie | copy, copy_tree, AirCopyOptions | copy_file_range (+ repli read/write) |
| Watchers | AirFileSystemWatcher, AirFileSystemEvent | fs::inotify (disponible couche 0) |
| Recherche | read_dir, glob, find_regex | getdents64, statx |
| Handle fichier | AirFile (RAII), AirFileMetadata, AirSeekFrom | openat2, read/write, pread/pwrite, lseek, ftruncate, dup_fd, fcntl, statx, close |
Différé / dépendances : AirFileSystemWatcher consomme fs::inotify (couche 0,
disponible — plus de PR coordonnée). ABI C différée. Recherche regex derrière feature
search-regex : différée à l’implémentation (règle des 80 % — regex expose une
API très large dont Air n’utiliserait qu’une fraction ; find_regex renvoie
Unsupported, le glob couvre le cas courant ; exception nommée vs report définitif à
trancher). copy_tree est borné en profondeur (anti-boucle de symlink, défaut
AirSearchLimits).
Stratégie de tests
- Couverture 100 % lignes + branches (couche fondatrice, Principe 1).
- Intégration (tmpfs/ext4) :
write_atomic(vérifier atomicité : interruption simulée → ancien contenu intact),canonicalize/resolve_within(évasion par../symlink rejetée),AirTempDirnettoyé auDrop,copyavec et sanscopy_file_range(forcer le repli),read_dir/globbornés, watchers (si le primitif couche 0 est dispo) : création/modif/suppression détectées, debounce. - Property-based (proptest) :
glob(matching correct vs un oracle naïf), bornage (jamais >max_entries),read_to_bytes(≤max_len),AirFileround-tripwrite_at/read_at(octets arbitraires, offsets bornés). - Fuzzing (cargo-fuzz) : parseur de glob et matching (données externes :
motif + noms), parsing des
inotify_event(quand le primitif existe).AirFilen’ajoute aucune cible (pas de surface de parsing d’octets ; cf. « Note fuzz » de la section 7). AirFile(§7) : round-tripopen/write/read/seek,pread/pwrite(curseur inchangé),set_length(grandir/rétrécir),metadata(régulier/ répertoire/symlink),duplicate(description partagée +FD_CLOEXEC), flagsF_GETFL/F_SETFL/F_GETFD/F_SETFD, et branches d’erreur (offset/longueuri64::MAX, octet NUL,opend’un absent,EXCLsur existant, FD non seekable).- Sûreté :
AirTempDir::Dropne panique jamais ;resolve_withinne laisse jamais s’échapper duroot(test adverse avec symlinks pointant dehors). - Doctests : exemples compilent et passent.
Décisions de fond
air-filesystemsynchrone ; l’asynchrone fichier est couche 2 (event loop).air-filesystemest la brique synchrone fiable.- Canonicalisation + confinement ici (différés du cœur
air-base-lib) ;resolve_withinvia les flags kernelRESOLVE_*d’openat2(sûreté par construction, pas de vérif lexicale contournable). - Zéro perte / zéro troncature muette (ADR-032) :
read_to_bytesborné mais erreur explicite au dépassement ; jamais de lecture partielle silencieuse. AirTempDirRAII,Dropnon-paniquant.- Copie :
copy_file_range+ repli transparent documenté. - Watchers sur
fs::inotify(couche 0, disponible) ; la logique de watch/debounce/récursivité/coalescence reste en couche 1.Q_OVERFLOW→ variante dédiéeOverflow(zéro-perte). - Recherche toujours bornée (
max_depth/max_entries) ; glob sans dépendance, regex optionnelle (feature + audit 80 %).
Travail à reprendre
find_regex: trancher l’exception 80 % deregex(exception nomméeEXCEPTIONS.mdou report définitif). En attendant,find_regexrenvoieUnsupported(featuresearch-regexinerte) ; le glob couvre le cas courant.- ABI C d’
air-filesystem(après stabilisation de l’API Rust). - Crates couche 1 suivantes :
air-process,air-socket,air-crypto,air-device,air-thread,air-memory; etair-base-lib(logging/id/config + ABI C).
Licence du document : MPL 2.0
Statut : Spécification technique de air-filesystem (couche 1). API Rust ; ABI C différée.