Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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-filesystem est 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-filesystem reste 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-lib services, c’est ici qu’on consomme massivement la famille fs. Chaque fois que l’implémentation devra descendre dans le code source d’un wrapper fs faute 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) et air-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 feature search-regex (audit 80 % à mener ; la recherche par glob est hand-rolled, sans dépendance). À documenter dans DEPENDENCIES.md.

Section 1 — Chemins canoniques & confinement

Reprend la canonicalisation explicitement différée du cœur air-base-lib (où AirPath ne 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 types air-sys-types::fs::{InotifyFlags, InotifyEventMask, WatchDescriptor, InotifyEvent, InotifyEvents} (cf. docs/specs/layer-0/family-fs-inotify.md). Le décodage des struct inotify_event (données kernel) est déjà fait et fuzzé en couche 0 (itérateur zéro-copie InotifyEvents, bornes get(), zéro-perte ADR-032 via truncated()). AirFileSystemWatcher consomme donc des InotifyEvent décodés — il ne re-parse pas d’octets bruts. La logique de couche 1 (filtrage, debouncing, récursivité, coalescence, corrélation MOVED_FROM/MOVED_TO par cookie, mapping vers AirFileSystemEvent) 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ée AirFileSystemEventKind::Overflow (événements perdus → signal de re-scan, zéro-perte ADR-032), pas Other.

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).

  1. Tier fidèle : un appel = un syscall. read/write peuvent être partiels (short read/write) ; l’appelant boucle. EINTR est remonté tel quel (jamais de retry automatique — convention 2), pour qu’une libc servant read(2)/write(2) puisse l’observer. Un helper « lecture intégrale avec retry » relève d’un autre chantier.
  2. Newtypes typés, pas d’entier magique. Le positionnement passe par AirSeekFrom (jamais (offset, whence) brut) ; les drapeaux par les bitflags OpenFlags/StatusFlags/FdFlags via fonctions dédiées (F_GETFL/ F_SETFL/F_GETFD/F_SETFD), jamais un fcntl(op, arg) générique (convention 3).
  3. Arithmétique défensive (Principe 2/4). Les offsets u64 du handle sont validés vers l’off_t signé du noyau par i64::try_from (jamais as) : une position/longueur > i64::MAX est rejetée en amont (InvalidInput), pas laissée au noyau.
  4. RAII, Drop non-paniquant. Le FD ferme au Drop d’OwnedFd (best-effort, erreur ignorée) ; close(self) permet de récupérer l’erreur (ex. EIO NFS). AirFile implémente AsFd (interop + ancre open_at).
  5. created en Option (ADR-032). Le birth-time (STATX_BTIME) n’est pas garanti par tous les FS : None signale 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 (AirSeekFromwhence, statxAirFileMetadata) 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

DomaineAPI principaleCouche 0 consommée
Chemins canoniques / confinementcanonicalize, resolve_within, existsopenat2 (RESOLVE_*), readlink
Atomiquewrite_atomic, read_to_bytesopenat2, write, fsync, rename
Temp dirsAirTempDir (RAII)mkdir, getrandom, getdents64, unlinkat
Copiecopy, copy_tree, AirCopyOptionscopy_file_range (+ repli read/write)
WatchersAirFileSystemWatcher, AirFileSystemEventfs::inotify (disponible couche 0)
Rechercheread_dir, glob, find_regexgetdents64, statx
Handle fichierAirFile (RAII), AirFileMetadata, AirSeekFromopenat2, 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), AirTempDir nettoyé au Drop, copy avec et sans copy_file_range (forcer le repli), read_dir/glob borné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), AirFile round-trip write_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). AirFile n’ajoute aucune cible (pas de surface de parsing d’octets ; cf. « Note fuzz » de la section 7).
  • AirFile (§7) : round-trip open/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), flags F_GETFL/F_SETFL/F_GETFD/F_SETFD, et branches d’erreur (offset/longueur

    i64::MAX, octet NUL, open d’un absent, EXCL sur existant, FD non seekable).

  • Sûreté : AirTempDir::Drop ne panique jamais ; resolve_within ne laisse jamais s’échapper du root (test adverse avec symlinks pointant dehors).
  • Doctests : exemples compilent et passent.

Décisions de fond

  1. air-filesystem synchrone ; l’asynchrone fichier est couche 2 (event loop). air-filesystem est la brique synchrone fiable.
  2. Canonicalisation + confinement ici (différés du cœur air-base-lib) ; resolve_within via les flags kernel RESOLVE_* d’openat2 (sûreté par construction, pas de vérif lexicale contournable).
  3. Zéro perte / zéro troncature muette (ADR-032) : read_to_bytes borné mais erreur explicite au dépassement ; jamais de lecture partielle silencieuse.
  4. AirTempDir RAII, Drop non-paniquant.
  5. Copie : copy_file_range + repli transparent documenté.
  6. Watchers sur fs::inotify (couche 0, disponible) ; la logique de watch/debounce/récursivité/coalescence reste en couche 1. Q_OVERFLOW → variante dédiée Overflow (zéro-perte).
  7. 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 % de regex (exception nommée EXCEPTIONS.md ou report définitif). En attendant, find_regex renvoie Unsupported (feature search-regex inerte) ; 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 ; et air-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.