Air Desktop — Index des documents de conception
Document de référence — Version 1.4 (2026-07-10, passe post-M5 : ADRs jusqu’à 087, sceaux couche-0-v1.12 / couche-1-v1.9, jalon std-sur-Air atteint)
Ce document recense l’ensemble des documents de conception du projet Air Desktop, élaborés pendant les sessions préparatoires. Il sert de :
- Index navigationnel pour retrouver rapidement un document.
- Checklist de complétude pour vérifier que tout est en place.
- Carte des travaux à reprendre pour les sessions futures.
Identité du projet
- Nom du projet : Air
- Identité publique : Air Desktop
- Domaine :
air-desktop.org - Organisation GitHub :
air-desktop-project - Repo principal :
github.com/air-desktop-project/air - Préfixe technique des crates :
air- - Licence : Mozilla Public License 2.0
Convention de nommage des fichiers
- ADRs (Architecture Decision Records) :
ADR-NNN-titre-court-LL.md(préfixeADR-, numéro à 3 chiffres, titre en kebab-case, suffixe de langue-frou-en). - Documents fondateurs (vision, charte, principes d’ingénierie) : nom thématique + suffixe de langue (
vision-fr.md,charter-en.md, etc.). - Specs et documents internes : nom thématique en kebab-case, langue implicite par contenu et contexte.
Structure de la documentation
air/
├── README.md # Vue d'ensemble publique du projet
├── README-fr.md # Vue d'ensemble publique du projet (FR)
├── LICENSE # MPL 2.0 (à copier)
├── CHANGELOG.md # À démarrer
├── CONTRIBUTING.md # À rédiger (TODO)
├── CODE_OF_CONDUCT.md # Contributor Covenant 2.1 (TODO)
├── SECURITY.md # À rédiger (TODO)
├── Cargo.toml # Workspace Rust privé (couche 0)
├── Cargo.lock # Lockfile committé
├── rust-toolchain.toml # Toolchain pinée
├── crates/
│ ├── air-sys-types/ # Types fondamentaux de la couche 0
│ └── air-sys-syscall/ # Wrappers Rust des syscalls Linux
└── docs/
├── INDEX.md # Ce document
├── EXCEPTIONS.md # Exceptions explicites au principe dépendances (ADR-024)
├── COVERAGE-EXCEPTIONS.md # Branches/lignes couche 0 non couvertes (raison légitime)
├── UNSUPPORTED.md # Syscalls/opcodes legacy non wrappés + remplaçants (io_uring 3b)
│
├── vision/
│ ├── vision-fr.md # Vision du projet (FR)
│ └── vision-en.md # Project Vision (EN)
│
├── charte/
│ ├── charte-fr.md # Charte (5 principes, FR)
│ └── charter-en.md # Charter (EN)
│
├── principes-ingenierie/
│ ├── principes-ingenierie-fr.md # 9 principes (FR)
│ └── engineering-principles-en.md # Engineering Principles (EN)
│
├── adrs/
│ ├── registre-adrs-fr.md # Registre / index des ADRs (v3.36)
│ ├── ADR-001-aircom-fr.md # 17 ADRs fondateurs + ADR-018 imagerie
│ ├── ADR-002-modele-objet-fr.md
│ ├── ADR-003-compositeur-wayland-fr.md
│ ├── ADR-004-linux-tier-1-fr.md
│ ├── ADR-005-systemd-fr.md
│ ├── ADR-006-profils-fr.md
│ ├── ADR-007-air-console-fr.md
│ ├── ADR-008-air-tui-fr.md
│ ├── ADR-009-framework-vues-fr.md
│ ├── ADR-010-airapp-fr.md
│ ├── ADR-011-phasage-fr.md
│ ├── ADR-012-versionnement-fr.md
│ ├── ADR-012-bis-telemetrie-fr.md
│ ├── ADR-013-distribution-fr.md
│ ├── ADR-014-catalogue-materiel-fr.md
│ ├── ADR-015-licence-gouvernance-fr.md
│ ├── ADR-016-i18n-fr.md
│ ├── ADR-017-accessibilite-fr.md
│ ├── ADR-018-modele-imagerie-fr.md
│ ├── ADR-019-modele-erreurs-fr.md # ADRs phase 0 (renumérotés +1)
│ ├── ADR-020-strategie-signaux-fr.md
│ ├── ADR-021-conventions-couche-0-fr.md
│ ├── ADR-022-architecture-io-uring-fr.md
│ ├── ADR-023-runtime-air-fr.md
│ ├── ADR-024-workflow-dependances-fr.md
│ ├── ADR-025-builds-reproductibles-fr.md # OK (clôt la série de design phase 0)
│ ├── ADR-026-contrat-air-tui-fr.md # OK (contrat 1.0 `air-tui`)
│ └── ADR-027-doc-polyglotte-fr.md # OK (doc polyglotte ABI C — diff. couche 2)
│
├── architecture/
│ └── macro-architecture-fr.md # Macro-architecture des 6 couches
│
├── notes/ # Pistes / cadrages exploratoires (NON engageants)
│ ├── libc-compat-exploration-fr.md
│ ├── android-vendor-interface-exploration-fr.md
│ ├── api-reseau-strategie-fr.md # cadrage `air-network` (couche 2)
│ ├── audit-dlmalloc-fr.md # distillation dlmalloc → allocateur global Air (couche 1)
│ └── audit-s2n-tls-fr.md # relevé chiffré s2n-tls → contingence air-tls maison (couche 2)
│
├── specs/
│ ├── layer-0/
│ │ ├── introduction-fr.md # Intro développeur couche 0 (encart dev C/C++)
│ │ ├── air-sys-types.md # Partiel, avec TODO
│ │ ├── family-process.md
│ │ ├── family-fs.md
│ │ ├── family-mem.md
│ │ ├── family-signal.md
│ │ ├── family-time.md
│ │ ├── family-net.md
│ │ ├── family-ipc.md
│ │ ├── family-security.md
│ │ ├── family-system.md
│ │ ├── family-device.md # uevent/evdev (sysfs → fs)
│ │ ├── family-ebpf.md # bpf() exhaustif + perf_event_open
│ │ ├── family-fs-inotify.md # extension inotify (prérequis AirFileSystemWatcher)
│ │ ├── family-mem-mmap-region.md # MmapRegion partageable refcounté (implémenté #31)
│ │ ├── family-process-privsep.md # setgroups/setres*id (prérequis drop_privileges)
│ │ ├── io-uring-overview.md # Synthèse, avec TODO
│ │ ├── io-uring-1-core.md # TODO
│ │ ├── io-uring-2a-filesystem.md # TODO
│ │ ├── io-uring-2b-network.md # TODO
│ │ ├── io-uring-2c-async.md # TODO
│ │ ├── io-uring-3a-registration.md # TODO
│ │ ├── io-uring-3b-linked.md # TODO
│ │ ├── io-uring-3c-multishot.md # TODO
│ │ ├── io-uring-3d-shared.md # TODO
│ │ └── io-uring-4-raw.md # TODO
│ ├── layer-1/
│ │ ├── air-base-lib-core.md # erreurs, chaînes/chemins, temps
│ │ ├── air-base-lib-services.md # logging, identifiants, config
│ │ ├── air-filesystem.md # AirFileSystem + watcher
│ │ ├── air-memory.md # allocateurs arena/pool/slab + comptabilité
│ │ ├── air-process.md # processus, pidfd, drop_privileges
│ │ ├── air-thread.md # threads, mutex/rwlock/sémaphore, MPSC
│ │ ├── air-socket.md # sockets + résolution de noms enfichable
│ │ ├── air-crypto.md # primitives cryptographiques (RustCrypto)
│ │ ├── air-device.md # énumération/surveillance natives (sysfs+uevent)
│ │ ├── air-config.md # compilateur config + artefact Cap'n Proto (crate dédiée)
│ │ ├── air-config-source-format.md # grammaire du format de source air-config (design)
│ │ └── air-config-schemas-v1.md # schémas .capnp v1 + BNF gelée du format de source
│ ├── layer-2/
│ │ ├── air-object.md # modèle d'objet C-ABI (clé de voûte couche 2)
│ │ ├── air-com.md # IPC natif AirCom (sans-IO, capability=fd, pub-sub anneau shm)
│ │ └── air-event.md # façade C-ABI de l'event loop async (sur air-runtime)
│ └── layer-4/
│ ├── air-tui-core.md # Noyau 1.0 `Capabilities`/focus/commandes
│ └── air-tui-runtime-api.md # Surface d'API conceptuelle runtime
│
├── setup/
│ ├── phase-A-decisions.md
│ ├── phase-B1-structure.md
│ ├── phase-B2-operations.md
│ ├── phase-C-infrastructure.md
│ └── phase-D-community.md
│
├── guides/ # TODO
│ ├── first-contribution.md
│ └── getting-started/
│
└── maintainers/ # TODO
├── release-process.md
├── moderation.md
├── onboarding-maintainers.md
└── cve-handling.md
Inventaire des documents produits
Documents fondateurs
| Document | Localisation | Statut |
|---|---|---|
| Vision (FR) | docs/vision/vision-fr.md | OK |
| Vision (EN) | docs/vision/vision-en.md | OK |
| Charte (FR) | docs/charte/charte-fr.md | OK |
| Charter (EN) | docs/charte/charter-en.md | OK |
| Principes d’ingénierie (FR) | docs/principes-ingenierie/principes-ingenierie-fr.md | OK |
| Engineering Principles (EN) | docs/principes-ingenierie/engineering-principles-en.md | OK |
| Registre des ADRs (v3.36) | docs/adrs/registre-adrs-fr.md | OK |
| Macro-architecture des 6 couches | docs/architecture/macro-architecture-fr.md | Passe 1 (nettoyage) OK ; passe 2 (complétion sections couches 1-5) à faire plus tard, en parallèle de l’implémentation |
ADRs fondateurs (17) et ADR-018 (modèle d’imagerie)
Voir le registre des ADRs pour la liste consolidée. Tous les fichiers sont sous docs/adrs/.
ADRs phase 0
| ADR | Titre | Localisation | Statut |
|---|---|---|---|
| 019 | Modèle d’erreurs hybride à deux niveaux | docs/adrs/ADR-019-modele-erreurs-fr.md | OK |
| 020 | Stratégie signaux signalfd par défaut | docs/adrs/ADR-020-strategie-signaux-fr.md | OK |
| 021 | Conventions transverses de la couche 0 | docs/adrs/ADR-021-conventions-couche-0-fr.md | OK |
| 022 | Architecture du module io_uring | docs/adrs/ADR-022-architecture-io-uring-fr.md | OK |
| 023 | Runtime asynchrone Air sur io_uring | docs/adrs/ADR-023-runtime-air-fr.md | OK |
| 024 | Workflow de gestion des dépendances | docs/adrs/ADR-024-workflow-dependances-fr.md | OK |
| 025 | Stratégie de builds reproductibles | docs/adrs/ADR-025-builds-reproductibles-fr.md | OK |
ADRs post-phase 0
| ADR | Titre | Localisation | Statut |
|---|---|---|---|
| 026 | Contrat 1.0 d’air-tui : UI texte riche, cellulaire, capability-gated | docs/adrs/ADR-026-contrat-air-tui-fr.md | OK |
| 027 | Stratégie de documentation polyglotte (ABI C et bindings multi-langages) | docs/adrs/ADR-027-doc-polyglotte-fr.md | OK |
| 028 | Soundness et téardown du module io_uring (S1/S2/S3), complète ADR-022 | docs/adrs/ADR-028-soundness-io-uring-fr.md | OK |
| 029 | Nommage de la surface publique : explicite, sans abréviation ; noms d’autorité conservés | docs/adrs/ADR-029-nommage-surface-publique-fr.md | OK |
| 030 | Périmètre de la règle des 80 % : production vs test-only (companion ADR-024) | docs/adrs/ADR-030-dependances-test-only-fr.md | OK |
| 031 | Mesure de couverture en root sur les runners self-hosted (ebpf, uinput) | docs/adrs/ADR-031-couverture-root-ci-fr.md | OK |
| 032 | Préservation des données confiées : zéro discard silencieux (Méthode) | docs/adrs/ADR-032-zero-discard-donnees-fr.md | OK |
| 033 | Modèle de configuration : source typée, compilation validée, artefact binaire (Architecture) | docs/adrs/ADR-033-modele-configuration-fr.md | OK |
| 034 | Discipline des dépendances cryptographiques (companion ADR-024) | docs/adrs/ADR-034-discipline-dependances-crypto-fr.md | OK |
| 035 | Taxonomie des exceptions de couverture (companion ADR-031) | docs/adrs/ADR-035-taxonomie-exceptions-couverture-fr.md | OK |
| 036 | Filtrage par chemin de la re-vérification d’une couche scellée (companion ADR-031/035) | docs/adrs/ADR-036-filtrage-reverification-couche-scellee-fr.md | OK |
| 037 | CI ARM seule (raspi-srv-2) + validation x86 par barrière pré-merge (companion ADR-031/036) | docs/adrs/ADR-037-ci-arm-x86-premerge-fr.md | OK |
| 038 | Modèle d’exécution : runtime async natif io_uring (sans tokio), couches basses synchrones, pas d’epoll (amende ADR-023) | docs/adrs/ADR-038-modele-execution-async-io-uring-fr.md | OK |
| 039 | Nommage & placement : runtime air-runtime (L1), modèle d’objet air-object (L2), ordonnancement single-thread/thread-per-core (amende ADR-002, précise ADR-023/038) | docs/adrs/ADR-039-nommage-runtime-objet-ordonnancement-fr.md | OK |
| 040 | Format de l’artefact binaire de configuration : Cap’n Proto (companion ADR-033) | docs/adrs/ADR-040-format-artefact-config-capnproto-fr.md | OK |
| 041 | Coexistence /etc : projection générée, lecture seule sélective, air-config seul écrivain (companion ADR-033) | docs/adrs/ADR-041-coexistence-etc-fr.md | OK |
| 042 | Pile TLS : rustls + aws-lc-rs (exception C nommée, étroite), TLS 1.3, air-tls maison en contingence | docs/adrs/ADR-042-pile-tls-rustls-awslc-fr.md | OK |
| 043 | Pile SSH : OpenSSH système en incubation, air-ssh maison à terme (client avant serveur) | docs/adrs/ADR-043-pile-ssh-fr.md | OK |
| 044 | Famille couche 0 poll/ppoll (attente bornée interruptible) | docs/adrs/ADR-044-extension-couche-0-famille-poll-fr.md | OK |
| 045 | Modèle d’erreurs de l’ABI C (AirStatus in-band) | docs/adrs/ADR-045-modele-erreurs-abi-c-fr.md | OK |
| 046 | La libc Air (Rust pur) : périmètre, layering, doctrine | docs/adrs/ADR-046-libc-air-fr.md | OK |
| 047 | Principes de construction de la libc Air | docs/adrs/ADR-047-principes-construction-libc-fr.md | OK |
| 048 | Descellement couche 0 : std-free + futex(2) | docs/adrs/ADR-048-descellement-couche0-std-free-futex-fr.md | OK |
| 049 | Runtime Air : TCB/TLS/démarrage (fondation *-linux-air) | docs/adrs/ADR-049-runtime-tcb-tls-fr.md | OK |
| 050 | Spécification de la cible *-linux-air (phase 1) | docs/adrs/ADR-050-cible-linux-air-spec-fr.md | OK |
| 051 | Descellement couche-0-v1.7 : 7 syscalls runtime & libc (famille arch) | docs/adrs/ADR-051-descellement-couche0-v1.7-libc-fr.md | OK |
| 052 | air-rt = objet couche 1 (AirRuntime), jamais consommateur direct de la couche 0 | docs/adrs/ADR-052-air-rt-objet-couche1-fr.md | OK |
| 053 | La libc Air s’appuie sur icu4x (ctype/locale/i18n) | docs/adrs/ADR-053-libc-sur-icu4x-fr.md | OK |
| 054 | Scission air-base-core (cœur sans i18n) hors d’air-base-lib | docs/adrs/ADR-054-air-base-core-scission-i18n-fr.md | OK |
| 055 | air-thread/air-process en no_std + crate air-env | docs/adrs/ADR-055-nostd-thread-process-air-env-fr.md | OK |
| 056 | Allocateur global air-alloc (couche 1) : inspiré de dlmalloc, simple, possédé — remplace AbortOnAlloc, maillon libc | docs/adrs/ADR-056-allocateur-air-alloc-fr.md | OK |
| 057 | libm Air (air-libm, couche 1) : vendoring de libm (rust-lang) en exception nommée — math flottante no_std (fondation libc/i18n, audit icu4x §3/§8), aucun consommateur encore | docs/adrs/ADR-057-libm-air-fr.md | OK |
| 058 | loom : activation du modèle de concurrence (test-only) | docs/adrs/ADR-058-loom-modele-concurrence-fr.md | OK |
| 059 | Stratégie i18n : Air utilise icu4x (Option V) | docs/adrs/ADR-059-i18n-strategie-option-v-icu4x-fr.md | OK |
| 060 | Descellement couche 0 : famille termios/tty | docs/adrs/ADR-060-descellement-couche0-termios-tty-fr.md | OK |
| 061 | Architecture AirTerminal : modèle canonique + codecs | docs/adrs/ADR-061-architecture-air-terminal-fr.md | OK |
| 062 | Sceau couche-1-v1.0 : gel de l’API Rust de la couche 1 | docs/adrs/ADR-062-sceau-couche-1-fr.md | OK |
| 063 | Instrumentation IO couche 1 (debug) : registre de handles & cohérence fork | docs/adrs/ADR-063-instrumentation-io-couche-1-fr.md | OK |
| 064 | Doctrine des signaux couche 1 : signalfd primaire, fautes → défaut (zéro unsafe) | docs/adrs/ADR-064-signaux-doctrine-couche-1-fr.md | OK |
| 065 | Descellement additif couche-1-v1.1 : AirRuntime::errno_location (délégation de re-sceau) | docs/adrs/ADR-065-descellement-couche1-v1.1-fr.md | OK |
| 066 | Descellement additif couche 0 : rt_sigaction non-faute (async réel) + rt_sigpending + trampoline | docs/adrs/ADR-066-descellement-couche0-sigaction-async-fr.md | OK |
| 067 | air-account (couche 1) : comptes /etc/passwd+/etc/shadow+/etc/group, bindée par la libc | docs/adrs/ADR-067-air-account-couche1-fr.md | OK |
| 068 | Surface fine credentials (uid/gid) en couche 1 (air-process), bindée par la libc | docs/adrs/ADR-068-credentials-couche1-fr.md | OK |
| 069 | Registre de handles fd-général (couche 1, air-handle) + moteur socket, façades PAL & libc | docs/adrs/ADR-069-registre-handles-fd-moteur-socket-fr.md | OK |
| 070 | Descellement additif couche 0 : codec sockaddr public pour la face libc | docs/adrs/ADR-070-descellement-couche0-sockaddr-libc-fr.md | OK |
| 071 | Descellement additif couche 0 : setsockopt/getsockopt entiers publics (face libc) | docs/adrs/ADR-071-descellement-couche0-sockopt-libc-fr.md | OK |
| 072 | CI : x86_64 runner primaire, aarch64 cross-check + natif sur main (révise ADR-037) | docs/adrs/ADR-072-ci-x86-primaire-aarch64-crosscheck-fr.md | OK |
| 073 | Doctrine de configuration binaire : pas de conf en texte clair, CLI + mot de passe admin | docs/adrs/ADR-073-configuration-binaire-doctrine-fr.md | OK |
| 074 | Vision air-sshd : démon SSH natif async, wire-compat OpenSSH, conf binaire, profil moderne | docs/adrs/ADR-074-air-sshd-vision-fr.md | OK |
| 075 | Fourniture du PAL : std::sys::pal::air via rust-src patché + build-std, upstream Tier-3 | docs/adrs/ADR-075-fourniture-pal-rust-src-patche-fr.md | OK |
| 076 | std natif sur la libc d’Air : cible env=musl + vendor=air (chantier B, option A) | docs/adrs/ADR-076-std-sur-air-libc-env-musl-fr.md | OK |
| 077 | Managers de domaine couche 1 : surface objet médiatrice des toits libc + PAL | docs/adrs/ADR-077-managers-domaine-couche1-fr.md | OK |
| 078 | Descellement additif couche-1 : additifs crypto pour air-tls (ECDSA/RSA-PSS/ML-KEM/AES-128) — earmark v1.10 | docs/adrs/ADR-078-descellement-couche1-v1.7-crypto-air-tls-fr.md | OK |
| 079 | air-netlink (couche 1) : transport netlink générique + descellement couche 0 (AF_NETLINK) | docs/adrs/ADR-079-air-netlink-couche1-fr.md | OK |
| 080 | Additifs air-socket : multicast UDP typé + codec DNS SRV/TXT/PTR + setsockopt structuré | docs/adrs/ADR-080-additifs-air-socket-multicast-dns-fr.md | OK |
| 081 | Additifs crypto air-quic/air-ssh : primitives lowlevel (AES bloc, ChaCha20, Poly1305) + RSA PKCS#1 v1.5 | docs/adrs/ADR-081-additifs-crypto-quic-ssh-fr.md | OK |
| 082 | Additifs crypto : AEAD à nonce explicite + BLAKE2s (WireGuard) + PBKDF2 (SASL/SCRAM) | docs/adrs/ADR-082-additifs-crypto-wireguard-mail-aead-nonce-fr.md | OK |
| 083 | Additifs couche 0 : TUN (TUNSETIFF) + adjtimex (discipline horloge) | docs/adrs/ADR-083-additifs-couche0-tun-adjtimex-fr.md | OK |
| 084 | Ratification air-url : couche 4 élargie (UI + fondation) | docs/adrs/ADR-084-ratification-air-url-couche4-elargie-fr.md | OK |
| 085 | Descellement couche 0 cumulé : primitives manquantes pour la face libc (chantier B) | docs/adrs/ADR-085-descellement-couche0-cumule-libc-std-fr.md | OK |
| 086 | Introspection thread cible pour la face libc : TLS dynamique, bornes de pile, auxv (M5) | docs/adrs/ADR-086-introspection-thread-cible-libc-fr.md | OK |
| 087 | syscall générique (raw_syscall) : escape hatch temporaire pour le pal std (M5) — À RETIRER | docs/adrs/ADR-087-syscall-escape-hatch-a-retirer-fr.md | OK (dette ouverte) |
| 088 | std Rust sur Air safe : PAL custom sur la couche 1, sans libc C (amende ADR-076 ; AirTaskManager ; résorbe raw_syscall) | docs/adrs/ADR-088-std-pal-safe-couche1-sans-c-fr.md | OK |
| 089 | Réservation du SecurityManager : sécurité active per-process embarquée par libair, consultée par les Managers sensibles (Allow/Deny+log) ; moteur différé | docs/adrs/ADR-089-reservation-security-manager-fr.md | OK |
Spécifications techniques de la couche 0
🔒 COUCHE 0 — SCELLÉE le 2026-06-14 (tag
couche-0-v1), re-scelléecouche-0-v1.1(errno additif, PR #77),couche-0-v1.2(extension exec/redirectionexecve/execveat/dup3/fchdir/chdir/exit_group, PR #100),couche-0-v1.3(execve_prepared— exec sans alloc pour air-process, PR #104), puiscouche-0-v1.4le 2026-06-25 (faccessathonore réellement ses flags viafaccessat2SYS 439 — ,couche-0-v1.5(famillepoll/ppoll— attente synchrone bornée interruptible, ADR-044, PR #129), puiscouche-0-v1.6le 2026-06-28 : la couche 0 devientstd-free (#![cfg_attr(not(test), no_std)], prouvé par le compilateur, Principe 4) via types FD natifs d’Air (OwnedFd/BorrowedFd/RawFd+close(2)interne, PR #148), famillefutex(2)(PR #147),std-free (ffi→alloc/core,OsString→octets,FutexMutex, PR #149) etclone3spawn de thread (clone_thread+ trampoline asm 2-arches, joinCHILD_CLEARTID, PR #150) — ADR-048 + Amendement 1 ; jalons antérieurs conservés) ; puiscouche-0-v1.7(famillearch—arch_prctlset_fs/get_fsTLS x86_64, ADR-051/052) ; puiscouche-0-v1.8le 2026-07-02 (familleterminal—termios/ttycomplète : attributstcgetattr/tcsetattr, contrôle-lignetcdrain/tcflush/tcflow/tcsendbreak,winsize(TIOCGWINSZ/S), PTY (/dev/ptmx+TIOCGPTN/TIOCSPTLCK/TIOCGPTPEER), session/job-control (tc[gs]etpgrp/TIOCSCTTY/TIOCNOTTY/TIOCGSID) ; fonctions dédiées typées ADR-021 conv. 3,Option<Pid>, EINTR remonté, RAII FD ; tests sur PTY réel/dev/ptmx+ cycle session en enfantsetsid; ADR-060, PRs #200–#204 ; 1 exception CHILD-EXIT) ; puiscouche-0-v1.9(signaux async, ADR-066),couche-0-v1.10(sockaddr/sockopt libc, ADR-070/071),couche-0-v1.11le 2026-07-09 (additifs cumulés libc/std —chroot/killpg/sigaltstack+AltStack/sendfile/fchmod/fchown/futimens/munmap_raw, ADR-085), puiscouche-0-v1.12le 2026-07-10 (raw_syscall** — escape hatch syscall générique, EXCEPTION assumée temporaire à retirer avec l’équipe Rust, ADR-087, jalon M5).** CODE complet (11 familles + io_uring 12 Temps + extensionsMmapRegion/privsep/fs::inotify/ affinité CPU/exec/faccessat2), barrière complète verte sur 2 arches (x86_64speedy, aarch64raspi-srv-2), 100 % de couverture hors exceptions documentées (couvrable VIDE au sens strict, ADR-035 ; lignes 97,74 % / branches 82,73 % brutes),// SAFETY:sur chaque blocunsafe, doc développeur FR + EN. Le socle syscall n’évolue plus que par RFC (ADR-015) ; les constantesErrnopeuvent être complétées additivement par les besoins des contrats couche 1 landés (re-sceauvX.Y, jalons antérieurs conservés). Couche 1 — 8/8 CRATES CŒUR IMPLÉMENTÉ :air-base-libcœur (PR #73) +air-crypto(PR #80) +air-socket(PR #86) +air-filesystem(PR #87) +air-memory(PR #88) +air-device(PR #94) +air-thread(PR #97) +air-process(PR #105). Reste pour clore la couche 1 :air-base-libservices (logging/identifiants ;air-config→ crate dédiée, Cap’n Proto, ADR-040/041) et l’ABI C (libair-base.so, différée).🔒 COUCHE 1 — SCELLÉE
couche-1-v1.0(2026-07-03, ADR-062) ; re-scellée v1.1 (ADR-065), v1.2 (ADR-066),couche-1-v1.3(2026-07-05, ADR-067 — crateair-account) ; v1.4/v1.5 (credentials + drop_to_user, ADR-068),couche-1-v1.6(air-handle + moteur socket, ADR-069),couche-1-v1.7le 2026-07-09 (Managers de domaine ADR-077 +AirSignalManager, chantier B),couche-1-v1.8le 2026-07-09 (air-process::spawn_process, socleposix_spawn, chantier B),couche-1-v1.9le 2026-07-10 (introspection thread cible —air-runtime::process_context[auxv+bornes de pile] +AirProcessManager::resource_limit, socle degetauxval/pthread_getattr_npde la libc, ADR-086, jalon M5 ; earmark ADR-078 crypto →couche-1-v1.10). API Rust publique figée sur 17 crates (air-base-core/air-base-lib,air-crypto,air-socket,air-filesystem,air-memory,air-alloc,air-device,air-env,air-stdio,air-terminal,air-poll,air-signal,air-thread,air-process,air-runtimeasync inclus,air-config*,air-libm). Précédée des audits pré-sceau (#212/#213), des 4 P0 (AirFile #214, air-poll #215, termios/PTY #216, air-signal #217) et du remodelos_strenv/args (#218). HORS sceau : C-ABI (air-base-capi+ libc, régime air-stable) · PALstd::sys· toolchain C/C++. Évolution ensuite par descellement additifv1.x(RFC, modèle ADR-051). Toit libc — jalon M0 (fondations) posé : split code / artefact (ADR-029) —crates/air-libc-capi(rlib pur, le code : buildable pour*-linux-air) +crates/air-libc-c(cdylibhost-only, l’artefactlibair_c.so). M1-b — split « logique / shims C-ABI » (couverture) : la LOGIQUE mesurable vit dans des crates sœurs SANS symbole libc#[no_mangle]—crates/air-libc-fmt(moteur deformatprintf, couvert à 100 %, surface format-string N°1) etcrates/air-libc-alloc(logiquemalloc/free/…, couvert à 100 %) ; les primitivesmem*/str*(#![no_builtins]) danscrates/air-libc-strmem. Seulair-libc-capiporte les shims#[no_mangle] extern "C"(qui délèguent), exclu du gate de couverture (commeair-libc-strmem) car ces symboles surchargent le runtime de couverture LLVM. Régime air-stable —errnoC-ABI (__errno_location+E*) +crt(__libc_start_main/air_main) câblé surair_runtime::start::bootstrap; registrelibc-conformance.md; harnais on-targetrt/crates/airlibc-return42(return 42). 1er additifv1.xrévélé :AirRuntime::errno_location() -> *mut i32(RFC de descellement à ratifier). Caveats de sceau résorbés post-sceau (PR #66, merge380d389) : gates qualité gravés encargo xtask(barrier/couvrable-vide/check-syscalls/audit-exceptions/repro, cf.outillage-xtask.md). La reproductibilité ADR-025 est outillée (repro, 4 rlibs bit-pour-bit) ; le re-sync desfichier:lignedu registre est outillé (audit-exceptions), qui a corrigé une dérive du décompte → registre à 69 entrées production (STRUCTURAL 38, FEATURE-KERNEL 13, PRIVILEGE 10, CHILD-EXIT 8, DEFERRED-TOOLING 0).check-syscallscertifie 161 numéros conformes (2 arches, 0 écart).
| Document | Localisation | Statut |
|---|---|---|
| Introduction (développeurs) — présentation de la couche 0, où trouver quoi, encart « dev C/C++ » | docs/specs/layer-0/introduction-fr.md | OK |
Crate air-sys-types (partiel) | docs/specs/layer-0/air-sys-types.md | OK (avec TODO) |
Famille process | docs/specs/layer-0/family-process.md | OK |
Famille arch | docs/specs/layer-0/family-arch.md | Implémenté (ADR-051, re-sceau couche-0-v1.7) — arch_prctl set_fs/get_fs (x86_64) ; TLS du thread principal x86, appelé par l’objet ThreadLocalStorage couche 1 (ADR-052, pas par le runtime en direct). Prouvé on-target (étape 6 phase A) : rt/air-rt recâblé en shim sur air_runtime::start::bootstrap ; errno #[thread_local] réel (cfg target_env="air") ; selftest airrt-selftest exit 42 sur x86_64, cross-build aarch64 OK (run raspi à suivre). Étape 6 phase B : air-runtime bascule en couverture per-fichier (retrait --exclude air-runtime, ADR-035 § amendement) — façade AirRuntime réalisée (errno/set_errno/current_tid) + args/env/reloc-parser/fork/lib mesurés à 100 % ; 4 fichiers TARGET-ONLY (TCB/TLS/spawn/bootstrap) ignorés par fichier + 5 lignes TARGET-ONLY résiduelles (reloc/fork) au registre COVERAGE-EXCEPTIONS |
Famille fs | docs/specs/layer-0/family-fs.md | OK |
Famille mem | docs/specs/layer-0/family-mem.md | OK |
Famille signal | docs/specs/layer-0/family-signal.md | OK |
Famille time | docs/specs/layer-0/family-time.md | OK |
Famille net | docs/specs/layer-0/family-net.md | OK |
Famille ipc | docs/specs/layer-0/family-ipc.md | OK |
Famille security | docs/specs/layer-0/family-security.md | OK |
Famille system | docs/specs/layer-0/family-system.md | OK |
Famille terminal | docs/specs/layer-0/family-terminal.md | Implémenté (ADR-060, re-sceau couche-0-v1.8) — termios/tty : types Termios/Winsize/PtyNumber (air-sys-types) + wrappers ioctl dédiés typés (air-sys-syscall/src/terminal.rs, ADR-021 conv. 3) — attributs/contrôle-ligne/winsize/PTY/session-job-control ; Option<Pid> (tcgetpgrp, sentinelle 0→None), EINTR remonté, RAII FD ; tests PTY réel /dev/ptmx + cycle session en enfant setsid (1 exception CHILD-EXIT justifiée) ; 100 % hors exception. isatty/ttyname = couche 1 (dérivés) |
Famille device | docs/specs/layer-0/family-device.md | OK — uevent/evdev, parsers zéro-alloc (sysfs → fs) |
Famille ebpf | docs/specs/layer-0/family-ebpf.md | OK — bpf() exhaustif (37 cmd) + perf_event_open |
Famille fs — extension inotify | docs/specs/layer-0/family-fs-inotify.md | Implémenté (PR #55) — RAII Inotify (IN_CLOEXEC défaut) + décodeur emprunté zéro-alloc / zéro-perte (queue tronquée signalée) ; fuzz 3 M runs ; décodeur 100 %/100 % |
Famille process — affinité CPU | docs/specs/layer-0/family-process-affinity.md | Implémenté (PR #55) — set/get_cpu_affinity(Option<Tid>, &CpuSet) (CpuSet réutilisé de system) ; débloque air-thread::cpu_affinity (couche 1) |
Famille mem — MmapRegion | docs/specs/layer-0/family-mem-mmap-region.md | Implémenté (PR #31) — a débloqué madvise (2a) + futex (2c) |
Famille process — extension privsep | docs/specs/layer-0/family-process-privsep.md | OK (spec) — setgroups/setresgid/setresuid à implémenter (prérequis air-process::drop_privileges, sécurité-critique) |
| Module io_uring (vue d’ensemble) | docs/specs/layer-0/io-uring-overview.md | Redirection (contenu repris par le document maître + specs par Temps) |
| Module io_uring (inventaire maître, cible 6.12) | docs/specs/layer-0/io-uring-0-inventaire.md | OK — validé |
| Module io_uring — Temps 1 (cœur) | docs/specs/layer-0/io-uring-1-core.md | OK — validé |
| Module io_uring — Temps 2a (filesystem) | docs/specs/layer-0/io-uring-2a-filesystem.md | OK — validé |
| Module io_uring — Temps 2b (réseau) | docs/specs/layer-0/io-uring-2b-network.md | OK — validé |
| Module io_uring — Temps 2c (async-spécifiques) | docs/specs/layer-0/io-uring-2c-async.md | OK — validé |
| Module io_uring — Temps 2d (URING_CMD) | docs/specs/layer-0/io-uring-2d-cmd.md | Implémenté (PR #37) — 4 cmd socket + générique UringCommand ; SQE128 soumission corrigée |
| Module io_uring — Temps 3a (registration) | docs/specs/layer-0/io-uring-3a-registration.md | Implémenté (PR #40) — 21 register opcodes + variantes direct + read/write fixed + fixed_fd_install ; CpuSet créé (air-sys-types) ; 4 écarts kernel documentés (§13 bis) |
| Module io_uring — Temps 3b (buffers fournis ring) | docs/specs/layer-0/io-uring-3b-provided.md | Implémenté (PR #42) — ProvidedBufferRing (PBUF_RING/UNREGISTER/STATUS) + sélection auto + guard RAII + incrémental + modes app/kernel_mmap ; docs/UNSUPPORTED.md créé |
| Module io_uring — Temps 3c (opérations liées) | docs/specs/layer-0/io-uring-3c-linked.md | Implémenté (PR #44) — LinkedChainBuilder soft/hard + with_link_timeout + staging/réservation atomique ; sémantique soft/hard correcte confirmée (short-read piège) |
| Module io_uring — Temps 3d (multishot) | docs/specs/layer-0/io-uring-3d-multishot.md | Implémenté (PR #46) — accept/recv/read/poll/timeout multishot + cancel_multishot ; cycle de vie S1 (CQE_F_MORE) prouvé Miri ; -ENOBUFS terminant distinct |
| Module io_uring — Temps 3e (multi-thread) | docs/specs/layer-0/io-uring-3e-shared.md | Implémenté (PR #49) — thread-per-core (msg_ring) + LockedIoUring (Send+Sync, verrou unique, sans poisoning) + RingPool (ATTACH_WQ workers bornés) + SqpollIoUring (réveil NEED_WAKEUP) ; unsafe impl Send for IoUring (ADR-022 D6) ; !Sync compile-fail + loom ; shared.rs branches 100 % / couvrable vide |
| Module io_uring — Temps 3f (confinement) | docs/specs/layer-0/io-uring-3f-sandbox.md | Implémenté (PR #51) — RestrictionSet (default-deny) + RegisterOp/SqeFlagSet + flux immuable R_DISABLED → REGISTER_RESTRICTIONS → ENABLE_RINGS (soundness S3) ; preuve de sécurité verte (openat2 refusé -EACCES sur ring « réseau seul ») ; sandbox.rs 100 % / couvrable vide |
| Module io_uring — Temps 4 (accès brut) | docs/specs/layer-0/io-uring-4-raw.md | Implémenté (PR #53) — RawSubmissionQueueEntry/RawCompletionQueueEntry/RawOpcode (#[repr(C)], asserts de layout) + soumission/complétion brutes + tag user_data bit 63 (slab génération 31 bits ⇒ coexistence sans collision) + # Safety exhaustif ; Miri + fuzz (2 M runs) ; raw.rs 100 % / couvrable vide. ➜ Module io_uring COMPLET (12 Temps) |
| Module io_uring — versions anglaises (15 docs) | docs/specs/layer-0/io-uring-*-en.md + docs/adrs/ADR-022/028-...-en.md | OK — traduction globale des 13 specs + ADR-022 + ADR-028 (suffixe -en.md) |
| Couche 0 — référence développeur en anglais (18 docs) | docs/specs/layer-0/{introduction,air-sys-types,family-*,io-uring-overview}-en.md | OK — traduction fidèle de l’intro développeur, air-sys-types, des 11 familles + 4 extensions et de la vue d’ensemble io_uring (suffixe -en.md) ; ADRs et QUESTIONS-implementation.md hors périmètre |
Spécifications techniques de la couche 1
| Document | Localisation | Statut |
|---|---|---|
air-base-core — cœur sans i18n (erreurs, id, chemins-octets, temps monotone, encodages, log) | (scission d’air-base-lib-core.md) | Implémenté (ADR-054) — crate extraite d’air-base-lib : AirError/AirResult, AirId128/AirUuid/AirMonotonicId, AirPath, AirOsStr/AirOsString (chaîne-OS opaque : env, argv… ; pendant std::ffi::OsStr/OsString, #[repr(transparent)], aucune fn unsafe exposée — remodel pré-sceau env/args), AirInstant/AirDuration (monotone), encoding (base64/hex), AirLog ; zéro dépendance icu4x, no_std ⇒ socle de la fermeture runtime *-linux-air (sans std/libm). Tests déplacés avec ; couvrable VIDE. |
air-env — porteuse de l’environnement de processus (octets) | (scission d’air-runtime::env) | Implémenté (ADR-055 D1 ; remodel pré-sceau env/args) — crate couche 1, no_std (unique dép interne air-base-core pour la chaîne-OS) : set_environ (publié au démarrage), get(&AirOsStr) -> Option<AirOsString> (copie possédée race-safe, façon std::env::var_os)/vars() en paires possédées (octets, ADR-049 D6). Vit sous air-runtime ET air-process ⇒ casse le cycle air-runtime → air-process (air-runtime::env ré-exporte ; air-process::build_envp lit air_env). 100 % lignes+branches. |
air-base-lib — i18n + ré-export du cœur (chaînes/locale, temps calendaire) | docs/specs/layer-1/air-base-lib-core.md | Implémenté (PR #73, merge cb480ac ; scindé ADR-054) — garde l’Unicode (AirString/AirLocale, segmentation/normalisation/casing/collation) + temps calendaire (AirDateTime/AirCalendar), icu4x 2.2 (compiled_data), et ré-exporte tout air-base-core (compat : air_base_lib::AirError/AirPath/… inchangés). Pont AirPath ↔ AirString = extension AirPathStringExt. 100 % couvrable VIDE ; fuzz×4 + property-based. |
air-base-lib — services (logging, identifiants) | docs/specs/layer-1/air-base-lib-services.md | Implémenté (PR #110) — AirLog (journald socket natif, zéro dép systemd, repli memfd+SCM_RIGHTS, non-fatal) + identifiants (AirUuid/AirId128/AirMonotonicId) ; zéro dép externe, zéro C ; 2 cibles fuzz ; 100 % hors exceptions STRUCTURAL. §3 Config SUPERSEDED → crate air-config dédiée (Cap’n Proto, ADR-040/041). air-base-lib complet (cœur + services). |
air-filesystem | docs/specs/layer-1/air-filesystem.md | Implémenté (PR #87, par carbon) — 6 sections sur la couche 0 (fs + fs::inotify), zéro dépendance externe, zéro C ; confinement resolve_within (RESOLVE_BENEATH), écriture atomique (fsync fichier+parent), AirTempDir RAII, copie (copy_file_range+repli), watchers (Overflow dédié, debounce, récursif) ; glob maison + property-based + fuzz (glob/watcher) ; couvrable VIDE (gardes défensives ADR-035 in-code). find_regex différé (80 % regex). Couche 1 : 3/8 crates cœur. #![no_std] (ADR-048/ADR-055) : alloc/core (cartes du watcher = BTreeMap/BTreeSet), $TMPDIR lu en octets via air_env, fenêtre de debounce = clock_nanosleep couche 0 (plus de std::thread::sleep) ; prouvé par cargo build --lib, 100 % maintenu. |
air-alloc (allocateur global — arène + global allocator) | docs/adrs/ADR-056-allocateur-air-alloc-fr.md ; docs/notes/audit-dlmalloc-fr.md | Phases 1 & 2 implémentées (ADR-056) — crate couche 1, #![no_std] (deps air-sys-types+air-sys-syscall, zéro externe). Phase 1 — cœur de l’arène host-testable inspiré de dlmalloc : étiquettes de frontière + fusion O(1), smallbins exacts + table de bits, liste triée des grands blocs (pas de treemap, D5), reliquat dv + frontière top, free-lists intrusives ; segments 64 Kio sous-alloués + cartographie dédiée ≥ 256 Kio (munmap). Trait Backing injectable (prod MmapBacking mmap réel sans MAP_NORESERVE, D7) ⇒ arène mono-thread (Send non Sync) ; property-based (proptest, modèle fantôme) + cargo-fuzz fuzz_air_alloc_arena. Phase 2 — global allocator réel : verrou futex maison (lock::Mutex à 3 états sur air-sys-syscall::futex, pas d’AirMutex, D3) enveloppant une arène globale unique ; GlobalAllocator (impl core::alloc::GlobalAlloc, échec ⇒ null déterministe, D10) ; reset_after_fork (D8). Host-testé à 100 % lignes+branches (arena/chunk/backing/lock/global, zéro exception ; contention déterministe 2 threads + stress N threads). Branché : #[global_allocator] dans rt/air-rt (remplace AbortOnAlloc) + gardien fork air-runtime → air-alloc. « Run réel » débloqué : airrt-selftest exit 42 x86_64 en allouant pour de vrai (thread principal + worker), build aarch64 OK (run raspi à suivre). |
air-libm (surface math flottante no_std) | docs/adrs/ADR-057-libm-air-fr.md | Posé (ADR-057) — crate couche 1, #![no_std] qui ré-exporte la surface de libm (rust-lang) (pub use libm::*;) : sqrt/cos/pow/hypot/tgamma/lgamma_r/… + Libm<T>. Vendoring en exception nommée à la règle des 80 % (ADR-024, modèle icu4x ; docs/EXCEPTIONS.md) — libm est pur Rust no_std, zéro dépendance transitive, fusionné dans compiler-builtins (le libm de la std pour wasm/no_std), licence MIT, pin =0.2.16. Comble le seul primitif système manquant de l’i18n/libc (audit icu4x §3/§8 : calendrical_calculations + LSTM segmenter via core_maths→libm). Aucun consommateur encore : fondation pour la libc/i18n future (ADR-053). Tests de fumée à valeurs connues (tolérance epsilon) → couverture 100 % (la crate n’a pas de logique propre : un pub use ne génère aucune ligne à manquer ; libm non mesuré, externe). Build *-linux-air prouvé (crate-sonde jetable rt/). Zéro unsafe/as/surface C. |
air-memory (allocateurs arena/pool/slab + comptabilité) | docs/specs/layer-1/air-memory.md | Implémenté (PR #88, par carbon) — 5 sections : AirMemoryTracker (comptabilité atomique, peak monotone), AirArena (bump, alignement checked_*, contrat reset/Drop non exécuté, unique unsafe // SAFETY:), AirObjectPool (Rc/RefCell, respecte Drop), AirSlab (anti-ABA, génération wrapping_add), AirBacking (heap ; mmap différé) ; zéro dépendance externe, zéro C ; property-based + Miri (test concurrent inclus) ; 100 % lignes+branches hors 1 exception STRUCTURAL (bras None du Drop d’AirPooled, doc in-code). Couche 1 : 5/8 crates cœur. |
air-process (processus, pidfd, privsep) | docs/specs/layer-1/air-process.md | Implémenté (PR #105) — AirCommand/AirProcess (clone3+CLONE_PIDFD, redirections dup3, chdir, execve_prepared, wait synchrone waitid-pidfd, signal/kill), AirPipe, AirExitStatus ; drop_privileges + drop_privileges_to (drop dans l’enfant avant exec, chemin alloc-free async-signal-safe, ordre sûr, regain irréversible prouvé root) ; aucune fn unsafe exposée ; tests réels + 4 tests root + Miri ; 100 % hors exceptions ADR-035 (CHILD-EXIT/STRUCTURAL). A nécessité l’extension couche 0 execve_prepared (re-sceau v1.3). Couche 1 : 8/8 crates cœur. #![no_std] (ADR-055 D2) : core/alloc::ffi, build_envp lit air_env (plus de std::env/std::os::unix) — entre dans la fermeture runtime *-linux-air. |
air-poll (multiplexeur d’événements générique) | docs/specs/layer-1/air-poll.md | Implémenté (roadmap §5.3 P0.2 ; audit face-libc #14) — crate couche 1 dédiée (le multiplexage n’est ni process- ni socket-spécifique) exposant fidèlement ppoll sur &mut [PollFd] arbitraire : poll(poll_fds, Option<AirDuration>, Option<&SignalMask>) -> AirResult<usize> (écrit les revents en place, rend le nombre de prêts) + ready() (itère les prêts, sans indexation paniquante) + ré-export PollFd/PollEvents/SignalMask. Timeout typé (None = infini, Some(ZERO)/≤0 = sondage), EINTR remonté sans retry (ADR-021), masque de signaux atomique optionnel. Découple le seul affleurement existant (air-process::wait_until, couplé au pidfd). Zéro unsafe exposé, zéro alloc, zéro dépendance externe, #![no_std] ; tests pipes réels ; 100 % hors 1 exception STRUCTURAL in-code (passthrough échec ppoll). Note fuzz : aucune surface de données externes (documenté). select(2) legacy = face-C au-dessus. |
air-signal (face signal générique : signalfd, masque, envoi, attente) | docs/specs/layer-1/air-signal.md | Implémenté (roadmap §5.3 P0.4 ; doctrine ADR-064) — crate couche 1 dédiée re-exposant fidèlement la face signal couche 0, cadrée par ADR-064. signalfd = face primaire : AirSignalFd (RAII) — create/create_nonblocking/block_and_drain (helper clé-en-main « bloquer + drainer », ADR-064 §3, discipline documentée), as_fd() (→ air-poll), read()/next_pending() (drainage décodé), update_mask, into_fd. Masque per-thread : block/unblock/replace_mask/current_mask + SignalMaskGuard (RAII restaure au Drop). Envoi : send_signal_to_process/_to_thread (kill/tgkill, None = signal 0), raise_signal (thread courant), queue_signal (rt_sigqueueinfo + SignalValue). Attente : wait_for_signal (bloquant) + wait_for_signal_until (borné via air-poll, comble l’EINVAL couche 0). Handler async NON-faute (additif couche-1-v1.2, ADR-066) : install_handler/restore_disposition (seul unsafe exposé — précondition async-signal-safe portée par l’appelant ; brique dual-face que la libc sigaction et le PAL consomment) + pending() (sigpending). Fautes synchrones : action par défaut du noyau — la couche 1 Rust n’installe AUCUN handler de FAUTE (SIGSEGV/SIGFPE/SIGBUS/SIGILL → terminate + core dump, décision BDFL ADR-064) ; install_handler ne les vise jamais. EINTR remonté sans SA_RESTART (ADR-064 §4), aucun état de signal maintenu (ADR-064 §6). Ré-export Signal/SignalMask/SignalFdInfo/SignalFdFlags/SignalValue/SigActionFlags/Pid/Tid. Zéro dépendance externe, #![no_std] ; tests signaux réels sans fork (thread appelant, install de handler déterministe via auto-raise) ; 100 % lignes hors 1 exception in-code (STRUCTURAL next_pending), aucune exception CHILD-EXIT. Note fuzz : décodage kernel en couche 0, aucune surface ici (documenté). Face libc C sigaction/signal = au-dessus (dual-face). |
air-thread (threads, mutex/rwlock/sémaphore, MPSC) | docs/specs/layer-1/air-thread.md | Implémenté (PR #97) — AirThreadBuilder (nom/stack/affinité CPU via couche 0) + AirThreadHandle, AirMutex/RwLock/Semaphore (futex maison, no-poisoning), AirChannel MPSC, atomics ré-exportés ; raw_futex (additif couche-1-v1.2, ADR-066) — primitives futex(2) typées (futex_wait/futex_wake, AirResult) sur un &AtomicU32 externe, brique de pthread_mutex_t/cond_t de la libc et de std::sys du PAL (distinct de runtime_primitives, façade Errno-brute TARGET-ONLY du join runtime) ; aucune fn unsafe exposée ; Miri (35 tests) ; 100 % lignes+branches hors 1 exception STRUCTURAL (usize::try_from(cpu), impossible 64 bits). Couche 1 : 7/8 crates cœur. #![no_std] (ADR-055 D3) : surface livrée pour *-linux-air = sync/channel/runtime_primitives (futex couche 0) ; le spawn ergonomique std::thread (thread.rs) est gardé #[cfg(not(target_env="air"))] (hôte seulement — le spawn Air-natif viendra d’air-runtime + allocateur). |
air-socket (sockets + résolution de noms enfichable) | docs/specs/layer-1/air-socket.md | Implémenté (PASSE 2, PR #86) — TCP/UDP/Unix (+ passage de FD SCM_RIGHTS), résolution enfichable IPv6-first, client DNS maison RFC 1035 (UDP + repli TCP), sans libc ; parseur DNS fuzzé (fuzz_dns_parse) ; netlink retiré (→ crate air-netlink, couche 1, ADR-079). Zéro dépendance externe, zéro surface C. Couche 1 : 3/8 crates cœur. Déjà #![no_std] (core::net/alloc, std seulement sous #[cfg(test)]) — ADR-048. |
air-crypto (primitives cryptographiques) | docs/specs/layer-1/air-crypto.md | Implémenté (PR #80, merge f7057dd) — 7 sections sur RustCrypto/dalek, purs Rust, zéro C ; XChaCha20Poly1305 défaut, BLAKE3 différé (enum #[non_exhaustive]) ; KAT de conformité officiels + property-based + zeroize vérifié + fuzz ; couvrable VIDE. Exception crypto nommée (EXCEPTIONS.md, ADR-034). Couche 1 : 2/8 crates cœur. |
air-device (énumération/surveillance natives) | docs/specs/layer-1/air-device.md | Implémenté (PR #94) — natif sysfs + netlink uevent (groupe KERNEL) + evdev §5 (AirInputDevice), sans libudev/libc ; typés net/block/usb ; next_event couvert par test root réel (uevent synthétique) ; aucune fn unsafe exposée ; 3 cibles fuzz (uevent/sysfs/input) ; production 100 % lignes+branches hors 1 exception STRUCTURAL (write_attribute partiel) + résidus test (ADR-035). Couche 1 : 6/8 crates cœur. Déjà #![no_std] (alloc/core ; test_fixtures.rs #![cfg(test)], std test-only) — ADR-048. |
air-netlink (transport netlink générique, sans-IO) | docs/specs/layer-1/air-netlink.md | Proposition v0.1 (tranché ADR-079, option B couche 1) — transport netlink générique (pair d’air-socket) : message framing nlmsghdr, codec d’attributs TLV (rtattr/nlattr, la surface hostile — fuzzée une fois, réutilisée partout), corrélation requête/réponse/dump/ACK. Cœur générique, familles au-dessus (rtnetlink intégré ; nl80211/nftables futurs). Maison, pur Rust, zéro-C, zéro unsafe, zéro tierce ; RAII fd, Send/!Sync. Patron sans-IO 9-composants (Handshaker/Flow absents, Mux/Timer minimaux — marqués). ~15 objets (9 surface + 4 cœur + module rtnetlink). Premier consommateur : air-network::AirNetworkPathMonitor (L2) ; autres : WireGuard/DHCP (L5), air-config (routes), wifi. Additif couche 0 couche-0-v1.11 requis (SocketDomain::Netlink=16 + SocketAddrNetlink, syscalls génériques déjà présents → aucun nouveau syscall). air-device inchangé (migration future possible). Implémentation à suivre |
air-runtime (runtime async natif io_uring) | docs/specs/layer-1/air-runtime.md | OK (spec v1.0, décisions BDFL validées) — runtime async sans tokio (ADR-023/038/039), modèle buffers possédés + reclaim différé (ADR-028), single-thread → thread-per-core sans work-stealing, multi-crate air-runtime-core/io/time/signal/sync + façade ; air-event (couche 2) en sera la façade C-ABI. Implémentation à suivre |
air-config (compilateur config + artefact Cap’n Proto) | docs/specs/layer-1/air-config.md | Implémenté — chaîne verticale complète (PR #116/#118/#120, tranches 1-3) — 3 crates : air-config-compile (parseur source maison fuzzé → bind schema-directed → encodage Cap’n Proto déterministe, conversions checked, secrets zeroizés), air-config-schema (4 domaines .capnp + code généré committé ADR-040, build sans tool C++), air-config (façade : lecteur mmap zéro-copie + vérif magic/checksum/version ; XDG ; générations/switch atomique/rollback ; 5 backends /etc : resolv.conf/hosts, systemd, fstab, passwd/group/shadow — émission swap-atomique + import inverse, PR #120/#122/#125). shadow 0600 dès la création, hashes zeroizés, jamais loggés (tranche 5). Validation cas-réels (PR #126) : corpus de fixtures + comparaison sémantique + harnais advisory /air/etc (fidélité prouvée sur le vrai /etc, zéro secret). Zéro unsafe/as/dép externe/surface C ; fuzz×6 ; 100 % hors exceptions STRUCTURAL. air-config COMPLET. #![no_std] (ADR-048/ADR-055) : air-config-compile (alloc/core, IP littérales via core::net, BTreeSet) ET air-config (cartes shadow/systemd = BTreeMap, IP resolv.conf via core::net, env XDG lu en octets via air_env) prouvés #![no_std] par cargo build --lib ; air-config-schema déjà sans std en code lib. 100 % maintenu. |
Spécifications techniques de la couche 2
| Document | Localisation | Statut |
|---|---|---|
air-object (modèle d’objet C-ABI — clé de voûte) | docs/specs/layer-2/air-object.md | OK (spec v1.0, décisions BDFL validées) — AirObject/AirClass, API C-ABI universelle air_object_* (zéro glue par classe), AirValue (union taguée ; propriété +1 retours / +0 arguments), #[air_class] + AirHandle, propriétés observables, politiques de thread déclarées (Immutable / MainThreadOnly debug-only / ThreadSafe), introspection en prod, libair-object.so (ABI 10 ans). Exception proc-macro (syn/quote/proc-macro2) actée. Chantier découpé (BDFL) AO.1→AO.5 puis AirCom inc.7. ✅ AO.1 IMPLÉMENTÉ : crate crates/air-value/ (couche 2, libair-value.so, header committé include/air_value.h) — AirValue union taguée #[repr(C)] layout figé (24 o : AirValueKind+AirValuePayload ; AIR_STRING/AIR_BYTES refcountés en-tête caché ; AIR_OBJECT opaque délégué à un hook de vtable installé par AO.2) + AirStatus in-band (ADR-045) + règle +1/+0 (AirValueOwned Drop=release/Clone=retain). Conformité ABI C + fuzz + couverture 98,5 %/100 % fn. ✅ AO.2 IMPLÉMENTÉ : crate crates/air-object/ (couche 2, libair-object.so, header committé include/air_object.h) — en-tête figé AirObject (#[repr(C)], 24 o, align 8 : isa/refcount=AtomicU32/flags/reserved), méta-classe opaque AirClass auto-référente (bootstrap métaclasse static, immortelle via AIR_OBJECT_FLAG_STATIC), API air_object_* (alloc refcount 1 + payload zéro-initialisé / retain/release finalize+dealloc / class / is_kind_of chaîne de parenté / root_class / runtime_init), refcount AtomicU32 protocole Arc saturant, smart-pointer Rust AirHandle<T> (Drop=release/Clone=retain). Hook AO.1 branché : runtime_init installe les ops objet dans air-value (preuve bout-en-bout : AIR_OBJECT AirValue retient/relâche réellement l’AirObject). Durcissement conformité ABI (AIR_ABI_CONFORMANCE_REQUIRED=1 en CI, dual gcc+clang) rétro-appliqué à air-value. Couverture 97,7 %/100 % fn (4 lignes = gardes OOM/saturation STRUCTURAL). ✅ AO.3 IMPLÉMENTÉ** : AirClass étendue (opaque côté C) — politique de thread obligatoire AirThreadPolicy (Immutable/MainThreadOnly/ThreadSafe), tables de propriétés (AirPropertyDescriptor/AirPropertyInfo, accesseurs typés get+1/set+0) et de méthodes (AirMethodDescriptor, sélecteur→imp). Surface : air_object_get_property(+1)/set_property(AirStatus)/send_message(+1, dispatch héritée parent)/observe/unobserve ; builder C-ABI air_class_new/_free/_thread_policy ; air_object_set_main_thread (enforcement MainThreadOnly debug-only). Observation (data binding) : registre par objet (tête dans reserved), verrou tournant core pur, notification ancienne/nouvelle sur instantané par valeur (ré-entrance/retrait sûrs) ; wrappers Rust AirObservable<T> + AirNotificationCenter (pub-sub par nom, intra-processus) + centre C-ABI global air_notification_*. Dép ajoutée air-thread (couche 1). Couverture 98,2 % lignes / 98,4 % fn. ✅ AO.4 IMPLÉMENTÉ : premier crate proc-macro d’Air crates/air-object-macros/ (couche 2, [lib] proc-macro = true, build-time only) — attribut #[air_class(thread = …)] générant (à partir d’une struct) le static AirClassCell (via with_members), un AirPropertyDescriptor par champ #[observable] (get +1 / set +0 typés, notification héritée d’air_object_set_property), la politique de thread obligatoire (absente ⇒ erreur de compilation) et Self::air_class() — « zéro glue par classe » (dispatch via les air_object_* génériques). Types de champ gérés v1 : bool/i64/f64 (String/bytes/objet différés : stockage possédé + finaliseur ; méthodes différées : table vide). Macro ré-exportée par air-object (pub use air_object_macros::air_class) ; header committé inchangé (proc-macro n’expose aucun type C ; parse_deps=false). Exception 80 % proc-macro actée (syn/quote/proc-macro2, docs/EXCEPTIONS.md — politique proc-macro d’Air). Tests : trybuild compile-fail (.stderr committés) + unités sur expand + e2e air-object/tests/macro_class.rs (classe générée traversant alloc/get/set/observe/send_message/is_kind_of/release + AirHandle). ✅ AO.5 IMPLÉMENTÉ — chantier air-object COMPLET : introspection (spec §7) — registre global de classes (enregistrement paresseux à la 1ʳᵉ air_object_alloc, liste intrusive via registered/next_registered ajoutés à AirClass [opaque côté C ⇒ header inchangé], verrou tournant, zéro alloc heap, désenregistrement par air_class_free). Surface C-ABI : air_class_list_all(tableau possédé, libéré par air_object_free_class_list)/air_class_properties(zéro-copie table AO.3)/air_class_parent/air_class_name/air_class_registered_count/air_object_describe(chaîne possédée bornée sans NUL, libérée par air_object_free_string — allocateur d’Air, pas free(3)). Invariant documenté : classe allouée-depuis = immortelle ou air_class_free. Preuve « zéro glue » Pattern A : conformance.c étendu (hiérarchie C Shape←Square énumérée/introspectée/décrite par nom découvert, parenté vérifiée, sans code par classe, gcc+clang REQUIRED) + #[air_class] (Widget) sur la même surface. Couverture 97,8 %/98,5 % fn (restant STRUCTURAL). Outillage lourd d’ABI-stabilité (symboles versionnés GNU, air-symver/air-abi-check) hors périmètre (chantier transverse). 🎉 Chantier air-object (AO.1→AO.5) COMPLET ; suite = AirCom inc.7 (call sur AirObject + air-notifyd) |
AirCom (IPC natif d’Air — spec couche 2) | docs/specs/layer-2/air-com.md | OK (spec v1.0, décisions BDFL validées 2026-07-12) — décline ADR-001 (immuable) sur le motif sans-IO ADR-091. Fige : jeu de crates air-com/air-com-proto/air-com-schema/air-com-codegen/air-registry (nommage acté ADR-029, macro-arch air-aircom* réconciliée) ; wire Cap’n Proto (ADR-040) ; control plane SEQPACKET + data plane memfd/air-shm zero-copy ; capability = fd (SCM_RIGHTS, ADR-010, pas de crypto) ; anatomie 9 composants spécifiée ; deux surfaces sync et async de 1ʳᵉ classe (cœur partagé, test croisé) ; pub-sub par anneau shm (1 producteur/N lecteurs, overwrite best-effort) ; bootstrap air-registry = 1 fd par service + lookup nommé. Fondation implémentée (PR #333, couche-1-v2.1). 7 incréments (§12) IMPLÉMENTÉS : inc.1 (Codec Cap’n Proto + schéma v1), inc.2 (StateMachine multi-états + Multiplexer channelId + Handshaker), inc.3 (transport SEQPACKET bout-en-bout, 2 surfaces sync+async + test croisé), inc.4 (data plane zero-copy + Flow Controller), inc.5 (pub-sub anneau shm), inc.6 (contrat air-registry mock couche 2), ✅ inc.7 — intégration modèle d’objet (call sur AirObject) + 1er service réel air-notifyd : corps d’invocation dédié air-com-schema/invoke.capnp (Call/Return/Error, ajout compatible ADR-012, methodId=sélecteur Text) ; cœur PUR air-com-proto/invoke.rs (no_std, zéro unsafe/panic, objectId/selector opaques §1.1 ; unit+property+fuzz fuzz_air_com_invoke 1,1 M runs 0 crash) ; pilote air-com/object.rs (registre d’export objectId→*mut AirObject retain/release équilibrés, marshalling AirValue↔capnp scalaires v1, dispatch call→send_message→returnMsg/error, Connection::call_object/serve_objects 2 surfaces + test croisé sync↔async) ; arête 2→2 air-com→air-object/air-value ; nouveau crate crates/air-notifyd/ (couche 2, 1er service ADR-001 : classe AirNotifyd méthode notify(title,body)→status + observable count, 2 exécutables aircom_notifyd_{server,client} on-target exit 0). Cible aval air-sshd (ADR-074) |
air-event (façade C-ABI de l’event loop async) | docs/specs/layer-2/air-event.md | OK (spec v1.0, décisions BDFL validées) — façade C-ABI du runtime air-runtime (L1) : AirEventLoop (single-thread, run/run_once/wakeup), AirFuture (modèle callbacks de complétion — le C ne fait pas await —, refcompté via air-object, annulation sound héritée des buffers possédés), timers/signaux/channels exposés, intégration AirCom + pont sd-event. Implémentation à suivre |
air-network (substrat de connexion ≈ Network.framework) | docs/specs/layer-2/air-network.md | Proposition v0.1 — substrat de connexion couche 2 visant la couverture fonctionnelle de Network.framework en vocabulaire Air (jamais de copie d’API ; note api-reseau-strategie). Compose air-socket (L1) + air-tls (L2) + air-runtime (L1) ; substrat où se branchent air-http/air-ssh (note réseau §2.5). N’est PAS un protocole filaire (pas de Framer/Codec/StateMachine propres — ceux-ci vivent dans air-tls/air-quic) : il orchestre + pilote l’I/O. ~20 objets : AirConnection (≈NWConnection, async), AirListener, AirBrowser (mDNS), AirParameters/AirProtocolStack (pile déclarative), AirEndpoint, AirNetworkPath/AirNetworkPathMonitor (path-awareness). Sécurité par défaut (secure()=TLS, clair explicite) ; async 1ʳᵉ classe (ADR-038, connexion Send/!Sync). expensive/constrained dérivés (type d’interface + politique — Linux n’a pas de bit noyau, dérivation honnête/testable). Path-monitor tranché (ADR-079) : consomme air-netlink (L1, RtnetlinkClient). Implémentation à suivre |
air-quic (QUIC v1 pur Rust, patron sans-IO) | docs/specs/layer-2/air-quic.md | Proposition v0.1 — QUIC v1 (RFC 9000/9001/9002) maison, pur Rust, zéro-C, zéro unsafe ; transport d’air-http (h3). Premier protocole Air à exercer les 9 composants sans-IO (flow control + mux natifs). Handshake TLS 1.3 via air-tls (frames CRYPTO), AEAD paquet via air-crypto — jamais le record layer TLS (⇒ exige air-tls d’exposer son cœur handshake découplé). ~32 objets (13 surface + 14 cœur + 5 enums). Congestion NewReno (RFC 9002) v1. Chemin d’implémentation à trancher (maison intégral vs distillation quinn-proto) après audit (modèle audit s2n-tls). Connexion Send/!Sync, endpoint démux par Connection ID, thread-per-core (ADR-038/039). Implémentation à suivre |
air-mdns (mDNS + DNS-SD pur Rust, patron sans-IO) | docs/specs/layer-2/air-mdns.md | Proposition v0.1 — mDNS (RFC 6762) + DNS-SD (RFC 6763) maison, pur Rust, zéro-C/unsafe/tierce ; consommé par air-network::AirBrowser/advertise. Réutilise le codec DNS RFC 1035 fuzzé d’air-socket (zéro duplication de parseur hostile). ~14 objets (8 surface : MdnsBrowser/MdnsResponder/ServiceRegistration/DiscoveredService… + 6 cœur) ; 4 composants absents/triviaux marqués (Handshaker/Flow/Mux absents, Framer trivial). Sécurité = parsing borné + cache plafonné + rate-limit (surface hostile = LAN) ; mDNS n’authentifie rien (par conception) → confiance via air-network/TLS. Async 1ʳᵉ classe. Additifs air-socket requis (codec noms/SRV/TXT/PTR exposés + multicast UDP typé). Implémentation à suivre |
air-http (HTTP/1.1 + HTTP/2 + HTTP/3, sans-IO) | docs/specs/layer-2/air-http.md | Proposition v0.1 — HTTP/1.1 (RFC 9112) + HTTP/2 (RFC 9113) + HTTP/3 (RFC 9114), h1/h2/h3 = modules internes d’un crate unique (note §4) + WebSocket (RFC 6455/8441). Maison, pur Rust, zéro-C/unsafe ; compose air-tls (h1/h2) + air-quic (h3) + air-network. Surface unifiée Request/Response/Body (version par ALPN). ~33 objets (14 surface + 15 cœurs sans-IO + 4 enums). Sécurité : anti-smuggling (h1), HPACK/QPACK bornés (anti-bomb), anti rapid-reset (CVE-2023-44487), TLS obligatoire h2/h3. Async 1ʳᵉ classe. Ordre : h1 → h2 → h3. Implémentation à suivre |
air-ssh (SSH maison, moderne-only, sans-IO) | docs/specs/layer-2/air-ssh.md | Proposition v0.1 (cadrée ADR-043) — SSH maison, memory-safe, zéro-C (pas russh ; crypto via air-crypto). RFC 4250-4254/4256 + certificats. Strict kex anti-Terrapin (CVE-2023-48795) dès le départ, moderne uniquement (legacy omis : SHA-1/CBC/RC4/3DES/ssh-rsa/ssh-dss). Kex X25519 + sntrup761 hybride PQ ; AEAD chacha20-poly1305/aes-gcm ; auth publickey (Ed25519/ECDSA/cert)/keyboard-interactive. ~30 objets (12 surface + 13 cœur + 5 enums), SFTP en subsystem. Memory-safety ⇒ classe regreSSHion éliminée. Client (ssh/scp/sftp) avant serveur ; sshd différé (audit externe requis). Additif air-crypto sntrup761 à instruire. Async 1ʳᵉ classe, session Send/!Sync, secrets zeroize. Implémentation à suivre |
air-wireguard (VPN WireGuard, sans-IO) | docs/specs/layer-2/air-wireguard.md | Proposition v0.1 — WireGuard (Noise IKpsk2) maison, pur Rust, zéro-C/unsafe ; crate-lib (cœur sans-IO) + daemon L5. Moderne par conception (un seul jeu d’algos → pas de downgrade). Crypto via air-crypto : X25519 ✓, ChaCha20Poly1305 ✓, + additif BLAKE2s requis (hash/MAC/KDF ; air-crypto v1 a blake3, pas blake2 — à instruire). ~15 objets (cryptokey routing PeerTable, NoiseIk, ReplayWindow anti-rejeu, cookie anti-DoS, secrets zeroize). Dépend d’une interface TUN (couche 0/air-device, additif). Interop kernel WireGuard/wireguard-go. Implémentation à suivre |
air-mail (SMTP + POP3 + IMAP4 client, sans-IO) | docs/specs/layer-2/air-mail.md | Proposition v0.1 — client mail : SMTP (soumission RFC 5321/6409), POP3 (RFC 1939), IMAP4rev2 (RFC 9051) en 3 modules internes d’un crate unique (codec-ligne + SASL + TLS partagés, comme air-http). Maison, pur Rust, zéro-C ; compose air-tls/air-network. ~20 objets. Sécurité : TLS d’abord (jamais d’AUTH en clair ; anti STARTTLS-stripping → TLS implicite 465/993/995 préféré), SASL moderne (SCRAM-SHA-256/OAUTHBEARER ; PLAIN sous TLS only) ; en-têtes RFC 5322 parsés bornés (anti CRLF-injection). Additif air-crypto possible : PBKDF2 (si SCRAM ; sinon OAUTHBEARER token sans PBKDF2). Implémentation à suivre |
air-tls (contingence maison — TLS 1.3 pur Rust) | docs/specs/layer-2/air-tls.md | Proposition v0.1 (spec de contingence) — instruit l’option air-tls maison gardée ouverte par ADR-042 §Contingence (la production reste rustls + aws-lc-rs, non remplacée ; bascule = RFC). TLS 1.3 uniquement (RFC 8446), legacy vulnérable omis par conception ; zéro crate tierce, zéro C, zéro unsafe (seules std + crates Air ≤ L1 ; primitives via air-crypto, jamais réimplémentées). Patron sans-IO 9-composants (note réseau §2.2) : Framer/Codecs (record, handshake, extensions, alertes, X.509/ASN.1 borné)/StateMachine/KeySchedule/Timers/Secrets zeroizés/Extensions. Machine à états mono-propriétaire multi-thread (connexion Send/!Sync sans verrou ; Config Arc Send/Sync, thread-per-core ADR-038). ~61 objets inventoriés (25 surface publique reprenant les familles s2n + 8 traits + 13 cœur + 6 traits crypto + 6 enums). Conformance = RFC 8448/BoGo/tlsfuzzer/interop/fuzz (registre docs/tls-conformance.md à créer). Additifs air-crypto requis (AES-128-GCM, ECDSA, RSA-PSS vérif., ML-KEM-768). Implémentation à suivre |
Spécifications techniques de la couche 4
| Document | Localisation | Statut |
|---|---|---|
Noyau air-tui 1.0 | docs/specs/layer-4/air-tui-core.md | OK |
API conceptuelle air-tui-runtime | docs/specs/layer-4/air-tui-runtime-api.md | OK |
air-url (chargement d’URL haut niveau ≈ URLSession) | docs/specs/layer-4/air-url.md | Proposition v0.1 — équivalent URLSession (couverture fonctionnelle, vocabulaire Air, jamais de copie d’API) : sessions + tâches (data/download/upload/WebSocket), cache HTTP (RFC 9111), cookies (RFC 6265 SameSite/Secure), défis d’auth + cert pinning, transferts en arrière-plan. Bâti sur air-http + air-network ; pas un protocole (orchestration applicative). ~16 objets. Sécurité : TLS défaut, cookies sûrs, cache correct, redirections validées (credential-stripping). Couche 4 (fondation) + nom air-url RATIFIÉS (ADR-084 — couche 4 élargie « UI + fondation », amende la macro-archi). Implémentation à suivre |
Spécifications techniques de la couche 5 (services système réseau)
| Document | Localisation | Statut |
|---|---|---|
air-dhcp (client DHCPv4/DHCPv6 + SLAAC) | docs/specs/layer-5/air-dhcp.md | Proposition v0.1 — client de config réseau auto (DHCPv4 RFC 2131, DHCPv6 RFC 8415, RA/SLAAC), crate-lib sans-IO + daemon .airservice. Applique le bail via air-netlink (adresses/routes) + air-config (DNS). ~12 objets. Options bornées + fuzzées (LAN hostile). Additifs : socket ICMPv6 brut (SLAAC), SO_BROADCAST typé. Implémentation à suivre |
air-ntp (client NTP/SNTP + NTS) | docs/specs/layer-5/air-ntp.md | Proposition v0.1 — client de synchronisation d’horloge (SNTP RFC 5905 + NTS RFC 8915, temps authentifié via air-tls). Discipline d’horloge bornée (anti-saut). ~10 objets. Additif couche 0 : réglage horloge privilégié (clock_settime/adjtimex). Implémentation à suivre |
Documents de setup phase 0
| Document | Localisation | Statut |
|---|---|---|
| Phase A : Décisions techniques | docs/setup/phase-A-decisions.md | OK |
| Phase B1 : Structure et style | docs/setup/phase-B1-structure.md | OK |
| Phase B2 : Conventions opérationnelles | docs/setup/phase-B2-operations.md | OK |
| Phase C : Infrastructure | docs/setup/phase-C-infrastructure.md | OK |
| Phase D : Communauté | docs/setup/phase-D-community.md | OK |
Point d’attention — Statut de la macro-architecture
La macro-architecture a fait l’objet d’une passe 1 de nettoyage (2026-05-20) qui a :
- Aligné la section 2 (couche 0) sur les ADRs phase 0 (ADR-019 modèle d’erreurs, ADR-020 stratégie signaux, ADR-021 conventions transverses, ADR-022 architecture io_uring) et sur le découpage acté en deux crates
air-sys-types+air-sys-syscall. - Neutralisé toutes les références prospectives ADR-018 à ADR-026 en marqueurs neutres (« ADR à produire — service [X] »). Les ADRs services système couche 5 recevront leur numéro définitif au moment de leur instruction (numéros ≥ ADR-028 ; ADR-027 est pris par la stratégie de documentation polyglotte).
Une passe 2 de complétion reste à mener plus tard, en parallèle de l’implémentation : enrichir les sections couches 1 à 5 avec les détails issus des ADRs et specs récents (notamment les conventions transverses dans le code couche 1, l’articulation détaillée AirCom/io_uring/runtime async, etc.).
Travaux à reprendre
Travaux prioritaires (à reprendre en premier)
CONTRIBUTING.mddétaillé- Localisation :
CONTRIBUTING.mdà la racine du repo. - Périmètre : guide complet de contribution avec workflow Git, conventions, DCO, processus RFC.
- Localisation :
Spec détaillée du module io_uring (9 documents)
À produire au niveau de détail des autres specs de familles :
docs/specs/layer-0/io-uring-1-core.md(Temps 1 : cœur API)docs/specs/layer-0/io-uring-2a-filesystem.md(Temps 2a : 21 opérations FS)docs/specs/layer-0/io-uring-2b-network.md(Temps 2b : 8 opérations réseau)docs/specs/layer-0/io-uring-2c-async.md(Temps 2c : 9 opérations async)docs/specs/layer-0/io-uring-3a-registration.md(Temps 3a : registration)docs/specs/layer-0/io-uring-3b-linked.md(Temps 3b : linked)docs/specs/layer-0/io-uring-3c-multishot.md(Temps 3c : multishot)docs/specs/layer-0/io-uring-3d-shared.md(Temps 3d : shared)docs/specs/layer-0/io-uring-4-raw.md(Temps 4 : raw)
Référence : ADR-022 + io-uring-overview.md.
Documents de conventions formels (mise en forme depuis Phases B et C)
STYLE_GUIDE.mdà la racine (depuis Phase B1).TESTING_GUIDE.mdà la racine (depuis Phase B2 section 1).ERROR_HANDLING.mdà la racine (depuis Phase B2 section 2 + ADR-019).COMMIT_CONVENTIONS.mdà la racine (depuis Phase B2 section 3).PR_REVIEW_GUIDE.mdà la racine (depuis Phase B2 section 3).RELEASE_PROCESS.mdà la racine (depuis Phase C section 3).INFRASTRUCTURE.mdà la racine (depuis Phase C section 2).
Spec exhaustive de air-sys-types
- Compléter
docs/specs/layer-0/air-sys-types.mdavec spec type par type (méthodes, invariants, tests). Probablement à produire en parallèle de l’implémentation effective de la crate.
Documents de la communauté
docs/guides/first-contribution.md: guide pas-à-pas du premier patch.docs/guides/getting-started/: guides de démarrage pour différents profils.SECURITY.mdà la racine : politique de divulgation des vulnérabilités.CODE_OF_CONDUCT.mdà la racine : Contributor Covenant 2.1 (texte officiel).CHANGELOG.mdà la racine : démarrage avec section[Unreleased].
Documents internes mainteneurs
docs/maintainers/release-process.md: procédure de release détaillée.docs/maintainers/moderation.md: procédure de modération.docs/maintainers/onboarding-maintainers.md: onboarding nouveau mainteneur.docs/maintainers/cve-handling.md: gestion des CVEs.
Statistiques globales
| Catégorie | Nombre |
|---|---|
| Documents fondateurs existants | 8 (vision FR/EN, charte FR/EN, principes FR/EN, registre, macro-architecture) |
| ADRs fondateurs + ADR-018 imagerie | 18 (+ ADR-012-bis) |
| ADRs phase 0 instruits | 7 (ADR-019 à ADR-025) |
| ADRs post-phase 0 instruits | 64 (ADR-026 → ADR-089, série continue) |
| ADRs proposés à rédiger | ADRs services système couche 5 (numérotation à assigner ≥ 090) + ADRs ouverts identifiés au registre |
| Specs couche 0 produites | 13 familles (process, fs, mem, signal, time, net, ipc, security, system, device, ebpf) + io_uring (maître + 13 Temps) |
| Specs couche 0 à compléter | air-sys-types exhaustif (type par type). Toutes les familles sont désormais spécifiées. |
| Documents de setup phase 0 | 5 |
| Documents à produire | ~27 |
Statistiques techniques (estimées)
| Élément | Estimation |
|---|---|
| ADRs au total (état actuel) | 90 (017 fondateurs + 012-bis + 018 imagerie + 019-025 phase 0 + 026-089 post-phase 0, série continue). ADRs services système couche 5 à instruire ultérieurement (numéros ≥ 090, non pré-réservés) |
| Fonctions wrapper en couche 0 | ~213 |
Types publics dans air-sys-types | ~187 |
| Syscalls Linux wrappés | ~180-200 sur 460 disponibles |
| Crates Air planifiées | ~30-40 selon les phases |
Pour les conversations futures
Quand tu démarres une nouvelle session sur Air, pointe Claude vers ce document docs/INDEX.md comme premier point de référence. Claude pourra ainsi rapidement :
- Comprendre ce qui existe déjà.
- Comprendre ce qui reste à produire.
- S’orienter dans la hiérarchie de documentation.
Suggestion de prompt initial :
« Bonjour, on continue le travail sur Air. Lis
docs/INDEX.mdpour comprendre l’état actuel du projet, puis on va travailler sur [sujet à préciser]. »
Ordre de priorité suggéré pour les travaux futurs
Court terme :
- README final + CONTRIBUTING — pour que le repo soit présentable.
Moyen terme (avant ouverture publique) :
- SECURITY.md, CODE_OF_CONDUCT.md, CHANGELOG.md.
- Documents de conventions formels (mise en forme).
- Configuration GitHub Actions de base.
Plus tard (en parallèle de l’implémentation) :
- Spec détaillée io_uring (9 documents).
- Spec exhaustive air-sys-types.
- Documents internes mainteneurs.
Licence du document : MPL 2.0
Statut : Document de référence et checklist. Version 1.4 — passe post-M5 (2026-07-10) : inventaire ADRs porté à 087, sceaux couche-0-v1.12 / couche-1-v1.9, jalon std-sur-Air atteint.
Suivi — état d’avancement & tâches à venir
Document unique de suivi vivant. C’est le point d’entrée « où en est-on / quelle est la prochaine tâche / par où on est passé / qu’a-t-on fait ». Il est tenu à jour au fil des merges (chaque jalon met à jour l’état + coche/ajoute une tâche). Référencé par
CLAUDE.mdetAGENTS.md.Rôle des autres documents (pour ne pas les confondre) :
INDEX.md— carte structurelle : ce qui existe, où le trouver.JOURNAL.md— archive chronologique append-only (le détail session par session, PR par PR). On y écrit l’historique ; on ne le « maintient » pas comme tableau de bord.roadmap-couche1-libc-pal-fr.md— plan détaillé §-numéroté de la clôture couche 1 (les specs/audits y renvoient via « §5.3 P0.x »). Le statut vivant de ce plan est tenu ici.
Dernière mise à jour : 2026-07-13 (🎉 std COMPLET sur le PAL safe + MERGÉ + RE-SCELLÉ — target_os="air", sans libc C, unsafe confiné couche 0. Portage std mergé (#326) + différés soft mergés (#327 : os::fd/pipe, options socket [+ descellement couche-0-v1.13], DNS). Tous sous-systèmes prouvés on-target 2 arches (x86_64/carbon + aarch64/raspi vrai matériel) : alloc/errno/futex+sync/stdio/env/exit/thread/mpsc/fd+fs(+current_exe)/process/net(TCP+UDP + timeouts/multicast)/random/os::fd/pipe/DNS. 1 seul fork externe (bitflags), RustCrypto/icu4x/air-base-lib hors fermeture (ADR-090). RE-SCEAUX POSÉS (signés) : couche-0-v1.13 (options socket à structure) + couche-1-v2.0 (jalon majeur : la couche 1 rend possible std sur Air). COUCHE 2 EN COURS : ✅ ADR-091 (motif réseau sans-IO normatif) ; ✅ métadonnée de couche + gate check-layers (#328) ; ✅ air-async inc 0→3 — runtime async couche 2 complet et sound : découpage air-uring(L1)/air-async(L2) [ADR-092], sûreté d’annulation io_uring (valgrind definitely-lost=0), exécuteur spawn/block_on, éveil inter-thread (handshake SeqCst, loom vert), roue de timers, back-pressure, thread-per-core (ReactorPool/msg_ring), multishot + provided buffers (soundness étendue), I/O fichier/réseau, embarquement hôte — mergés #329-332, zéro unsafe couche 1, zéro descellement couche 0. ✅ IPC AirCom — FONDATION posée (#333) : air-com/air-com-proto (sans-IO sur air-async, ADR-091), capability = fd (SCM_RIGHTS, ADR-010), wire Cap’n Proto (ADR-040) ; 4 lacunes couche 1 comblées — air-shm (memfd+seals, zero-copy), SCM_RIGHTS async (air-uring/air-async), SEQPACKET + pont OwnedFd (air-socket additif → re-sceau couche-1-v2.1) ; preuve : fd-capability portant un shm scellé traverse le socket, pair mmap zero-copy. ✅ Spec AirCom posée (#334) (couche 2, wire format + transport + pub-sub anneau + bootstrap air-registry). ✅ AirCom inc.1 — Codec Cap’n Proto réel : nouveau crate air-com-schema (schéma v1 figé Envelope/CapRef/ShmRef/MessageKind, code généré committé, no_std+alloc), Codec réel remplaçant le placeholder Ping/Pong (Envelope schema-first, décodage borné anti-hostile, zéro unsafe/panic), pilote+exemple Call→Return (exit 0), fuzz fuzz_air_com_codec (871k runs, 0 crash) ; verdict no_std : capnp no_std+alloc OK (prouvé en graphe isolé alloc-only) — chemin nominal, aucune nouvelle exception. Cœur 98 % lignes / pilote 98 %. ✅ AirCom inc.2 — StateMachine multi-états + Multiplexer + Handshaker : StateMachine à deux niveaux (cycle de connexion Opening→Established→Draining→Closed/Faulted + une machine par canal Idle→AwaitingReturn→{Returned|Errored|Cancelled}, transitions illégales rejetées sans panic), Multiplexer (ChannelTable : channelId par canal, parité de rôle client-pairs/serveur-impairs, borné maxChannels, isolation inter-canaux prouvée), Handshaker (négo de version Hello capnp ajouté au schéma v1, intersection déterministe min-version/bornes + extensions communes triées, échec → Faulted sans I/O) ; pilote + exemple ping_pong : handshake + 2 requêtes concurrentes multiplexées sur 1 connexion (exit 0) ; model-based tests de la StateMachine (proptest : modèle de référence, puits terminaux, pas de deadlock) + fuzz fuzz_air_com_handshake (Hello hostile, 506k runs, 0 crash). Cœur 98,9 % lignes (state/channel 100 %) / pilote 99 %. ✅ AirCom inc.3 — transport SEQPACKET bout-en-bout + 2 surfaces + exemple 2-exe : le bootstrap std::os::unix::net d’inc.0 a disparu — le control plane passe sur AirUnixSeqpacket réel (socketpair et adresse abstraite nommée, bridge into_owned_fd→air-async) ; deux surfaces de première classe partageant le même cœur (async sur air-async/io_uring : Connection::connect_async/Listener::{bind_async,accept}/call/serve ; sync sur air-socket bloquant : connect_sync/{bind_sync,accept_blocking}/call_blocking/serve_blocking) ; test croisé client sync ↔ serveur async et l’inverse (preuve du partage du cœur, spec §11) ; livrable développeurs = 2 vrais binaires aircom_server/aircom_client (méthode « greet », 2 requêtes multiplexées sur channelId 0/2, adresse abstraite, README tutoriel) — exécutés en 2 process, exit 0 des deux côtés. Cœur PUR inchangé (aucune fn unsafe exposée), pilote >90 %. ✅ AirCom inc.3b — accept SEQPACKET pleinement io_uring : le STOP-and-report d’inc.3 est clos — l’accept(2) bloquant du listener nommé a disparu. Additif couche 1 (air-socket, autorisé BDFL — pur, zéro changement sémantique des API existantes, miroir de AirUnixSeqpacket) : AirUnixSeqpacketListener::into_owned_fd(self) -> OwnedFd (transfère l’ownership hors du registre air-handle, testé bind→into_owned_fd→ré-adoption→accept réel sans double-close, 100 % lignes+branches sur le neuf). Pilote async air-com : Listener::bind_async bride désormais le fd d’écoute vers air-async (TcpListener::from_owned) et Listener::accept fait un IORING_OP_ACCEPT (plus d’accept(2) bloquant) ; surface sync inchangée (accept_blocking natif). Exemple 2-process relancé (exit 0 des deux côtés) + test pilote named_async_roundtrip (accept io_uring round-trip). Pilote air-com (surface.rs) 100 % lignes. Additif L1 en attente du re-sceau couche-1-v2.2, DIFFÉRÉ au jalon de finalisation d’AirCom (posé par le superviseur — aucun tag posé ici). ✅ AirCom inc.4 — data plane zero-copy intégré + Flow Controller : composant 5/9 Flow Controller réalisé (cœur PUR air-com-proto/flow.rs, remplace le marqueur unité) — crédits par canal, fenêtre bornée (modèle reactive-streams), grant_credit/consume_credit/available_credit intégrés à la table de canaux du Session Context ; jamais de file non bornée, crédit jamais négatif (checked ops), property-tests proptest (invariant somme, borne respectée, épuisement→blocage, recharge→déblocage). Message MessageKind::credit orthogonal à la StateMachine de requête (routé hors du cycle de vie de canal — ni on_send ni on_receive). Data plane intégré au flux de messages (pilote air-com/dataplane.rs) : Connection::call_with_payload/serve_with_payload — une invocation porte une charge volumineuse via ShmRef (schéma capnp v1 étendu par ajout compatible Envelope.shmRefs @5, discipline ADR-012, ShmRef déjà présent activé) ; le memfd scellé voyage par SCM_RIGHTS avec le message de contrôle, le pair mmap zero-copy (la charge ne traverse jamais le socket — seul le fd). Conventions §5 appliquées : sealing obligatoire (segment non scellé refusé côté production ET réception → Faulted ; défense en profondeur : sceaux réels du memfd vérifiés, un sealed=true menti est rejeté), partage read-only (F_SEAL_WRITE + mmap PROT_READ), length logique ≤ taille validé en amont (Principe 4). Le cœur ne voit que la référence (invariant §1.1 — aucun fd ; le pilote apparie slot↔fd). Flow control appliqué au data plane (fenêtre initiale semée à l’ouverture, crédit consommé avant émission, rendu par le consommateur après lecture). Exemple 2-process aircom_dataplane_{server,client} : le client publie 1 Mio scellé, le serveur mmap zero-copy et confirme (len+checksum) — exécutés sur carbon, exit 0 des deux côtés, checksums coïncidents. fuzz fuzz_air_com_codec étendu (champs shmRefs). Cœur 98,9 % lignes (flow/state 100 %, channel 99,6 %) / pilote 97,6 % lignes (dataplane 95,4 %). Cœur PUR inchangé (aucune fn unsafe exposée, no_std). ✅ AirCom inc.5 — pub-sub par anneau de mémoire partagée (§7, diffusion 1→N) : 9ᵉ patron autonome distinct du RPC (canal dédié, hors StateMachine requête/réponse). Cœur PUR air-com-proto/ring.rs — anneau SPMC (1 producteur / N consommateurs) : disposition figée v1 ({capacity, slotPayloadCap, writeSeq, notify} + slots {stamp, len, payload}, arithmétique checked_*/saturante, zéro as nu), RingProducer::ring_publish (overwrite best-effort, ne bloque JAMAIS) / RingConsumer::ring_poll (détection de perte par saut de writeSeq au-delà de capacity, compteur lost persisté), protocole seqlock (estampille writing-impair/committed-pair, barrières Release/Acquire) rejetant toute lecture déchirée (comptée perdue, jamais rendue) — zéro fn unsafe, zéro panic, no_std ; accès mémoire délégués au pilote via traits RingReader/RingWriter (le cœur ne touche aucun octet partagé, invariant §1.1). Pilote air-com/pubsub.rs : Publisher (memfd scellé FUTURE_WRITE|GROW|SHRINK — producteur garde l’écriture, abonnés read-only), Subscriber (mmap RO, poll / next_blocking avec réveil futex réel portée Shared inter-process sur le mot notify), AsyncSubscriber (air-async — sonde temporisée : le réveil futex-via-io_uring/eventfd exige 2 pièces couche 1 absentes, hors additif atomique unique → différé honnêtement, best-effort §7). Additif couche 1 air-shm (accesseurs atomic_u32/atomic_u64 bornés+alignés sur vues mmap — l’unsafe reste couche 1, jamais dans le pilote). Model-based + property tests (roundtrip sans perte, abonné lent détecte l’overwrite, perte en bord de fenêtre reportée, lecture déchirée détectée/comptée/retentée, 2 abonnés reçoivent chacun tout, len mensongère bornée) + fuzz fuzz_air_com_ring (anneau hostile : writeSeq/stamp/len mensongers, charge tronquée — 2 000 000 runs, 0 crash, jamais de charge > slotPayloadCap). Cœur ring.rs 95,7 % lignes (100 % fn ; régions restantes = gardes SizeOverflow/conversions injoignables sur 64 bits, Principe 2) / pilote pubsub.rs 97,4 % lignes. DÉCISION BDFL : ship du mécanisme prouvé in-process ; bootstrap control-plane (§7 « Établissement » : publier le fd d’anneau via ShmRef+MessageKind::event) + exemple 2-exécutables pub-sub DIFFÉRÉS (→ inc.6/additif). Additif L1 air-shm en attente du re-sceau couche-1-v2.2 groupé au jalon de finalisation AirCom (avec l’additif inc.3b). ✅ AirCom inc.6 — contrat air-registry matérialisé (mock couche 2, §9) : la découverte nommée par fd (l’impl réelle est couche 5, hors périmètre). Cœur PUR air-com-proto/registry.rs (no_std+alloc, zéro unsafe/panic, sans fd §1.1) — wire maison length-prefixé borné et anti-hostile (ServiceName newtype validé « parse, don’t validate » : non vide / UTF-8 (from_utf8 Result) / borné MAX_SERVICE_NAME_LEN=255 ; RegistryRequest{Register,Lookup} + RegistryResponse{Ok,NotFound,Denied}, décodage slice::get/checked_*, tag inconnu / longueur mensongère / octets excédentaires rejetés), table de services nom→handle opaque u64 (register/duplicate/lookup, le cœur ne voit jamais de fd — le pilote apparie handle↔fd), et application du verdict d’entitlement (resolve_lookup/resolve_register : reçoivent un booléen, Denied l’emporte sur NotFound, spec §9). Pilote air-com/registry.rs : rendez-vous par socketpair, fd-only — register(name) cède reg_side au registry par SCM_RIGHTS (le service garde svc_side = canal d’acceptation) ; lookup(name) → le registry crée une paire fraîche, pousse service_end au service par le canal stocké et rend client_end au client, chacun enveloppant son fd en Connection (handshake Requester/Responder). Additif de surface couche 2 Connection::from_connected_async(fd,role,id) (aucun additif/descellement couche 1). Preuve end-to-end : service register(«greet») → client lookup(«greet») obtient un fd → call → le service serve répond Hello, Ada — aucune adresse échangée (matchmaking par fd, capability infalsifiable ADR-010) ; cas NotFound (inconnu) et Denied (entitlement refusé masquant un service présent) prouvés. fuzz fuzz_air_com_registry (décodeur de requêtes/réponses hostile + round-trip + table/verdict, 1 000 000 runs, 0 crash). Cœur registry.rs 100 % lignes (99,5 % régions ; 1 garde u16 injoignable par construction, Principe 2) / pilote registry.rs 98 % lignes (95 % fn ; seule fn non couverte = closure d’échec socketpair(2) injoignable). PAS d’exemple 2-process pour cet incrément (décision BDFL : contrat prouvé in-process ; l’exemple viendra avec le bootstrap pub-sub différé). Prochain (AirCom) : AirCom inc.7 — intégration modèle d’objet (ADR-002) : call sur AirObject + 1er service réel air-notifyd → air-sshd. ✅ Chantier air-object (ADR-002) OUVERT — découpage validé BDFL : AO.1 (air-value) → AO.2 (air-object cœur : AirObject/AirClass, refcount AtomicU32, AirHandle) → AO.3 (propriétés observables + send_message + politiques de thread) → AO.4 (proc-macro #[air_class]) → AO.5 (introspection + conformité ABI complète), puis câblage AirCom inc.7 (MessageKind::call sur AirObject + air-notifyd). ✅ AO.1 — air-value RÉALISÉ : nouveau crate crates/air-value/ (couche 2, cdylib+rlib → libair-value.so, calqué sur air-base-capi) livrant l’AirValue — union taguée #[repr(C)] à LAYOUT FIGÉ (ABI 10 ans, ADR-012) : AirValueKind(u32) + AirValuePayload(union 16 o : i:i64/f:f64/b:bool/buffer:AirBuffer{ptr,len}/obj:*mut c_void) ⇒ AirValue = 24 o, align 8 (kind@0, pad 4..8, payload@8), gelé et vérifié par sizeof/offsetof C + tests Rust. AirStatus in-band ({code:AirStatusCode(u32), message}, ADR-045, miroir AirErrorKind + codes frontière NULL_ARGUMENT=100/WRONG_TYPE=102). Surface air_value_* : constructeurs (null/int/float/bool/string/bytes/object), getters typés (as_int…as_object, WRONG_TYPE sur mauvais type), retain/release/clone, set_object_ops. Règle de propriété « +1 retours / +0 arguments » implémentée fidèlement : côté C manuelle, côté Rust automatisée par AirValueOwned (Drop=release, Clone=retain). Buffers AIR_STRING/AIR_BYTES = comptés par référence (en-tête AtomicU32 caché avant ptr — l’ABI ne voit que (ptr,len) ; AIR_STRING valide l’UTF-8). AIR_OBJECT découplé d’air-object (qui n’existe pas encore) via un hook de vtable global (AtomicPtr, air_value_set_object_ops) : retain/release délèguent au hook si installé (AO.2 l’installera), sinon no-op sûr — zéro unsafe non justifié, zéro panic, zéro UB. Header committé include/air_value.h (cbindgen, diffé en CI, xtask CAPI_CRATES étendu). Tests : unit + property (proptest) + conformité ABI C (tests/abi/conformance.c : layout gelé + règle +1/+0 avec ops objet installées par le C) + header_committed + allocateur compteur (tests/alloc_balance.rs : zéro fuite, libération à refcount 0) + multi-thread (retain/release concurrents) + fuzz fuzz_air_value_string (1 000 000 runs, 0 crash, ASAN : ni fuite ni double-free). Barrière verte (fmt/clippy -D warnings/machete/check-layers 2→1) ; couverture 98,5 % lignes / 100 % fonctions (7 lignes restantes = gardes OOM/non_exhaustive injoignables, documentées STRUCTURAL, Principe 2). Dép unique air-base-lib (comme air-base-capi : source #![no_std], l’artefact cdylib hérite de l’allocateur/panic-handler du std du graphe). ✅ AO.2 — air-object cœur RÉALISÉ : nouveau crate crates/air-object/ (couche 2, cdylib+rlib → libair-object.so, calqué sur air-value) livrant le cycle de vie & l’identité d’objet. AirObject — en-tête #[repr(C)] à LAYOUT FIGÉ (ABI 10 ans, ADR-012) : { isa:*const AirClass, refcount:AtomicU32, flags:u32, reserved:u64 } = 24 o, align 8 (isa@0, refcount@8, flags@12, reserved@16 ; reserved = espace figé pour l’extension d’en-tête AO.3+), suivi d’un payload variable zéro-initialisé ; vérifié par sizeof/offsetof C + Rust. Champ isa (idiome ObjC, évite le mot-clé C++ class). Refcount = AtomicU32 côté Rust (interior mutability correcte — le premier prototype u32+from_ptr était UB derrière un &, corrigé), rendu uint32_t côté ABI (post-traitement cbindgen dans xtask). AirClass — méta-classe (une classe est un AirObject) : name/parent(nullable)/instance_size/finalize(nullable) ; layout non gelé (vtable/propriétés → AO.3/AO.5), opaque côté C (forward-déclarée). Bootstrap métaclasse : static ROOT auto-référente (isa → elle-même), immortelle via fanion AIR_OBJECT_FLAG_STATIC (retain/release = no-op, jamais libérée, sûr en .rodata) ; classes déclarables via AirClassCell (const, Sync). API C-ABI : air_object_alloc (refcount 1 + payload zéro), air_object_retain/release (Arc, release saturant ; à 0 → finalize puis dealloc), air_object_class, air_object_is_kind_of (remonte isa→parent), air_object_root_class, air_object_runtime_init. AirHandle<T> (RAII Rust : Drop=release/Clone=retain). Branchement du hook AO.1 prouvé bout-en-bout : air_object_runtime_init installe les ops objet dans air-value (air_value_set_object_ops) ⇒ un AirValue AIR_OBJECT retient/relâche réellement l’AirObject (test C et Rust : refcount varie, finalisé au bon moment). Durcissement conformité ABI (demande superviseur) : env AIR_ABI_CONFORMANCE_REQUIRED=1 (posée en CI, job test-coverage/air-x86_64) ⇒ l’absence de cc n’est plus un skip silencieux mais un ÉCHEC ; si gcc ET clang présents, le consommateur C est compilé+exécuté avec les deux ; rétro-appliqué à air-value. libair-object.so agrège air-value (rlib) et ré-exporte air_value_* (un consommateur du modèle d’objet lie une seule .so ⇒ hook unique). Header committé include/air_object.h (xtask CAPI_CRATES étendu). Tests : unit + property (proptest) + conformité ABI C (layout gelé, métaclasse, cycle de vie, is_kind_of, intégration AO.1) + header_committed + allocateur compteur (alloc_balance.rs) + multi-thread (retain/release concurrents, finalize 1×) + intégration hook (integration_hook.rs). Pas de nouveau fuzz (justifié : aucune frontière d’octets externe — pointeurs+entiers ; la frontière octets vit dans air-value, déjà fuzzée). Barrière verte (fmt/clippy -D warnings/machete/check-layers/gen-capi-header/mdbook) ; couverture 97,7 % lignes / 100 % fonctions (4 lignes restantes = gardes OOM/overflow/saturation injoignables, documentées STRUCTURAL, Principe 2). ✅ AO.3 — propriétés observables + send_message + politiques de thread RÉALISÉ : air-object étendu (spec §5/§2/§6). AirClass étendue (toujours opaque côté C) : politique de thread obligatoire AirThreadPolicy (Immutable/MainThreadOnly/ThreadSafe — AirClassCell::new/with_members l’exigent, pas de défaut silencieux) + table de propriétés (AirPropertyDescriptor = AirPropertyInfo{nom,AirValueKind} + accesseurs typés get(+1)/set(+0), jamais d’offset brut) + table de méthodes (AirMethodDescriptor = sélecteur → imp(args +0 → +1)). Nouvelles structs C figées committées au header : AirPropertyInfo, AirPropertyDescriptor, AirMethodDescriptor, AirThreadPolicy (repr(u32), variantes préfixées AIR_THREAD_POLICY_*) ; AirObservation/AirNotificationToken opaques (forward-déclarées). Propriétés : air_object_get_property(+1, AIR_NULL si inconnu) / air_object_set_property(AirStatus : NOT_FOUND inconnu, UNSUPPORTED Immutable/lecture-seule, WRONG_TYPE via le setter) ; résolution héritée (chaîne parent). Observation (data binding) : air_object_observe/unobserve, set notifie ancienne/nouvelle valeur (+0) sur un instantané par valeur — ré-entrance sûre (un observateur peut set/observe/unobserve), retrait pendant notif sans UB, ordre déterministe. Registre par objet : tête de liste doublement chaînée logée dans les 8 o reserved de l’en-tête figé (usage documenté ; jamais touché par le C), sérialisée par un verrou tournant core pur (no_std, sections courtes) ; purge automatique à la finalisation (zéro fuite, prouvé par l’allocateur compteur). Wrappers Rust : AirObservable<T> (cellule réactive mono-thread) + AirNotificationCenter (pub-sub par nom, closures) — plus un centre C-ABI global air_notification_subscribe/unsubscribe/post ; portée intra-processus clairement documentée (≠ AirCom). Messages : air_object_send_message(+1, dispatch via classe + chaîne parent, AIR_NULL si sélecteur inconnu / args NULL avec n>0). Politiques de thread (§6, sécurité par construction) : MainThreadOnly debug-only (décision ratifiée : debug_assert en debug, zéro coût en release — Main Thread Checker UIKit) via air_object_set_main_thread (TID principal, air-thread::current_tid couche 1, arête 2→1) ; enforcement sur get/set/send_message/release ; prédicat air_object_thread_violation (doc(hidden), testable — le panic=abort du profil de test interdit d’intercepter un vrai debug_assert) ; Immutable refuse set. Layout AirObject (24 o) inchangé/figé. Dép ajoutée : air-thread (couche 1, no_std, zéro dép externe) pour current_tid. Header committé régénéré (air_object.h tire désormais air_value.h). Tests : 54 unit+property (get/set round-trips, inconnu/mauvais type/lecture-seule/écriture-seule, héritage props+méthodes, observation ancienne/nouvelle/ré-entrance/unobserve-pendant-notif/multi-observateurs/purge-à-la-finalisation, send_message dispatch/héritage/inconnu/args, AirObservable, AirNotificationCenter Rust+C-ABI global, Immutable refuse set, noms bornés hostiles, observation concurrente ThreadSafe), + intégration thread_policy (2 arches de la logique MainThreadOnly), conformité ABI C durcie (conformance.c : classe avec props+méthodes définie depuis C, get/set, observateur C notifié, send_message — gcc ET clang), header_committed, alloc_balance (nœuds d’observation zéro fuite). Barrière verte (fmt/clippy -D warnings/machete/check-layers 2→1/gen-capi-header/mdbook) ; couverture 98,2 % lignes / 98,4 % fonctions (restant = gardes OOM/overflow d’alloc AO.2, garde isa NULL, SpinLock::new const-évalué — injoignables, documentés STRUCTURAL, Principe 2). ✅ AO.4 — proc-macro #[air_class] RÉALISÉ : premier crate proc-macro d’Air crates/air-object-macros/ (couche 2, [lib] proc-macro = true, build-time only — compile pour l’hôte, jamais liée dans un artefact cible). Attribut #[air_class(thread = …)] générant (à partir d’une struct) exactement les descripteurs const écrits à la main en AO.3 : un static AirClassCell (via with_members, const), un AirPropertyDescriptor par champ #[observable] (accesseurs get(+1)/set(+0) typés via offset_of! dans le payload #[repr(C)] après l’en-tête figé 24 o ; la notification des observateurs est héritée du chemin générique air_object_set_property d’AO.3 — la macro n’y ajoute rien), la politique de thread issue de thread = …, et un accesseur Self::air_class() -> *const AirClass. « Zéro glue par classe » : aucune fonction C par classe — la classe passe par les air_object_* génériques ; interop AirHandle<Self> gratuite (générique sur le payload). Contrainte forte respectée : thread = … OBLIGATOIRE (absent ⇒ erreur de compilation claire, jamais de défaut silencieux) ; type de champ #[observable] non géré ⇒ erreur de compilation ; jamais de panique de macro non contrôlée (toute mauvaise utilisation = compile_error! à span correct). Périmètre v1 (documenté/justifié) : champs gérés = bool/i64/f64 (scalaires AirValue, Copy, valides zéro-initialisés, sans stockage possédé → aucun finaliseur) ; différés = champs à valeur possédée (AIR_STRING/AIR_BYTES/AIR_OBJECT : exigent stockage refcompté + finaliseur généré + discipline retain/release — incrément à part) et méthodes riches (table de méthodes vide ; le dispatch reste exercé : send_message rend AIR_NULL sur sélecteur inconnu depuis une classe générée). Structure testable : toute la logique vit dans expand.rs (sur proc_macro2::TokenStream, unit-testée — couverte par llvm-cov) ; seul le fin adaptateur #[proc_macro_attribute] (proc_macro↔proc_macro2) est non instrumentable (exécuté par rustc, couvert par trybuild). Macro ré-exportée par air-object (pub use air_object_macros::air_class — dépendance normale, proc-macro compile pour l’hôte donc zéro fork ADR-090 ; header committé air_object.h inchangé : parse_deps=false + une proc-macro n’expose aucun type #[repr(C)]). Exception 80 % proc-macro ACTÉE (syn/quote/proc-macro2, versions pinnées =2.0.117/=1.0.45/=1.0.106, syn default-features=false) dans docs/EXCEPTIONS.md — fixe la politique proc-macro d’Air (vaut pour toute future crate *-macros : build-time only, non livrée, pur Rust zéro-C) — + crates/air-object-macros/DEPENDENCIES.md. Traitement couche/gate : le crate vit sous crates/air-* (contrairement à xtask, hors crates/, donc non scanné) ⇒ il déclare [package.metadata.air] layer = 2 (métadonnée INERTE ; zéro dép air-* → aucune arête à vérifier ; air-object→air-object-macros = arête 2→2 conforme). Tests : trybuild compile-fail (8 cas, .stderr committés : thread manquant/()/inconnu, clé inattendue, args superflus, type de champ non géré, cible non-struct, tuple, générique) + 16 tests unitaires sur expand (tous les bras diagnostiques + génération, validation lexicale de la sortie) + e2e air-object/tests/macro_class.rs (4 tests : une classe #[air_class] ThreadSafe/Immutable traverse alloc/get/set [+ mauvais type→WRONG_TYPE, inconnu→NOT_FOUND]/observation [ancienne/nouvelle]/send_message/is_kind_of/release + AirHandle Clone/Drop — compile ET tourne, plus fort qu’un compile-pass). Pas de fuzz (justifié : la macro consomme du code source de confiance, pas des octets hostiles runtime). Barrière verte (fmt/clippy -D warnings/machete/check-layers 59 couches conformes/gen-capi-header air_object.h identique/deny licences ok/mdbook). Prochain (air-object) : AO.5 — introspection + conformité ABI complète. ✅ AO.5 — introspection (list_all/properties/parent/name/describe) + conformité ABI MERGÉ (#347) — chantier air-object COMPLET : air-object étendu (spec §7). Registre global de classes — enregistrement paresseux automatique à la première air_object_alloc d’une classe (« zéro glue » : le développeur ne déclare rien), liste intrusive (fanion registered: AtomicBool + maillon next_registered: AtomicPtr ajoutés à AirClass — layout non gelé, opaque côté C, donc header inchangé ; les statiques vivent en .data car AirClass contient des atomiques ⇒ mutation définie), zéro allocation heap pour le registre, mutations sérialisées par un verrou tournant REGISTRY_LOCK (core pur). Invariant de durée de vie documenté : une classe allouée-depuis doit être immortelle (#[air_class]/AirClassCell static/air_class_new) ou libérée par air_class_free (qui la désenregistre — pas de pointeur pendouillant) ; air_class_free gère l’unlink tête et multi-pas. Surface C-ABI nouvelle : air_class_list_all(*mut usize) -> *const *const AirClass (instantané possédé pris sous verrou, libéré par air_object_free_class_list(list, n)), air_class_properties(cls, *mut usize) -> *const AirPropertyDescriptor (zéro-copie : table AO.3 de la classe, propriétés propres ; inheritées via remontée parent — décision documentée : renvoie le descripteur, dont .info=AirPropertyInfo{nom,kind}, plutôt que copier un tableau AirPropertyInfo), air_class_parent, air_class_name, air_class_registered_count, et air_object_describe(obj) -> char* — chaîne de debug possédée (nom de classe + propriétés nom=valeur, héritées comprises), bornée et sans octet NUL (scalaires imprimés, agrégats String/Bytes/Object en jetons <…> ⇒ ne re-sérialise aucun octet externe), libérée par air_object_free_string (convention documentée : allocateur d’Air via CString, pas free(3)). AirClass reste opaque côté C (header air_object.h régénéré : 6 fonctions ajoutées, zéro type nouveau). Preuve « zéro glue » (Pattern A générique) — livrable de conformité : conformance.c étendu d’une suite d’introspection qui, sans aucun code par classe, construit une hiérarchie Shape←Square depuis C (air_class_new), énumère toutes les classes (list_all), liste leurs propriétés (properties), lit/écrit par nom découvert dynamiquement (round-trip val+1), décrit un objet (describe → contient nom + propriété propre + héritée), vérifie parenté (parent/is_kind_of) — gcc ET clang, REQUIRED ; + preuve qu’une classe #[air_class] (Widget) traverse la même surface d’introspection générique (macro_class.rs). Tests : 67 unit+property (registre : présence/absence-si-jamais-allouée, énumération, première alloc concurrente 16 threads → inscription unique [barrière], désenregistrement tête/multi-pas/no-op, properties/parent/name + bras NULL, describe scalaires/héritées/write-only/toutes variantes de kind/table NULL/nom NULL) + conformité ABI C durcie (dual-compilateur) + alloc_balance étendu (describe→free_string et list_all→free_class_list zéro fuite, allocateur compteur) + header_committed. Pas de nouveau fuzz (justifié : l’introspection produit, ne parse aucun flux externe ; noms bornés cstr_bytes, déjà couverts). Correctif induit : le test AO.2 air_class_cell_new_at_runtime (cellule sur la pile) fuit désormais intentionnellement sa cellule (Box::leak → 'static) pour respecter le nouvel invariant de registre. Stabilité ABI : la suite de conformité fonctionnelle est livrée ; l’outillage lourd (symboles versionnés GNU/.map, air-symver/air-abi-check/air-deprecation-tracker) reste HORS périmètre AO.5 (chantier transverse séparé, Principe 8) — non construit, noté comme suite. Barrière verte (fmt/clippy -D warnings/machete/check-layers 2→1/gen-capi-header air_object.h identique/deny/mdbook) ; couverture 97,8 % lignes / 98,5 % fonctions (restant = gardes défensives injoignables : saturation/OOM/overflow AO.2, isa NULL, SpinLock::new const-évalué, double-check de course register/unregister, registre vide, CString NUL ; + monomorphisations génériques AirHandle<T>/AirObservable<T> reportées vides par CGU mais exercées — documentées STRUCTURAL, Principe 2). Merge #347 durci en revue (2 findings mineurs, ABI-neutre) : air_class_properties ramène *n_out = 0 sur une classe malformée (table NULL avec compte déclaré > 0 — plus d’itération C props[0..n] sur base NULL ; aligné sur air_object_describe, test dédié verrouillant les deux bras du garde), et le doc-comment de air_class_list_all documente la durée de vie des AirClass* empruntés (une classe heap air_class_new libérée pendant qu’on tient l’instantané ⇒ pointeur pendouillant ; les statiques usuelles restent immortelles). Barrière + test-coverage rejoués verts après correctif. 🎉 Chantier air-object (ADR-002) COMPLET (AO.1→AO.5 : air-value, cœur, propriétés/observation/messages/thread, #[air_class], introspection). ✅ AirCom inc.7 — intégration modèle d’objet (MessageKind::call sur AirObject) + 1er service réel air-notifyd — MERGÉ #349 (main f8ac34b) : schéma corps d’invocation dédié (air-com-schema/invoke.capnp : Call{objectId,selector:Text,args}/Return{value}/Error{code,message} — ajout compatible coexistant ADR-012, aircom.capnp inchangé byte-pour-byte ; methodId = sélecteur textuel cohérent avec air_object_send_message, alternative table-d’ids UInt64 rejetée+documentée) ; cœur PUR air-com-proto/invoke.rs (no_std, zéro unsafe/panic, objectId/selector opaques — sans fd ni objet §1.1) — codec Call/Return/InvokeError borné anti-hostile (unit + property round-trip + fuzz fuzz_air_com_invoke 1,1 M runs, 0 crash ; ~98 % lignes) ; pilote air-com/object.rs — registre d’export objectId → *mut AirObject (retain/release équilibrés, prouvé par finaliseur-compteur), marshalling AirValue ↔ capnp (scalaires v1, AIR_OBJECT différé), dispatch call → send_message → returnMsg/error (objet inconnu / sélecteur inconnu AIR_NULL / violation de politique de thread), Connection::call_object/serve_objects sur les deux surfaces async+sync (+ test croisé sync↔async dans les deux sens) ; nouvelle arête 2→2 air-com → air-object/air-value (check-layers vert) ; air-notifyd (nouveau crate couche 2, 1er service ADR-001) — classe AirNotifyd (méthode notify(title,body)→status + propriété observable count) + 2 exécutables aircom_notifyd_{server,client} exécutés on-target (carbon), exit 0 des deux côtés. NB : #[air_class] diffère encore la génération de méthodes (AO.4) ⇒ la classe notify est câblée à la main via AirClassCell::with_members (forme exacte que la macro émettra). Durci en revue avant merge (superviseur, revue adverse par agent → verdict SAIN ; ABI-neutre) : #![forbid(unsafe_code)] posé sur le cœur air-com-proto (l’invariant §11 « le cœur n’expose aucune fn unsafe » passe de garanti-par-revue à vérifié par le compilateur) + un doc-comment corrigé. 🎉 AirCom : les 7 incréments (inc.1→7) sont MERGÉS — l’IPC natif d’Air a son 1er service réel (air-notifyd). Restent, différés/soft : args AIR_OBJECT + génération de méthodes par #[air_class] ; bootstrap control-plane pub-sub + exemple 2-exe pub-sub (différés inc.5) ; re-sceau couche-1-v2.2 groupé (additifs L1 air-socket::into_owned_fd inc.3b + air-shm inc.5). Prochain : couche 2 — framework réseau → air-sshd (ADR-074, la vraie cible produit). ✅ CHAPITRE RÉSEAU OUVERT (2026-07-13) — stratégie ratifiée BDFL : air-sshd en direct (sur air-async+air-socket+air-crypto en sans-IO ; SSH ≠ TLS → framework générique air-network/air-tls différé, hors chemin critique). ADR-093 rédigé + refondu (directive BDFL v1/v2) (phase 1 = couche transport SSH-2, sous-ADR d’ADR-074, draft pour ratification) : topologie de crates figée — cœur sans-IO air-ssh-proto (invariant, réutilisé verbatim en v2) + air-sshd scindé (pilote d’octets swappable SshByteStream : v1 air-async / v2 stack « ssh » d’air-network enveloppant le même cœur ; couche service invariante) + facilité partagée air-service (trait ServiceHost : v1 systemd socket-activation+sd_notify a minima / v2 air-launchd). air-sshd publie ses événements de session sur AirCom (pub-sub §7 + objet-service interrogeable list_sessions) → air-com dépendance v1. Algos modernes only (curve25519-sha256/ssh-ed25519/chacha20-poly1305@openssh.com/aes256-gcm@openssh.com), aléa injecté, conf binaire air-config (existe ; conf par-utilisateur HomeDirectory en P2), cible wire-compat OpenSSH ; 4 incréments T.1→T.4 (3 crates+seams+schéma events+identification+Framer → KEXINIT+négo+event → KEX+dérivation → chiffré+NEWKEYS+preuve ssh réel). 2 ADR compagnons v2 (air-launchd ; stack « ssh » air-network). Socle entièrement prêt (air-crypto profil SSH implémenté, air-async, air-socket, air-config ~16k l., AirCom, air-process/privsep, PTY, signalfd, air-account). Après ratification : impl en incréments délégués carbon (re-vérif+merge comme AirCom)).
0. Actualité — CIBLE DE PRODUCTION (2026-07-10)
🎯 OBJECTIF PREMIER (pivot 2026-07-10, ADR-088) : une toolchain Rust dont la
stdrepose uniquement sur Air, par un PAL custom safe bindant la couche 1, SANS libc C. Sans cet outil fondamental, on ne peut pas produire sereinement le reste. Le jalon M5 est ATTEINT mais par le chemin de l’option A (ADR-076) :stdliait la libc Clibair_c— or la frontière C réintroduit de l’unsafe, incohérent avec la doctrine («unsafeen couche 0 seulement »). ADR-088 amende ADR-076 : PAL customstd::sys::pal::airbindant les Managers couche 1 Rust safe (premier backendstdsafe de bout en bout), cibletarget_os="air",libair_csurvit mais découplée destd,AirTaskManagerremplaceAirProcessManager, résorberaw_syscall. M5 = marchepied de faisabilité. Étude :etude-std-pal-air-safe-fr.md.Cible produit ensuite :
air-sshd— le serveur SSH d’Air, full Rust, async io_uring, wire-compat OpenSSH (ADR-074) — la toolchain safe est son prérequis. (Il n’y a pas de jalon « compiler OpenSSH réel » : OpenSSH n’était qu’une fonction de forçage, rôle rempli.)
Acquis récents (couche 1 scellée → toits) :
- 🎉 M5 ATTEINT (2026-07-10) —
stdtourne sur Air, ZÉRO glibc, 2 arches. Le lien dehello-stdcontrelibairpasse de 12 → 0 symboles indéfinis (5 PR :__xpg_strerror_r#308,open/fcntl#309, introspection thread couche 1 [ADR-086] #310,getauxval/pthread_self/détection-pile #311, TLS keys#[thread_local]#312,syscallgénérique [ADR-087] #313). Run on-target :hello from std on air+ exit 0 sur x86_64-air (carbon) ET aarch64-air (raspi). Un programme Ruststdstatic-PIE lié intégralement sans glibc. Pari ADR-076 tenu de bout en bout. Re-sceauxcouche-0-v1.12(ADR-087raw_syscall, escape hatch temporaire à retirer avec l’équipe Rust) +couche-1-v1.9(ADR-086 introspection thread). - Campagne réseau ADR-069 close : registre de handles
air-handle, moteur socketair-socket, surface socket libc complète (30 symboles,libair_c.so) +sendmsg/recvmsg/SCM_RIGHTS. Re-sceauxcouche-0-v1.10(ADR-070 sockaddr + ADR-071 sockopt) etcouche-1-v1.6(ADR-069). CI basculée x86 (ADR-072). - Doctrines gravées : config binaire (ADR-073),
vision
air-sshd(ADR-074) — la vraie cible (OpenSSH n’était qu’une fonction de forçage), fourniture du PAL (ADR-075). - Chantier A du PAL COMPLET : 6 briques additives couche 1 (
AirCondvar,AirOnce, os_str, mutation d’env,yield_now/set_thread_name, destructeursthread_local). - Bug de fondation aarch64 CORRIGÉ (gdb) : le TCB (64 o) recouvrait la zone TLS
(
tpidr_el0base commune TCB+TLS à+16) → tout 2ᵉ#[thread_local]cassait. Fix validé 2 arches (selftestrtx86-air = 42 ET aarch64-air = 42). - Chantier B lancé, option A prouvée :
build-std=stdcompile entièrement pour Air (env=musl, zéro patch libc/std) ; reste le lien (-lc= libair,-lunwind= stub).
⚠️ 2026-07-08 — RÈGLE + ARCHITECTURE (ADR-077) : la libc bind EXCLUSIVEMENT la couche 1 (jamais la couche 0), via des Managers de domaine objet (
AirFileManager/AirTaskManager/AirNetworkManager/AirSystemManager/AirSignalManager/AirEnvironmentManager/AirMemoryManager). Remédiation livrée (re-sceaucouche-1-v1.7, ADR-077) : les Managers sont la surface médiatrice objet consommée par les toits (libc et PAL), jamais la couche 0 en direct. Les symbolesair-libc-*bindent les Managers / briques couche 1.
Prochaines tâches (pivot ADR-088 — chantier PAL safe). Le jalon M5 (option A : std sur
la libc C libair_c) est atteint et conservé comme marchepied de faisabilité, mais il
réintroduit de l’unsafe par la frontière C — incohérent avec la doctrine. ADR-088
pivote : std reposera sur un PAL custom safe (std::sys::pal::air + sys/<domaine>/air.rs)
bindant les Managers couche 1 Rust safe, zéro FFI, cible target_os="air" (Air n’est
plus déguisé en unix), libair_c découplée de std, escape hatch raw_syscall résorbé.
Stratégie de sceau : desceller la couche 1 une fois pour tout le chantier, resceller une fois
au jalon (comme couche-1-v1.0).
- ✅ Étapes 1–2 faites : note d’étude (
etude-std-pal-air-safe-fr.md)- ADR-088 acté (tranche §6.a–e). ADR-089 réserve le
SecurityManager(#324).
- ADR-088 acté (tranche §6.a–e). ADR-089 réserve le
- ✅ Lot 1 mergé (#322/#323) — seul changement cassant : renommage
AirProcessManager → AirTaskManager(médieair-process/air-thread/air-runtime+resource_limit) + surfacefutex/gettid; propagation doc (macro-archi, ADR-077/085/086, INDEX, registre, libc-conformance). - ✅ Étape 3 faite (incrément 0+1, branche
feat/pal-safe-air-target, en revue) — reprofilage cible : cible canonique*-linux-airreprofiléetarget_os="air"(retraitenv=musl/target-family), option-A renommée marchepied*-linux-air-musl(M5 préservé, baseline rejouée :hello from std on air/exit 0/zéro glibc). Forkrust-srclocalisé réversible (rt/rust-src-air/: patch +apply-air-pal.sh+ copievendor/gitignorée, override__CARGO_TESTS_ONLY_SRC_ROOT).stdCOMPILE pour*-linux-air, 2 arches (x86_64 + aarch64), lien d’un binairestdminimal sans symbole libc indéfini (patch minimal : 5 fichiersstd::sys, murs A1–A4).os=airinconnue ⇒restricted_std(voie standard des ports custom). - ✅ Incrément 2 — PROTOTYPE
rustc-dep-of-stdsurair-alloc: PROUVÉ + dé-risqué (commit5f12621, branche).stdcompile 2 arches avec le vraiair-alloc(arènemmap/futexcouche 0) comme allocateur global — sondeair-alloc-probe: ELF static-pie, zéro glibc, zéro symbole indéfini, 5 Kio (arène réelle liée, plus le placeholder). Mesure décisive : la couche 0/1 ne change QU’EN build-config (3Cargo.toml, +38 l., featurerustc-dep-of-std+ façadesrustc-std-workspace-*) — ZÉRO.rssource, zéro sémantique, zéro gate/scellé touché. Vérifié superviseur : tests hôte des 3 crates scellées verts (23+807+199, 0 échec). ⇒ Le descellement formel de la couche 0 N’EST PAS nécessaire (crates Air déjàno_std-propres). - ✅ Incrément 3 — AUDIT deps externes (commit
cfe216d, branche) : fardeau brut de binding naïf = ~71 forks (33 icu4x viaair-base-lib+ 37 RustCrypto viaair-crypto+ 1bitflags), intenable — MAIS presque entièrement évitable par DÉCOUPLAGE, pas par fork. Aucune externe n’arustc-dep-of-stdamont (toutesno_std) ; les 11 proc-macros compilent pour l’hôte → zéro fork. Trouvaille : les 2 grappes 🔴 n’entrent dansstdque par couplage accidentel — (1)AirError/AirResult/AirPathsont définis dansair-base-core(icu-free) ;air-base-libne fait que les ré-exporter + ajouter icu ⇒ rediriger les imports Managers fs/net/process/runtime versair-base-coresort icu4x du graphestd(chemin d’import, zéro sémantique) ; (2)AirRandom::filln’appelle queair-sys-syscall::getrandom(couche 0) ⇒ isoler le random sort RustCrypto du graphe. Vérifié source :air-base-coredéfinit bien les types, Vague 0 dépend deair-base-core(pas-lib). - ✅ ADR-090 ratifié (2026-07-11, sur
main) — grave la discipline : binding parrustc-dep-of-std(build-config, pas de descellement) ; importsair-base-corejamaisair-base-lib; exceptions 80 % (icu4x/RustCrypto) hors fermeturestd;AirRandomhors crypto ; frontièreno_std= relation àstd; industrialisation par vagues. - ✅ Vague 0 — binding réel du noyau (commit
a9769c4, branche) :stdcompile ET lie pour x86_64 ET aarch64 sur 5 vrais Managers —io/error→air-base-core,pal/air::futex+sync/*(Mutex/Condvar/RwLock/Once/Parkerréels)→air-thread::raw_futex,stdio→air-stdio,env→air-env(+alloc→air-alloc). Fermeture vérifiée : uniquementbitflags(ni icu4x, ni RustCrypto, niair-base-lib) — fork count = 1. Zéro.rscouche 0/1 touché, hôte scellé vert. Lien : static-pie zéro glibc ; x86_64 =__tls_get_addrseul indéfini (hook TLS runtime), aarch64 = zéro indéfini. Placeholders restants :random,thread_local. - ✅ Vague 1a — découplage
air-base-lib→air-base-core(commitsb9b4edc/89ba1d4/9d80380, branche) : seulsair-filesystem+air-socketdépendaient directement d’air-base-lib(les 3 autres déjà surair-base-core). Redirection ABI-neutre, aucun cas d’i18n réel (le seulfrom_utf8_lossy= wrapper stdlib zéro-icu). Vérifié :air-filesystem/air-socket/air-processont icu4x=0, air-base-lib=0 dans leur fermeture (fork=1).exitréel bindé surair-process::exit_process(prouvé au désassemblage :__NR_exit_group, pasabort()). Hôte scellé vert (aucune régression). - 🎯 Vague 1b — JALON ATTEINT (commits
396944c/636eda7/168dd4b, branche) :hello-stds’exécute sur le PAL safe. Résidus Vague 0 levés : (1)__tls_get_addrsupprimé via"tls-model": "local-exec"dans les specs cibles (TLS statique, accès%fs:OFFSETdirect) ; (2)_startabouté au bootstrap d’air-runtime(crt0 Air : self-reloc/TLS/TCB/canari/argv →__air_start→bootstrap→main→println!→exit_process),_starthors de std (côté binaire, ne casse pas les#![no_main]).air-runtime+air-memoryenrustc-dep-of-std(build-config, zéro source couche 1 touchée), fermeture sans icu4x/crypto, fork=1. VÉRIFIÉ SUPERVISEUR sur 2 arches :hello from std on air+exit 0 sur x86_64/carbon ET aarch64/raspi-srv-2 (sha256 identique, static-pie, 0 glibc, 0 UND). Hôte scellé vert (127 tests feature OFF), zéro FFI hors_startasm/extern "C"(SAFETY documenté), aucun descellement. - ✅ Bug spawn aarch64 : NON REPRODUCTIBLE (2026-07-11, raspi) —
air_runtime::thread::spawn(clone3+CLONE_SETTLS Variant I) prouvé sound (~16 000 spawns : canonique.tbss+ instrumenté.tdata+ isolation ; selftest rt=42, 5/5 re-vérif superviseur). Les 2 bugs historiques sont corrigés (TCB/TLS overlap 2026-07-07 ; asmx19→x21#150). Le TODO « fix bug spawn aarch64 » était stale ⇒sys/thread(PAL) est débloqué côté runtime. - ✅ Vague 1c —
sys/threadréel (commits2e953ef/2ccb15e, branche) :std::thread::spawn+joins’exécutent sur le PAL safe, 2 arches VÉRIFIÉES superviseur (x86_64/carbon + aarch64/raspi : workersum=5050, join, exit 0 ; stress 800 threads OK). BindingThread::new→air_runtime::thread::spawn(trampolineextern "C" fn(usize),Box<ThreadInit>pointeur fin,expose_provenance),join→futexCHILD_CLEARTID,set_name/yield_now→air-thread,sleep→air-base-core, id→current_tid;available_parallelism/garde de pile = stubs documentés. Zéro source couche 0/1 touchée, fermeture toujours 1 fork,air-runtimedéjà bindé (rien à ajouter). - ✅
air-allocalignement fort — MERGÉ surmain(#325,384c80b, 2026-07-11) : implémenté l’alignement fort (align > 16,memaligndlmalloc) dans l’arène, lève ADR-056 D5 (amendé). Découvert viastd::sync::mpsc(align 64 → l’ancien refusalign>16→nullfaisait abort SIGILL). 30 tests (matrice 32/64/128/256/page +realloc/dedicated/alloc_zeroed/OOM) + property + fuzz (44 600 exécutions). CI verte (test-coverage root ✓, supply-chain ✓, cross-aarch64 ✓). ⇒mpsc/ crossbeam/types sur-alignés débloqués (la branche PAL rebasera sur cemainpour en bénéficier). - ✅
std::sync::mpscVALIDÉ sur le PAL, 2 arches (branche, merge927e624+ sonded85405e) : après intégration du fixair-alloc(mergemain, conflit nul), la sondehello-mpsc(8 producteurs × 1000, somme32004000) s’exécute — x86_64/carbon (120 runs 0 échec) ET aarch64/raspi (vrai ARM), exit 0, static-pie 0 glibc. Le cas SIGILL (V1c-6) est clos, fix align-fort prouvé en contexte.air-alloc= dépendancepathdestd(rustc-dep-of-std) ⇒ patch rust-src inchangé. - ✅
sys/fd+ minimalsys/fsréels (commitsd32fb79/f05907c, branche) :FileDesc(OwnedFd) →AirFileManager(read/write/read_at/write_at/seek/fsync/ftruncate/set_nonblocking) +duplicate→air-handle;File::open/create/read/write/seekminimal. Aller-retour fichierstdprouvé 2 arches (sondehello-fs: 16 Ko écrits+relus identiques + seek, exit 0 ; x86_64/carbon 10/10 + aarch64/raspi). Zéro additif couche 1, zérounsafedans le bras, zéro source couche 0/1, fermeture 1 fork. Frontière assumée (Vfd-3) :std::os::fdnon exposé (éviterait un portagenet/pipe/stdio-fd complet) — unificationair-handle(ADR-069) = chantier distinct. - ✅
sys/fsCOMPLÉTÉ (commitsc2879d5/9aa5290, branche) : métadonnées (statx→FileAttr/FileType/FilePermissions, temps),ReadDir/DirEntry(getdents64),unlink/rename/mkdir/rmdir/DirBuilder/remove_dir_all, liens (symlink/link/readlink/canonicalize/copy),set_permissions/set_times,exists;current_exe(readlinkat /proc/self/exe, brassys/paths/air.rs). Zéro additif couche 1, fermeture propre (fork=1, icu/crypto/regex=0). Prouvé 2 arches (sondehello-fs2:read_dirtrié + métadonnées + liens + rename + suppression +current_exe, exit 0 ; x86_64/carbon + aarch64/raspi — 1ʳᵉ panique aarch64 = artefact de renommage du binaire par le superviseur, re-run au bon nom = OK).std::fslargement fonctionnel. - ✅
sys/processréel (commits76a8dc1/f372a9c, branche) :std::process::Commandspawn/exec/wait/status/kill — bâti surAirProcess(pidfd :clone3+CLONE_PIDFD/execve/waitid/pidfd_send_signal, wait+kill en couche 1, passpawn_processnu qui exigerait unwaitidcouche 0).StdioInherit/Null/Fd;Piped/outputdifférés (avecstd::os::fd/pipe). Prouvé 2 arches (hello-proc:/bin/true→0,/bin/false→1, introuvable→127, exit 0 ; x86_64/carbon 20/20 + aarch64/raspi). Zéro additif couche 1, zéro FFI, zérounsafe, fermeture 1 fork (air-account→air-base-core, découplage tenu). - ✅
sys/netréel (commits54a53f6/ce6e8d7, branche) :std::netTCP + UDP — bâti surair_socket::engine::AirSocket(couche 1&self, pas les façades&mut self).TcpStreamconnect/read/write/peek/shutdown/nodelay/keepalive/nonblocking/addrs ;TcpListenerbind/accept (SO_REUSEADDR, backlog 128) ;UdpSocketsend_to/recv_from/peek_from + connect/send/recv ; adresses numériques (DNS différé). Prouvé 2 arches (hello-net: TCP + UDP loopback roundtrip, exit 0 ; x86_64/carbon 10/10 + aarch64/raspi). Zéro additif fonctionnel couche 1, fermeture 1 fork. - ✅
stdFONCTIONNELLEMENT COMPLET pour fichiers / threads / mpsc / process / réseau — le réseau était le dernier gros sous-système. Reste pour « std bouclé » :sys/random(dernier mur dur), + différés soft. - 🎉
sys/randomréel —stdFONCTIONNELLEMENT BOUCLÉ (commits82517f1/12b676e/6e336c0, branche) : isolation par micro-crateair-random(couche 1,no_std, surgetrandomcouche 0) —air-cryptola ré-exporte (surfacefill/generate_keyinchangée, régression hôte 0 : air-crypto 42/42, air-random 3/3). Gate ADR-090 vérifié superviseur : fermetureair-random={air-sys-syscall, air-sys-types, bitflags}, RustCrypto = 0 (build-std 33 crates : RustCrypto/icu4x/air-base-lib = 0, fork = 1).sys/random/air.rs(zérounsafe) →HashMap/RandomStateamorcés sur CSPRNG kernel. Prouvé 2 arches (hello-random: entropie réelle distincte +HashMap1000 entrées, exit 0 ; x86_64/carbon 5/5 + aarch64/raspi). ⇒ tous les murs durs destdfranchis. - ✅ Portage
stdMERGÉ surmain(#326,ce46a0b, 2026-07-11) : squash, CI verte (test-coverage root + supply-chain + build + loom + cross-aarch64), Verified GitHub. Couche 1 évoluée (découplageair-filesystem/air-socket→air-base-core, micro-crateair-random, featuresrustc-dep-of-std) mais NON re-scellée — le re-sceau viendra après les différés soft (décision BDFL : ne desceller qu’une fois). Forkrust-srclocalisé (rt/rust-src-air,vendor/gitignoré). - ⏭️ Séquence en cours (BDFL, branche
feat/std-osfd-pipe, 1 PR à la fin) : différés soft PUIS re-sceau. (a) ✅std::os::fd+sys/pipe(commit3689ebb) —AsRawFd/OwnedFd,io::pipe,Stdio::Piped/Command::outputdébloqués ; zéro additif couche 1 (viasys/pipe+AirStdio::Fd), fermeture 1 fork ; prouvé 2 arches (hello-pipe:io::pipe+ capture"hello-air\n"+os::fd, exit 0). (b) ✅ options socket (commit4105188) — additif couche 1air-socket(méthodes typées&self, +5 tests, zérounsafe) : ttl/linger/nodelay+keepalive getters/broadcast/multicast loop+ttl/only_v6/connect_timeout (ppoll+SO_ERROR)/duplicate ; prouvé 2 arches (hello-sockoptset==get, exit 0), fermeture 1 fork, régression hôte 0. Options à STRUCTURE — additif COUCHE 0 v1.13 FAIT (commits27195dd/1f89c98) :air-sys-types(ip_mreq/ipv6_mreq#[repr(C)],const assert!ABI) +air-sys-syscall(6 wrappers dédiés typés) + couche 1air-socket+ PAL ⇒read_timeout/write_timeout(+ read vide expiré) + multicast join/leave v4/v6, prouvés 2 arches. Plus aucununsupportedsur les options socket. ⚠️ Barrière : plancher lignes rouge (93.29%<94) = artefactllvm-covd’hôte (net.rs 64.53% au baseline = impossible pour couche 0 scellée ; branches +12/0-manque) → arbitre = CItest-coverageroot ; tagcouche-0-v1.13contingent à CI verte. (c) ✅ DNS FAIT (commits0ec7032/e18117c) — câblagelookup_hostsurAirNameResolverexistant (best_effort[hosts, DNS RFC 1035, localhost], IPv6-first) + additifair-socket(AirLocalhostSource, pontscore::net, zérounsafe) ; prouvé 2 arches (hello-dns: localhost + numérique + connect-par-nom, exit 0), fermeture 1 fork (résolveur maison,getaddrinfo0). ⇒ TOUS les différés soft traités.hostname()laisséunsupported(tireraitair-system— chantier distinct). Reste : PR unique + CI (arbitre couverture couche 0) + re-sceaux (couche-0-v1.13 + couche-1). - 📌 DIRECTIVE BDFL (2026-07-11, après consolidation) : rendre normatif le motif réseau sans-IO
(
docs/notes/reseau-architecture-crates-fr.md) via un ADR — toute impl réseau doit s’y conformer (seul motif testant la pile indépendamment). Précurseur :AirDnsSource. À faire avantair-async/ couche 2. Voir mémoire projet. - 🗺️ PHASE SUIVANTE = couche 2 (séquence BDFL) : runloop
air-async(réacteur io_uring↔Waker/ exécuteur/timers) → IPC Air → framework réseau Air →air-sshd(démonstration couche 2). En parallèle, à traiter : hygiène pré-merge branche PAL (Cargo.lockstubs façades →deny/audit/machete+ repro), merge de la branche PAL, re-sceau couche 1 (ADR-088 « resceller une fois »). Différés softstd(non bloquants pour un programme courant) :std::os::fd(AsRawFd/FromRawFd) +sys/pipe(io::pipe, chantier libc-fd, débloqueStdio::Piped/Command::output) ; options socket fines (timeouts/ttl/linger/broadcast/multicast) ; DNS (brancherAirNameResolversurlookup_host) ;available_parallelism/park-unpark/TLS dynamique. RFC amont (etude-rfc-trait-backend-std-fr.md). - 🗺️ Roadmap au-delà du portage
std(séquence BDFL 2026-07-11) : (1) bouclerstd(fs/process/net) ; (2) couche 2 — la runloop (=air-async, réacteur io_uring↔Waker/exécuteur/timers, pièce maîtresse identifiée dansarchitecture-prerequis-air-sshd-fr.md), puis l’IPC Air, puis le framework réseau Air ; (3)air-sshd(ADR-074) — composant réseau primordial et démonstration du fonctionnement de la couche 2. Puis hygiène pré-merge (Cargo.lockstubs façades →deny/audit/machete+ repro ADR-025), merge de la branche, re-sceau couche 1 (ADR-088 « desceller une fois, resceller une fois »). RFC amont Tier-3 + backendstd::syssafe (etude-rfc-trait-backend-std-fr.md) : la preuve downstream est acquise. - ⏭️ Étape 4 (conditionnée à la décision) — implémentation par sous-système (facile→dur, chacun bindant son Manager) :
exit/random/time/os_str/args/env→fd/stdio→futex+sync(les 3 wrappers débloquentMutex/Condvar/RwLock/Once/Parker) →fs→thread+thread_local→process→net. Manques couche 1 (§5 étude :current_exe,DirEntrytypé,park/unpark, options socket) comblés en additifsv1.xau fil de l’eau. - 🎯 Jalon de succès :
hello-stdre-tourne sur le PAL safe, 2 arches, sanslibair_cliée — preuve d’un userland Rust safe de bout en bout (unsafeconfiné couche 0). Puis re-sceau couche 1 unique + RFC amont (Tier-3*-linux-air, backendstd::syssafe).
Dettes/leçons associées : raw_syscall/GlobalAlloc-safe traités dans ce chantier (§5) ;
lane CI nightly hello-std/rt à monter ; leçon gravée jamais différer les runs raspi (un
cross-build aarch64 vert ne prouve rien au runtime). Cible produit après la toolchain safe :
air-sshd (ADR-074).
1. Où en est-on (vue d’ensemble)
| Couche | État | Détail |
|---|---|---|
Couche 0 (air-sys-types + air-sys-syscall) | 🔒 SCELLÉE couche-0-v1.11 (ADR-066 v1.9 signaux async + ADR-070/071 v1.10 sockaddr/sockopt libc, campagne réseau ; ADR-085 v1.11 le 2026-07-09 — additifs cumulés libc/std : chroot, kill_process_group→killpg, sigaltstack+AltStack, sendfile, fchmod/fchown/futimens, munmap_raw ; pause sans additif via ppoll ; ADR-087 v1.12 le 2026-07-10 — raw_syscall, escape hatch syscall générique nécessaire au pal std, EXCEPTION assumée temporaire à retirer avec l’équipe Rust, jalon M5) | Code complet (11 familles + io_uring 12 Temps + termios/tty). N’évolue plus que par descellement additif (RFC, ADR-015/ADR-051). Additif v1.9 (ADR-066) : sous-module async_handler (rt_sigaction non-faute = délivrance async réelle, install/restore) + trampoline rt_sigreturn x86_64 (global_asm) + rt_sigpending ; les 4 fautes restent inertes (ADR-064). Tag posé par le superviseur après barrière x86 + CI aarch64. Couverture 100 % hors exceptions documentées, 2 arches. |
| Couche 1 (briques Rust : libc + PAL) | 🔒 SCELLÉE couche-1-v1.0 → re-scellée jusqu’à couche-1-v1.9 (v1.1 errno_location ADR-065 ; v1.2 signaux/futex ADR-066 ; v1.3 air-account ADR-067 ; v1.4/v1.5 credentials+drop_to_user ADR-068 ; v1.6 campagne réseau air-handle+moteur socket ADR-069 ; v1.7 le 2026-07-09 — Managers de domaine (ADR-077) : AirFileManager/AirProcessManager/AirEnvironmentManager/AirMemoryManager/AirSystemManager/AirNetworkManager + nouveau AirSignalManager (set_alt_stack/pause), surface médiatrice objet consommée par la libc, chantier B ; v1.8 le 2026-07-09 — air-process::spawn_process (fork+file-actions+exec async-signal-safe) + types SpawnFileAction/SpawnAttributes, socle de la face libc posix_spawn, chantier B ; v1.9 le 2026-07-10 — introspection thread cible (ADR-086) : air-runtime::process_context [auxv + bornes de pile] + AirProcessManager::resource_limit, socle de getauxval/pthread_getattr_np de la libc, jalon M5 ; earmark ADR-078 crypto air-tls → couche-1-v1.10) | API Rust publique figée (17 crates, voir ADR-062). Évolue par descellement additif v1.x (RFC ; v1.1 = AirRuntime::errno_location, ADR-065 ; v1.2 = air-signal::install_handler/restore_disposition/pending — brique d’install de handler non-faute dual-face — + air-thread::raw_futex — primitives futex(2) typées sur mémoire externe, ADR-066). v1.3 = crate air-account (codecs comptes /etc/passwd+/etc/group, brique bindée par la libc, ADR-067). Tag superviseur après validation couche 0. Les 4 P0 + remodel env/args pré-sceau. |
| Toit libc C-ABI (scopée OpenSSH) | 🔨 En production (air-libc-capi, roadmap roadmap-libc-openssh-fr.md) | M0/M1-a/M1-b/M1-c/M1-d faits (crt+errno ; str*/mem* ; malloc+moteur format fuzzé ; FILE*+exit/atexit/getenv ; frontière variadique printf). M2 implémenté (à valider superviseur) : I/O fichier fd brut + métadonnées (struct stat ABI Air) + répertoires (DIR opaque) + mkstemp/realpath/getcwd — crate air-libc-fileio (logique mesurée) + shims air-libc-capi::fileio + shim C variadique fileio_shim.c (open/openat/fcntl). Archi libc : logique mesurée / shims C-ABI exclus — 6 crates de logique (air-libc-{fmt,alloc,stdio,stdlib,printf,fileio}) + shims air-libc-capi (cf. docs/libc-conformance.md). M3 Phase A faite (fondations couches 0/1, ADR-066 : install async non-faute + raw_futex). M3 Phase B VALIDÉE x86 (barrière verte : lignes 96.4 %, branches 82.9 %) : familles signaux (sigaction/signal/kill/raise/sigprocmask/sigsuspend/sigset* — crate air-libc-signal, 4 fautes INERTES ADR-064) et pthread (mutex/cond/rwlock/once/key/create/join/… sur air-thread::raw_futex — crate air-libc-thread, cycle de vie/TLS hôte). Types opaques ABI Air (mutex 40/cond 48/rwlock 56, sigset_t 128) + trailer cbindgen hand-written. Preuve d’exécution C (air-libc-c/tests/m3/) : mutex end-to-end + sigaction/raise handler async + déréf NULL → SIGSEGV coredump inerte. pthread_create NON testable via C -lair_c sur l’hôte (récursion d’interposition std::thread→pthread_create ; sans glibc/on-target = OK) — couvert par tests unitaires air-libc-thread. Couverture : air-libc-c (artefact cdylib pur) EXCLU des runs llvm-cov (barrier + CI) — son test --lib force-lierait les shims pthread/TLS surchargeant la glibc (SIGSEGV) ; air-libc-capi modules signal/thread gatés #[cfg(not(test))]. Déviations D-M3.1..D-M3.9 ; additifs remontés (pthread_exit propre, dtor TLS cible, rt_sigsuspend atomique). M4.2 Temps VALIDÉE x86 : clock_gettime (REALTIME+MONOTONIC), gettimeofday, time — crate air-libc-time (logique mesurée, sur air-base-core couche 1, ZÉRO additif : monotone brut reconstruit via AirInstant::duration_since(zéro)). Types timeval/time_t/clockid_t ; preuve d’exécution C (tests/m4/time.c). Additifs remontés M4 : nanosleep (sommeil non-retry couche 1 — air-base-core::sleep retry-EINTR ≠ POSIX), clock_getres, strftime/localtime_r (tz icu4x). M4 Comptes VALIDÉE x86 : getpwnam_r/getpwuid_r (réentrants) — crate air-libc-pwd (parseur /etc/passwd mesuré + empaquetage borné anti-overflow, lecture via air-filesystem::read_to_bytes couche 1) ; struct passwd/uid_t/gid_t ; convention *_r (errno rendu direct, ERANGE/absent) ; preuve C (tests/m4/pwd.c contre le vrai /etc/passwd). Refactor ADR-067 : air-libc-pwd re-câblé en pur binder d’air-account (parseur retiré, ABI inchangée) ; groupes getgrnam_r/getgrgid_r (crate air-libc-grp, packing gr_mem aligné/borné). Non-réentrants + shadow = tranches suivantes. shadow (getspnam_r, codec durci ShadowHash zeroïsé) et identifiants (ADR-068 : air-process::credentials + binders getuid/setuid/setresuid/setgroups/initgroups, sémantique POSIX setuid ratifiée) faits. A.6 air-process::drop_to_user VALIDÉE x86+aarch64 (boucle A close 6/6) : résolveur AirPrivilegeDrop::for_user (résout un nom via air-account → descripteur de largage enrichissable : uid/gid primaires + groupes supplémentaires réels, sémantique initgroups) + convenance drop_to_user (résout puis drop_privileges) ; ADR-068 ; PAS de binder libc (OpenSSH largue via les syscalls individuels déjà exposés) ; re-sceau additif couche-1-v1.5. |
| Couches 2+ | ⏳ Non commencées | Toits (libc C-ABI, PAL std::sys) construits au-dessus de la couche 1 scellée. |
Couche 1 scellée le 2026-07-03 (tag couche-1-v1.0, ADR-062). Voir §4 pour les jalons.
2. Sceau couche-1 : FAIT — et file des tâches à venir
Décision de gouvernance BDFL : combler les P0 des audits, remodeler ce qui l’exigeait, PUIS sceller. Tout est fait :
| # | Tâche | Statut |
|---|---|---|
| P0.1 | AirFile — handle I/O fichier générique (air-filesystem) | ✅ Mergé (#214) |
| P0.2 | air-poll — multiplexeur ppoll générique (crate dédiée) | ✅ Mergé (#215) |
| P0.3 | termios/PTY/job-control re-exposé (air-terminal) | ✅ Mergé (#216) |
| P0.4 | air-signal — face signal générique, 100 % safe (ADR-064) | ✅ Mergé (#217) |
| Remodel | os_str — AirOsStr/AirOsString, env/args figés (sceau-readiness) | ✅ Mergé (#218) |
| §5.4 | Sceau couche-1-v1.0 — ADR-062 + tag git | ✅ SCELLÉ 2026-07-03 |
Prochaine étape (voir §0 Actualité en tête) : air-sshd — le serveur SSH d’Air full Rust (ADR-074). Le chantier B du PAL est clos : std lie et tourne sur la libc d’Air (M5 atteint, hello-std 2 arches). Toits libc C-ABI et PAL prouvés ; la toolchain Rust full-Air est acquise et sert désormais de socle à la production air-sshd.
Additifs v1.x (descellement additif, non bloquants) : spawn ergonomique §2.B.2,
Condvar/Once, set_var/mutation d’env, destructeurs TLS, getsockopt/setsockopt,
mman fichier-adossé, stat/fstat public, mkstemp, barrière de poison + fault_state()
(ADR-064 §7), gap aarch64 crypto (#14), instrumentation IO debug (ADR-063), regex
(§4-I), AirRuntime::errno_location() -> *mut i32 (socle de __errno_location() de la
libc — premier additif révélé par le toit libc M0, roadmap §6 ; RFC de descellement
additif requis), M3 Phase A — signaux+pthread (ADR-066) : install de handler async
réel pour les signaux non-faute (air-signal::install_handler/restore_disposition,
unsafe localisé) + pending(), et primitives futex(2) typées sur mémoire externe
(air-thread::raw_futex) pour bâtir pthread_mutex_t/cond_t de la libc et std::sys du
PAL ; fondation posée en couches 0 (couche-0-v1.9) et 1 (couche-1-v1.2), rien en
couche 2. (La face sigaction-de-faute n’est PAS un additif — les 4 fautes restent
inertes, ADR-064 §2 ; seul l’install de handler non-faute est descellé, ADR-066.)
Cette liste se complète au fil des points identifiés.
3. Méthode de production (rappel pour agents)
- Doctrine de couche : seule la couche 1 consomme la couche 0 ; les tiers couche 1
sont fidèles (un appel = un syscall), EINTR remonté (jamais de retry auto),
zéro
unsafeexposé, erreursAirError/AirResult, arithmétique défensive (jamaisaslossy), nommage ADR-029 (zéro abréviation). - Qualité, avant tout merge :
cargo fmt/clippy -D/test,cargo xtask barrier(WARN = vert ; 5 advisoryunsafepré-existantssecurity.rs/inotify.rsignorés), couverture 100 % lignes (exceptions STRUCTURAL = marqueur in-code// COVERAGE (STRUCTURAL, ADR-035), commeair-process/air-poll), fuzz sur toute surface de parsing d’octets externes (sinon « Note fuzz » justifiée dans la spec). - Git : trunk-based, tout par PR sauf doc pure → commit direct sur
main(pas de CI pour de la doc). DCO-s+ signature GPG (clé BDFL), jamais de trailerCo-Authored-By. Production déléguée àclaude -pheadless (carbon/speedy) ; le superviseur pilote validation + push + PR + merge (squash) + re-vérif main/trailer. - Nouvelle crate ⇒ committer
Cargo.lock(reproductibilité ADR-025).
4. Jalons réalisés (résumé — détail dans JOURNAL.md)
- Couche 0 — scellée
couche-0-v1(2026-06-14) puis re-scellée jusqu’àcouche-0-v1.8(2026-07-02, familleterminal/termios-tty, ADR-060), puis les descellements additifs de la campagne libc/std : v1.9 (signaux async, ADR-066), v1.10 (sockaddr/sockopt libc, ADR-070/071), v1.11 (additifs cumulés libc/std —chroot/killpg/sigaltstack/sendfile/fchmod/fchown/futimens/munmap_raw, ADR-085) et v1.12 le 2026-07-10 (raw_syscall, escape hatch syscall générique, exception temporaire à retirer, ADR-087, jalon M5). Code complet, plus d’évolution hors RFC (descellement additif). - Couche 1 — cœur livré :
air-base-lib(cœur + services),air-crypto,air-socket,air-filesystem,air-memory,air-device,air-thread,air-process,air-config,air-stdio,air-runtime, temps, i18n no_std (icu4x, ADR-059), futex maison, loom (ADR-058). - Couche 1 — SCELLÉE
couche-1-v1.0(2026-07-03, ADR-062) : campagne close. Audits pré-sceau face-libc (#212) / face-PAL (#213) ; les 4 P0 — AirFile (#214), air-poll (#215), termios/PTY (#216), air-signal (#217) ; remodel os_str env/args pré-sceau (#218) ; doctrines gravées ADR-063 (instrumentation IO) + ADR-064 (signaux, dont §7 barrière de poison). Re-scellée additivement jusqu’àcouche-1-v1.9(v1.1 errno_location ADR-065 ; v1.2 signaux/futex ADR-066 ; v1.3 air-account ADR-067 ; v1.4/v1.5 credentials + drop_to_user ADR-068 ; v1.6 air-handle + moteur socket ADR-069 ; v1.7 Managers de domaine ADR-077 ; v1.8spawn_processsocle posix_spawn ; v1.9 introspection thread cible ADR-086, jalon M5). Earmark ADR-078 cryptoair-tls→ v1.10. - 🎉 Jalon M5 —
stdtourne sur Air, zéro glibc (2026-07-10). Un programme Ruststd(hello-std, static-PIE) lié intégralement contrelibair(la libc d’Air, sans glibc) s’exécute on-target sur les 2 arches (hello from std on air+ exit 0 sur x86_64-air/carbon ET aarch64-air/raspi). Le lien passe de 12 → 0 symboles indéfinis (5 PR #308–#314 :__xpg_strerror_r,open/fcntl, introspection thread [ADR-086],getauxval/pthread_self/détection-pile, TLS keys,syscallgénérique [ADR-087]). Le pari ADR-076 (stdsur la libc d’Air) est tenu de bout en bout : toolchain Rust full-Air prouvée. Re-sceauxcouche-0-v1.12+couche-1-v1.9.
5. Dettes connues (à planifier, non bloquantes pour le sceau)
- 🔧 Escape hatch
syscall/raw_syscall(ADR-087) — à RETIRER avec l’équipe Rust. Le palstdde*-linux-airappelle quelques syscalls (gettid,futex…) via la fonction C génériquesyscall, ce qui a imposé unraw_syscallen couche 0 (entorse assumée à ADR-021 conv. 3 + à la règle « libc bind couche 1 »). Dette explicite : travailler avec l’équipe Rust pour que le palstdsur Air se passe dusyscallgénérique (chaque op via son wrapper typé), puis retirerraw_syscallde la couche 0. - 🔧
GlobalAllocsafe (air-alloc) — à RENDRE safe avec l’équipelibsRust. Doctrine fondatrice : leunsafedoit vivre en couche 0 uniquement (barrière kernel) ; l’impl deGlobalAllocparair-alloc(couche 1) resteunsafepar le trait. But = supprimer ceunsafedans le cas d’Air (action item amont teamslibsRust), même registre queraw_syscall. Voir la doctrineunsafe(macro-architecture, Section 1/2/8). - Lane CI nightly
hello-std/rtà monter : le harnais M5 (rt/, hors-arbre) n’est pas encore construit/exécuté en CI. Leçon gravée : jamais différer les runs raspi. - Gates de couverture couche 1 :
cargo xtask couvrable-vide/audit-exceptionsne réconcilient mécaniquement quecrates/air-sys-*(couche 0). Les crates couche 1 documentent leurs exceptions STRUCTURAL in-code. Étendre les gates à la couche 1 bute sur une collision de basename (time.rscouche 0 ET couche 1) → décision de design de gate (RFC/tâche séparée). - mdBook non pinné — à figer pour la reproductibilité (ADR-025).
air-doc-publish.shvit hors dépôt (speedy) — à éventuellement versionner soustools/.- Instrumentation IO (ADR-063) : décidée, à implémenter en additif
v1.x. - Flake préexistant
air-libc-stdio(révélé souscargo test --workspaceen forte charge, taux faible) : des tests inspectanterrnoaprès échec (fputs_null_and_write_on_readonly,close_reports_backend_error) échouent par intermittence — course probable sur l’état global (registreFILE) entre tests parallèles, bien qu’errnosoitthread_local. Hors périmètre M3 (crate non modifiée). À corriger séparément (sérialiser ces tests).
Ce document se met à jour à la main au fil des merges. Pour l’historique fin
(session par session, PR par PR), voir JOURNAL.md.
Roadmap — clôture de la couche 1 pour la libc C et le PAL Rust
✅ PLAN ACCOMPLI (2026-07-10) — ce document est désormais HISTORIQUE. La couche 1 a été scellée
couche-1-v1.0le 2026-07-03 (ADR-062) puis re-scellée additivement jusqu’àcouche-1-v1.9(v1.1→v1.9 : errno_location, signaux/futex, air-account, credentials/drop_to_user, air-handle+moteur socket, Managers de domaine, spawn_process, introspection thread cible). Ses deux toits sont prouvés : la libc C-ABI (~256 symboles) et le PAL Rust —stdlie et tourne sur la libc d’Air, zéro glibc, 2 arches (jalon M5 atteint). Ce document décrit le plan d’origine ; le statut vivant est dansetat-avancement.md, et la prochaine phase =air-sshd(ADR-074). Le tableau de bord §8 ci-dessous reflète l’état au moment du plan, pas l’état courant.
Plan détaillé §-numéroté (les specs/audits y renvoient via « §5.3 P0.x »). Le statut vivant (ce qui est fait/en cours/à venir) est tenu dans le document de suivi
etat-avancement.md, pas ici — ce document décrit le plan, le tableau de bord §8 en donne l’ossature.
But. Faire une passe de production complète de la couche 1 avant de la sceller (
couche-1-v1.0), plutôt que sceller/desceller au fil de l’eau comme on l’a subi sur la couche 0. On n’éliminera pas tout descellement futur — on transforme les descellements structurels (« il manque une brique entière ») en descellements additifs mineurs (« il manque une fonction dans une brique existante »).Principe directeur. La couche 1 a deux toits qui s’y assoient — la libc C-ABI (ADR-046/047/053) et le PAL Rust (la
stdcible*-linux-air, ADR-049/052). Seule la couche 1 consomme la couche 0 (ADR-052) : les deux toits sont au-dessus, clients, jamais dedans. Chaque brique de cette roadmap doit répondre à « quelle fonction libc et/ou quelle primitive PAL la réclame ? » — sinon elle attend. La complétude est pilotée par la demande des deux consommateurs, pas par une exhaustivité abstraite.Sources. Inventaire couche 1 (crates + specs
docs/specs/layer-1/),docs/notes/audits/RAPPORT-CONSOLIDE-libc-fr.md(41 familles musl), ADR-046/047/053 (libc),docs/notes/audit-icu4x-rust-pur-fr.md(i18n), ADR-049/052/055/056 (PAL/runtime). Statut de départ :main = 2689d69.
Légende
- ☐ à faire · ◐ partiel/en cours · ☑ fait
- Criticité : 🔴 bi-consommateur (libc ET PAL — rigueur maximale, dette figée si erreur) · 🟠 mono-libc · 🟡 mono-PAL · ⚪ interne/packaging
- Colonnes Libc / Pal : ● consommateur direct · ○ indirect · — non concerné
1. Ce qui est DÉJÀ en place (socle acquis)
Ne pas refaire ; à auditer face-libc/face-PAL avant le sceau (§5).
| Brique | Rôle | L | P | no_std | Statut |
|---|---|---|---|---|---|
air-base-core | erreurs, ids, chemins-octets, temps monotone, encodage, log (ADR-054) | ● | ● | oui | ☑ |
air-crypto | hash, AEAD, signatures, AirRandom (CSPRNG getrandom) | ● | ● | non¹ | ☑ |
air-socket | TCP/UDP/Unix + résolveur DNS maison | ● | ● | oui | ☑ |
air-filesystem | canonicalize, écriture atomique, watcher, glob | ● | ● | oui | ◐ (find_regex différé ; prendra une dép sur air-stdio, §2.D.2) |
air-memory | arène/pool/slab + comptabilité | ● | ○ | oui | ◐ (backing mmap différé) |
air-device | énumération/surveillance (uevent/evdev) | ○ | ○ | oui | ☑ |
air-process | clone3/pidfd/execve/signaux/drop_privileges | ● | ● | oui | ☑ |
air-env | environnement process (octets), zéro-dép (ADR-055) | ● | ● | oui | ☑ |
air-alloc | allocateur global malloc↔GlobalAlloc (ADR-056) | ● | ● | oui | ☑ |
air-libm | math flottante no_std (façade libm, ADR-057) | ● | ○ | oui | ☑ |
air-config* | compilateur config + backends /etc (passwd/shadow/hosts/resolv/fstab) | ● | ○ | oui | ☑ |
air-runtime | TCB/TLS/reloc/crt0/fork/spawn (ADR-052) — prouvé on-target 2 arches | ● | ● | oui | ◐ (async runtime dans la passe, §4-K) |
air-base-capi | frontière ABI C (libair-base.so) | ● | — | n/a | ◐ (T1 fait ; T2/T3 §4-J) |
¹ air-crypto/air-base-lib tirent encore des deps qui bloquent le no_std strict → visés par §2.
Bi-consommateurs déjà acquis (🔴 mais faits) : air-alloc, air-env,
air-crypto::AirRandom, air-process, air-socket, air-runtime (TCB/TLS).
Ils restent sacrés : toute évolution passe par RFC une fois scellés.
2. Chantiers RESTANTS — bi-consommateurs 🔴 (rigueur maximale, chemin critique)
Ce sont les briques qui portent le plus de risque : une erreur d’API casse les deux toits, et la garantie ABI libc (10 ans, Principe 8) fige la dette.
2.A — i18n : Air utilise icu4x (Option V, ADR-059) 🔴 · L● P●
Sert la libc (ctype, locale, collation, strcoll, normalisation,
segmentation, calendriers) et le PAL/str (to_uppercase, normalisation,
comparaison Unicode). Brique sensible — mais plus le long pole.
DÉCISION BDFL (ADR-059, data-backée). Vérification empirique faite : la fermeture icu4x compile pour
*-linux-air(--release+default-features = false; LSTM inclus,libmOK) — la réécriture du socle n’est pas nécessaire. Air utilise icu4x (extension exception ADR-016), possède l’APIair-stringdual-face + le pipeline de données (à terme). ⇒ le §2.A se réduit à un chantier no_std, pas une réimplémentation.
- ☑ 2.A.1
air-base-lib→#![no_std]— dernière crate cœur non-no_std.#![cfg_attr(not(test), no_std)]+extern crate alloc(posé #152, ADR-048 ; confirmé ici, fermeture icu prouvée on-target). Débloque la fermeture couche 1 complète (i18n comprise) pour la cible. - ☑ 2.A.2 deps
icu_*d’air-base-lib→default-features = false(coupe transitivement la featurestd; 6 lignes du[workspace.dependencies]racine). Fermeture (air-base-lib+ icu + socle) prouvée compilable on-targetx86_64/aarch64-unknown-linux-airen release (build-std=core,alloc) — le repli logging debug d’icu_providerest inactif en release. - ☐ 2.A.3
air-stringdual-face : ajouter la face C-ABI (libc) au-dessus de l’AirStringexistant (le moteur existe déjà via icu4x). - ☐ 2.A.4 Re-sonder la fermeture couche 1 complète pour
*-linux-air(post-no_std air-base-lib) — preuve on-target. - ☐ 2.A.5 (optionnel/différé) pipeline datagen Air (host, réutilise le datagen icu4x sur sources CLDR/UCD) si Air veut maîtriser ses propres tables —
compiled_datasuffit pour démarrer. Abandonné (ADR-059): vendoring des 6 utils, port du socle, port decalendrical_calculations— inutiles, icu4x compile tel quel.air-libm(☑) reste surface math Air publique mais n’est pas requis par icu (icu tirelibmviacore_maths).- Segmentation LSTM incluse (décision BDFL) : fournie par
icu_segmenterfeatureauto— compile déjà pour la cible (prouvé).
2.B — air-thread en no_std + futex maison 🔴 · L● P●
Sert pthread_mutex/rwlock/cond (libc) et std::sync + std::thread (PAL).
C’est aussi la zone non-déterministe discutée pour l’instrumentation (loom).
- ☑ 2.B.1
MigrerDÉJÀ FAIT : la migration a eu lieu de facto au passageAirMutex/AirRwLock/AirSemaphoredu backendstd::sync→ futex maison#![no_std]d’air-thread(#182, ADR-055) —sync.rsmontre les 3 primitives surair_sys_syscall::futex+AtomicU32, « aucune dépendance àstd::sync». (L’inventaire INDEX/etat-avancement était périmé sur ce point.) - ☐ 2.B.2 Spawn Air-natif : closure boxée via
air-alloc(☑) — retirer lestd::threadconditionnel (cfg(not(target_env="air"))). - ☐ 2.B.3 Couvrir les branches de contention de façon déterministe — voir §3 (loom) : à cadrer avant le sceau, car sceller fige la surface.
2.C — Temps (monotone + horloge murale) 🔴 · L● P●
clock_gettime/time/nanosleep/gmtime (libc) ↔ Instant/SystemTime (PAL).
- ◐ 2.C.1 Confirmer que
AirInstant/AirSystemTime(base-lib core) couvrent monotone + mur +nanosleep,no_std-propres, et exposables aux deux faces. (Le calendaire au-dessus dépend de 2.A.3.)
2.D — air-stdio : brique stdio/console bas-niveau 🔴 · L● P●
FILE* (libc) ↔ std::io::{Stdin,Stdout,Stderr} (PAL). Le niveau brut
(handles fd 0/1/2 + isatty + verrou) est partagé ; le moteur FILE*
bufferisé et printf/scanf restent libc (§4). Aujourd’hui non spécifié côté
PAL (rapport PAL : « stdio bas-niveau MANQUE »).
- ☐ 2.D.1 Créer
air-stdio(brique dédiée, décision BDFL) : handles standard partagés (lecture/écriture brutes,isatty, verrou par flux, sécuritéatfork),no_std, consommée par les deux toits. - ☐ 2.D.2
air-filesystemprend une dépendance surair-stdio(décision BDFL) — les E/S de plus haut niveau s’appuient sur les handles standard.
2.E — Finitions des briques acquises 🔴/🟠
- ☐ 2.E.1
air-memory: backing mmap (différé) — piles/TLS des threads spawn. - ◐ 2.E.2
air-crypto: rendreno_std-strict (auditer les deps qui bloquent). FAIT :default-features = falsesursha2/sha3/aes-gcm/chacha20poly1305/argon2/ed25519-dalek(coupe la fuitestdtransitive ; features réactivées = strict nécessaire, aléa toujours viaAirRandomcouche 0). Preuve on-targetx86_64-unknown-linux-air(release,build-std=core,alloc) : fermeture crypto entièreno_std, zérolibc/std. Reste (chantier dédié) : suraarch64,cpufeatures(dép non-optionnelle, détection ARMv8) lielibc(getauxval) qui n’a pas de profilenv=air→ cheminlibc-freeair_auxval(AT_HWCAP)à fournir. Détail :crates/air-crypto/DEPENDENCIES.md.
3. Étude PRÉ-SCEAU — instrumentation déterministe (à trancher avant §5)
Non une brique livrée : un outillage test-only à cadrer avant le sceau (le sceau fige la surface, autant avoir les seams dans la bonne forme). Détail en mémoire projet ; motivé par la variance de couverture (plancher 96→94, #189) et par la testabilité de contrats libc.
- ☑ 3.1 loom — déterminisme de concurrence. FAIT (ADR-058) : gouvernance gravée + harnais (alias d’atomique
core/loom+ shim futexyield/no-op). Tous les verrous futex de couche 1 modélisés :air-threadAirMutex/AirRwLock/AirSemaphore(5 modèles) + leMutexd’air-alloc+ leStreamLock(verrou par flux) d’air-stdio— 7 modèles verts, exécutés en CI par un jobloomdédié (--cfg loom,--locked). Plancher couverture lignes remonté 94→96 sur mesure empirique (5 passes : 96.65–96.66 %, variance 0.01 % ⇒ #189 annulé, filet resserré). Cargo.lock complété (arêtesloom, repro ADR-025). Garde-fou : loom = sûreté ; vivacité validée on-target. - ☐ 3.2
air-fault(nom proposé ; pasair-debug, réservé auLIB_AIR_DEBUGruntime) — injection de fautes syscall (EINTR,EFAULT,mmap/mallocéchoue au N-ième). Rend testables des contrats aujourd’hui quasi-intestables : ADR-021 « EINTR remonté à l’appelant », vision « malloc échoue plutôt que hang ». Seamcfg-gated à la frontière L1→L0, zéro coût prod. - Garde-fou non négociable : le selftest on-target (
rt/, 2 arches) reste juge de la réalité. L’instrumentation monte le % de couverture ; elle ne remplace jamais la preuve on-target (piège du mock — fatal pour un projet « fait ce qu’il dit »).
4. Chantiers RESTANTS — mono-toit (importants, moins « sacrés »)
Rigueur normale couche 1 (100 % couverture), mais un seul consommateur → une erreur ne casse qu’un toit.
| # | Brique | Crit. | L | P | Contenu | Statut |
|---|---|---|---|---|---|---|
| 4-F | termios (L0, ADR-060) + air-terminal (L1, ADR-061) | 🟠 | ● | — | Descellement couche 0 famille termios/tty (attributs/contrôle-ligne/winsize/PTY/session) puis objet couche 1 air-terminal = tag + codec + modèle canonique (1ᵉʳ codec ansi/termios). Branche feat/termios-couche0 (type Termios fait). air-termdb/termcap RETIRÉ du périmètre libc = ncurses/couche 2 (xterm-only, différé). | ◐ |
| 4-G | comptes passwd/grp/shadow | 🟠 | ● | — | itérateurs owned (pas de buffer static), lckpwdf réel, atfork. S’appuie sur les backends /etc d’air-config (☑) → API comptes au-dessus. | ◐ |
| 4-H | îlot asm setjmp/longjmp | 🟠 | ● | — | inexprimable en Rust sain (saute les Drop) → surface FFI unsafe C-ABI seule, asm x86_64 + aarch64. À éviter si OpenSSH s’en passe. | ☐ |
| 4-I | moteur regex | 🟠 | ● | ○ | POSIX basic/extended. Différé post-sceau (décision BDFL/reco) : ce sera une crate additive neuve → son ajout ultérieur ne descelle rien de l’API figée. Ne bloque que air-filesystem::find_regex + libc regcomp (non-critiques). Règle 80 % (ADR-024) interdit de vendorer regex tel quel → impl. minimale possédée le moment venu. | ☐ (post-sceau) |
| 4-J | ABI C — air-base-capi T2/T3 + libair-base.so | ⚪ | ● | — | T2 (AirLog), T3 (temps POD), assemblage cdylib, liste des symboles exportés + versioning, header air_base.h. Au-dessus du sceau couche 1 (packaging de la face C). | ◐ |
| 4-K | air-runtime async (io_uring) | ⚪ | ○ | ○ | Exécuteur air-runtime-{core,io,time,signal,sync} (spec v1.0 validée). Consommateur = applis couche 2+, pas la libc/PAL (sync) — mais inclus dans la passe et le sceau (décision BDFL : sceller toute la couche 1 en une fois). Voir bloc ci-dessous. | ☐ |
4-K (détail) — air-runtime async io_uring, dans la passe
Spec docs/specs/layer-1/air-runtime.md (v1.0 validée BDFL), non implémentée.
Objet couche 1 : consomme air-sys-syscall (io_uring) directement (il EST couche 1).
- ☐ 4-K.1
air-runtime-core: exécuteur mono-thread,Task/spawn/block_on, réacteur. - ☐ 4-K.2
air-runtime-io: futures sur complétions,AirIoBuf(modèle buffers possédés, décision centrale de la spec), enregistrement fd. - ☐ 4-K.3
air-runtime-time:sleep/timeout/intervalle. - ☐ 4-K.4
air-runtime-signal: signaux async viasignalfd(ADR-020). - ☐ 4-K.5
air-runtime-sync:AirAsyncMutex,AirNotify, channel async. - ☐ 4-K.6 Mode multi-cœur : thread-per-core via
msg_ring(peut suivre env1.xadditif si besoin).
Note de cohérence. L’async ne sert pas d’assise à la libc/PAL, mais le BDFL a tranché de le produire et le sceller dans la même passe que le reste — le sceau
couche-1-v1.0couvre toute la couche 1, pas seulement le sous-ensemble face-libc/face-PAL.
5. Porte de SCEAU — couche-1-v1.0
Après §2 (et le mono-toit §4 requis par le périmètre v1), avant de construire la libc et le PAL au-dessus :
- ☐ 5.1 Audit face-libc : rejouer les 41 familles musl contre l’inventaire couche 1 — chaque fonction du périmètre v1 (ADR-046 : ~25-30 familles, cible OpenSSH) a-t-elle sa brique ? (completeness critic)
- ✅ 5.2 Audit face-PAL (fait,
docs/notes/audit-face-pal-cloture-fr.md) : ZÉRO gap fondateur ; les manques (Condvar/Once/dtors TLS/env-mutation/os_str) livrés en chantier A du PAL. Toitstden cours (chantier B, option A, ADR-076) :stdcompile sur la libc d’Air. - ☐ 5.3 Combler les manques additifs remontés par 5.1/5.2. (L’async §4-K est inclus dans la surface scellée mais n’a pas d’audit « face-toit » — ses consommateurs sont couche 2+.)
- ☐ 5.4 Sceller : figer l’API Rust couche 1 en
couche-1-v1.0(RFC). ⚠️ Sceau couche 1 = API Rust ; la C-ABI vit au-dessus (crate libc/air-base-capi) avec son propre régime de stabilité (zones air-stable). Sceau couche 1 ≠ figer les signatures C. - Modèle de re-sceau (précédent couche 0, ADR-051) : les manques futurs se traitent en descellement additif
v1.x(jalons antérieurs conservés), pas en refonte. On accepte qu’il y en aura — moins souvent que sur la couche 0.
6. Hors périmètre de ce sceau (à ne pas confondre)
- libc C-ABI (ADR-046/047) et PAL
std::sys(ADR-049 phase 3) : ce sont les toits, construits sur la couche 1 scellée — hors de cette roadmap (qui prépare leur assise). - (NB : l’
air-runtimeasync, un temps envisagé hors passe, est finalement inclus dans le sceau — cf. §4-K, décision BDFL.) - Familles libc différées post-v1 (ADR-046 D7) :
aio,ipcSysV,mq,ldso/dlopen,wordexp/glob(rejetés — RCE/injection).
7. Ordre d’exécution proposé (topologique)
- §3.1 loom (parallèle, faible risque) — stabilise la couverture avant tout figeage.
- §2.B
air-threadno_std + futex — brique de synchro fondatrice ; débloque le spawn Air-natif (adosseair-alloc☑). - §2.C temps + §2.D
air-stdio+ §2.E finitions — petits, parallélisables. - §2.A i18n Rust-pur — le long pole : 2.A.1 → 2.A.2 → 2.A.3/2.A.4 → 2.A.5 → 2.A.6 (LSTM). Peut avancer en parallèle des petits chantiers.
- §4-K
air-runtimeasync — track parallèle indépendant (ne dépend ni de la libc ni du PAL) ; peut démarrer tôt et avancer en fond. - §4 mono-toit (F/G/H) — une fois le cœur stable ; parallélisables. 4-I regex différé post-sceau (crate additive).
- §5 audits face-libc/face-PAL (completeness critic) → sceau
couche-1-v1.0(couvre toute la couche 1, async inclus). - Au-dessus du sceau : §4-J (ABI C), puis libc C-ABI et PAL
std::sys; puis §4-I regex (additif).
8. Tableau de bord (à cocher au fil des PR)
| Chantier | Crit. | Statut |
|---|---|---|
| 2.A.1 air-base-lib → no_std (Option V, ADR-059) | 🔴 | ☑ |
| 2.A.2 deps icu_* → default-features=false | 🔴 | ☑ |
| 2.A.3 air-string face C-ABI (libc) | 🔴 | ☐ |
| 2.A.4 re-sonde fermeture couche 1 on-target | 🔴 | ☐ |
| 2.A.5 datagen Air (optionnel/différé) | 🟡 | ☐ |
| — | ✗ abandonné (ADR-059) | |
| 2.B.1 futex maison air-thread | 🔴 | ☑ (via #182) |
| 2.B.2 spawn Air-natif | 🔴 | ☐ |
| 2.B.3 couverture contention déterministe | 🔴 | ☐ |
| 2.C.1 temps monotone/mur/nanosleep | 🔴 | ◐ |
2.D.1 air-stdio (brique bas-niveau) | 🔴 | ☐ |
| 2.D.2 air-filesystem → dép air-stdio | 🔴 | ☐ |
| 2.E.1 air-memory backing mmap | 🟠 | ☐ |
| 2.E.2 air-crypto no_std strict | 🔴 | ◐ (deps df=false ; x86_64 on-target ☑ ; aarch64 = cpufeatures→libc env=air, chantier dédié) |
| 3.1 loom (déterminisme concurrence) | — | ☑ (air-thread ×3 + air-alloc) |
| 3.2 air-fault (injection fautes) | — | ☐ |
4-F termios L0 (ADR-060, couche-0-v1.8) ☑ · air-terminal L1 + air-termdb | 🟠 | ◐ |
| 4-G comptes passwd/grp/shadow | 🟠 | ◐ |
| 4-H îlot asm setjmp/longjmp | 🟠 | ☐ |
| 4-K air-runtime async (core/io/time/signal/sync) | ⚪ | ☐ |
| 4-J ABI C T2/T3 + libair-base.so (au-dessus) | ⚪ | ◐ |
| 5.1 audit face-libc | — | ☐ |
| 5.2 audit face-PAL | — | ☐ |
| 5.4 sceau couche-1-v1.0 (async inclus) | — | ☐ |
| 4-I moteur regex (post-sceau, additif) | 🟠 | ☐ |
Roadmap — libc C-ABI scopée OpenSSH (sur la couche 1 scellée)
✅ STATUT 2026-07-10 — M0–M4 livrés ; JALON M5 (toolchain
std-sur-Air) ATTEINT. La libc C-ABI compte ~256 symboles (Rust pur) ;stdlie et tourne contrelibair(zéro glibc),hello-stds’exécute on-target 2 arches (hello from std on air
- exit 0) — la toolchain Rust full-Air est prouvée (ADR-076).
⚠️ Recadrage BDFL (2026-07-10) — le jalon « compiler OpenSSH réel » est RETIRÉ. Cette roadmap a rempli son rôle : compiler OpenSSH n’était qu’une fonction de forçage (le consommateur C le plus exigeant, pour piloter la libc et révéler les additifs couche 1). Elle a donné une libc C-ABI de ~256 symboles et une toolchain
stdprouvée. On ne vise pas à faire tourner le C d’OpenSSH upstream sur Air. La vraie cible =air-sshd, notre serveur SSH full Rust (async io_uring, wire-compat OpenSSH, conf binaire — ADR-074). Le §6 « M5 — OpenSSH » ci-dessous est conservé comme historique ; la production courante passe àair-sshd. Statut vivant :etat-avancement.md.
But. Produire la libc C-ABI d’Air, bornée à ce dont OpenSSH a besoin (ADR-046/047), comme toit sur la couche 1 scellée (
couche-1-v1.0, re-scellée additivement jusqu’àcouche-1-v1.9). Jalon final =sshd+sshconfigurent, linkent et tournent sur Air. La libc sert de spec-de-certitude pour le PAL (std::sys) et de driver pour révéler les additifsv1.xde la couche 1.Ordre stratégique (décision BDFL) : libc d’abord (consommateur le plus exigeant, sur-ensemble des besoins du PAL) → PAL ensuite (sous-ensemble + delta Rust) → ABI C
air_*(riche/sécure/hors-normes) en couronnement.Priorité de production, dans cet ordre : SÉCURITÉ > QUALITÉ > PERFORMANCE (Principe 5 : sur-sécuriser puis mesurer, jamais l’inverse).
1. Périmètre & non-buts
- Cible unique : OpenSSH (sshd + ssh + ssh-keygen/agent). Rien de plus.
- Familles dans le scope (ADR-046, ~25-30) :
crt/startup,errno,stdio(FILE*),string/mem,stdlib(malloc/exit/getenv/qsort…),unistd/fcntl,stat/dirent,signal,pthread,network(socket/getaddrinfo),time,termios/pty,pwd/grp/shadow,setjmp/longjmp,misc(getopt/realpath/getpass/openpty/syslog/basename). - Non-buts (hors scope, différés) : locale complète (minimum vital seulement),
printf/scanfexotiques (positions%n$, locale numérique),aio, IPC SysV,mq,dlopen/ldso,wordexp/glob(rejetés RCE, ADR-046 D7), NSS/pam,iconvlarge. Ajoutés additivement si un besoin OpenSSH réel émerge. - Kernel = bible, pas POSIX : conformité fonctionnelle (SHOULD→MUST), pas conformité de certification. Voir §3.
2. Architecture
- Rust-first, shim mince. Chaque symbole libc = un
#[no_mangle] pub extern "C"fin au-dessus d’une brique couche 1 scellée (dual-face : la brique Rust est conçue 1 fois, consommée par la libc et le PAL). L’unsafe/FFI est assumé côté frontière C (C n’a pas de sûreté) ; l’implémentation derrière reste des appels couche 1 sûrs. - Structure de crates (à figer en M0, sur le modèle
air-base-capi) : des crates*-capipar famille (ex.air-stdio-capi,air-posix-io-capi…) agrégées en l’artefact finallibc(.so+.a) + objetscrt1.o/crti.o/crtn.o. - Architecture « logique mesurée / shims C-ABI exclus » (gravée dès M1-b, étendue
M1-c) — le POURQUOI : nos symboles
#[no_mangle] extern "C"(malloc,memcpy,fwrite…) surchargent les symboles du runtime de couverture LLVM et de l’allocateur du harnais ⇒ l’écriture du profilllvm-covéchoue (crate à 0 %). Règle absolue : une crate dont on veut MESURER la couverture ne lie AUCUNE crate exportant un symbole libc#[no_mangle](ni sien, ni transitif). On scinde donc : la LOGIQUE pure (politique d’allocation, moteurformat,FILE*bufferisé,atexit/getenv…) vit dans des crates sans#[no_mangle], donc mesurées à ~100 % (air-libc-fmt,air-libc-alloc,air-libc-stdio,air-libc-stdlib, …) ; les shims#[no_mangle] extern "C"minces qui délèguent vivent dansair-libc-capi(exclus du gate de couverture,--ignore-filename- regex). Les shims divergents (exit/abort/_Exit -> !) y vivent aussi (insondables par un test du gate, commeair-process/src/exit.rs). - crt / startup :
_start→__libc_start_main→main, câblé surair_runtime::start::bootstrap(déjà prouvé on-target, selftest exit 42) : TLS,environ/argv/envp,atexit,errno#[thread_local]. errno: per-thread viaair-runtime(déjà en place). Zéro globale libc (vision Air) ;errnoin-band, pas d’état caché.FILE*: struct C de la couche libc (au-dessus de la couche 1), bufferisée (_IOFBF/_IOLBF/_IONBF), enveloppant unAirFile/air-stdio(fd) + tampon + orientation + flags error/eof + verrou (flockfile). La couche 1 reste sans tampon ; toute la bufferisation vit ici.sigaction: au-dessus d’air-signal. Sur les fautes synchrones : fourni mais INERTE (compile, réussit, handler jamais appelé, coredump toujours — ADR-064 §2). Signaux asynchrones : émulés via signalfd + drainage (ousigactionréel non- faute, décision M3).
3. Doctrine de conformité (rigueur exigée)
Chaque symbole porte, dans sa doc (§4), une étiquette de conformité :
| Étiquette | Sens |
|---|---|
POSIX | PILE conforme C/POSIX : comportement, valeurs de retour, errno identiques à la norme. |
POSIX-PARTIEL | Fonctionnellement POSIX, avec déviations documentées (ex. erreur plus stricte, zéro troncature muette, borne explicite). Chaque déviation listée + justifiée. |
NON-POSIX-DÉLIBÉRÉ | On choisit de dévier (ex. sigaction inerte sur fautes) — raison obligatoire + renvoi ADR. |
AIR-EXT | Extension Air (air_* : variantes riches/sécures qui restituent le Result). Hors POSIX, valeur ajoutée. |
- Registre central :
docs/libc-conformance.md— table symbole → étiquette → (déviation/justification/ADR). Vérifié en CI (chaque symbole exporté a une étiquette). - Cas phares à tracer :
sigaction/signalinertes sur fautes (NON-POSIX-DÉLIBÉRÉ, ADR-064) ;free/air_free(POSIXvoidvsAIR-EXTint) ; toute fonction qui refuse une troncature muette (POSIX-PARTIEL, ADR-032).
4. Documentation — 3 sorties depuis UNE source
Source de vérité unique : les blocs de doc sur les extern "C" Rust (///),
incluant l’étiquette de conformité (§3) et le POURQUOI (transparence Air : dire ce
qu’on fait, les pièges, les alternatives — pas que la signature).
- Rust :
rustdocnatif sur les fonctions FFI. - Doxygen : le header C committé (
.h, déjà discipliné en CI — gate « header ABI C ≟ committé ») porte les commentaires doxygen/** */, miroir de la doc Rust (mêmes étiquettes). Doxygen → HTML + XML. - man (3ᵉ format) : dérivé de doxygen —
GENERATE_MAN = YESproduit les pagesman(3)depuis la même source doxygen. → Oui, le 3ᵉ format est produisible depuis les deux premiers (précisément : depuis la source doxygen).
- Cohérence outillée : un gate CI vérifie Rust-doc ↔ header ↔ étiquettes (extension
du gate
header ABI C committéexistant). Aucune dérive silencieuse. - Pinning (ADR-025) : version de doxygen figée (reproductibilité).
5. Règles de production (intégralement respectées, dans l’ordre)
① SÉCURITÉ. Zéro UB ; toute donnée externe parsée (format de printf/scanf,
getaddrinfo, strtol…) est bornée + fuzzée (cargo-fuzz). Pas de troncature
muette (ADR-032). Les tables d’état couche 1 (handles/allocs) alimentent la
traçabilité (vision instrumentable, ADR-063 en debug). Frontière C = unsafe assumé
et localisé ; implémentation derrière = couche 1 sûre.
② QUALITÉ. cargo xtask barrier vert ; couverture (100 % lignes sur la logique
Rust ; les shims triviaux couverts par les tests de conformité fonctionnels +
harnais C réels) ; tests de conformité ABI (ADR-012 : air-abi-check/air-symver
sur les symboles exportés) ; corpus de vecteurs (ex. printf contre une référence).
Zéro Co-Authored-By ; DCO + signature.
③ PERFORMANCE. Après sécurité + qualité (Principe 5) : mesurer (chemins chauds
= memcpy/stdio/malloc), justifier par écrit toute optimisation qui allège une
défense, garantir l’invariant par construction. Cible matériel modeste (Pi 4).
6. Jalons (topologiques — chacun testable, additif, PR par PR)
- M0 — Fondations. ☑ fait. Structure de crates
*-capi+ conventions ABI-C figées ; pipeline doc (rustdoc + doxygen + man) câblé + gate cohérence ;crt(crt1/i/n surair-runtime::bootstrap) +errno;libc-conformance.mdinitialisé. Test :int main(){return 42;}C linke sur la libc Air et sort 42 on-target. - M1 — stdio + string/mem + stdlib cœur.
FILE*bufferisé surair-stdio/AirFile(fopen/fclose/fread/fwrite/fflush/setvbuf/fgets/fputs), moteurvfprintffuzzé,malloc/free/calloc/realloc(surair-alloc),str*/mem*,exit/atexit/abort,getenv/setenv(surair-env). **Test :printfhello-world C- une poignée de programmes C réels.** Découpé en tranches PR : M1-a string/mem ☑,
M1-b malloc + moteur de format fuzzé ☑, M1-c
FILE*+exit/atexit/getenv ☑, M1-d frontière variadiqueprintf(option A : shim Cva_arg+ glue Rust mesuréeair-libc-printf+ hello-world C) ☑.
- Frontière variadique
printf— décision BDFL : option (A). Rust-stable ne peut définir une fonction C variadique (c_variadic= nightly), or la libc doit rester sur stable (ADR-025, barrier, couverture). Retenu : parseur + moteur de format 100 % Rust fuzzé (surface d’attaque N°1 en Rust) + un shim C mince faisant le seulva_argmécanique (piloté par le plan d’args du parseur, puis re-délègue au moteur Rust). C’est la 1ʳᵉ exception nommée à « zéro C » — minimale, sans logique ni surface d’attaque, justifiée par l’impossibilitéc_variadicsur stable. À consigner dansdocs/EXCEPTIONS.md+ le registre de conformité à l’impl (M1-d).
- une poignée de programmes C réels.** Découpé en tranches PR : M1-a string/mem ☑,
M1-b malloc + moteur de format fuzzé ☑, M1-c
- M2 — I/O fichier & métadonnées. ☑ fait (validé).
open/ openat/creat/read/write/close/lseek/pread/pwrite/dup/dup2/dup3/fcntl/pipe/pipe2(sur couche 0, FD brut,EINTRremonté,O_CLOEXECPOSIX rétabli),stat/fstat/ lstat/fstatat/access/faccessat(struct statABI Air),opendir/fdopendir/ readdir/closedir/rewinddir(DIRopaque +struct dirent, parseurgetdents64fuzzé),mkstemp/mkdtemp(entropie kernel),realpath(surcanonicalize),getcwd/chdir/fchdir. Architecture : logique mesuréeair-libc-fileio+ shimsair-libc-capi::fileio+ shim C variadiquefileio_shim.c(open/openat/fcntl, option A — même exception « zéro C » queprintf). Déviations gravées D-M2.1..D-M2.5 (cf.libc-conformance.md). Test : I/O fichier + parcours de répertoire (round-trips réels + fuzzfuzz_libc_dirent). - M3 — signaux & threads. ☑ fait.
sigaction/signal(inertes sur fautes, ADR-064),sigprocmask/kill/raise/sigsuspend(surair-signal) ;pthread_*(create/ join/mutex/cond/rwlock/once/key+dtor, surair-thread+TLS). Test : threading + signaux non-faute drainés ; faute → coredump, handler ignoré. - M4 — réseau, temps, tty, comptes, setjmp, misc. ☑ fait.
socket/bind/connect/listen/ accept/send/recv/getaddrinfo/getnameinfo/inet_pton(surair-socket) ;time/ clock_gettime/nanosleep/gmtime/strftime;tcgetattr/tcsetattr/cfmakeraw/openpty(surair-terminal) ;getpwnam/getpwuid/getgrnam(backends /etcair-config) ;setjmp/longjmp(îlot asm, ADR-047 §4-H) ;getopt/getpass/syslog/basename. M5 — OpenSSH.RETIRÉ (recadrage BDFL 2026-07-10) — conservé comme historique. L’intention d’origine : fournir les headers manquants, compiler OpenSSH upstream contre la libc Air, combler les manques révélés. Cette étape est abandonnée en tant que jalon : compiler OpenSSH n’était qu’une fonction de forçage (elle a rempli son rôle — libc ~256 symboles + toolchainstd). Remplacée parair-sshd: notre serveur SSH full Rust (async io_uring, wire-compat OpenSSH, conf binaire — ADR-074). Le jalon « toolchainstd-sur-Air » (M5 au sens du suivi) est, lui, ATTEINT (cf. bandeau). Les manques libc éventuels révélés parair-sshdrestent traités par descellement additifv1.xdocumenté.
(Chaque jalon = une ou plusieurs PR ; barrier vert + conformité + doc + coverage
avant merge. Chaque additif couche 1 révélé est traité par RFC additif.)
7. Points de cohabitation avec le PAL (à capturer au fil de l’eau)
Dans un process mixte Rust(PAL)+C(libc), à documenter dès qu’un jalon les touche :
allocateur partagé (air-alloc sous les deux) · errno cohérent (per-thread
air-runtime) · TLS (un seul modèle) · fd 0/1/2 (bufferisation FILE* vs
stdio du PAL) · délivrance des signaux (un seul régime). Ces notes deviennent la
spec de certitude du PAL.
8. Rapport de fin de production (livrable exigé)
En fin de production (et à chaque jalon majeur mergé), un rapport détaillé :
- Choix faits + POURQUOI, famille par famille (dont structure de crates, modèle
FILE*,errno, gestion signaux). - Table de conformité consolidée (POSIX / POSIX-PARTIEL / NON-POSIX-DÉLIBÉRÉ / AIR-EXT) + journal des déviations (chacune justifiée + ADR).
- Additifs couche 1
v1.xdéclenchés par la libc (descellements additifs). - Notes de cohabitation PAL (§7).
- Mesures de performance (chemins chauds) + toute défense allégée justifiée.
9. Exécution autonome
Même boucle que la campagne de sceau : production déléguée claude -p headless
(carbon/speedy), superviseur pilote git (branche/valide/barrier/PR/squash-merge/
re-vérif main+trailer) ; doc pure = commit direct main signé (worktree isolé) ;
zéro Co-Authored-By. Pilotage autonome jalon par jalon ; rapport détaillé
en fin de production (§8). Un seul acte réservé à autorisation BDFL explicite : si un
choix structurant non prévu émerge (ex. un NON-POSIX-DÉLIBÉRÉ nouveau, ou un
compromis sécurité), je le remonte avant de graver.
Registre de conformité — libc C-ABI d’Air
Registre central de la doctrine de conformité (roadmap
roadmap-libc-openssh-fr.md§3). Chaque symbole exporté par la libc C-ABI d’Air y figure avec son étiquette de conformité, sa déviation éventuelle (listée + justifiée) et l’ADR de référence.Source de vérité = le
///sur chaqueextern "C"(roadmap §4). Ce fichier agrège ces étiquettes. Cohérence outillée : le testair-libc-capi/tests/conformance_labels.rséchoue si un symboleextern "C"défini dansair-libc-capi/src/(errno, crt, shimsmalloc) n’a pas d’étiquette dans son///ou n’apparaît pas ici. Lesmem*/str*sont définis dansair-libc-strmem(ré-exportés parair_libc_capi::string) et y portent leurs étiquettes ; ils figurent au registre ci-dessous.
Mise à jour M2 (2026-07-04) : familles I/O fichier fd brut (fcntl.h/
unistd.h), métadonnées (sys/stat.h, struct stat ABI Air) et répertoires
(dirent.h, DIR opaque) — logique mesurée air-libc-fileio, shims
air-libc-capi::fileio, frontière variadique open/openat/fcntl via
fileio_shim.c (option A, comme printf). Cf. section « M2 » et déviations
D-M2.1..D-M2.5. Précédent :
Dernière mise à jour : 2026-07-04 (jalon M1-d — frontière variadique printf
(option A) : famille printf/fprintf/sprintf/snprintf + v* via un mince shim
C va_arg et la glue Rust mesurée air-libc-printf, au-dessus du moteur fuzzé
air-libc-fmt ; sur M1-a/M1-b/M1-c). Architecture « logique / shims » étendue :
logique stdio dans air-libc-stdio, exit/atexit/getenv dans air-libc-stdlib,
glue printf dans air-libc-printf (crates mesurées) ; shims extern "C"
minces dans air-libc-capi::{stdio,stdlib} (exclus du gate) ; 1ʳᵉ exception « zéro C »
= le shim C va_arg (air-libc-c/csrc/printf_shim.c, cf. EXCEPTIONS.md). Six crates
de logique libc mesurées : air-libc-fmt, air-libc-alloc, air-libc-stdio,
air-libc-stdlib, air-libc-printf.
Taxonomie (roadmap §3)
| Étiquette | Sens |
|---|---|
POSIX | Pile conforme C/POSIX : comportement, valeurs de retour, errno identiques à la norme. |
POSIX-PARTIEL | Fonctionnellement POSIX, avec déviations documentées (erreur plus stricte, zéro troncature muette, borne explicite). Chaque déviation listée + justifiée. |
NON-POSIX-DÉLIBÉRÉ | On choisit de dévier / de fournir un symbole hors POSIX — raison obligatoire + renvoi ADR. |
AIR-EXT | Extension Air (air_*) — hors POSIX, valeur ajoutée. |
M0 — Fondations (air-libc-capi) : errno + crt
Fonctions
| Symbole | Étiquette | Déviation / justification | ADR |
|---|---|---|---|
__errno_location | POSIX | Sémantique conforme : rend l’lvalue errno par-thread, pointeur stable pour la vie du thread, jamais NULL. Nuance de nom : __errno_location n’est pas dans le texte POSIX (qui spécifie la macro errno) — c’est la convention ABI Linux/glibc, fournie à l’identique pour que le code C et les <errno.h> du système marchent sans modification. Socle : air_runtime::AirRuntime::errno_location() (canal unique, zéro globale, partagé libc ⇄ PAL). | ADR-046 (D4), ADR-049 (D3), ADR-052 |
__libc_start_main | NON-POSIX-DÉLIBÉRÉ | Non-POSIX assumé : ABI de démarrage Linux/glibc (pas un symbole POSIX). Air en fournit une variante minimale à 4 arguments (main, argc, argv, envp) là où la glibc en prend 7 (init/fini/rtld_fini/stack_end) — le crt appelant est celui d’Air (bootstrap, couche 1), pas celui de gcc ; les crochets .init_array/atexit/ld.so sont hors M0 (M1 pour atexit/flush stdio). Élargissement à la signature 7-args = additif d’un jalon ultérieur si besoin réel. | ADR-046, ADR-050 |
air_main | AIR-EXT | Bridge air_* interne à la chaîne de démarrage d’Air (_start → bootstrap → air_main → __libc_start_main) — TARGET-ONLY (cfg(target_env = "air")), non destiné à l’appel par du code C applicatif. Convertit les types Air (usize/*const *const u8) vers la signature C-runtime. | ADR-052, ADR-049 (D4) |
Types
| Symbole | Étiquette | Note | ADR |
|---|---|---|---|
AirMainFn | POSIX (support) | typedef int (*AirMainFn)(int, char **, char **) — type du main C (ABI System V). Support de type pour __libc_start_main. | ADR-046 |
Constantes E* (numéros errno Linux)
Étiquette : POSIX (toutes). Valeurs conformes à asm-generic/errno{,-base}.h
(identiques x86_64 / aarch64). Source de vérité unique = couche 0
(air_sys_types::Errno) : les #define du header sont des littéraux
croisés-vérifiés contre la couche 0 (test errno_constants_match_layer0), jamais
inventés. Périmètre M0 = les errno déjà ancrés en couche 0 ; d’autres seront
ajoutés par famille (jalons M1+), au fil des besoins réels d’OpenSSH.
EPERM(1) · ENOENT(2) · ESRCH(3) · EINTR(4) · E2BIG(7) · ENOEXEC(8) ·
EBADF(9) · ECHILD(10) · EAGAIN(11) · EWOULDBLOCK(11, alias EAGAIN) ·
ENOMEM(12) · EACCES(13) · EFAULT(14) · EBUSY(16) · EEXIST(17) ·
EXDEV(18) · ENODEV(19) · ENOTDIR(20) · EINVAL(22) · ENFILE(23) ·
EMFILE(24) · ENOTTY(25) · ENOSPC(28) · ESPIPE(29) · EPIPE(32) ·
ERANGE(34) · ENOSYS(38) · ELOOP(40) · ETIME(62) · EPROTO(71) ·
EBADMSG(74) · EMSGSIZE(90) · EOPNOTSUPP(95) · ECONNRESET(104) ·
ENOBUFS(105) · ETIMEDOUT(110) · EALREADY(114) · ECANCELED(125).
Code vs artefact (air-libc-capi / air-libc-c, ADR-029)
Les symboles ci-dessus sont définis dans la crate de code air-libc-capi
(rlib pur — leur source de vérité /// + header committé y vivent). L’artefact
partagé libair_c.so est produit par la crate sœur air-libc-c (cdylib,
host-only), qui ne définit aucun symbole propre : elle se contente de
ré-exporter (pub use) et d’ancrer (#[used]) ceux d’air-libc-capi pour
forcer leur export dans la .so. Aucune entrée de registre supplémentaire n’est
donc requise pour air-libc-c (surface ABI identique, zéro symbole ajouté).
M1 — tranche string/mem (air-libc-strmem ; ré-export air-libc-capi::string)
Primitives mem*/str* sans allocation : #[no_mangle] extern "C" définis
dans la crate air-libc-strmem (isolée pour porter #![no_builtins], cf. le split)
et ré-exportés par le module air_libc_capi::string (chemin stable + visibilité
cbindgen via parse.include/extra_bindings). Shims minces sur une logique
sûre (tranches bornées / marches de pointeurs bornées par n, zéro indexation
paniquante, zéro UB — roadmap §5.①). Toutes POSIX : mêmes
valeurs de retour, mêmes pointeurs, comparaisons en unsigned char conformes à
la norme. Aucune déviation de comportement ; la seule garantie plus forte
(sûreté, non observable côté C) est le court-circuit n == 0 (pointeurs nuls
tolérés là où from_raw_parts/copy de Rust exigent du non-nul) — pas une
déviation POSIX. strdup (allocation) est différé à la tranche stdlib.
| Symbole | Étiquette | Note | ADR |
|---|---|---|---|
memcpy | POSIX | Régions disjointes exigées (norme) ; rend dst. | ADR-046 |
memmove | POSIX | Chevauchement géré (core::ptr::copy) ; rend dst. | ADR-046 |
memset | POSIX | c réduit en unsigned char (sans as lossy) ; rend s. | ADR-046 |
memcmp | POSIX | Comparaison en unsigned char ; signe conforme. | ADR-046 |
memchr | POSIX | Rend void * (non const, convention norme) ou NULL. | ADR-046 |
strlen | POSIX | Longueur non bornée (marche de pointeur). | ADR-046 |
strnlen | POSIX | POSIX.1-2008 ; ne lit jamais au-delà de maxlen. | ADR-046 |
strcpy | POSIX | Non borné (contrat C) ; rend dst. | ADR-046 |
strncpy | POSIX | Piège normé documenté : pas de NUL si strlen(src) ≥ n ; padding NUL sinon. Bornage strict ≤ n octets. | ADR-046 |
strcat | POSIX | Non borné (contrat C) ; rend dst. | ADR-046 |
strncat | POSIX | Toujours NUL-terminé (≤ n+1 octets ajoutés). | ADR-046 |
strcmp | POSIX | Comparaison en unsigned char. | ADR-046 |
strncmp | POSIX | Bornée par n, comparaison unsigned char. | ADR-046 |
strchr | POSIX | Première occurrence, NUL terminal compris ; rend char *. | ADR-046 |
strrchr | POSIX | Dernière occurrence, NUL terminal compris. | ADR-046 |
strstr | POSIX | Naïf O(n·m), sans allocation ; aiguille vide ⇒ haystack. | ADR-046 |
strtok_r | POSIX | Réentrant (état dans *saveptr, zéro global) ; modifie s in place. | ADR-046 |
strerror | POSIX | Table statique errno → message (standard Linux). Chaîne immuable statique (jamais invalidée, contrairement au buffer partagé glibc). Logique mesurée air-libc-strerror. | ADR-076 |
strerror_r | POSIX | Variante POSIX/musl (int, Air ABI-musl) : copie bornée dans le buffer, ERANGE si trop petit (tronqué + NUL). | ADR-076 |
__xpg_strerror_r | POSIX | Alias XSI de strerror_r (nom émis par std/glibc/musl pour la variante int) : même logique mesurée, copie bornée, ERANGE si trop petit (tronqué + NUL). | ADR-076 |
M1-b — famille malloc (logique air-libc-alloc ; shims air-libc-capi::malloc)
Famille allocation de stdlib.h. Split « logique / shims C-ABI » : la
LOGIQUE (malloc_impl/free_impl/…) vit dans la crate air-libc-alloc (Rust pur,
mesurée à 100 %, aucun symbole #[no_mangle]), au-dessus de l’arène globale
scellée d’air-alloc (verrou futex, fusion O(1), échec ⇒ null déterministe,
ADR-056) ; les shims extern "C" minces qui délèguent vivent dans
air_libc_capi::malloc (exclus du gate de couverture, car leurs #[no_mangle]
surchargent le runtime de couverture LLVM). La logique porte le modèle d’en-tête
borné (placé devant chaque bloc) qui rend free/realloc possibles sans que
le C repasse taille/alignement. Sécurité #1 (roadmap §5.①) :
overflow calloc checked (checked_mul), zéro as lossy (try_from /
checked_* partout), zéro double-free/UAF introduit (chaque bloc libéré une
fois via son en-tête ; realloc recopie puis libère l’ancien après succès du
neuf). strdup/strndup allouent ⇒ ici, pas dans string.
| Symbole | Étiquette | Note / déviation | ADR |
|---|---|---|---|
malloc | POSIX | malloc(0) rend un pointeur unique, non-NULL, libérable (la norme autorise NULL ou un pointeur unique — on choisit le plus sûr). Échec ⇒ NULL + errno = ENOMEM. | ADR-056 |
free | POSIX | free(NULL) = no-op. Double-free/UAF = UB (contrat C, comme toute libc). | ADR-056 |
calloc | POSIX | Produit nmemb * size checked (débordement ⇒ NULL + errno = ENOMEM — anti heap-overflow). Mémoire entièrement à zéro. calloc(0, x)/calloc(x, 0) ⇒ pointeur unique libérable. | ADR-056 |
realloc | POSIX-PARTIEL | realloc(NULL, n) == malloc(n) ; realloc(p, 0) == free(p) puis rend NULL (comportement glibc historique ; C17 implementation-defined — déviation gravée). Échec (size ≠ 0) ⇒ NULL + errno = ENOMEM, ancien bloc conservé. | ADR-056 |
aligned_alloc | POSIX-PARTIEL | alignment puissance de deux non nulle sinon NULL + errno = EINVAL. Contrainte C11 « size multiple de alignment » relâchée (comme glibc) — déviation documentée (plus permissif, jamais moins sûr). | ADR-056 |
posix_memalign | POSIX | alignment puissance de deux et multiple de sizeof(void *) sinon EINVAL (sans toucher errno ni *memptr). Succès ⇒ 0 + *memptr. Échec ⇒ ENOMEM (ne pose pas errno, contrat POSIX). | ADR-056 |
strdup | POSIX-PARTIEL | POSIX.1-2008. Durcissement : strdup(NULL) rend NULL (au lieu du déréférencement de NULL indéfini — sécurité #1). Échec d’allocation ⇒ NULL + errno = ENOMEM. | ADR-056 |
strndup | POSIX-PARTIEL | POSIX.1-2008. Résultat toujours NUL-terminé (≤ n+1 octets). Durcissement : strndup(NULL, …) rend NULL (sécurité #1). Échec ⇒ NULL + errno = ENOMEM. | ADR-056 |
Moteur de format (crate air-libc-fmt — aucun symbole extern "C")
Le cœur du rendu printf ([air_libc_fmt], extrait dans sa propre crate
pour être mesuré à 100 % par llvm-cov) est du Rust pur, sans unsafe, sans
allocation, agnostique de la source des arguments (FormatArgs). Il
n’exporte aucun symbole C (donc aucune entrée de registre requise, et rien
à parser pour cbindgen) : le câblage variadique printf/snprintf/vfprintf
(option A, extern "C", dans air-libc-capi)
est le jalon M1-d. C’est LA surface d’attaque « format-string », isolée et
fuzzée (cible fuzz_libc_format, cf. fuzz/). Invariants garantis par
construction + prouvés au fuzz : zéro panic, zéro débordement du puits borné
(snprintf-exact : écrit min(longueur théorique, capacité)), zéro boucle
infinie/DoS (remplissages bornés par la capacité, compteur saturating_add), et
%n REJETÉ — rendu littéral, aucun argument consommé, jamais une
écriture mémoire (vecteur d’exploitation format-string classique neutralisé). Cf.
déviation D-M1b.5 ci-dessous.
M1-c — stdio.h (FILE* bufferisé) : air-libc-stdio (logique) ; shims air-libc-capi::stdio
Famille stdio.h. Split « logique / shims C-ABI » : la LOGIQUE (FILE
bufferisé, registre des flux, flush-at-exit) vit dans air-libc-stdio (Rust pur,
mesurée, aucun symbole #[no_mangle]), au-dessus d’AirFile/air-stdio
(couche 1) ; les shims extern "C" minces vivent dans air_libc_capi::stdio
(exclus du gate). Bufferisation bloc/ligne/sans (_IOFBF/_IOLBF/_IONBF),
verrou par-FILE (thread-safety POSIX, façon flockfile), boucle sur écriture
courte + retry EINTR dans le toit (ADR-021 conv.2). Sécurité #1 : bornage
strict des tampons (get/copy_from_slice, jamais d’indexation), zéro as lossy,
overflow size*nmemb checked (fread/fwrite). Parseur de mode fopen
fuzzé (fuzz_libc_stdio_mode) ; bufferisation fuzzée (fuzz_libc_stdio_buffer,
backend mémoire).
| Symbole | Étiquette | Note / déviation | ADR |
|---|---|---|---|
fopen | POSIX | Perm. de création 0666 masquées par l’umask ; modes tolérants façon glibc (b no-op Linux, x=O_EXCL avec w, octets inconnus ignorés) ; mode invalide ⇒ NULL+EINVAL. | ADR-046 |
fclose | POSIX | Flush + fermeture + désenregistrement + destruction du FILE. | ADR-046 |
fread | POSIX | Produit size*nmemb checked (débordement ⇒ 0). Rend le nb d’éléments complets. | ADR-046 |
fwrite | POSIX | Produit size*nmemb checked. Rend le nb d’éléments complets. | ADR-046 |
fflush | POSIX | fflush(NULL) vide tous les flux ouverts (registre process-global). | ADR-046 |
fseek | POSIX | Position logique corrigée de la bufferisation (pré-lecture / octets non flushés). | ADR-046 |
ftell | POSIX | Position logique (offset descripteur ± tampon). | ADR-046 |
rewind | POSIX | fseek(f,0,SEEK_SET) + clearerr. | ADR-046 |
fgetc / getc | POSIX | Lit un octet (0..=255) ou EOF. getc = fonction (pas seulement macro). | ADR-046 |
getchar | POSIX | getc(stdin). | ADR-046 |
fgets | POSIX | Au plus size-1 octets, s’arrête au \n (inclus)/EOF, NUL-termine. Jamais d’écriture au-delà de size (bornage strict). Verrou tenu sur toute la ligne (atomicité). | ADR-046 |
fputc / putc | POSIX | Écrit un octet (réduit en unsigned char). | ADR-046 |
putchar | POSIX | putc(c, stdout). | ADR-046 |
fputs | POSIX | Écrit la C-string (sans le NUL). | ADR-046 |
puts | POSIX | C-string + \n sur stdout (contrat C). | ADR-046 |
feof / ferror / clearerr | POSIX | Indicateurs EOF/erreur par-FILE. | ADR-046 |
setvbuf | POSIX-PARTIEL | Déviation de sécurité (D-M1c.1) : le tampon fourni par l’appelant est ignoré (jamais d’aliasing d’une mémoire C au cycle de vie incontrôlé) ; seuls mode/size honorés. Mode invalide ⇒ -1+EINVAL. | ADR-046/032 |
setbuf | POSIX-PARTIEL | ≡ setvbuf (mêmes déviations ; buf nul ⇒ _IONBF, sinon _IOFBF). | ADR-046/032 |
fileno | POSIX | Descripteur brut sous-jacent (POSIX.1, hors C standard). | ADR-046 |
Données (FILE *)
| Symbole | Étiquette | Note | ADR |
|---|---|---|---|
stdin / stdout / stderr | POSIX | Trois FILE pré-ouverts (static, sémantique OS). stdout = ligne sur terminal sinon bloc ; stderr = sans tampon (_IONBF). Symboles de données (FILE *), hors périmètre du scanner conformance_labels (qui ne lit que les extern "C" fn). | ADR-046 |
Constantes <stdio.h>
Étiquette POSIX (toutes, littéraux ABI standard) : EOF(-1) · SEEK_SET(0) ·
SEEK_CUR(1) · SEEK_END(2) · _IOFBF(0) · _IOLBF(1) · _IONBF(2) · BUFSIZ(8192).
M1-c — stdlib.h : exit/atexit/abort + getenv (air-libc-stdlib ; shims air-libc-capi::stdlib)
Split « logique / shims » : le registre atexit (borné), la séquence de
nettoyage (perform_exit_cleanup) et le cache getenv vivent dans air-libc-stdlib
(mesurée) ; les shims — dont exit/_Exit/_exit/abort qui divergent
(-> !, insondables par le gate, comme air-process/src/exit.rs) — vivent dans
air_libc_capi::stdlib. Séquence exit : handlers atexit (ordre inverse) →
flush tous les FILE → air_process::exit_process.
| Symbole | Étiquette | Note / déviation | ADR |
|---|---|---|---|
exit | POSIX | Handlers atexit + flush stdio + exit_group. Ne revient jamais. | ADR-046 |
_Exit / _exit | POSIX | Sortie immédiate (ni handlers, ni flush). | ADR-046 |
abort | POSIX | SIGABRT (air-signal, coredump par défaut), aucun flush (contrat). Durcissement : sortie forcée 128+SIGABRT si le signal ne termine pas. | ADR-046/064 |
atexit | POSIX | Registre borné (≥ 32, ATEXIT_MAX), ordre inverse. Durcissement (D-M1c.3) : atexit(NULL) rend une valeur non nulle. | ADR-046 |
getenv | POSIX-PARTIEL | Nuance de durée de vie gravée (D-M1c.2) : pointeur stable pour la vie du processus via un cache process-global ; un getenv répété rend le même pointeur. setenv/unsetenv invalident l’entrée de cache (lecture fraîche ensuite) en fuyant l’ancienne valeur (les pointeurs déjà rendus restent valides). | ADR-046 |
setenv | POSIX | overwrite==0 + variable existante ⇒ no-op. Nom invalide ⇒ EINVAL, pointeur nul ⇒ EFAULT. Sur AirEnvironmentManager (couche 1, ADR-077). | ADR-046/077 |
unsetenv | POSIX | Nom vide / contenant = ⇒ EINVAL, pointeur nul ⇒ EFAULT. Sur AirEnvironmentManager. | ADR-046/077 |
M1-d — frontière variadique printf (shim C va_arg + glue air-libc-printf)
Famille printf (stdio.h). Décision BDFL — option (A) (roadmap §6) :
Rust-stable ne peut pas définir de fonction C variadique (c_variadic = nightly).
Retenu : parseur + moteur de rendu 100 % Rust fuzzé (air-libc-fmt, la surface
d’attaque « format-string » N°1) + un mince shim C (air-libc-c/csrc/ printf_shim.c) faisant le seul va_arg mécanique. C’est la 1ʳᵉ exception
nommée à « zéro C » (cf. docs/EXCEPTIONS.md).
Architecture. Le shim ne parse rien (zéro logique de format en C) : il
(a) demande le plan des types d’args à la glue Rust mesurée air-libc-printf
(air_fmt_arg_kinds), (b) fait le va_arg(ap, <type promu>) dicté par le plan,
(c) re-délègue au moteur Rust (air_fmt_render_buf/_stream). Le plan et le
rendu passent par le même parseur (air_libc_fmt::format) ⇒ zéro divergence.
Sécurité #1 : %n rejeté (rendu littéral, aucun argument tiré, jamais
d’écriture-arrière — cf. déviation D-M1b.6) ; puits snprintf borné (jamais
de débordement) ; promotions C par défaut respectées. Fuzzé : moteur
(fuzz_libc_format) et glue (fuzz_libc_printf). Preuve d’exécution :
harnais hello-world C (air-libc-c/tests/hello_world.rs, « Hello, Air 42 »).
Symboles fournis par le shim C (déclarés dans air_c.h, exportés dans
libair_c.so — nm -D) :
| Symbole | Étiquette | Note / déviation | ADR |
|---|---|---|---|
printf | POSIX | stdout, bufferisé (flush-at-exit). %n rejeté (D-M1b.6). Rend le nb d’octets écrits (négatif sur erreur d’écriture). | ADR-046 |
fprintf | POSIX | Vers un FILE *. | ADR-046 |
sprintf | POSIX | Buffer non borné (contrat C ; préférer snprintf). NUL-terminé. | ADR-046 |
snprintf | POSIX | Écrit au plus size octets (NUL inclus — jamais de débordement) ; rend la longueur théorique (C99). | ADR-046 |
vprintf / vfprintf / vsprintf / vsnprintf | POSIX | Variantes va_list (mêmes sémantiques). | ADR-046 |
Conversions supportées (moteur M1-b) : d i u x X o c s p % ; drapeaux - + espace 0 # ; largeur/précision (dont */.*) ; longueurs hh h l ll z t j. Différés
(hors scope) : flottants e f g a (rendus littéralement tant qu’air-libm n’est pas
câblé — aucun argument tiré) ; positions %n$ ; locale numérique. %n : rejeté
(NON-POSIX-DÉLIBÉRÉ, sécurité — cf. D-M1b.6).
Points d’entrée de support de la glue Rust (air-libc-printf, #[no_mangle],
exportés dans libair_c.so mais hors ABI libc standard — consommés uniquement
par le shim ; non scannés par conformance_labels, hors air-libc-capi/src) :
| Symbole | Étiquette | Note |
|---|---|---|
air_fmt_arg_kinds | AIR-EXT (support interne) | Parse fmt, écrit le plan des kinds d’args (borné), rend le compte ou -1. Ne tire aucun va_arg. |
air_fmt_render_buf | AIR-EXT (support interne) | Rendu borné (snprintf) : ≤ cap octets, rend la longueur théorique. |
air_fmt_render_stream | AIR-EXT (support interne) | Rendu stream : pousse la sortie via un callback (fwrite/copie), rend la longueur théorique ou -1. |
M2 — I/O fichier fd brut + métadonnées + répertoires (air-libc-fileio ; shims air-libc-capi::fileio)
Familles fcntl.h/unistd.h (fd brut), sys/stat.h (métadonnées) et
dirent.h (répertoires). Split « logique / shims C-ABI » : la LOGIQUE
(open_impl/read_impl/stat_impl/opendir_impl/readdir_impl/mkstemp_impl/…,
le remplissage de la struct stat ABI Air, la bufferisation getdents64 derrière
DIR) vit dans air-libc-fileio (Rust pur, mesurée, aucun symbole libc
#[no_mangle] standard), au-dessus de la couche 0 (openat2/read/write/
pread/pwrite/close/lseek/statx/faccessat/getdents64/dup_fd/dup3/
pipe2/getrandom/getcwd/chdir) et de la couche 1 (canonicalize →
realpath) ; les shims extern "C" minces vivent dans air_libc_capi::fileio
(exclus du gate). Sécurité #1 : chemins via C-strings bornées (octets, zéro
présomption UTF-8) ; buffers getdents64/readdir bornés (get, jamais
d’indexation) ; zéro as lossy sur les champs stat (try_from/wrapping_shl) ;
fd empruntés sans double-close.
Sémantique fd brut : le C gère la vie du fd (open rend un int désolidarisé,
close reconstruit l’OwnedFd et le ferme une fois ; les autres empruntent). EINTR
remonté (jamais de retry auto — ADR-021 conv.2). O_CLOEXEC : couche 0 le force
toujours ; open/dup/dup2 effacent FD_CLOEXEC si l’appelant ne l’a pas
demandé, rétablissant la sémantique POSIX (fd héritable par exec) — D-M2.1.
struct stat/dirent/DIR = ABI Air (largeur fixe, arch-indépendant, distinct
de la glibc — comme FILE) : les programmes compilés contre air_c.h voient notre
layout (D-M2.2). Le parseur getdents64 → dirent (données kernel externes) est
fuzzé (fuzz_libc_dirent).
Fonctions non variadiques (shims air-libc-capi::fileio)
| Symbole | Étiquette | Note / déviation | ADR |
|---|---|---|---|
creat | POSIX | ≡ open(path, O_CREAT|O_WRONLY|O_TRUNC, mode) (non variadique). | ADR-046 |
read | POSIX | Lecture partielle possible ; EINTR remonté. | ADR-046 |
write | POSIX | Écriture partielle possible ; EINTR remonté. | ADR-046 |
pread | POSIX | N’altère pas l’offset ; offset < 0 ⇒ EINVAL. | ADR-046 |
pwrite | POSIX | N’altère pas l’offset ; offset < 0 ⇒ EINVAL. | ADR-046 |
readv | POSIX | Lecture vectorisée (iovcnt buffers). Sur AirFileManager::read_vectored (couche 0 readv, sans additif). fd<0 ⇒ EBADF, iovcnt<0 ⇒ EINVAL, iov nul ⇒ EFAULT. | ADR-077 |
writev | POSIX | Écriture vectorisée. Sur AirFileManager::write_vectored. | ADR-077 |
preadv | POSIX | readv à l’offset absolu (n’altère pas la position) ; offset<0 ⇒ EINVAL. Sur AirFileManager::read_vectored_at. | ADR-077 |
pwritev | POSIX | writev à l’offset absolu ; offset<0 ⇒ EINVAL. Sur AirFileManager::write_vectored_at. | ADR-077 |
splice | Linux | Transfert kernel fd_in→fd_out (au moins un pipe) ; off_in/off_out nuls ⇒ position courante, sinon départ explicite mis à jour. fd<0 ⇒ EBADF, *off<0 ⇒ EINVAL. Sur AirFileManager::splice (couche 0 splice, sans additif). | ADR-077 |
close | POSIX | Ferme une fois (reconstruit l’OwnedFd). | ADR-046 |
lseek | POSIX | SEEK_SET/CUR/END + extensions Linux SEEK_DATA/SEEK_HOLE. | ADR-046 |
dup | POSIX | Non close-on-exec (efface FD_CLOEXEC, D-M2.1). | ADR-046 |
dup2 | POSIX | oldfd == newfd valide ⇒ no-op ; non close-on-exec (D-M2.1). | ADR-046 |
dup3 | POSIX | Seul O_CLOEXEC valide ; oldfd == newfd ⇒ EINVAL. | ADR-046 |
pipe | POSIX | Non close-on-exec (D-M2.1). | ADR-046 |
pipe2 | POSIX | O_CLOEXEC/O_NONBLOCK/O_DIRECT ; bit inconnu ⇒ EINVAL. | ADR-046 |
stat | POSIX-PARTIEL | struct stat layout ABI Air (D-M2.2). Suit les liens. | ADR-046 |
fstat | POSIX-PARTIEL | Idem stat sur un fd (AT_EMPTY_PATH). Layout ABI Air (D-M2.2). | ADR-046 |
lstat | POSIX-PARTIEL | Idem, sans suivre le lien final. Layout ABI Air (D-M2.2). | ADR-046 |
fstatat | POSIX-PARTIEL | flags AT_* ; layout ABI Air (D-M2.2). | ADR-046 |
access | POSIX | R_OK/W_OK/X_OK/F_OK, suit les liens. | ADR-046 |
faccessat | POSIX | Honore AT_SYMLINK_NOFOLLOW/AT_EACCESS (bascule faccessat2). | ADR-046 |
opendir | POSIX | O_RDONLY|O_DIRECTORY ; DIR opaque. | ADR-046 |
fdopendir | POSIX | Prend possession du fd (fermé par closedir). | ADR-046 |
readdir | POSIX | dirent * interne réutilisé (écrasé au prochain appel) ; fin ⇒ NULL sans errno. Nom > NAME_MAX sauté (D-M2.5). | ADR-046/032 |
closedir | POSIX | Ferme le fd + libère le DIR. | ADR-046 |
rewinddir | POSIX | lseek(0) + vidage du tampon. | ADR-046 |
mkstemp | POSIX | O_CREAT|O_EXCL, 0600, entropie kernel (getrandom). Patron invalide ⇒ EINVAL ; espace épuisé ⇒ EEXIST. | ADR-046 |
mkdtemp | POSIX | mkdir 0700, entropie kernel ; rend template. | ADR-046 |
realpath | POSIX-PARTIEL | Sur canonicalize (couche 1). realpath(path, NULL) (forme GNU malloc) différée ⇒ EINVAL (D-M2.4). Canonique ≥ PATH_MAX ⇒ ENAMETOOLONG. | ADR-046 |
getcwd | POSIX-PARTIEL | getcwd(NULL, 0) (forme GNU) différée ⇒ EINVAL (D-M2.4) ; buffer trop petit ⇒ ERANGE. | ADR-046 |
chdir | POSIX | Change le répertoire de travail. | ADR-046 |
fchdir | POSIX | Idem par descripteur de répertoire. | ADR-046 |
mkdir | POSIX | Crée un répertoire (mode filtré par umask). Sur AirFileManager (couche 1, ADR-077). | ADR-046/077 |
rmdir | POSIX | Supprime un répertoire vide (unlinkat+AT_REMOVEDIR). Sur AirFileManager. | ADR-046/077 |
unlink | POSIX | Supprime fichier/lien (no-follow). Sur AirFileManager. | ADR-046/077 |
ftruncate | POSIX | Fixe la taille d’un fd (tronque/étend). length < 0 ⇒ EINVAL. Sur AirFileManager. | ADR-046/077 |
fsync | POSIX | Flush données et métadonnées d’un fd. Sur AirFileManager. | ADR-046/077 |
fdatasync | POSIX | Flush données d’un fd. Sur AirFileManager. | ADR-046/077 |
rename | POSIX | Renomme/déplace (renameat2 sans drapeau). Sur AirFileManager. | ADR-046/077 |
link | POSIX | Lien physique (linkat, lien final non suivi). Sur AirFileManager. | ADR-046/077 |
symlink | POSIX | Lien symbolique (symlinkat). Sur AirFileManager. | ADR-046/077 |
readlink | POSIX | Cible d’un lien (readlinkat), sans NUL ; tronqué à bufsiz ; bufsiz==0 ⇒ EINVAL. Sur AirFileManager. | ADR-046/077 |
chmod | POSIX | Permissions (fchmodat, suit les liens). Sur AirFileManager. | ADR-046/077 |
chown | POSIX | Propriétaire/groupe (fchownat, suit les liens). Sur AirFileManager. | ADR-046/077 |
lchown | POSIX | Comme chown mais no-follow (AT_SYMLINK_NOFOLLOW). Sur AirFileManager. | ADR-046/077 |
dirfd | POSIX | fd sous-jacent d’un DIR (possédé, ne pas fermer). Accès direct au flux. | ADR-046 |
flock | POSIX | Verrou consultatif (LOCK_SH/EX/UN ± NB). Sur AirFileManager. | ADR-046/077 |
mkfifo | POSIX | Tube nommé (mknodat+S_IFIFO). Sur AirFileManager. | ADR-046/077 |
utimensat | POSIX | Horodatages atime/mtime (UTIME_NOW/UTIME_OMIT ; times nul ⇒ now). tv_nsec hors plage ⇒ EINVAL. Sur AirFileManager. | ADR-046/077 |
fchmod | POSIX | Permissions par fd (additif couche 0 fchmod, ADR-085). fd < 0 ⇒ EBADF. Sur AirFileManager::change_mode_by_fd. | ADR-085 |
fchown | POSIX | Propriétaire/groupe par fd (additif couche 0 fchown, ADR-085). (uid,gid)=(−1,−1) ⇒ inchangé ; fd < 0 ⇒ EBADF. Sur AirFileManager::change_owner_by_fd. | ADR-085 |
futimens | POSIX | Horodatages par fd (additif couche 0 futimens = utimensat(fd, NULL, …), ADR-085). times nul ⇒ now ; tv_nsec hors plage ⇒ EINVAL ; fd < 0 ⇒ EBADF. Sur AirFileManager::set_times_by_fd. | ADR-085 |
sendfile | POSIX | Transfert zéro-copie kernel in_fd→out_fd (additif couche 0 sendfile, ADR-085). offset nul ⇒ position courante de in_fd ; sinon départ explicite mis à jour. Rend les octets transférés ; fd < 0 ⇒ EBADF, *offset < 0 ⇒ EINVAL. Sur AirFileManager::send_file. | ADR-085 |
Fonctions variadiques (shim C fileio_shim.c, déclarées air_c.h — trailer)
| Symbole | Étiquette | Note / déviation | ADR |
|---|---|---|---|
open | POSIX | 3e arg mode lu si O_CREAT. Non close-on-exec par défaut (D-M2.1). | ADR-046 |
openat | POSIX | Comme open, relatif à dirfd. | ADR-046 |
fcntl | POSIX-PARTIEL | Commandes à argument int (F_DUPFD/F_DUPFD_CLOEXEC/F_GETFD/F_SETFD/F_GETFL/F_SETFL). F_GETFL sans mode d’accès (D-M2.3) ; verrous F_*LK différés ⇒ EINVAL. | ADR-046 |
Points d’entrée de support (glue variadique, air-libc-fileio, #[no_mangle])
Exportés dans libair_c.so (consommés uniquement par le shim C fileio_shim.c ;
hors ABI libc standard, non scannés par conformance_labels — hors air-libc-capi/src).
| Symbole | Étiquette | Note |
|---|---|---|
air_libc_open | AIR-EXT (support interne) | Point d’entrée non-variadique d’open (le shim a fait le va_arg du mode). |
air_libc_openat | AIR-EXT (support interne) | Idem openat. |
air_libc_fcntl | AIR-EXT (support interne) | Point d’entrée non-variadique de fcntl (arg entier déjà tiré). |
Constantes <fcntl.h>/<unistd.h>/<sys/stat.h>/<dirent.h>
Étiquette POSIX (toutes, littéraux ABI Linux). O_* : O_RDONLY(0)…O_CLOEXEC
(0o2000000) ; O_DIRECTORY/O_NOFOLLOW arch-dépendants (trailer #ifdef).
F_* : F_DUPFD(0)…F_DUPFD_CLOEXEC(1030), FD_CLOEXEC(1). AT_* : AT_FDCWD
(-100), AT_SYMLINK_NOFOLLOW(0x100), AT_REMOVEDIR/AT_EACCESS(0x200),
AT_NO_AUTOMOUNT(0x800), AT_EMPTY_PATH(0x1000). Accès : F_OK(0)/X_OK(1)/
W_OK(2)/R_OK(4). S_* : S_IFMT…S_IXOTH (+ macros S_IS* au trailer). DT_* :
DT_UNKNOWN(0)…DT_SOCK(12).
Types (struct stat/timespec/struct dirent/DIR)
| Symbole | Étiquette | Note | ADR |
|---|---|---|---|
struct stat | POSIX-PARTIEL | Layout ABI Air (largeur fixe, arch-indépendant ; D-M2.2). Champs st_dev/st_ino/st_nlink/st_mode/st_uid/st_gid/st_rdev/st_size/st_blksize/st_blocks/st_atim/st_mtim/st_ctim. | ADR-046 |
struct timespec | POSIX | tv_sec/tv_nsec (i64). | ADR-046 |
struct dirent | POSIX-PARTIEL | Layout ABI Air ; d_name[256] borné (NAME_MAX+1). | ADR-046 |
DIR | POSIX | Type opaque (typedef struct DIR DIR;), comme la norme. | ADR-046 |
M3 — signaux (signal.h) : air-libc-signal ; shims air-libc-capi::signal
Famille signaux. Split « logique / shims C-ABI » : la LOGIQUE
(sigaction_impl/kill_impl/sigprocmask_impl/…, la table process-globale de
dispositions, la traduction struct sigaction↔Disposition) vit dans
air-libc-signal (Rust pur, mesurée, aucun #[no_mangle]), au-dessus de la
couche 1 (air-signal : install_handler async non-faute, masque, envoi,
pending ; air-thread::raw_futex pour le sigsuspend interruptible) — jamais
air-sys-syscall (empilement strict ADR-066). Les shims extern "C" minces vivent
dans air_libc_capi::signal (exclus du gate).
LE POINT DE SÉCURITÉ (ADR-064). sigaction/signal sur les 4 fautes
(SIGSEGV/SIGBUS/SIGFPE/SIGILL) ACCEPTENT (retour 0) et mémorisent la
disposition (illusion d’API : les getters rendent la valeur posée) mais
N’INSTALLENT RIEN côté kernel — une faute prend toujours l’action par défaut
(coredump). Déviation NON-POSIX-DÉLIBÉRÉE majeure (D-M3.1). Les signaux
gérables (non-faute) sont, eux, installés en async réel (le kernel appelle le
handler C, ADR-066).
| Symbole | Étiquette | Note / déviation | ADR |
|---|---|---|---|
sigemptyset | POSIX | Vide sigset_t. | ADR-046 |
sigfillset | POSIX | Peuple sigset_t (__bits[0] = tous). | ADR-046 |
sigaddset | POSIX | signo hors [1, 64] ⇒ EINVAL. | ADR-046 |
sigdelset | POSIX | Idem sigaddset. | ADR-046 |
sigismember | POSIX | 1/0 ; signo invalide ⇒ EINVAL. | ADR-046 |
sigaction | POSIX (gérables) / NON-POSIX-DÉLIBÉRÉ (fautes) | Non-fautes : install async réel (air-signal). Fautes : mémorisé mais jamais installé → coredump (D-M3.1). SIGKILL/SIGSTOP ⇒ EINVAL. | ADR-064/066 |
signal | POSIX (gérables) / NON-POSIX-DÉLIBÉRÉ (fautes) | Sémantique glibc (SA_RESTART, masque vide). Rend le handler précédent ou SIG_ERR. Fautes idem sigaction (D-M3.1). | ADR-064/066 |
sigprocmask | POSIX | Masque per-thread (air-signal). how invalide ⇒ EINVAL. | ADR-046 |
pthread_sigmask | POSIX | Idem, convention pthread_* (rend le code errno). | ADR-046 |
sigpending | POSIX | air-signal::pending. set nul ⇒ EFAULT. | ADR-046 |
sigsuspend | POSIX-PARTIEL | Composé replace_mask + futex_wait interruptible ; petite fenêtre de course vs rt_sigsuspend atomique (D-M3.3). Rend toujours -1/EINTR. | ADR-066 |
kill | POSIX-PARTIEL | pid > 0 seulement ; pid <= 0 (groupe/diffusion) ⇒ EINVAL (D-M3.2). signum == 0 = test d’existence. | ADR-046 |
killpg | POSIX | Signal à tout un groupe de processus (pgrp == 0 = groupe de l’appelant ; pgrp < 0 ⇒ EINVAL). Sur l’additif couche 0 kill_process_group (ADR-085) via air-signal::send_signal_to_process_group. Lève la restriction « groupe différé » de D-M3.2. | ADR-085 |
raise | POSIX | Envoie au thread appelant. | ADR-046 |
sigaltstack | POSIX | Installe/interroge la pile de signal alternative du thread (pour un SIGSEGV de débordement de pile, usage std). Additif couche 0 sigaltstack (ADR-085) + type AltStack (stack_t) → AirSignalManager::set_alt_stack. Pile trop petite ⇒ ENOMEM/EINVAL. | ADR-085 |
pause | POSIX | Suspend jusqu’à un signal capté ; rend toujours -1/EINTR. Composé sur AirSignalManager::pause = ppoll(0 fd, ∞, masque courant) (sans additif couche 0). Chemin bloquant = exception de couverture (non injectable, comme le EINTR d’air-poll). | ADR-085 |
Types & constantes <signal.h> (ABI Air, trailer hand-written)
sigset_t (128 octets, parité glibc ; seul __bits[0] porté), struct sigaction
(union sa_handler/sa_sigaction, sa_mask, sa_flags, sa_restorer — layout
ABI Air, D-M3.7), siginfo_t (128 octets, champs si_signo/si_code/si_addr/
si_pid/si_uid/si_status), sighandler_t (void (*)(int)). Constantes
(POSIX) : SIG_DFL(0)/SIG_IGN(1)/SIG_ERR(-1) ; SIG_BLOCK(0)/SIG_UNBLOCK(1)/
SIG_SETMASK(2) ; SIGHUP(1)…SIGSYS(31), SIGRTMIN/SIGRTMAX ; SA_*
(SA_NOCLDSTOP=1…SA_RESTART=0x10000000…SA_RESETHAND=0x80000000, identiques
x86_64/aarch64). Écrits à la main dans le trailer cbindgen.toml (union +
arch-vérifiés), exclus de la génération cbindgen.
M3 — threads (pthread.h) : air-libc-thread ; shims air-libc-capi::thread
Famille pthread. Split « logique / shims » : la LOGIQUE
(pthread_mutex_lock_impl/pthread_cond_wait_impl/…) vit dans air-libc-thread
(Rust pur, mesurée), bâtie sur air-thread::raw_futex opérant sur le mot futex
dans la mémoire opaque du pthread_*_t fourni par l’appelant (jamais un
AirMutex qui posséderait son stockage — ADR-066). Types opaques ABI Air à
largeur fixe (parité taille glibc), zéro-initialisables
(PTHREAD_MUTEX_INITIALIZER = {{0}}). Empilement strict : consomme uniquement
air-thread (jamais air-sys-syscall). Le cycle de vie (pthread_create/join/
…) et le TLS reposent sur le spawn/std::thread d’air-thread — hôte
seulement (cfg(not(target_env = "air"))), comme air_thread::thread.
Synchronisation (bâtie sur futex, exportée partout)
| Symbole | Étiquette | Note | ADR |
|---|---|---|---|
pthread_mutex_init / pthread_mutex_destroy | POSIX | Type via attribut ; destroy d’un mutex verrouillé ⇒ EBUSY. | ADR-066 |
pthread_mutex_lock / pthread_mutex_trylock / pthread_mutex_unlock | POSIX | Mutex futex 3 états ; NORMAL/RECURSIVE/ERRORCHECK (owner = Tid) ; EDEADLK/EBUSY/EAGAIN/EPERM. | ADR-066 |
pthread_mutex_timedlock | POSIX | Échéance absolue CLOCK_REALTIME ; ETIMEDOUT. | ADR-066 |
pthread_mutexattr_init / destroy / settype / gettype | POSIX | type NORMAL/RECURSIVE/ERRORCHECK. | ADR-066 |
pthread_cond_init / destroy / signal / broadcast | POSIX | Condition futex par compteur de séquence ; broadcast = FUTEX_WAKE ALL. | ADR-066 |
pthread_cond_wait / pthread_cond_timedwait | POSIX | Déverrouille/attend/ré-acquiert ; échéance absolue (REALTIME/MONOTONIC selon attribut) ; ETIMEDOUT. | ADR-066 |
pthread_condattr_init / destroy / setclock / getclock | POSIX | Horloge REALTIME (défaut) / MONOTONIC. | ADR-066 |
pthread_rwlock_init / destroy | POSIX | destroy d’un verrou détenu ⇒ EBUSY. | ADR-066 |
pthread_rwlock_rdlock / tryrdlock / wrlock / trywrlock / unlock | POSIX-PARTIEL | Mot d’état lecteurs/écrivain sur futex ; lecteur-préférentiel (famine d’écrivain possible sous charge continue — D-M3.9) ; EBUSY/EPERM. Variantes timed* différées. | ADR-066 |
pthread_rwlockattr_init / destroy | POSIX | No-op (aucun attribut honoré en M3). | ADR-066 |
pthread_once | POSIX | Exécute init_routine exactement une fois (état futex 3 valeurs). | ADR-066 |
pthread_attr_init / destroy / setdetachstate / getdetachstate / setstacksize / getstacksize | POSIX | Attributs honorés : état detach + taille de pile (autres différés). | ADR-066 |
Cycle de vie + TLS (hôte uniquement, cfg(not(target_env = "air")))
| Symbole | Étiquette | Note / déviation | ADR |
|---|---|---|---|
pthread_create | POSIX | Sur le spawn d’air-thread. Honore detach/pile ; ENOMEM à l’échec. pthread_t = Tid noyau (D-M3.4). | ADR-066 |
pthread_join | POSIX | Récupère la valeur de retour ; ESRCH/EINVAL. Thread paniqué ⇒ retour NULL. | ADR-066 |
pthread_detach | POSIX | Rend non joignable ; ESRCH/EINVAL. | ADR-066 |
pthread_self | POSIX | Tid noyau du thread courant (air_thread::runtime_primitives::current_tid, D-M3.4). Export cible aussi (target_vendor="air", ADR-086). | ADR-066 |
pthread_getattr_np | POSIX-PARTIEL | Initialise l’attribut avec les bornes de pile du thread principal (air_runtime::main_thread_stack, ADR-086). Threads spawnés différés. Export cible-only. | ADR-086 |
pthread_attr_getstack | POSIX | Lit (stackaddr, stacksize) d’un attribut renseigné par pthread_getattr_np. | ADR-086 |
pthread_attr_getguardsize | POSIX | Lit la taille de garde de pile d’un attribut renseigné par pthread_getattr_np. | ADR-086 |
pthread_equal | POSIX | Égalité de pthread_t. | ADR-066 |
pthread_exit | NON-POSIX-DÉLIBÉRÉ | Additif REMONTÉ (D-M3.5) : pas de sortie de thread propre en couche 1 + panic = "abort". Rejoue les destructeurs TLS puis sommeil indéfini du thread appelant (ne revient jamais, sans abort du processus). | ADR-064/066 |
pthread_key_create / pthread_key_delete | POSIX | Registre de clés borné (PTHREAD_KEYS_MAX) ; EAGAIN si épuisé. | ADR-066 |
pthread_setspecific / pthread_getspecific | POSIX | Valeurs par-thread. Hôte : std::thread_local! (destructeurs via Drop). Cible : #[thread_local] natif (target_tls, ADR-086) ; rejeu des destructeurs à la sortie d’un thread spawné = crochet couche 1 différé (thread principal réclamé à la sortie du processus). | ADR-066 |
Types & constantes <pthread.h> (ABI Air, trailer hand-written)
Types opaques largeur/alignement FIXES (D-M3.7) : pthread_mutex_t (40),
pthread_cond_t (48), pthread_rwlock_t (56), pthread_once_t (4),
pthread_attr_t (56), pthread_mutexattr_t/pthread_condattr_t (4),
pthread_rwlockattr_t (8) ; pthread_t = unsigned long (parité glibc, encode le
Tid), pthread_key_t = unsigned int. Initialiseurs (POSIX) :
PTHREAD_MUTEX_INITIALIZER/PTHREAD_COND_INITIALIZER/PTHREAD_RWLOCK_INITIALIZER
(= {{0}}), PTHREAD_ONCE_INIT (= 0). Constantes : PTHREAD_MUTEX_NORMAL(0)/
RECURSIVE(1)/ERRORCHECK(2)/DEFAULT(0) ; PTHREAD_CREATE_JOINABLE(0)/
DETACHED(1) ; CLOCK_REALTIME(0)/CLOCK_MONOTONIC(1) ; struct timespec
(partagée avec M2). Écrits à la main dans le trailer cbindgen.toml, exclus de
la génération cbindgen.
Liste exhaustive des symboles M3 (référence du gate conformance_labels)
Chaque extern "C" ci-dessous porte son étiquette dans le /// de son shim
(air-libc-capi::{signal,thread}) et figure verbatim ici (le test vérifie la
présence de chaque nom) :
sigemptyset, sigfillset, sigaddset, sigdelset, sigismember, sigaction,
signal, sigprocmask, pthread_sigmask, sigpending, sigsuspend, kill,
raise, pthread_mutex_init, pthread_mutex_destroy, pthread_mutex_lock,
pthread_mutex_trylock, pthread_mutex_timedlock, pthread_mutex_unlock,
pthread_mutexattr_init, pthread_mutexattr_destroy, pthread_mutexattr_settype,
pthread_mutexattr_gettype, pthread_cond_init, pthread_cond_destroy,
pthread_cond_signal, pthread_cond_broadcast, pthread_cond_wait,
pthread_cond_timedwait, pthread_condattr_init, pthread_condattr_destroy,
pthread_condattr_setclock, pthread_condattr_getclock, pthread_rwlock_init,
pthread_rwlock_destroy, pthread_rwlock_rdlock, pthread_rwlock_tryrdlock,
pthread_rwlock_wrlock, pthread_rwlock_trywrlock, pthread_rwlock_unlock,
pthread_rwlockattr_init, pthread_rwlockattr_destroy, pthread_once,
pthread_attr_init, pthread_attr_destroy, pthread_attr_setdetachstate,
pthread_attr_getdetachstate, pthread_attr_setstacksize,
pthread_attr_getstacksize, pthread_create, pthread_join, pthread_detach,
pthread_self, pthread_equal, pthread_exit, pthread_key_create,
pthread_key_delete, pthread_setspecific, pthread_getspecific.
Journal des déviations (roadmap §8)
| # | Symbole | Type de déviation | Justification | ADR |
|---|---|---|---|---|
| D-M0.1 | __libc_start_main | Signature 4-args (vs 7-args glibc) | crt d’Air (pas gcc) ; crochets init/fini hors M0 | ADR-046/050 |
| D-M0.2 | __errno_location | Nom = convention ABI Linux (hors texte POSIX) | Compat ABI C indispensable pour OpenSSH ; sémantique POSIX conforme | ADR-046 |
| D-M1b.1 | malloc / calloc | malloc(0)/calloc(0,x) rendent un pointeur unique non-NULL libérable | Choix POSIX-autorisé, plus sûr : free() du retour toujours valide | ADR-056 |
| D-M1b.2 | realloc | realloc(p, 0) == free(p) puis rend NULL | Comportement glibc historique (C17 implementation-defined) — gravé et documenté | ADR-056 |
| D-M1b.3 | calloc | Produit nmemb * size checked ⇒ débordement = NULL + ENOMEM | Défense anti heap-overflow (sécurité #1) — un calloc naïf débordant ouvrirait un overflow | ADR-056 |
| D-M1b.4 | aligned_alloc | Contrainte C11 « size multiple de alignment » relâchée | Aligné sur glibc — plus permissif, jamais moins sûr | ADR-056 |
| D-M1b.5 | strdup / strndup | strdup(NULL)/strndup(NULL, …) rendent NULL (durci) | Évite le déréférencement d’un NULL indéfini (sécurité #1) | ADR-056 |
| D-M1b.6 | moteur de format (interne) | %n rejeté — rendu littéral, aucune écriture mémoire | Vecteur d’exploitation format-string classique (écriture-arrière) neutralisé par construction (sécurité #1) | ADR-046/047 |
| D-M1c.1 | setvbuf / setbuf | Tampon fourni par l’appelant IGNORÉ (seuls mode/size honorés) | Sécurité #1 : ne jamais aliaser une mémoire C au cycle de vie incontrôlé (classe de bugs stdio) ; Air gère toujours son tampon interne | ADR-032/046 |
| D-M1c.2 | getenv | Pointeur rendu stable pour la vie du processus (au lieu de « jusqu’au prochain setenv ») | Environ scellé lecture seule en M1-c (pas de setenv) ⇒ aucune invalidation possible ; cache à pointeur figé, race-safe vis-à-vis du futur setenv | ADR-046 |
| D-M1c.3 | atexit | atexit(NULL) rend une valeur non nulle (durci) | Évite l’appel indéfini d’un handler nul (sécurité #1) | ADR-046 |
| D-M2.1 | open/openat/creat/dup/dup2/pipe | Le fd rendu n’est pas close-on-exec par défaut (POSIX) — on efface FD_CLOEXEC que la couche 0 force | La couche 0 force O_CLOEXEC (posture Air) ; POSIX/OpenSSH exigent des fd héritables par exec. La fenêtre cloexec transitoire est un durcissement | ADR-021/046 |
| D-M2.2 | struct stat/dirent/DIR | Layout ABI Air (largeur fixe, arch-indépendant), distinct de la glibc | C’est notre ABI (comme FILE) : les programmes compilent contre air_c.h. Évite le layout glibc arch-dépendant/padding | ADR-046 |
| D-M2.3 | fcntl (F_GETFL) | Ne restitue pas le mode d’accès (O_RDONLY/WRONLY/RDWR) ; verrous F_*LK différés ⇒ EINVAL | La couche 0 get_status_flags ne modélise que les drapeaux de statut ; le round-trip F_GETFL|O_NONBLOCK→F_SETFL (cas OpenSSH) reste correct. Verrous = additif si besoin réel | ADR-046 |
| D-M2.4 | realpath/getcwd | Forme GNU allouante (realpath(_,NULL)/getcwd(NULL,0)) différée ⇒ EINVAL | Pas d’allocation malloc dans la crate mesurée (le buffer appelant suffit au cas courant OpenSSH). Additif possible via le shim | ADR-046 |
| D-M2.5 | readdir | Un nom d’entrée > NAME_MAX (255) est sauté (jamais tronqué en silence) | ADR-032 (zéro troncature muette) : un nom débordant d_name[256] ne peut être rendu intègre ⇒ omis plutôt que tronqué | ADR-046/032 |
| D-M3.1 | sigaction/signal (fautes) | Les 4 fautes (SIGSEGV/SIGBUS/SIGFPE/SIGILL) acceptent (retour 0) et mémorisent la disposition, mais aucun handler n’est installé côté kernel — une faute prend toujours l’action par défaut (coredump) | Doctrine « fail-fast » d’Air : aucun code utilisateur dans un contexte corrompu (un handler de crash est une surface d’exploitation). Illusion d’API maintenue pour la compat source | ADR-064 |
| D-M3.2 | kill | pid <= 0 (groupe de processus / diffusion) non supporté ⇒ EINVAL | La couche 1 send_signal_to_process modélise un Pid positif ; groupe/diffusion = additif si besoin réel OpenSSH | ADR-046 |
| D-M3.3 | sigsuspend | Petite fenêtre de course (masque posé puis attente, non atomiques) vs rt_sigsuspend kernel | Pas d’rt_sigsuspend atomique en couche 1 (escalade additif) ; composition replace_mask + futex_wait interruptible, correcte hors fenêtre minuscule | ADR-066 |
| D-M3.4 | pthread_t | pthread_t = Tid noyau (élargi en unsigned long), non un pointeur opaque de TCB glibc | Identité de thread stable, obtenue via air_thread::runtime_primitives::current_tid (couche 1) ; pthread_self/equal/join cohérents. Réutilisation de Tid après mort de thread = caveat pthread standard | ADR-066 |
| D-M3.5 | pthread_exit | Ne réalise pas une sortie de thread propre : rejoue les destructeurs TLS puis endort indéfiniment le thread appelant (valeur non récupérable par join) | air-thread n’expose aucune sortie de thread sans unwind, et l’artefact est panic = "abort" (pas d’unwind) → additif couche 1 REMONTÉ, sans invention de mécanisme ; comportement confiné au thread appelant (ni abort du processus, ni corruption) | ADR-064/066 |
| D-M3.6 | pthread_setspecific/getspecific (destructeurs) | Destructeurs TLS rejoués à la sortie de thread via std (hôte) ; absents sur la cible *-linux-air | air-thread n’expose pas de crochet de destruction TLS à la sortie de thread → additif couche 1 REMONTÉ ; sur l’hôte mesuré, la voie std (thread_local! Drop) les rejoue réellement | ADR-066 |
| D-M3.7 | sigset_t/struct sigaction/pthread_*_t | Layout ABI Air (largeur fixe, arch-indépendant, disposition interne propre), distinct de la glibc bit-à-bit (parité taille seulement) | C’est notre ABI (comme FILE/struct stat) : les programmes compilent contre air_c.h. Évite le layout glibc arch-dépendant | ADR-046/066 |
| D-M3.8 | pthread_cond_timedwait (broadcast) | broadcast réveille tous les waiters (FUTEX_WAKE ALL, troupeau) plutôt que FUTEX_REQUEUE | Correction avant performance (Principe 5) ; zéro réveil perdu, zéro deadlock. requeue = optimisation additive si mesure le justifie | ADR-066 |
| D-M3.9 | pthread_rwlock_* | Verrou lecteur-préférentiel (famine d’écrivain possible sous charge de lecture continue) ; variantes timed* différées | Implémentation correcte et simple sur futex (Principe 5) ; verrou d’équité = additif si un besoin réel émerge | ADR-066 |
Additifs couche 1 v1.x déclenchés par la libc (roadmap §8)
| Additif | Crate couche 1 | Raison (libc) | Statut |
|---|---|---|---|
AirRuntime::errno_location() -> *mut i32 | air-runtime | Socle de __errno_location() : l’errno scellé n’exposait que errno()/set_errno() (par valeur) ; C exige l’adresse (lvalue). | Additif v1.x — RFC de descellement additif requis (ADR-062). Premier additif révélé par la libc (roadmap §6). |
air_env::set_var/unset_var (mutation d’environ) | air-env | setenv/putenv/unsetenv de la libc — M1-c ne fournit que getenv (environ lecture seule). Le design du cache getenv (pointeur figé) est déjà compatible avec un setenv futur. | Additif v1.x à poser (délégation B) si un besoin OpenSSH réel émerge — getenv seul suffit à la lecture d’$HOME/$PATH/… (le cas courant). Signalé par M1-c. |
Sortie de thread propre (air-thread) | air-thread | pthread_exit(retval) doit terminer le thread appelant sans unwind (artefact panic = "abort") en rendant sa valeur récupérable par join. La couche 1 n’expose aucune telle primitive → M3 endort le thread (D-M3.5). | Additif à poser — révélé par M3 (roadmap §8). Le spawn Air-natif (air-runtime, cible) devra exposer une sortie de thread propre. |
Crochet de destruction TLS à la sortie de thread (air-thread) | air-thread | Destructeurs pthread_key_create à rejouer à la fin de chaque thread, y compris sur cible *-linux-air (M3 ne les rejoue que via std, hôte — D-M3.6). | Additif à poser — révélé par M3. Lié au spawn Air-natif. |
rt_sigsuspend atomique (couche 0/1) | air-signal | sigsuspend POSIX exige masque + attente atomiques ; M3 compose replace_mask + futex_wait (petite fenêtre de course, D-M3.3). | Additif à poser si la course s’avère problématique — révélé par M3. |
kill groupe de processus / diffusion (air-signal) | air-signal | send_signal_to_process prend un Pid positif ; kill(pid<=0, …) (groupe/diffusion, killpg) non modélisé (D-M3.2). | Additif à poser si un besoin OpenSSH réel émerge — révélé par M3. |
Sommeil non-retry (air-base-core) | air-base-core | nanosleep POSIX doit rendre EINTR + le temps restant à l’appelant, sans retry auto ; or air-base-core::sleep réessaie en interne sur EINTR. | Additif à poser — révélé par M4. Exige un sommeil couche 1 qui remonte EINTR+reste (base de nanosleep/clock_nanosleep/select/poll timeouts). |
clock_getres (couche 1) | air-base-core | clock_getres (résolution d’horloge) — non exposé par la couche 1. | Additif à poser si un besoin réel émerge — révélé par M4. |
Base tz + calendrier (strftime/localtime_r) | air-base-lib (icu4x) | strftime/localtime_r/gmtime_r exigent la base tz + le calendaire (roadmap i18n, icu4x). | Différé — dépend du pipeline données i18n (ADR-059). Révélé par M4. |
M4 — temps (time.h/sys/time.h) : air-libc-time ; shims air-libc-capi::time
Lectures d’horloge de la libc, bâties exclusivement sur la couche 1
(air-base-core : AirSystemTime = CLOCK_REALTIME, AirInstant =
CLOCK_MONOTONIC) — jamais air-sys-syscall (empilement strict). La valeur
monotone brute est reconstruite via l’API publique
(AirInstant::now().duration_since(AirInstant::from_monotonic_micros(0, 0))),
sans additif couche 1. Le sommeil (nanosleep), la résolution (clock_getres)
et le formatage tz (strftime/localtime_r) sont remontés comme additifs
(section suivante).
Fonctions
| Symbole | Étiquette | Notes | ADR |
|---|---|---|---|
clock_gettime | POSIX | CLOCK_REALTIME/CLOCK_MONOTONIC ; horloge inconnue ⇒ EINVAL ; tp nul ⇒ EFAULT. Validation en amont (horloge avant pointeur). | ADR-046 |
gettimeofday | POSIX | Obsolescent. tv murale (s + µs, troncature ns→µs conforme) ; tz ignoré (obsolète, jamais déréférencé) ; tv nul ⇒ EFAULT. | ADR-046 |
time | POSIX | Secondes Unix murales ; time(NULL) valide (rend sans écrire) ; (time_t)-1 + errno sur échec (inatteignable). | ADR-046 |
Types
timespec (POSIX, tag dédupliqué avec air-libc-fileio par cbindgen),
timeval (POSIX), time_t/suseconds_t/clockid_t (POSIX) : largeur
fixe arch-indépendante — tv_sec/tv_nsec/tv_usec = int64_t (64 bits sur
x86_64 ET aarch64), clockid_t = int. Constantes CLOCK_REALTIME/
CLOCK_MONOTONIC déjà émises par M3 (pthread_cond).
M4 — comptes (pwd.h) : air-libc-pwd ; shims air-libc-capi::pwd
Variantes réentrantes (_r) interrogeant /etc/passwd, lu via la couche 1
(AirFileSystem::read_to_bytes, bornée 1 MiB) — jamais air-sys-syscall. Parseur
pur mesuré + empaquetage borné dans le buffer appelant (sécurité : getpwnam_r =
vecteur d’overflow ; buffer trop petit ⇒ ERANGE, jamais de dépassement). Convention
*_r : rendent l’errno directement (0 = trouvé/absent ; >0 = erreur), sans
toucher l’errno global. Les formes non-réentrantes getpwnam/getpwuid
(stockage statique, shims air-libc-capi::pwd) enveloppent les _r : elles
rendent un struct passwd * (écrasé au prochain appel), NULL si absent, NULL +
errno global sur erreur — non thread-safe par contrat POSIX.
Fonctions
| Symbole | Étiquette | Notes | ADR |
|---|---|---|---|
getpwnam_r | POSIX | Entrée par nom ; *result=&pwd (trouvée) / NULL (absente), retour 0 ; ERANGE (buffer trop petit) ; EINVAL (pointeur nul) ; errno de lecture propagé. Empaquetage borné. | ADR-046 |
getpwuid_r | POSIX | Comme getpwnam_r, recherche par UID. | ADR-046 |
getpwnam/getpwuid | POSIX | Formes non-réentrantes (requises par OpenSSH) : struct passwd * vers un stockage statique (NSS_BUFLEN_PASSWD = 1024), NULL si absent, NULL+errno global (ERANGE) sur erreur. Non thread-safe — variantes _r en concurrence. | ADR-046 |
Types
struct passwd (POSIX : pw_name/pw_passwd/pw_uid/pw_gid/pw_gecos/pw_dir/pw_shell, char * dans le buffer appelant), uid_t/gid_t (POSIX, unsigned int 32 bits x86_64 ET aarch64).
M4 — groupes (grp.h) : air-libc-grp (binder) sur air-account
Variantes réentrantes (_r) interrogeant /etc/group via la brique couche 1
air-account (ADR-067) — la libc ne réimplémente rien, elle bind + empaquette
l’ABI C. Empaquetage borné du buffer appelant, y compris le tableau gr_mem
(char **, NULL-terminé, aligné pointeur — sécurité). Convention *_r : errno
rendu directement (0 = trouvé/absent ; ERANGE/EINVAL).
Fonctions
| Symbole | Étiquette | Notes | ADR |
|---|---|---|---|
getgrnam_r | POSIX | Entrée name de /etc/group empaquetée dans buf (dont gr_mem aligné/NULL-terminé) ; *result=&grp/NULL (retour 0) ; ERANGE ; EINVAL (pointeur nul). | ADR-046, ADR-067 |
getgrgid_r | POSIX | Comme getgrnam_r, recherche par GID. | ADR-046, ADR-067 |
getgrnam/getgrgid | POSIX | Formes non-réentrantes (requises par OpenSSH) : struct group * vers un stockage statique (4096 octets, gr_mem inclus), NULL si absent, NULL+errno global (ERANGE) sur erreur. Non thread-safe — variantes _r en concurrence. | ADR-046, ADR-067 |
Types
struct group (POSIX : gr_name/gr_passwd/gr_gid/gr_mem) — char * et char **gr_mem pointent dans le buffer appelant. gid_t (POSIX, unsigned int 32 bits, déjà émis par M4 comptes).
M4 — shadow (shadow.h) : air-libc-shadow (binder) sur air-account
Variante réentrante getspnam_r interrogeant /etc/shadow (root-only) via la brique couche 1 air-account (ADR-067). SENSIBLE : le hachage transite par air-account où il est zeroïsé (ShadowHash, écriture volatile) et jamais journalisé (Debug redacté) ; la comparaison temps-constant relève d’air-crypto. Le codec decode seul (l’écriture de /etc/shadow est différée, Principe 5).
Fonctions
| Symbole | Étiquette | Notes | ADR |
|---|---|---|---|
getspnam_r | NON-POSIX-DÉLIBÉRÉ | shadow.h = extension Linux/glibc (hors POSIX), fournie car requise par OpenSSH (auth mot de passe). *result=&sp/NULL (retour 0) ; ERANGE ; EINVAL ; EACCES en non-privilégié (root-only). | ADR-046, ADR-067 |
Types
struct spwd (NON-POSIX-DÉLIBÉRÉ, extension shadow : sp_namp/sp_pwdp/sp_lstchg/sp_min/sp_max/sp_warn/sp_inact/sp_expire/sp_flag) — champs de vieillissement long (-1 = non défini) ; sp_namp/sp_pwdp pointent dans le buffer appelant.
M4 — identifiants (unistd.h/grp.h) : air-libc-id sur air-process::credentials (ADR-068)
Getters/setters d’identifiants du processus courant, bindant la surface fine air-process::credentials (getters + set_resuid/set_resgid + groupes) sur la couche 0 existante — aucun additif couche 0. drop_privileges reste la voie recommandée du largage réel ; ces primitives sont les 1:1 POSIX. initgroups combine air_account::groups_of_user + setgroups.
Fonctions
| Symbole | Étiquette | Notes | ADR |
|---|---|---|---|
getuid/geteuid/getgid/getegid | POSIX | Infaillibles ; réel/effectif via getresuid/getresgid. | ADR-068 |
setuid/setgid | POSIX | Sémantique conditionnelle au privilège (privilégié ⇒ r/e/s ; sinon effectif seul si uid ∈ {real,saved}, sinon EPERM). 0/−1+errno. | ADR-068 |
setresuid/setresgid | NON-POSIX-DÉLIBÉRÉ | Extension Linux/BSD requise par OpenSSH ; -1 = ne pas changer. | ADR-068 |
setgroups | NON-POSIX-DÉLIBÉRÉ | Extension ; EINVAL si size > NGROUPS_MAX/list nul ; EPERM non privilégié. | ADR-068 |
initgroups | NON-POSIX-DÉLIBÉRÉ | Extension BSD/glibc ; groupes de /etc/group (+ group) → setgroups. | ADR-068 |
getgroups | POSIX | Groupes supplémentaires courants (requise par OpenSSH uidswap.c : sauvegarde/restauration autour d’un changement d’UID). size == 0 ⇒ rend le nombre sans écrire ; sinon écrit jusqu’à size entrées et rend le nombre, ou −1+EINVAL si size insuffisant. Logique mesurée (air-libc-id). | ADR-068 |
Chantier B — famille processus (unistd.h/sched.h) : air-libc-process sur air-process::AirTaskManager (ADR-077)
Identité et ordonnancement du processus courant, bindant l’objet médiateur
AirTaskManager (couche 1) sur la couche 0 existante — aucun additif couche 0.
Lot process-1 : symboles infaillibles (aucun errno). Contrôle (setpgid/setsid),
attente (waitpid/waitid) et exec/spawn élargiront la famille (lots suivants).
| Symbole | Étiquette | Note / déviation | ADR |
|---|---|---|---|
getpid | POSIX | PID courant. Infaillible. Sur AirTaskManager::current_pid. | ADR-077 |
getppid | POSIX | PID parent (0 si réattribué à l’init). Infaillible. Sur AirTaskManager::parent_pid. | ADR-077 |
sched_yield | POSIX | Cède le CPU ; rend 0 (ne peut échouer sous Linux). Sur AirTaskManager::yield_now. | ADR-077 |
getpgid | POSIX | Groupe de processus de pid (0 = courant). −1+errno (ESRCH…). Sur AirTaskManager::process_group. | ADR-077 |
getsid | POSIX | Session de pid (0 = courant). −1+errno. Sur AirTaskManager::session_id. | ADR-077 |
setpgid | POSIX | Place pid (0 = courant) dans le groupe pgid (0 = son PID). 0/−1+errno. Sur AirTaskManager::set_process_group. | ADR-077 |
waitpid | POSIX | Attente d’un enfant (pid >0/-1/0/<-1) ; encode le wstatus (macros W*) via waitid couche 0. WNOHANG/WUNTRACED/WCONTINUED. 0 si WNOHANG sans événement ; −1+errno. Sur AirTaskManager::wait. | ADR-077 |
sched_getaffinity | POSIX/Linux | Affinité CPU de pid (0 = tâche courante) écrite dans mask (au plus cpusetsize octets, reste à zéro). mask nul ⇒ EFAULT ; cpusetsize nul ⇒ EINVAL. Sur AirTaskManager::cpu_affinity (couche 0 get_cpu_affinity, sans additif syscall ; type CpuSet gagne as_mut_bytes). | ADR-077 |
sched_setaffinity | POSIX/Linux | Fixe l’affinité CPU de pid depuis mask. Masque vide ⇒ EINVAL ; mask nul ⇒ EFAULT. Sur AirTaskManager::set_cpu_affinity. | ADR-077 |
execve | POSIX | Remplace l’image du processus par path (argv/envp = tableaux char* NULL-terminés). Ne retourne pas en succès ; échec ⇒ -1+errno (path nul ⇒ EFAULT, ENOENT/EACCES/ENOEXEC…). Sur AirTaskManager::exec (couche 0 execve, sans additif). | ADR-077 |
execveat | POSIX | Comme execve mais path relatif à dirfd (AT_FDCWD = cwd ; AT_EMPTY_PATH = exécuter dirfd, socle fexecve) + flags. dirfd<0 hors AT_FDCWD ⇒ EBADF. Sur AirTaskManager::exec_at. | ADR-077 |
execv | POSIX | Comme execve mais avec l’environnement courant implicite (bloc environ reflétant setenv, via AirEnvironmentManager::environ_block). path nul ⇒ EFAULT. Sur AirTaskManager::exec. | ADR-077 |
execvp | POSIX | Comme execv mais recherche file dans PATH (sauf s’il contient / ; dir vide = cwd ; défaut confstr si PATH absent). file nul ⇒ EFAULT. Cœur exec_resolving sur AirTaskManager::exec. | ADR-077 |
execvpe | NON-POSIX (GNU) | Comme execvp mais envp explicite. Cœur exec_resolving. | ADR-077 |
chroot | NON-POSIX (Unix/SVr4) | Change le répertoire racine du processus (confinement, CAP_SYS_CHROOT ; EPERM sinon = exception PRIVILEGE). path nul ⇒ EFAULT. Sur additif couche 0 chroot (ADR-085) → AirTaskManager::change_root. | ADR-085 |
posix_spawn | POSIX | Lance path (direct). Traduit les types opaques (posix_spawn_file_actions_t/posix_spawnattr_t, disposition ABI musl) → air_process::spawn_process (couche 1, fork+file-actions+exec async-signal-safe). Rend le numéro d’erreur (0 = succès), *pid reçoit le PID. | ADR-077 |
posix_spawnp | POSIX | Comme posix_spawn mais recherche PATH si file sans / (candidats pré-résolus, l’enfant essaie en ordre). | ADR-077 |
posix_spawn_file_actions_init | POSIX | Initialise une liste d’actions vide. | ADR-077 |
posix_spawn_file_actions_destroy | POSIX | Libère la liste (chemins possédés inclus) ; idempotent. | ADR-077 |
posix_spawn_file_actions_addopen | POSIX | Ajoute open(path, oflag, mode) → installé sur fildes. Chemin copié (POSIX). | ADR-077 |
posix_spawn_file_actions_addclose | POSIX | Ajoute close(fildes). | ADR-077 |
posix_spawn_file_actions_adddup2 | POSIX | Ajoute dup2(fildes, newfildes). | ADR-077 |
posix_spawn_file_actions_addchdir_np | NON-POSIX (GNU) | Ajoute chdir(path) dans l’enfant. | ADR-077 |
posix_spawnattr_init | POSIX | Attributs à zéro (rien d’appliqué). | ADR-077 |
posix_spawnattr_destroy | POSIX | No-op (sans état alloué). | ADR-077 |
posix_spawnattr_setflags | POSIX | Fixe POSIX_SPAWN_{SETPGROUP,SETSIGDEF,SETSIGMASK} (autres non supportés). | ADR-077 |
posix_spawnattr_getflags | POSIX | Lit les drapeaux dans *flags. | ADR-077 |
posix_spawnattr_setpgroup | POSIX | Groupe de processus de l’enfant (0 = le sien). | ADR-077 |
posix_spawnattr_setsigmask | POSIX | Masque de signaux de l’enfant (1er mot du sigset_t = signaux 1..=64). | ADR-077 |
posix_spawnattr_setsigdefault | POSIX | Signaux remis à l’action par défaut dans l’enfant. | ADR-077 |
Chantier B — famille système (unistd.h) : air-libc-system sur air-system::AirSystemManager (ADR-077)
Fonctions
| Symbole | Conformité | Sémantique | Réf |
|---|---|---|---|
gethostname | POSIX | Écrit le nom d’hôte (terminé NUL) dans name (≤ len octets). name nul ⇒ EFAULT ; buffer trop court ⇒ ENAMETOOLONG. Sur AirSystemManager::hostname (couche 0 gethostname via uname, sans additif). | ADR-077 |
sethostname | POSIX/Linux | Fixe le nom d’hôte aux len octets de name. EPERM sans CAP_SYS_ADMIN (chemin succès = exception PRIVILEGE, runner CI root) ; NUL interne ⇒ EINVAL ; name nul ⇒ EFAULT. Sur AirSystemManager::set_hostname. | ADR-077 |
sysconf | POSIX | Config système : _SC_PAGESIZE=4096 (x86_64/Pi4 4 Kio, à dériver d’AT_PAGESZ), _SC_CLK_TCK=100, _SC_OPEN_MAX=1024, _SC_ARG_MAX/_SC_NGROUPS_MAX/_SC_GET{PW,GR}_R_SIZE_MAX statiques ; _SC_NPROCESSORS_{ONLN,CONF} réels via AirSystemManager::online_processor_count (sched_getaffinity). name inconnu ⇒ -1. | ADR-077 |
getauxval | POSIX-PARTIEL | Entrée du vecteur auxiliaire (AT_PAGESZ/AT_HWCAP/AT_RANDOM…) via air_runtime::process_context::auxval (capturé au bootstrap, ADR-086). Entrée absente ⇒ 0. Export cible-only (target_vendor="air" ; l’auxv n’existe qu’on-target). | ADR-086 |
syscall | POSIX-PARTIEL | Trappe générique long syscall(long, ...) que le pal std exige (futex/gettid en direct). EXCEPTION assumée temporaire (docs/EXCEPTIONS.md, ADR-087) : seul shim tapant la couche 0 (raw_syscall). Convention __syscall_ret (-errno→-1+errno). Export cible-only. À retirer avec l’équipe Rust. | ADR-087 |
Chantier B — famille mémoire (sys/mman.h) : air-libc-mem sur air-memory::AirMemoryManager (ADR-077 / ADR-085)
Fonctions
| Symbole | Conformité | Sémantique | Réf |
|---|---|---|---|
mmap | POSIX | Mapping anonyme (MAP_ANONYMOUS, fd < 0) ou fichier-adossé de length octets. Rend l’adresse, ou MAP_FAILED ((void *)-1) + errno. MAP_FIXED non supporté ⇒ EINVAL (jamais un placement silencieux, ADR-032) ; offset négatif ⇒ EINVAL. Sur AirMemoryManager::map_anonymous/map_file. | ADR-085 |
mprotect | POSIX | Modifie les protections de [addr, addr+len). 0/−1+errno (addr nul/non aligné ⇒ EINVAL). Sur AirMemoryManager::protect. | ADR-085 |
munmap | POSIX | Libère [addr, addr+len) via l’additif couche 0 munmap_raw (ADR-085). 0/−1+errno (addr nul/non aligné ⇒ EINVAL). Sur AirMemoryManager::unmap. | ADR-085 |
M4 — famille réseau (arpa/inet.h, ADR-069 Lot 3)
Codec d’adresses et byte-order. Logique mesurée dans air-libc-socketio
(errno-aware) et codec pur/fuzzé air-libc-socket ; shims extern "C" minces dans
air-libc-capi::socket (exclus du gate de couverture).
| Symbole | Étiquette | Notes |
|---|---|---|
inet_pton | POSIX | Grammaire stricte (pas le legacy inet_aton) ; EAFNOSUPPORT sur famille inconnue. |
inet_ntop | POSIX | Sortie canonique RFC 5952 (IPv6) ; ENOSPC si size insuffisant. |
htons | POSIX | Hôte → réseau (16 bits), fonction pure. |
ntohs | POSIX | Réseau → hôte (16 bits), fonction pure. |
htonl | POSIX | Hôte → réseau (32 bits), fonction pure. |
ntohl | POSIX | Réseau → hôte (32 bits), fonction pure. |
Aucune déviation. Familles fd-level (socket/bind/connect/…) : lots 3.b–3.d.
M4 — cycle de vie des sockets (sys/socket.h, ADR-069 Lot 3.b)
Logique mesurée air-libc-socketio (errno-aware) sur le moteur air-socket ;
shims air-libc-capi::socket. Types : struct sockaddr, socklen_t.
| Symbole | Étiquette | Notes |
|---|---|---|
socket | POSIX | SOCK_CLOEXEC forcé ; SOCK_NONBLOCK honoré ; domaine inconnu EAFNOSUPPORT. |
bind | POSIX | sockaddr décodé via le codec couche 0 (ADR-070). |
connect | POSIX | EINPROGRESS remonté (non bloquant). |
listen | POSIX | backlog négatif traité comme 0. |
accept | POSIX | accept4(flags=0) ; adresse du pair tronquée à *addrlen. |
accept4 | POSIX | extension Linux (POSIX de fait) ; SOCK_CLOEXEC/SOCK_NONBLOCK. |
getsockname | POSIX | longueur réelle dans *addrlen (signale la troncature). |
getpeername | POSIX | idem getsockname. |
shutdown | POSIX | SHUT_RD/SHUT_WR/SHUT_RDWR ; how inconnu EINVAL. |
socketpair | POSIX | paire connectée écrite dans sv[2]. |
Aucune déviation. send/recv/sendmsg : lot 3.c ; setsockopt/getsockopt : lot 3.d.
M4 — transfert de données socket (ADR-069 Lot 3.c)
Logique mesurée air-libc-socketio::transfer sur le moteur air-socket ; shims
air-libc-capi::socket.
| Symbole | Étiquette | Notes |
|---|---|---|
send | POSIX | socket connecté ; MSG_NOSIGNAL ajouté par la couche 0. |
recv | POSIX | 0 = pair fermé. |
sendto | POSIX | datagramme adressé (ou pair connecté si dest nul). |
recvfrom | POSIX | écrit l adresse source dans src (si non nul). |
Aucune déviation. sendmsg/recvmsg (msghdr + SCM_RIGHTS) et setsockopt/getsockopt : lots suivants.
M4 — résolution inverse numérique (netdb.h, ADR-069 Lot 4.a)
Logique mesurée air-libc-socketio::nameinfo. Numérique uniquement au jalon M4.
| Symbole | Étiquette | Notes |
|---|---|---|
getnameinfo | POSIX-PARTIEL | numérique ; adresse + port en clair ; EAI_OVERFLOW si buffer court. |
gai_strerror | POSIX | message statique pour un code EAI_*. |
Déviation D-M4.2 : DNS inverse et résolution de service par nom non supportés.
getaddrinfo/freeaddrinfo : lot 4.b.
M4 — résolution nom → adresses (netdb.h, ADR-069 Lot 4.b)
Logique mesurée air-libc-socketio::addrinfo. Liste possédée (une alloc Box par nœud).
| Symbole | Étiquette | Notes |
|---|---|---|
getaddrinfo | POSIX-PARTIEL | service numérique ; nom numérique ou résolu ; filtre ai_family ; AI_PASSIVE. |
freeaddrinfo | POSIX | libère la liste. |
Déviation D-M4.3 : ai_canonname non rempli.
M4 — multiplexage evenements (poll.h, ADR-069 Lot 5.a)
Logique mesuree air-libc-socketio::poll sur air-poll (ppoll). Zero copie (struct pollfd reinterprete en PollFd couche 0, revents ecrits en place).
| Symbole | Etiquette | Notes |
|---|---|---|
poll | POSIX | timeout < 0 = infini ; EINTR remonte ; poll(NULL,0,t) = veille bornee. |
Aucune deviation. select/ppoll : lots suivants.
M4 — multiplexage historique (sys/select.h, ADR-069 Lot 5.b)
Logique mesuree air-libc-socketio::select, AU-DESSUS de poll (fd_set projetes en PollFd puis reconstruits depuis revents).
| Symbole | Etiquette | Notes |
|---|---|---|
select | POSIX | via poll ; nfds hors 0..=FD_SETSIZE => EINVAL ; timeout NULL = infini. |
Macros FD_ZERO/FD_SET/FD_CLR/FD_ISSET fournies au header (trailer).
M4 — messages vectorises + passage de fd (sys/socket.h, ADR-069)
Logique mesuree air-libc-socketio::message sur le moteur air-socket. Parse/build des cmsg SCM_RIGHTS (buffer de controle C), sans fuite de fd (les non-livres sont fermes).
| Symbole | Etiquette | Notes |
|---|---|---|
sendmsg | POSIX | iovec + adresse + fds SCM_RIGHTS extraits du msg_control C. |
recvmsg | POSIX | ecrit adresse source + fds recus (cmsg SCM_RIGHTS) ; CTRUNC si troncature. |
Macros CMSG_* + FD_* fournies au header. Cle de la privsep OpenSSH.
Chantier B (std) — routines Unwind* (stubs)
Sonde build-std=std : std réfère 82 symboles libc absents de libair (surface pour lier std). Lot 0 = les 11 _Unwind_*, fournis en stubs (cible panic = abort ⇒ jamais invoqués ; présents pour le lien). Crate air-libc-capi::unwind (gaté not(test)).
| Symbole | Étiquette | Notes |
|---|---|---|
_Unwind_Backtrace .. _Unwind_SetIP (11) | STUB | panic=abort ; avorte si atteint (bug). Pour le lien de std uniquement. |
Air — Vision du projet
Document fondateur — Version 1.0
Qu’est-ce qu’Air
Air est un environnement de bureau pour systèmes Linux, conçu avec soin sur la durée. Il prend la forme d’un ensemble cohérent de couches logicielles, des primitives système jusqu’aux applications, organisées comme un stack vertical où chaque niveau a une responsabilité claire et un contrat stable avec ses voisins.
L’architecture en couches est un principe d’ingénierie éprouvé, partagé par les systèmes d’exploitation sérieux depuis plusieurs décennies. Air en hérite et l’adapte aux possibilités offertes par l’écosystème Linux moderne et par le langage Rust. Chaque couche y est conçue avec un périmètre clair, des interfaces stables, et un objectif de qualité industrielle.
Le projet vise deux profils de fonctionnement, conçus avec un soin égal. Le profil console, appelé air-base, fournit un environnement de bureau complet en mode texte, utilisable sur des machines sans interface graphique ou pour des usages purement console. Le profil graphique, appelé air-desktop, étend ce socle avec un compositeur Wayland, un framework UI déclaratif, et l’ensemble des composants attendus d’un bureau moderne. Les deux profils partagent leurs fondations et ne se distinguent que par le sommet de la pile.
Air est écrit majoritairement en Rust, langage choisi pour ses garanties de sûreté mémoire, son écosystème mature, et sa capacité à produire des bibliothèques exposables en C, donc consommables depuis Swift, Python, Ruby, et d’autres langages. Air est pensé comme polyglotte dès sa conception : les composants centraux exposent leurs APIs dans une forme accessible à plusieurs langages, sans glue spécifique à écrire pour chacun.
Pour qui Air est fait
Air est construit pour des utilisateurs qui veulent un système d’exploitation desktop crédible, productif, et auquel ils peuvent faire confiance pour leurs données. Quatre profils en particulier sont à l’horizon du projet.
Les utilisateurs venant de macOS ou de Windows et frustrés par les évolutions de leurs systèmes actuels. Hausse des prix, obsolescence matérielle programmée, publicité dans l’interface, comptes obligatoires, télémétrie envahissante, transformation progressive de l’utilisateur en produit. Air offre une alternative qui prend ces préoccupations au sérieux, sans les transformer en argument marketing.
Les institutions, administrations, universités, organisations à but non lucratif, qui ont besoin d’un poste de travail prévisible, auditable, et libre de surprises commerciales. Air, dont le code est entièrement libre sous licence MPL 2.0, dont la gouvernance est progressivement transférée à une fondation à but non lucratif, et dont la conception privilégie la stabilité contractuelle, est conçu pour répondre à ces besoins sans dépendance à un acteur commercial unique.
Les développeurs en quête d’un environnement Unix moderne, cohérent, et de qualité industrielle. Air offre un SDK polyglotte, des bibliothèques aux contrats clairs, une approche capability-based de la sécurité, et une infrastructure de développement (tests, fuzzing, benchmarks) qui sera ouverte aux contributeurs avec le même niveau d’exigence que le code lui-même.
Les utilisateurs soucieux de la longévité de leur matériel. Air est conçu pour fonctionner durablement sur des machines modestes ou anciennes. Un MacBook Pro Intel que Apple a cessé de supporter, un PC milieu de gamme de 2018, un Raspberry Pi 4 : tous doivent rester pleinement utilisables sous Air pendant au moins dix ans après leur sortie commerciale.
Air n’est pas conçu pour tous les usages. Il n’est pas pensé pour les gamers haut de gamme cherchant l’exploitation maximale des GPU les plus récents. Il n’est pas pensé pour les utilisateurs qui ont besoin de toutes les applications mainstream sous leur forme native exacte ; un grand nombre d’applications seront disponibles via Flatpak pendant la phase d’incubation, mais l’écosystème natif d’applications Air prendra des années à se constituer. Il n’est pas pensé pour les early adopters qui veulent essayer la dernière fonctionnalité en avant-première ; Air avancera lentement, en privilégiant la qualité à la nouveauté.
Pourquoi Air, et pourquoi maintenant
L’écosystème Linux desktop est dans un état paradoxal. Sa qualité technique sous-jacente n’a jamais été aussi bonne. Le kernel Linux, systemd, Wayland, PipeWire, Vulkan, les drivers graphiques libres : autant de fondations matures sur lesquelles construire. Les langages modernes, Rust en tête, offrent des outils qui permettent d’aborder la programmation système sans les fragilités du C historique.
Pourtant, au niveau de l’expérience utilisateur, le Linux desktop reste fragmenté et inégal. GNOME et KDE, les deux environnements de bureau majeurs, fournissent un travail considérable et de grande qualité ; chacun a sa philosophie, ses utilisateurs fidèles, et ses contributions à l’écosystème. Les utilisateurs grand public, cependant, restent largement absents du Linux desktop dans son ensemble : la part de marché progresse peu malgré la qualité technique réelle, et beaucoup d’utilisateurs venant de macOS ou de Windows n’y trouvent pas immédiatement leurs marques.
Air propose une troisième alternative, construite avec soin sur la durée, à côté des deux environnements établis. Le projet ne cherche pas à se substituer à GNOME ou KDE, ni à dupliquer leur travail. Il explore une autre approche, fondée sur d’autres choix techniques et une autre cadence d’évolution, en complémentarité plutôt qu’en opposition. Pendant que cette construction se fait, les distributeurs Linux n’ont rien à changer à leurs offres actuelles. Quand Air aura démontré sa qualité et sa stabilité, ils pourront choisir de le packager comme un troisième environnement officiel, en faisant ce qu’ils savent faire mieux que nous : packager et distribuer à grande échelle. La transmission progressive du projet aux distributeurs établis fait partie du plan, pas de la concurrence.
Le moment est favorable pour plusieurs raisons. Rust a atteint la maturité qui permet d’envisager sereinement un stack système ambitieux. Wayland a remplacé X11 comme protocole d’affichage moderne, ouvrant la possibilité de repenser le compositeur sans dette héritée. Le projet Asahi Linux a ouvert le matériel Apple Silicon à Linux, étendant les machines accessibles à un parc important. Les utilisateurs ont une fatigue croissante face aux écosystèmes commerciaux dominants. Et l’écosystème open source a démontré, avec des projets comme Blender, qu’un logiciel libre gouverné par une fondation peut atteindre une qualité industrielle reconnue dans son domaine.
Les principes fondateurs
Air est guidé par cinq principes qui orientent tous les choix de conception et de gouvernance.
Crédible plutôt qu’extraordinaire. Air ne cherche pas à être le meilleur, le plus beau, le plus rapide, ou le plus sécurisé dans l’absolu. Air cherche à être crédible : un système dans lequel on peut avoir confiance pour son usage quotidien, qui fait son travail sans bruit ni démonstration. La crédibilité se construit par la constance et par le soin, pas par le marketing.
Productif au quotidien. Air est un outil de travail, pas une vitrine technologique. Les choix de conception privilégient ce qui rend l’utilisateur productif : démarrage rapide, applications qui se lancent vite, navigation fluide, intégration cohérente. Pas d’animations longues qui font joli mais ralentissent. Pas de notifications intrusives. Le système sert l’utilisateur, jamais l’inverse.
Confiance par défaut. Les utilisateurs doivent pouvoir faire confiance à Air sans avoir à le surveiller. Air est construit en sachant que les données des utilisateurs sont activement recherchées, monnayées, exfiltrées par défaut dans la plupart des écosystèmes commerciaux. Air est conçu pour ne pas faire cela, jamais. Aucune télémétrie utilisateur. Aucun compte obligatoire. Aucun cloud forcé. Aucun partage de données par défaut. Un sandboxing applicatif fondé sur des capabilities protège les apps les unes des autres et les données de l’utilisateur de toutes.
Longévité du matériel. Air est conçu pour fonctionner sur des machines pendant longtemps. Au moins dix ans après leur sortie commerciale, ou dix ans après l’abandon par leur fabricant d’origine. Cette contrainte est volontaire et structurante : elle oriente le design vers la fluidité sur matériel modeste plutôt que l’exploitation maximale de matériel haut de gamme. Les machines de référence du projet, sur lesquelles Air est validé en continu, sont des machines modestes et diversifiées (Raspberry Pi 4 pour ARM64, Mac mini et MacBook Pro Intel pour x86_64).
Indépendance et durabilité institutionnelle. Air est entièrement libre, gouverné par une fondation à but non lucratif à constituer, indépendante d’un acteur commercial unique. Le modèle de financement est diversifié pour éviter toute dépendance critique à un sponsor. La marque “Air” est destinée à devenir propriété de la fondation et protégée contre l’appropriation hostile. Ce n’est pas une posture idéologique, c’est une condition de la confiance utilisateur sur le long terme : un système dont la gouvernance peut être achetée n’est pas un système auquel on peut confier ses données.
L’architecture en bref
Air est organisé en six couches logicielles, conçues bottom-up. Chaque couche a un périmètre clair et un contrat stable avec les couches adjacentes. Cette description reste à un niveau d’aperçu ; un document distinct de macro-architecture en présente les détails.
La couche 0 est une façade Rust propre sur les services du kernel Linux : syscalls, I/O asynchrone via io_uring, primitives de sandboxing via Landlock et seccomp, communication inter-processus via Unix sockets. Elle abstrait le kernel sans le cacher, et reste assez fine pour qu’un développeur système retrouve ses repères Unix.
La couche 1 fournit les primitives système fondamentales : processus, threading, mémoire, temps, sockets, cryptographie, accès aux devices via udev. Elle est écrite en Rust, exposée également en ABI C, et conçue avec un objectif de couverture de tests de 100 %.
La couche 2 est le cœur conceptuel du système. Elle porte le modèle d’objet d’Air, exposé en ABI C, qui permet à toute classe d’avoir une identité runtime, des propriétés observables, et d’être bindable sans glue spécifique depuis n’importe quel langage. Elle porte également AirCom, le protocole IPC d’Air, capability-based, schema-first, performant, qui remplace D-Bus pour les communications internes au stack Air tout en permettant l’interopérabilité avec l’écosystème freedesktop existant. Elle fournit l’event loop unifié, les collections Unicode-aware, le système de notification, l’intégration avec systemd, et les services fondamentaux que toutes les couches supérieures consomment.
Le choix de AirCom comme IPC interne d’Air, plutôt que D-Bus, mérite une note. D-Bus est le mécanisme d’IPC historique de l’écosystème Linux, utilisé par systemd, NetworkManager, BlueZ, polkit, et la grande majorité des services freedesktop. Air le reconnaît et continue à parler D-Bus pour interopérer avec ces services existants. En revanche, pour les communications entre les services Air natifs eux-mêmes, Air a conçu AirCom, qui répond à des besoins spécifiques de sécurité capability-based, de stabilité contractuelle sur dix ans, et de performance sur matériel modeste que D-Bus ne couvre pas par construction. Ce n’est ni un rejet de D-Bus, ni une réinvention pour le plaisir : c’est un complément orienté vers les exigences propres d’Air. La justification technique complète, les sources, et l’articulation avec les autres décisions du projet sont consignées dans l’ADR-001.
La couche 3 prend en charge le rendu et la composition. Elle inclut deux compositeurs jumeaux qui partagent leur backend (DRM, evdev, seats via logind, rendu de glyphes) : air-wm pour le mode graphique Wayland, et air-console pour le mode texte. Le mode console n’est pas un sous-cas dégradé du mode graphique ; c’est un compositeur à part entière qui contourne les limitations de la console kernel pour offrir aux applications TUI l’accès aux véritables événements d’entrée et un rendu pleine maîtrise via DRM/KMS. La couche 3 inclut également le moteur audio (consommation de PipeWire) et le rendu de texte avec support bidirectionnel pour les écritures de droite à gauche.
La couche 4 fournit les frameworks applicatifs. Elle se décline en deux frameworks symétriques : air-ui pour le mode graphique, et air-tui pour le mode texte. Les deux partagent un modèle de composition de vues déclaratif, inspiré de SwiftUI et de Xilem, transposé en Rust idiomatique avec une emphase sur l’explicitation du flux de données plutôt que la magie cachée. Une application Air peut être écrite pour fonctionner dans les deux modes avec un seul code, certaines fonctionnalités étant capability-gated selon le contexte d’exécution.
La couche 5 prend en charge le cycle de vie des applications et les services transverses. Les applications Air sont distribuées sous forme de bundles .airapp (ou .airservice pour les composants système), signés, autonomes et déplaçables, sous la forme d’un répertoire structuré qui regroupe le binaire, ses ressources et son manifeste. Le launcher applique au démarrage les entitlements déclaratifs de l’application : isolation par namespaces, sandbox par Landlock, distribution des capabilities AirCom initiales. L’utilisateur conserve un contrôle fin sur ce que chaque application peut faire.
L’accessibilité et l’internationalisation ne sont pas des modules optionnels ajoutés après coup ; elles sont conçues dès la couche 2. Le modèle d’objet C-ABI porte les propriétés accessibility universelles, ce qui rend toute application Air accessible par construction. Le système consomme icu4x pour l’internationalisation, supporte Unicode 16 et au-delà, et est conçu pour le bidirectionnel et les méthodes de saisie complexes dès le départ.
Calendrier et patience
Air sera construit lentement, par phases, avec un objectif de qualité plutôt qu’un objectif de date. Le phasage suit une logique bottom-up : chaque phase produit un livrable démontrable de bout en bout, et chaque phase repose sur la stabilité des phases précédentes.
Une phase préliminaire met en place l’infrastructure de qualité : intégration continue, couverture de tests, fuzzing, benchmarks, conventions de code, infrastructure de validation matérielle. Cet investissement initial significatif rapporte ensuite à chaque phase.
La phase 0 construit la couche d’abstraction système et le noyau des primitives système, avec un objectif de couverture de tests de 100 %. La phase 1 construit le modèle d’objet C-ABI et le transport AirCom. La phase 2 ajoute le typage Cap’n Proto, l’intégration systemd, et livre le premier service système démontrable, marquant la première publication publique du projet. La phase 3 construit air-console, le compositeur de mode texte. La phase 4 construit air-tui et une première application démonstrative ; elle marque la sortie d’air-base 1.0, un environnement console complet et utilisable.
Les phases suivantes construisent le compositeur Wayland air-wm, le framework graphique air-ui, les applications graphiques, et l’écosystème nécessaire à air-desktop. Une version d’Air OS capable d’accueillir confortablement des utilisateurs grand public viendra ensuite, quand l’ensemble du stack aura atteint la maturité requise.
Air ne s’engage pas sur des dates publiques pour ces phases. L’expérience des projets ambitieux montre que les engagements de date conduisent soit au compromis sur la qualité, soit à la déception. Air communiquera sur ses avancées au fur et à mesure qu’elles seront acquises, sans surpromettre.
Cette patience est un choix. Elle correspond à la cadence d’une construction soigneuse, à la durée nécessaire pour valider chaque décision en pratique, et à l’horizon réaliste de l’adoption par les distributeurs Linux. Elle correspond aussi, plus simplement, à ce que demande un travail de qualité.
Avant que la version 1.0 ne soit figée, Air entrera dans une période exploratoire pendant laquelle les API publiques seront marquées comme stables candidates mais pourront encore être corrigées sur la base des retours utilisateurs. Cette période vise à figer l’ABI stable seulement quand l’usage réel l’aura validée. La promesse de stabilité ABI sur dix ans commence à partir d’Air 1.0 figé, pas avant.
Comment Air vit
Le code d’Air est entièrement publié sous la licence Mozilla Public License 2.0. Cette licence protège le cœur du projet contre l’appropriation propriétaire tout en permettant aux applications, qu’elles soient libres ou commerciales, de consommer librement les bibliothèques d’Air. Les contributions externes sont accueillies sous le Developer Certificate of Origin, qui garantit aux contributeurs qu’ils conservent leurs droits.
La gouvernance technique évolue avec le projet. Pendant la phase d’incubation, le fondateur assume le rôle de BDFL, prenant les décisions techniques et arbitrant les désaccords. Quand le projet atteint une base régulière de contributeurs, un comité technique est constitué, composé du fondateur et de contributeurs cooptés puis élus. Les décisions structurantes passent par un processus de RFC public, sur le modèle éprouvé de Rust. À terme, lorsque le projet atteindra Air 1.0, une fondation à but non lucratif sera constituée pour porter juridiquement le projet, gérer la marque, et garantir l’indépendance institutionnelle.
Le modèle économique s’inspire de la Blender Foundation, sans copier mécaniquement. Donations individuelles, sponsoring d’entreprises sans pouvoir directionnel exclusif, partenariats avec distributeurs Linux et fabricants de matériel, services optionnels (support entreprise, certification développeurs, formations) opérés par la fondation. Aucune publicité dans le système. Aucune vente de données. Aucun modèle freemium amputant la version libre.
La marque “Air” sera protégée juridiquement avec une politique d’usage souple inspirée du modèle Linux : usage libre pour la communauté, les distributions, les éditeurs d’applications, sans autorisation préalable formelle ; action juridique réservée aux abus manifestes.
Quand viendra le temps de l’adoption à plus grande échelle, les distributeurs Linux établis seront invités à packager Air comme un troisième environnement de bureau à côté de GNOME et KDE. Le travail de qualité accompli en amont — stabilité ABI tenue, dépendances minimales et auditées, tests reproductibles, documentation complète — rendra ce travail possible sans friction. Air n’a pas vocation à devenir une distribution Linux majeure de plus ; il a vocation à devenir un environnement de bureau crédible, distribuable par celles qui existent déjà.
Une dernière chose
Ce document est une boussole, pas une promesse contractuelle. Air est un projet en construction. Certaines décisions architecturales seront raffinées à l’usage. Certaines estimations de calendrier seront révisées. Mais les principes posés ici sont stables, et les décisions structurantes prises avant l’ouverture publique du projet — consignées dans dix-sept ADRs fondateurs, dans la Charte du projet, et dans les Principes d’ingénierie — encadrent toute évolution future.
Air est une invitation à construire patiemment quelque chose de propre.
Licence du document : MPL 2.0 Statut : Document fondateur immuable. Toute évolution requiert un RFC dédié.
Air — Project Vision
Founding document — Version 1.0
What Air is
Air is a desktop environment for Linux systems, designed with care and built for the long term. It takes the form of a coherent set of software layers, from system primitives up to applications, organised as a vertical stack where each level has a clear responsibility and a stable contract with its neighbours.
Layered architecture is a well-established engineering principle, shared by serious operating systems for several decades. Air inherits it and adapts it to the possibilities offered by the modern Linux ecosystem and by the Rust language. Each layer is designed with a clear scope, stable interfaces, and an objective of industrial-grade quality.
The project targets two operating profiles, designed with equal care. The console profile, called air-base, provides a complete desktop environment in text mode, suitable for machines without a graphical interface or for purely console-oriented uses. The graphical profile, called air-desktop, extends this foundation with a Wayland compositor, a declarative UI framework, and the full set of components expected from a modern desktop. Both profiles share the same foundations and differ only at the top of the stack.
Air is written mostly in Rust, a language chosen for its memory safety guarantees, its mature ecosystem, and its ability to produce libraries exposable in C, and therefore consumable from Swift, Python, Ruby, and other languages. Air is designed as polyglot from the outset: its central components expose their APIs in a form accessible to several languages, with no language-specific glue to write for each.
Who Air is for
Air is built for users who want a credible, productive desktop operating system that they can trust with their data. Four profiles in particular are on the project’s horizon.
Users coming from macOS or Windows and frustrated by the direction those systems have taken. Rising prices, planned hardware obsolescence, advertising in the user interface, mandatory accounts, pervasive telemetry, gradual transformation of the user into the product. Air offers an alternative that takes these concerns seriously, without turning them into marketing material.
Institutions, public administrations, universities, and non-profit organisations needing a workstation that is predictable, auditable, and free from commercial surprises. Air, whose code is fully free under the MPL 2.0 licence, whose governance is progressively transferred to a non-profit foundation, and whose design prioritises contractual stability, is built to meet these needs without dependency on any single commercial actor.
Developers in search of a modern, coherent, industrial-grade Unix environment. Air offers a polyglot SDK, libraries with clear contracts, a capability-based approach to security, and a development infrastructure (tests, fuzzing, benchmarks) that will be open to contributors with the same level of rigour as the code itself.
Users concerned about the longevity of their hardware. Air is built to run sustainably on modest or older machines. An Intel MacBook Pro that Apple has stopped supporting, a mid-range PC from 2018, a Raspberry Pi 4: all should remain fully usable under Air for at least ten years after their commercial release.
Air is not designed for every use. It is not aimed at high-end gamers seeking maximum exploitation of the latest GPUs. It is not aimed at users requiring every mainstream application in its exact native form; a wide range of applications will be available via Flatpak during the incubation phase, but a native Air application ecosystem will take years to develop. It is not aimed at early adopters wanting to try the newest feature first; Air will move slowly, prioritising quality over novelty.
Why Air, and why now
The Linux desktop ecosystem is in a paradoxical state. Its underlying technical quality has never been higher. The Linux kernel, systemd, Wayland, PipeWire, Vulkan, the open-source graphics drivers: all mature foundations to build on. Modern languages, Rust foremost among them, offer tools that make systems programming approachable without the historical fragilities of C.
And yet, at the level of user experience, the Linux desktop remains fragmented and uneven. GNOME and KDE, the two major desktop environments, deliver considerable work of high quality; each has its philosophy, its loyal users, and its contributions to the ecosystem. General-purpose users, however, remain largely absent from the Linux desktop as a whole: market share progresses slowly despite the real technical quality, and many users coming from macOS or Windows do not immediately find their bearings.
Air proposes a third alternative, built with care over time, alongside the two established environments. The project does not seek to replace GNOME or KDE, nor to duplicate their work. It explores another approach, founded on different technical choices and a different pace of evolution, in complementarity rather than opposition. While this construction takes place, Linux distributors have nothing to change in their current offerings. Once Air has demonstrated its quality and stability, they will be free to package it as a third official environment, doing what they do better than we do: packaging and distributing at scale. The progressive transmission of the project to established distributors is part of the plan, not a contest.
The timing is favourable for several reasons. Rust has reached a maturity that allows an ambitious systems stack to be undertaken with confidence. Wayland has replaced X11 as the modern display protocol, opening the possibility of rethinking the compositor without inherited debt. The Asahi Linux project has opened Apple Silicon hardware to Linux, extending the range of accessible machines to an important hardware base. Users have a growing fatigue with dominant commercial ecosystems. And the open-source ecosystem has shown, through projects such as Blender, that free software governed by a foundation can reach a level of industrial quality recognised in its field.
Founding principles
Air is guided by five principles that shape every decision in design and governance.
Credible rather than extraordinary. Air does not aim to be the best, the most beautiful, the fastest, or the most secure in absolute terms. Air aims to be credible: a system one can trust for daily use, that does its job without noise or display. Credibility is built through consistency and care, not through marketing.
Productive day to day. Air is a working tool, not a technology showcase. Design choices privilege what makes the user productive: fast startup, applications that launch quickly, smooth navigation, coherent integration. No lengthy animations that look nice but slow things down. No intrusive notifications. The system serves the user, never the other way around.
Trust by default. Users should be able to trust Air without having to keep an eye on it. Air is built with the awareness that user data is actively sought, monetised, and exfiltrated by default in most commercial ecosystems. Air is designed not to do this, ever. No user telemetry. No mandatory accounts. No forced cloud. No data sharing by default. An application sandbox based on capabilities protects apps from each other and protects user data from all of them.
Longevity of hardware. Air is designed to run on machines for a long time. At least ten years after their commercial release, or ten years after their abandonment by the original manufacturer. This constraint is deliberate and structural: it orients design towards fluidity on modest hardware rather than maximum exploitation of high-end machines. The project’s reference machines, on which Air is continuously validated, are modest and varied (Raspberry Pi 4 for ARM64, Mac mini and MacBook Pro Intel for x86_64).
Independence and institutional durability. Air is fully free, governed by a non-profit foundation to be established, independent of any single commercial actor. The funding model is diversified to avoid any critical dependence on a single sponsor. The “Air” trade mark is intended to become the property of the foundation and protected against hostile appropriation. This is not an ideological posture but a condition of long-term user trust: a system whose governance can be bought is not a system one can entrust with one’s data.
The architecture in brief
Air is organised in six software layers, designed bottom-up. Each layer has a clear scope and a stable contract with adjacent layers. The description below remains at the level of an overview; a separate macro-architecture document presents the details.
Layer 0 is a clean Rust facade over kernel services: system calls, asynchronous I/O via io_uring, sandboxing primitives via Landlock and seccomp, inter-process communication via Unix sockets. It abstracts the kernel without hiding it, and remains thin enough that a Unix systems developer keeps their bearings.
Layer 1 provides fundamental system primitives: processes, threading, memory, time, sockets, cryptography, device access via udev. It is written in Rust, also exposed as a C ABI, and designed with the objective of 100% test coverage.
Layer 2 is the conceptual heart of the system. It hosts Air’s object model, exposed as a C ABI, which gives every class a runtime identity, observable properties, and bindability with no language-specific glue. It also hosts AirCom, Air’s IPC protocol: capability-based, schema-first, performant, replacing D-Bus for communication internal to the Air stack while allowing interoperability with the existing freedesktop ecosystem. It provides the unified event loop, Unicode-aware collections, the notification system, integration with systemd, and the fundamental services consumed by all higher layers.
The choice of AirCom as Air’s internal IPC, rather than D-Bus, deserves a note. D-Bus is the historical IPC mechanism of the Linux ecosystem, used by systemd, NetworkManager, BlueZ, polkit, and the vast majority of freedesktop services. Air acknowledges this and continues to speak D-Bus to interoperate with those existing services. For communication between Air’s own native services, however, Air has designed AirCom, which addresses specific needs around capability-based security, contractual stability over ten years, and performance on modest hardware that D-Bus does not cover by construction. This is neither a rejection of D-Bus nor a reinvention for its own sake: it is a complement oriented towards Air’s specific requirements. The complete technical justification, the sources, and the articulation with the other decisions of the project are recorded in ADR-001.
Layer 3 handles rendering and composition. It includes two twin compositors that share their backend (DRM, evdev, seats via logind, glyph rendering): air-wm for graphical Wayland mode, and air-console for text mode. Console mode is not a degraded sub-case of graphical mode; it is a full compositor that bypasses the limitations of the kernel console to give TUI applications access to true input events and full rendering control via DRM/KMS. Layer 3 also includes the audio engine (consuming PipeWire) and text rendering with bidirectional support for right-to-left scripts.
Layer 4 provides application frameworks. It comes in two symmetric frameworks: air-ui for graphical mode, and air-tui for text mode. Both share a declarative view composition model, inspired by SwiftUI and Xilem, transposed to idiomatic Rust with an emphasis on explicit data flow rather than hidden magic. An Air application can be written to run in both modes with a single codebase, with certain features being capability-gated depending on the execution context.
Layer 5 handles application lifecycle and cross-cutting services. Air applications are distributed as .airapp bundles (or .airservice for system components): signed, self-contained, movable, structured as directories grouping the binary, its resources, and its manifest. The launcher applies the application’s declarative entitlements at startup: isolation via namespaces, sandboxing via Landlock, distribution of initial AirCom capabilities. The user retains fine-grained control over what each application is permitted to do.
Accessibility and internationalisation are not optional modules added afterwards; they are designed from layer 2. The C-ABI object model carries universal accessibility properties, which makes every Air application accessible by construction. The system consumes icu4x for internationalisation, supports Unicode 16 and beyond, and is designed for bidirectional text and complex input methods from the outset.
Schedule and patience
Air will be built slowly, in phases, with a quality objective rather than a date objective. The phasing follows a bottom-up logic: each phase produces a demonstrable end-to-end deliverable, and each phase rests on the stability of the previous ones.
A preliminary phase sets up the quality infrastructure: continuous integration, test coverage, fuzzing, benchmarks, code conventions, hardware validation infrastructure. This significant initial investment pays off in every subsequent phase.
Phase 0 builds the system abstraction layer and the core of the system primitives, with an objective of 100% test coverage. Phase 1 builds the C-ABI object model and the AirCom transport. Phase 2 adds Cap’n Proto typing, systemd integration, and delivers the first demonstrable system service, marking the project’s first public publication. Phase 3 builds air-console, the text-mode compositor. Phase 4 builds air-tui and a first demonstration application; it marks the release of air-base 1.0, a complete and usable console environment.
The subsequent phases build the air-wm Wayland compositor, the air-ui graphical framework, the graphical applications, and the ecosystem needed for air-desktop. A version of Air OS able to comfortably welcome general-purpose users will come thereafter, once the entire stack has reached the required maturity.
Air does not commit to public dates for these phases. The experience of ambitious projects shows that date commitments lead either to compromise on quality or to disappointment. Air will communicate on its progress as it is achieved, without overpromising.
This patience is a choice. It matches the pace of careful construction, the time needed to validate each decision in practice, and the realistic horizon for adoption by Linux distributors. More simply, it matches what quality work requires.
Before version 1.0 is frozen, Air will enter an exploratory period during which the public APIs will be marked as stable candidates but may still be corrected on the basis of real user feedback. This period aims to freeze the stable ABI only when real-world use has validated it. The ten-year stable ABI commitment begins from Air 1.0 frozen, not before.
How Air lives
Air’s code is fully published under the Mozilla Public License 2.0. This licence protects the project’s core against proprietary appropriation while allowing applications, free or commercial, to consume Air’s libraries freely. External contributions are welcomed under the Developer Certificate of Origin, which guarantees that contributors retain their rights.
Technical governance evolves with the project. During the incubation phase, the founder takes on the role of BDFL, making technical decisions and arbitrating disagreements. When the project reaches a regular base of contributors, a Technical Committee is formed, composed of the founder and contributors first co-opted and later elected. Structural decisions go through a public RFC process, on the proven model of Rust. In time, when the project reaches Air 1.0, a non-profit foundation will be established to carry the project legally, manage the trade mark, and guarantee institutional independence.
The economic model takes inspiration from the Blender Foundation, without mechanically copying it. Individual donations, sponsoring by companies with no exclusive directional power, partnerships with Linux distributors and hardware manufacturers, optional services (enterprise support, developer certification, training) operated by the foundation. No advertising in the system. No data sales. No freemium model crippling the free version.
The “Air” trade mark will be legally protected with a permissive usage policy inspired by the Linux model: free use for the community, distributions, and application publishers, with no formal prior authorisation; legal action reserved for manifest abuses.
When the time comes for adoption at a larger scale, established Linux distributors will be invited to package Air as a third desktop environment alongside GNOME and KDE. The quality work done beforehand — stable ABI honoured, minimal and audited dependencies, reproducible tests, complete documentation — will make this work possible without friction. Air does not aim to become yet another major Linux distribution; it aims to become a credible desktop environment, distributable by those that already exist.
One last thing
This document is a compass, not a contractual promise. Air is a project under construction. Some architectural decisions will be refined through use. Some schedule estimates will be revised. But the principles set out here are stable, and the structural decisions taken before the project’s public opening — recorded in seventeen founding ADRs, in the Project Charter, and in the Engineering Principles — frame any future evolution.
Air is an invitation to build something clean, patiently.
Document licence: MPL 2.0 Status: Founding document, immutable. Any evolution requires a dedicated RFC.
Charte du projet Air
Document fondateur — Version 1.0
Air est un environnement de bureau Linux dont la qualité des fondations détermine la confiance que les utilisateurs et les développeurs lui accorderont. La Charte énonce les valeurs qui guident le projet, distinctes des choix techniques (qui sont consignés dans les ADRs) et de la méthode d’ingénierie (consignée dans les Principes d’ingénierie). Cinq principes constituent l’identité d’Air en tant que projet.
Principe 1 — Crédible plutôt qu’extraordinaire
Air ne cherche pas à être le meilleur, le plus beau, le plus rapide, ou le plus sécurisé dans l’absolu. Air cherche à être crédible : un système dans lequel on peut avoir confiance pour son usage quotidien, qui fait son travail sans bruit ni démonstration. La crédibilité se construit par la constance et par le soin, pas par le marketing.
Cette posture s’oppose à la tentation, fréquente dans le logiciel libre comme dans le logiciel commercial, de chercher à se distinguer par des caractéristiques superlatives. Air refuse cette voie : mieux vaut un environnement fiable et un peu ennuyeux qu’un environnement spectaculaire et fragile. Le travail technique est tendu vers la robustesse, la prévisibilité, la durabilité.
Principe 2 — Productif au quotidien
Air est un outil de travail, pas une vitrine technologique. Les choix de conception privilégient ce qui rend l’utilisateur productif : démarrage rapide, applications qui se lancent vite, navigation entre applications fluide, gestion de fenêtres efficace, raccourcis clavier puissants, intégration cohérente entre les applications.
Pas de friction inutile. Pas d’animations longues qui font joli mais ralentissent. Pas de notifications intrusives. L’OS sert l’utilisateur, jamais l’inverse.
Cette exigence informe la conception des frameworks d’interface et la sélection des comportements par défaut. Une application Air doit être lancée et utilisable rapidement même sur du matériel modeste. Une interaction utilisateur doit aboutir au plus vite à un résultat tangible.
Principe 3 — Confiance par défaut
Les utilisateurs doivent pouvoir faire confiance à Air sans avoir à le surveiller. Nous savons que les données des utilisateurs sont activement recherchées, monnayées, exfiltrées par défaut dans la plupart des écosystèmes commerciaux. Air est construit pour ne pas faire cela, jamais.
Aucune télémétrie utilisateur. Aucun compte obligatoire. Aucun cloud forcé. Aucun partage de données par défaut. Le sandboxing applicatif capability-based protège les apps les unes des autres et les données de l’utilisateur de toutes. Le système est conçu pour qu’un utilisateur prudent puisse garder ses données vraiment sur sa machine.
Cette confiance est matérialisée techniquement par les choix d’architecture (sandboxing strict, capabilities, télémétrie strictement opt-in et limitée à l’outillage développeur) et par la transparence (code entièrement libre, gouvernance ouverte, schémas de données auditables).
Principe 4 — Longévité du matériel
Air est conçu pour fonctionner sur des machines pendant longtemps. Pas de “ce matériel n’est plus supporté après 4 ans”. Pas d’obsolescence programmée par la lourdeur croissante des nouvelles versions. Un MacBook Intel 2015, un PC milieu de gamme 2018, un Raspberry Pi 4, doivent rester pleinement utilisables sous Air en 2030 et au-delà.
Cette contrainte oriente le design : on optimise pour la fluidité sur matériel modeste, pas pour l’exploitation maximale de matériel haut de gamme. Les Principes d’ingénierie d’Air (validation amont, optimisation après mesure, cibles matérielles modestes et diversifiées) servent aussi cet objectif.
Cette promesse a un corollaire technique : le support kernel de longue durée pour les architectures cibles, l’engagement à ne pas augmenter les exigences matérielles minimales d’une version majeure à l’autre, et la validation continue d’Air sur les machines de référence du catalogue de matériel certifié.
Principe 5 — Indépendance et durabilité institutionnelle
Air est gouverné par une fondation à but non lucratif (à constituer), indépendante d’un acteur commercial unique. Le code est entièrement open source sous licence MPL 2.0. Le modèle de financement est diversifié pour éviter toute dépendance critique à un sponsor. La marque “Air” est destinée à devenir propriété de la fondation et protégée contre l’appropriation hostile.
Ce n’est pas une posture idéologique : c’est une condition de la confiance utilisateur sur le long terme. Un OS dont la gouvernance peut être achetée n’est pas un OS auquel on peut confier ses données.
La gouvernance évolue avec le projet (BDFL en phase d’incubation, Comité Technique à mesure que la communauté grandit, Fondation à maturité) selon un parcours documenté. La transmission progressive du projet aux distributeurs Linux établis fait partie du plan : Air a vocation à devenir un environnement de bureau crédible, distribuable par les acteurs Linux qui existent déjà, sans chercher à se substituer à eux.
Statut et évolution
Ces cinq principes sont immuables. Les ADRs et spécifications techniques d’Air y sont conformes par construction. Toute évolution de la Charte requerrait un consensus communautaire exceptionnel, formalisé par un RFC dédié et adopté par le mécanisme de gouvernance en vigueur.
Quand un choix technique se présente avec plusieurs options, on tranche en regardant si une option viole un principe de la Charte. Si oui, elle est exclue.
Licence du document : MPL 2.0 Statut : Document fondateur immuable.
Air Project Charter
Founding document — Version 1.0
Air is a Linux desktop environment whose foundational quality will determine the trust that users and developers place in it. The Charter sets out the values that guide the project, distinct from technical choices (recorded in the ADRs) and from engineering methodology (recorded in the Engineering Principles). Five principles constitute Air’s identity as a project.
Principle 1 — Credible rather than extraordinary
Air does not aim to be the best, the most beautiful, the fastest, or the most secure in absolute terms. Air aims to be credible: a system one can trust for daily use, that does its job without noise or display. Credibility is built through consistency and care, not through marketing.
This posture stands against the temptation, frequent in both free and commercial software, to seek distinction through superlative claims. Air rejects this path: better a reliable and slightly dull environment than a spectacular and fragile one. Technical work is directed toward robustness, predictability, and durability.
Principle 2 — Productive day to day
Air is a working tool, not a technology showcase. Design choices privilege what makes the user productive: fast startup, applications that launch quickly, smooth navigation between applications, efficient window management, powerful keyboard shortcuts, coherent integration between applications.
No unnecessary friction. No lengthy animations that look nice but slow things down. No intrusive notifications. The operating system serves the user, never the other way around.
This requirement informs the design of interface frameworks and the selection of default behaviours. An Air application should be launched and usable quickly even on modest hardware. A user interaction should reach a tangible result as soon as possible.
Principle 3 — Trust by default
Users should be able to trust Air without having to keep an eye on it. We know that user data is actively sought, monetised, and exfiltrated by default in most commercial ecosystems. Air is built not to do this, ever.
No user telemetry. No mandatory accounts. No forced cloud. No data sharing by default. Capability-based application sandboxing protects apps from each other and protects user data from all of them. The system is designed so that a cautious user can keep their data truly on their machine.
This trust is materialised technically by the architectural choices (strict sandboxing, capabilities, telemetry strictly opt-in and limited to developer tooling) and by transparency (fully free code, open governance, auditable data schemas).
Principle 4 — Longevity of hardware
Air is designed to run on machines for a long time. No “this hardware is no longer supported after 4 years”. No planned obsolescence driven by the growing weight of new versions. A 2015 Intel MacBook, a mid-range 2018 PC, a Raspberry Pi 4, must remain fully usable under Air in 2030 and beyond.
This constraint orients design: we optimise for fluidity on modest hardware, not for maximum exploitation of high-end machines. Air’s Engineering Principles (upfront validation, optimisation after measurement, modest and varied hardware targets) serve this objective as well.
This commitment has a technical corollary: long-term kernel support for the target architectures, a commitment not to raise minimum hardware requirements from one major version to the next, and continuous validation of Air on the reference machines of the certified hardware catalogue.
Principle 5 — Independence and institutional durability
Air is governed by a non-profit foundation (to be established), independent of any single commercial actor. The code is fully open source under the MPL 2.0 licence. The funding model is diversified to avoid any critical dependence on a single sponsor. The “Air” trade mark is intended to become the property of the foundation and protected against hostile appropriation.
This is not an ideological posture: it is a condition of long-term user trust. A system whose governance can be bought is not a system one can entrust with one’s data.
Governance evolves with the project (BDFL during the incubation phase, Technical Committee as the community grows, Foundation at maturity) along a documented path. The progressive transmission of the project to established Linux distributors is part of the plan: Air aims to become a credible desktop environment, distributable by the Linux actors that already exist, without seeking to replace them.
Status and evolution
These five principles are immutable. Air’s ADRs and technical specifications conform to them by construction. Any evolution of the Charter would require exceptional community consensus, formalised through a dedicated RFC and adopted by the governance mechanism in force.
When a technical choice arises with several options, we decide by checking whether any option violates a Charter principle. If so, it is excluded.
Document licence: MPL 2.0 Status: Founding document, immutable.
Principes d’ingénierie d’Air
Document fondateur — Version 1.0
Air est un stack d’infrastructure desktop. La qualité de ses fondations détermine la confiance que les développeurs accorderont à l’ensemble. Neuf principes guident toute contribution au code Air. Ils sont distincts de la Charte du projet (qui énonce les valeurs) et des ADRs (qui consignent les décisions techniques) : les Principes d’ingénierie portent sur la méthode, sur comment construire.
Ces principes sont immuables. Les ADRs et spécifications y sont conformes par construction. Tout code intégré au projet Air respecte ces principes.
Principe 1 — Test exhaustif des fondations
Les couches 0 et 1 sont les fondations de tout ce qui est au-dessus. Aucun bug dans ces couches n’est acceptable. Coverage de tests à 100 % (lignes + branches), incluant tests unitaires, property-based testing, tests d’intégration, fuzzing systématique sur toute API qui accepte des données externes (parsing, deserialization, buffers).
Stratégies de test complexes acceptées si nécessaires : harnais de test custom, simulateurs de syscalls pour isoler les tests des conditions kernel, injection de fautes pour valider les chemins d’erreur, tests de stress concurrents. Le temps passé à concevoir une stratégie de test solide est du temps bien investi, jamais perdu.
Les couches supérieures (2 et au-delà) visent un coverage très élevé (> 90 %) mais peuvent avoir une marge selon la nature du code (UI difficile à tester pixel-perfect, par exemple). La rigueur reste, mais l’objectif n’est plus strictement 100 %.
Principe 2 — Arithmétique défensive
Tout calcul numérique est conçu avec conscience de la taille du type. Toute opération qui peut overflow utilise les variantes checked_*, saturating_*, ou wrapping_* explicitement choisies selon la sémantique voulue — jamais l’opération nue qui panique en debug et wrappe silencieusement en release.
Conversion de types numériques : as interdit pour les conversions potentiellement lossy. Utilisation de TryFrom ou try_into qui retourne Result. Les cas où une conversion lossy est intentionnelle doivent être commentés et localisés.
Lints clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_lossless, clippy::arithmetic_side_effects activés en deny dans les couches 0 et 1. Activés en warn minimum dans les couches supérieures.
Principe 3 — Chaînes, buffers, encodages : zéro présomption
Toute manipulation de chaîne distingue explicitement bytes (&[u8], Vec<u8>) et UTF-8 valide (&str, String). Pas de conversion silencieuse. from_utf8 (qui retourne Result) systématique, jamais from_utf8_unchecked sauf dans des cas justifiés et localisés.
Buffers : taille toujours validée avant usage, pas de présomption sur la longueur. Slicing avec get() qui retourne Option plutôt que indexation directe qui peut paniquer. Index toujours bornés et vérifiés.
Encodages : explicites partout. Pas de “string par défaut, l’OS gère”. Pour les chemins de fichiers, distinction explicite Path/OsStr (qui peut contenir non-UTF-8 sur Unix) et str quand on sait que c’est UTF-8. Conversion explicite avec gestion d’erreur.
Parsing de données externes : schema-first quand possible (Cap’n Proto pour AirCom, validation explicite ailleurs). Pas de unwrap() sur des résultats de parsing en code de production.
Principe 4 — Validation en amont, logique en aval
Toute fonction publique valide ses paramètres au début, avant tout traitement. Les invariants attendus sont explicités, vérifiés, et retournent Result (ou Option) en cas de violation. Le corps de la fonction peut alors présumer que les invariants sont respectés et se concentrer sur la logique métier.
Privilégier le pattern « parse, don’t validate » : transformer les entrées en types qui portent la garantie de validité dans leur type, plutôt que valider à plusieurs endroits. Une fonction qui prend un EmailAddress (newtype validé) plutôt qu’un String n’a pas à revalider.
Sur-tester les paramètres est acceptable en couches 0 et 1 ; on optimisera après mesure. Plus jamais de bug parce qu’on a oublié de valider un cas.
Principe 5 — Optimiser après mesure, jamais avant
Les principes 2, 3, 4 produisent du code volontairement défensif. Ce code peut être plus lent et plus verbose que sa version optimiste. C’est assumé.
À chaque fin de cycle (fin de phase, ou jalon majeur), un audit de performance est conduit : profiling sur cas d’usage représentatifs, identification des hotspots, mesure de la part du temps passée dans les validations vs la logique métier. Si — et seulement si — une validation est mesurée comme étant un coût significatif sur un chemin chaud, on peut envisager de la déplacer, l’alléger, ou la supprimer, avec justification écrite et test additionnel garantissant que l’invariant est respecté par construction.
Le sens du flux est : sur-sécuriser puis dégraisser après mesure, jamais sous-sécuriser puis durcir après bug. L’inverse, qu’on voit trop souvent, produit des CVE.
Principe 6 — Règle des 80 % sur les dépendances
Air s’appuie sur des crates Rust existants quand ils sont de qualité, matures, et utiles. Pour chaque dépendance candidate, la règle d’usage est : si moins de 80 % du code de la crate est effectivement utilisé par Air, alors la dépendance est refusée. À la place, on intègre le code strictement nécessaire, on l’adapte au style d’Air, on en fait un sous-module ou une crate Air, qu’on maintient nous-mêmes.
Justification : le code mort dans une dépendance n’est pas neutre. Il continue d’être compilé, doit être maintenu (mises à jour de sécurité, ajustements pour nouvelles versions de Rust), expose une surface d’attaque (CVE sur du code qu’on n’utilise pas mais qui est embarqué), et augmente le binaire. Une dépendance qu’on n’utilise qu’à 10 % est une dette de maintenance à 100 % pour quelqu’un.
Ce principe s’évalue par audit explicite à chaque ajout de dépendance, documenté dans un fichier DEPENDENCIES.md par crate Air : nom, version, % d’API utilisée (estimé), justification. Revue régulière (à chaque fin de phase) pour détecter les dépendances dont l’usage a diminué.
Exceptions explicites possibles pour des crates très matures et structurants où le ratio peut être plus bas si la dépendance est universellement nécessaire et impossible à reproduire raisonnablement. Ces exceptions sont nommées et justifiées dans EXCEPTIONS.md, pas implicites. Exemple consigné : icu4x adopté en exception explicite (cf. ADR-016) au titre de référence Unicode mondiale dont la reproduction est impossible raisonnablement.
Principe 7 — Verbosité au service de la clarté
Air privilégie la verbosité explicite quand elle clarifie le data-flow, le typage, les invariants, le coût. Pas de magie cachée. Pas de runtime introspectif qui invalide la lecture du code. Pas d’abstractions à plusieurs niveaux quand une seule suffit. Le code Air doit être lisible par un développeur qui découvre le projet, sans connaissance préalable d’un framework « magique ».
Cette préférence se traduit dans la conception des APIs publiques : abonnements explicites plutôt qu’implicites par traçage, conversions explicites plutôt qu’implicites par déréférencement, dépendances passées en argument plutôt qu’injectées en background. Quelques caractères supplémentaires à écrire pour des centaines de fois moins de surprises à comprendre.
Principe 8 — Stabilité contractuelle
Tout symbole air-stable (cf. ADR-012) doit avoir des tests de conformité ABI en plus des tests fonctionnels. Le test charge un binaire de référence compilé contre une version antérieure et vérifie qu’il continue de marcher contre la version courante. Ces tests sont gardés en CI pour toutes les versions supportées.
La stabilité ABI sur dix ans n’est pas un vœu pieux : elle est outillée. Les outils air-abi-check, air-symver, air-deprecation-tracker (introduits dès la phase 0) automatisent la vérification. Un changement qui casserait l’ABI sur un symbole air-stable est rejeté en CI avant même la revue humaine.
Les zones air-internal et air-experimental n’ont pas ces contraintes : elles permettent l’évolution rapide entre release majeures. La discipline ABI est concentrée là où elle est promise.
Principe 9 — Cibles matérielles modestes et diversifiées
Air est développé et validé en continu sur du matériel modeste et diversifié représentatif (Raspberry Pi 4 4 Go et 8 Go en ARM64, Mac mini Intel i5 8 Go et MacBook Pro Intel i7 16 Go en x86_64, en phase initiale, élargi via le catalogue ADR-014). Cette contrainte est volontaire : elle force chaque composant à respecter ses budgets mémoire et CPU, garantit la pertinence multi-architecture (ARM64 + x86_64) dès le jour 1, valide la diversité de GPU et de drivers, et assure que la promesse de longévité du matériel (Charte, principe 4) tient en pratique sur des classes de machines variées.
Si Air ne tourne pas correctement sur les machines de référence, c’est Air qui s’adapte, pas la cible matérielle qui change.
Cette discipline a un effet positif moins évident : elle protège Air contre le syndrome bien connu des logiciels développés exclusivement sur des machines puissantes, qui deviennent imperceptiblement lourds parce que les développeurs ne ressentent pas la lourdeur. Une équipe qui développe sur Raspberry Pi 4 sait immédiatement quand quelque chose ralentit.
Application et évolution
Ces neuf principes sont immuables. Les ADRs et spécifications y sont conformes par construction. Toute évolution requerrait un RFC dédié et un consensus communautaire exceptionnel.
Les principes ne sont pas des slogans : ils sont opérationnels. Ils se traduisent en lints clippy, en checks CI, en revues de code obligatoires, en audits par phase. Un PR qui viole un principe ne passe pas en main, même si le code paraît correct par ailleurs.
Licence du document : MPL 2.0 Statut : Document fondateur immuable.
Air Engineering Principles
Founding document — Version 1.0
Air is a desktop infrastructure stack. The quality of its foundations determines the trust developers will place in the whole. Nine principles guide every contribution to Air’s code. They are distinct from the Project Charter (which sets out values) and from the ADRs (which record technical decisions): the Engineering Principles concern method, the how of building.
These principles are immutable. ADRs and specifications conform to them by construction. All code integrated into the Air project respects these principles.
Principle 1 — Exhaustive testing of foundations
Layers 0 and 1 are the foundations of everything above. No bug in these layers is acceptable. Test coverage at 100% (lines + branches), including unit tests, property-based testing, integration tests, systematic fuzzing on any API that accepts external data (parsing, deserialisation, buffers).
Complex testing strategies accepted if necessary: custom test harnesses, syscall simulators to isolate tests from kernel conditions, fault injection to validate error paths, concurrent stress tests. Time spent designing a solid testing strategy is time well invested, never wasted.
Higher layers (2 and above) aim for very high coverage (> 90%) but may have leeway depending on the nature of the code (pixel-perfect UI testing, for example). Rigour remains, but the objective is no longer strictly 100%.
Principle 2 — Defensive arithmetic
Every numeric computation is designed with awareness of the type’s size. Every operation that could overflow uses checked_*, saturating_*, or wrapping_* variants explicitly chosen according to the intended semantics — never the naked operation, which panics in debug and wraps silently in release.
Numeric type conversion: as forbidden for potentially lossy conversions. Use TryFrom or try_into which return Result. Cases where a lossy conversion is intentional must be commented and localised.
Lints clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_lossless, clippy::arithmetic_side_effects set to deny in layers 0 and 1. Set to warn minimum in higher layers.
Principle 3 — Strings, buffers, encodings: zero presumption
All string handling explicitly distinguishes between bytes (&[u8], Vec<u8>) and valid UTF-8 (&str, String). No silent conversion. from_utf8 (which returns Result) systematically, never from_utf8_unchecked except in justified and localised cases.
Buffers: size always validated before use, no presumption on length. Slicing with get() which returns Option rather than direct indexing which can panic. Indices always bounded and checked.
Encodings: explicit everywhere. No “default string, the OS handles it”. For file paths, explicit distinction Path/OsStr (which can contain non-UTF-8 on Unix) and str when we know it is UTF-8. Explicit conversion with error handling.
Parsing of external data: schema-first when possible (Cap’n Proto for AirCom, explicit validation elsewhere). No unwrap() on parsing results in production code.
Principle 4 — Validation upfront, logic afterwards
Every public function validates its parameters at the start, before any processing. Expected invariants are made explicit, verified, and return Result (or Option) on violation. The function body can then presume the invariants hold and focus on business logic.
Favour the “parse, don’t validate” pattern: transform inputs into types that carry the validity guarantee in their type, rather than validating in multiple places. A function that takes an EmailAddress (validated newtype) rather than a String need not revalidate.
Over-testing parameters is acceptable in layers 0 and 1; we will optimise after measurement. Never again a bug because we forgot to validate a case.
Principle 5 — Optimise after measurement, never before
Principles 2, 3, 4 produce deliberately defensive code. This code may be slower and more verbose than its optimistic version. That is assumed.
At the end of every cycle (end of phase, or major milestone), a performance audit is conducted: profiling on representative use cases, identification of hotspots, measurement of time spent in validations versus business logic. If — and only if — a validation is measured as a significant cost on a hot path, we may consider moving, lightening, or removing it, with written justification and an additional test guaranteeing that the invariant holds by construction.
The flow direction is: over-secure then trim after measurement, never under-secure then harden after bug. The opposite, seen too often, produces CVEs.
Principle 6 — The 80% rule on dependencies
Air relies on existing Rust crates when they are quality, mature, and useful. For each candidate dependency, the rule is: if less than 80% of the crate’s code is effectively used by Air, the dependency is refused. Instead, we integrate only the strictly necessary code, adapt it to Air’s style, and turn it into an Air sub-module or crate that we maintain ourselves.
Justification: dead code in a dependency is not neutral. It continues to be compiled, must be maintained (security updates, adjustments for new Rust versions), exposes an attack surface (CVEs on code we do not use but ship), and inflates the binary. A dependency used at 10% is a 100% maintenance debt for someone.
This principle is evaluated by explicit audit at each dependency addition, documented in a DEPENDENCIES.md file per Air crate: name, version, % of API used (estimated), justification. Regular review (at each phase end) to detect dependencies whose usage has dropped.
Explicit exceptions are possible for very mature and structuring crates where the ratio can be lower if the dependency is universally needed and impossible to reproduce reasonably. These exceptions are named and justified in EXCEPTIONS.md, not implicit. Example recorded: icu4x adopted as an explicit exception (see ADR-016) as the worldwide Unicode reference whose reproduction is not reasonably possible.
Principle 7 — Verbosity in service of clarity
Air favours explicit verbosity when it clarifies data flow, typing, invariants, and cost. No hidden magic. No introspective runtime that invalidates code reading. No multi-level abstractions when one suffices. Air’s code must be readable by a developer discovering the project, without prior knowledge of a “magical” framework.
This preference shows in the design of public APIs: explicit subscriptions rather than implicit by tracing, explicit conversions rather than implicit by dereferencing, dependencies passed as arguments rather than injected in the background. A few extra characters to write for hundreds of times fewer surprises to understand.
Principle 8 — Contractual stability
Every air-stable symbol (see ADR-012) must have ABI conformance tests in addition to functional tests. The test loads a reference binary compiled against an earlier version and verifies that it continues to run against the current version. These tests are kept in CI for all supported versions.
The ten-year ABI stability commitment is not wishful thinking: it is tooled. The tools air-abi-check, air-symver, air-deprecation-tracker (introduced from phase 0) automate verification. A change that would break the ABI on an air-stable symbol is rejected in CI even before human review.
The air-internal and air-experimental zones do not have these constraints: they allow rapid evolution between major releases. ABI discipline is concentrated where it is promised.
Principle 9 — Modest and diversified hardware targets
Air is continuously developed and validated on modest and diversified hardware (Raspberry Pi 4 4 GB and 8 GB in ARM64, Mac mini Intel i5 8 GB and MacBook Pro Intel i7 16 GB in x86_64, in initial phase, expanded via the ADR-014 catalogue). This constraint is deliberate: it forces each component to respect its memory and CPU budgets, guarantees the relevance of multi-architecture (ARM64 + x86_64) from day one, validates the diversity of GPUs and drivers, and ensures that the hardware longevity commitment (Charter, principle 4) holds in practice across varied classes of machines.
If Air does not run correctly on the reference machines, it is Air that adapts, not the hardware target that changes.
This discipline has a less obvious positive effect: it protects Air against the well-known syndrome of software developed exclusively on powerful machines, which becomes imperceptibly heavy because developers do not feel the heaviness. A team developing on Raspberry Pi 4 knows immediately when something slows down.
Application and evolution
These nine principles are immutable. ADRs and specifications conform to them by construction. Any evolution would require a dedicated RFC and exceptional community consensus.
The principles are not slogans: they are operational. They translate into clippy lints, into CI checks, into mandatory code reviews, into per-phase audits. A PR that violates a principle does not pass review, even if the code looks correct otherwise.
Document licence: MPL 2.0 Status: Founding document, immutable.
Air — Macro-architecture
Document fondateur — Version 1.2 (2026-07-10 : passe post-M5 + révision doctrinale — Air = userland Rust safe pour Linux 6.12+ (pas POSIX) ; unsafe en couche 0 seulement ; stabilité par couche (0/1 internes, couche 2 = contrat public) ; surfaces libair (Rust) / libair_c (libc) / ABI C objet ; Managers de domaine [ADR-077] ; cible air-sshd [ADR-074])
Préambule
À qui s’adresse ce document
Ce document décrit l’architecture logicielle d’Air, environnement de bureau pour systèmes Linux, à un niveau de détail intermédiaire entre la Vision (qui pose le pourquoi et le quoi général) et les spécifications de composants (qui détailleront chaque crate, chaque API, chaque protocole).
Il est destiné principalement aux ingénieurs qui contribueront au code d’Air, aux développeurs qui voudront écrire des applications natives .airapp ou des services .airservice, et aux mainteneurs de distributions Linux qui voudront comprendre comment packager Air. Il suppose une familiarité avec les concepts Unix modernes, le langage Rust, le protocole Wayland, et l’écosystème Linux desktop contemporain. Il n’est pas un manuel d’introduction à ces sujets.
Comment lire ce document
La section 1 (Vue d’ensemble) donne le panorama global. Elle peut être lue isolément pour comprendre l’architecture d’Air à grands traits.
Les sections 2 à 7 détaillent chacune une couche : périmètre, composants internes, contrats exposés vers le haut et consommés vers le bas, choix techniques clés. Elles peuvent être lues séquentiellement (bottom-up, cohérent avec la philosophie de construction) ou consultées indépendamment selon les besoins.
La section 8 traite les aspects transverses (sécurité, internationalisation, accessibilité, observabilité, énergie, confidentialité, stabilité) qui ne tiennent pas dans une couche unique. La section 9 formalise les contrats inter-couches et les règles d’isolation. La section 10 traite l’évolution dans le temps et la stabilité des contrats.
Conventions
Les noms de composants Air sont préfixés par air- quand ils sont nommés concrètement (air-wm, air-com, air-object). Les concepts génériques restent en français ou en anglais selon le terme le plus précis dans le domaine (compositeur, sandbox, capability).
Les références aux ADRs prennent la forme ADR-NNN et désignent les Architecture Decision Records du registre fondateur d’Air. La connaissance des ADRs est supposée acquise ; ce document les met en cohérence sans les répéter.
Le présent document est sous licence MPL 2.0, comme l’ensemble du projet Air.
Section 1 — Vue d’ensemble du stack
Doctrine fondatrice — ce qu’Air est. Air est le userland Rust safe du kernel Linux. Pas « un OS POSIX de plus » : Air vise Linux, pas POSIX (cf. ADR-004, ADR-046) — conformité fonctionnelle au kernel, pas conformité de certification. Quatre invariants en découlent, valables sur tout le stack :
- Linux-only, définitif. Kernel Linux 6.12 (LTS) minimum. Aucune vocation à un autre kernel ; aucun fallback FreeBSD/Windows/macOS — l’architecture épouse Linux.
unsafeen couche 0 uniquement (étoile polaire). L’interface avec le kernel est le seul endroit où duunsafeRust doit exister — c’est la barrière de sécurité. Partout au-dessus : Rust safe, garanti safe. Là où duunsafesubsiste encore au-dessus (bootstrap/TCB d’air-runtime,GlobalAllocd’air-alloc, frontière C des toits libc), c’est un site nommé, audité, minimal, traité comme dette à faire descendre/éliminer.- Les couches sont un rempart devant le kernel. Elles filtrent et détectent au plus tôt ce qui serait transmis au kernel comme non conforme : Air protège le kernel des imperfections des applications (Principe d’ingénierie 4, validation en amont).
- Sens montant : zéro masquage, zéro transformation. Ce que le kernel retourne, Air le restitue fidèlement en l’habillant d’idiomes Rust safe (types,
Result,Option, RAII). Les API collent aux features et au mode de fonctionnement du kernel — Air n’adapte pas, ne réinvente pas une sémantique : « le kernel est la référence ».
Le principe de l’architecture en couches
Air est organisé en six couches logicielles superposées, numérotées de 0 (la plus basse, au contact du kernel) à 5 (la plus haute, au contact de l’utilisateur). Chaque couche a une responsabilité claire et un contrat stable avec ses voisines immédiates. Cette organisation suit une discipline stricte : une couche ne peut consommer que les services de la couche immédiatement inférieure, sauf cas exceptionnels nommés et justifiés. Cette discipline garantit que la complexité reste localisée et que chaque couche est remplaçable ou évolutive indépendamment des autres.
La séparation en couches n’est pas qu’un découpage conceptuel : elle se matérialise dans le packaging, les processus, et les contrats publics d’Air. Une couche basse compile et tourne sans connaître l’existence des couches supérieures. Une couche haute consomme la couche basse via des API stables sans présumer de ses détails d’implémentation. Cette propriété rend l’architecture compréhensible, testable couche par couche (cf. Principe d’ingénierie 1), et évolutive sans casser les contrats existants (cf. ADR-012).
Les six couches en bref
┌─────────────────────────────────────────────────────────┐
│ Couche 5 — Cycle de vie applicatif et services système │
│ (air-launchd, air-registry, air-trust, │
│ air-firewalld, air-printd, air-bluetoothd, ...) │
├─────────────────────────────────────────────────────────┤
│ Couche 4 — Frameworks applicatifs │
│ (air-ui pour graphique, air-tui pour console) │
├─────────────────────────────────────────────────────────┤
│ Couche 3 — Composition, rendu, audio, input │
│ (air-wm compositeur Wayland, air-console mode texte, │
│ rendu Vello/Cosmic Text, intégration PipeWire) │
├─────────────────────────────────────────────────────────┤
│ Couche 2 — Modèle d'objet, IPC, services fondamentaux │
│ (air-object modèle C-ABI, air-com IPC, │
│ intégration systemd/udev, services de base) │
├─────────────────────────────────────────────────────────┤
│ Couche 1 — Primitives système │
│ (processus, threading, mémoire, sockets, crypto, │
│ accès devices, exposée Rust + C ABI) │
├─────────────────────────────────────────────────────────┤
│ Couche 0 — Abstraction kernel Linux │
│ (façade Rust : syscalls, io_uring, Landlock, │
│ seccomp, namespaces, Unix sockets, perf_event) │
└─────────────────────────────────────────────────────────┘
│
▼
Kernel Linux 6.12+ (LTS) + systemd
Le schéma ci-dessous matérialise les crates existants dans chaque couche
(couche 0 scellée couche-0-v1.12, couche 1 scellée couche-1-v1.0 → re-scellée
additivement jusqu’à couche-1-v1.9, les toits — libc C-ABI et PAL Rust std::sys
— en production ; couches 3 à 5 à venir) :
🎉 Jalon toolchain (M5, 2026-07-10) —
stdRust tourne sur la libc d’Air, zéro glibc. Un programmestd(hello-std, static-PIE) lié intégralement contre la libc d’Air (libair_c, les cratesair-libc-*— à ne pas confondre aveclibair, la lib Rust de la couche 2) s’exécute on-target sur les 2 arches (x86_64-air et aarch64-air). Les deux toits de la couche 1 sont donc prouvés : la libc C-ABI (libair_c, ~256 symboles, la face C) et le PAL Rust (std::sysde la cible*-linux-air— ADR-076, ADR-075). Ces deux toits ne consomment jamais la couche 0 en direct : ils bindent la couche 1 via des Managers de domaine objets (ADR-077 :AirFileManager,AirTaskManager,AirNetworkManager,AirSystemManager,AirSignalManager,AirEnvironmentManager,AirMemoryManager). Prochaine cible de production :air-sshd— le serveur SSH d’Air full Rust, async io_uring, wire-compat OpenSSH (ADR-074) ; compiler OpenSSH n’aura été qu’une fonction de forçage de la libc.
Couche 0 — Abstraction kernel Linux. Façade Rust propre sur les services du kernel. Encapsule les appels système, l’I/O asynchrone via io_uring, les primitives de sandboxing (Landlock, seccomp, namespaces, cgroups), la communication inter-processus via Unix sockets, l’accès aux devices via netlink et sysfs, l’observabilité kernel via eBPF et perf_event. Cette couche abstrait le kernel sans le cacher : un développeur système Linux retrouve ses repères. Elle est volontairement fine, conçue pour être testable à 100 % et auditable en intégralité.
Couche 1 — Primitives système. Fournit les briques fondamentales que toute couche supérieure consomme : gestion des processus et du threading, allocation mémoire structurée, primitives I/O synchrones et asynchrones unifiées, sockets BSD enrobés en API Rust idiomatique, primitives cryptographiques de base, accès aux devices via udev. Cette couche est écrite en Rust pur et exposée également en ABI C pour les consommateurs polyglottes. Elle vise un coverage de tests de 100 % (cf. Principe d’ingénierie 1).
Couche 2 — Modèle d’objet, IPC, services fondamentaux. Le cœur conceptuel d’Air. Elle porte le modèle d’objet C-ABI (air-object) qui donne à toute classe une identité runtime, des propriétés observables, et la bindabilité polyglotte (cf. ADR-002). Elle porte AirCom (air-com), le protocole IPC capability-based d’Air (cf. ADR-001). Elle fournit l’event loop unifié, les collections Unicode-aware, le système de notification, l’intégration avec systemd via sd-bus peer-to-peer, le pont D-Bus optionnel, et les services fondamentaux que toute couche supérieure consomme. Cette couche est le lieu où le système devient cohérent : tout ce qui est au-dessus parle le langage du runtime d’objets et de AirCom.
Couche 3 — Composition, rendu, audio, input. Prend en charge la mise à l’écran et la mise en son. Elle inclut deux compositeurs jumeaux qui partagent leur backend : air-wm pour le mode graphique Wayland (cf. ADR-003), et air-console pour le mode texte avec bypass de la VT kernel (cf. ADR-007). Elle inclut le rendu graphique 2D via Vello (GPU-first), le rendu de texte avec support bidirectionnel via Cosmic Text et HarfBuzz, et l’intégration PipeWire pour l’audio. Le backend matériel (DRM/KMS, evdev, seats via logind, glyph rasterizer) est partagé entre air-wm et air-console, ce qui garantit la cohérence des deux modes.
Couche 4 — Frameworks applicatifs (développeur : UI + fondation). Fournit aux développeurs d’applications les outils pour écrire des applications — frameworks d’interface et commodités de fondation (périmètre élargi par ADR-084, sur le modèle de Foundation d’Apple qui coexiste avec UIKit). Côté UI, deux frameworks symétriques : air-ui pour le mode graphique (cf. ADR-009), et air-tui pour le mode texte (cf. ADR-008). Les deux partagent un modèle de composition de vues déclaratif transposé en Rust idiomatique, avec une emphase sur l’explicitation du flux de données (Principe d’ingénierie 7). Widgets atomiques et vues composites de haut niveau (panneaux de paramètres, calendriers, alertes, etc.). Une application peut viser un mode unique ou les deux modes simultanément, avec capability-gating pour les fonctionnalités spécifiques à un mode. Côté fondation, les commodités applicatives bâties sur les services système — dont air-url (chargement d’URL haut niveau ≈ URLSession, sur air-http/air-network de la couche 2, cf. ADR-084).
Couche 5 — Cycle de vie applicatif et services système. Englobe tout ce qui orchestre la vie des applications et fournit les services transverses du système. air-launchd lance les applications avec leurs entitlements et sandbox (cf. ADR-010), air-registry gère la découverte des services AirCom (cf. ADR-001), air-trust vérifie les signatures et notarizations (cf. ADR-010, ADR-015). Les services système comme air-notifyd (notifications), air-firewalld (FireWall, cf. ADR à produire — air-firewalld), air-power (énergie, cf. ADR à produire — air-power), air-printd (impression, cf. ADR à produire — sous-systèmes hardware), air-bluetoothd (Bluetooth, cf. ADR à produire — sous-systèmes hardware), air-share (partage entre appareils, cf. ADR à produire — air-share) vivent ici. air-shell (shell graphique en mode air-desktop) est implémenté comme .airapp particulière avec entitlements étendus. Cette couche est la plus diverse et la plus visible côté utilisateur final.
Une couche transverse implicite : systemd et le kernel
Au-dessous de la couche 0 se trouvent le kernel Linux et systemd. Ils ne sont pas une couche Air à proprement parler — Air ne les développe pas, mais s’appuie dessus comme un socle stable.
Le kernel Linux fournit les services standards (gestion des processus, mémoire, FS, réseau, drivers, etc.) que la couche 0 d’Air encapsule. Air est Linux-only, définitivement (cf. ADR-004) : il n’a aucune vocation à être porté sur un autre kernel. Ce n’est pas une réserve prudente mais une décision fondatrice — l’architecture entière (io_uring, Landlock, seccomp-bpf, eBPF, namespaces/cgroups, clone3, pidfd) épouse le kernel Linux et n’a de sens que sur lui. La couche 0 n’est pas une couche de portabilité multi-OS ; c’est la façade Rust de Linux. Version kernel Linux minimale requise : 6.12 (LTS) — io_uring mature, Landlock, cgroups v2, eBPF complet. Aucun fallback vers les kernels antérieurs.
systemd est traité comme un socle système d’Air (cf. ADR-005). Air consomme systemd via sd-bus en mode peer-to-peer (pas via le broker D-Bus), avec sd-event, sd-notify, sd-journal, libudev, logind, et systemd --user. Cette intégration est dure : Air ne fonctionne pas sans systemd. Les composants Air sont packagés comme units systemd (system pour les .airservice, user pour les .airapp), ce qui leur donne supervision, socket activation, hardening, et logging structurés gratuitement.
Le mode console et le mode graphique : deux profils, une architecture
Air supporte deux profils de fonctionnement (cf. ADR-006) qui se distinguent par la présence ou l’absence des couches 3 et 4, mais qui partagent intégralement les couches 0, 1, 2, et une partie de la couche 5.
air-base est le profil console. Il contient les couches 0, 1, 2 complètes, et la partie non-graphique de la couche 5 (services système comme air-notifyd, air-firewalld, air-power, etc.). Il permet d’exécuter des applications console (CLI tools, TUI apps, daemons) et de faire fonctionner un système Linux complet sans environnement graphique. Aucun composant air-desktop n’est requis ni installé en air-base.
air-desktop est le profil graphique. Il étend air-base avec les couches 3 (compositeur Wayland air-wm, audio, rendu) et 4 (framework UI graphique air-ui), et les composants couche 5 spécifiques au desktop (shell graphique, panneau de préférences, applications graphiques).
Cette séparation est un invariant architectural, pas un détail de packaging. Le mode console est de première classe (cf. ADR-007) et bénéficie de son propre compositeur air-console qui contourne les limitations de la VT kernel pour offrir aux applications TUI un accès réel aux événements d’entrée et un rendu pleine maîtrise. Le framework air-tui permet d’écrire des applications console modernes avec un modèle déclaratif comparable à celui d’air-ui.
Sur une machine, on installe air-base seul (typiquement serveur, conteneur, machine headless) ou air-base + air-desktop (machine utilisateur classique). Il n’existe pas de configuration où une couche supérieure est requise par une couche inférieure.
Cohérence verticale : le modèle d’objet et AirCom comme colonne vertébrale
Trois propriétés se diffusent verticalement à travers toutes les couches d’Air et lui donnent sa cohérence.
Le modèle d’objet C-ABI (cf. ADR-002) introduit en couche 2 est consommé par les couches 3, 4 et 5. Une AirView en couche 4 est un AirObject qui vit dans le runtime de la couche 2. Une fenêtre air-wm en couche 3 est un AirObject. Un service AirCom en couche 5 expose des AirObject. Cette uniformité signifie qu’un outil d’introspection, un debugger, un binding polyglotte, ou un lecteur d’écran (cf. ADR-017) peut inspecter, manipuler, ou observer n’importe quel objet vivant dans Air via une seule API runtime. Aucun composant Air ne réinvente son propre système d’objets, et aucun langage tiers n’a besoin d’écrire des bindings spécifiques par classe.
AirCom (cf. ADR-001) est l’IPC universel d’Air. Toute communication inter-processus entre composants Air passe par AirCom. La couche 2 fournit le transport et le runtime ; les couches 3 à 5 exposent leurs services et consomment ceux des autres via AirCom. air-wm expose air.wm.accessibility, air.wm.clipboard, et consomme air-launchd via AirCom ; air-notifyd expose air.system.notifications, consommé par les apps .airapp ; et ainsi de suite. Cette uniformité signifie qu’un service Air, qu’il soit en couche 5 (système) ou consommé par une application en couche 4, est conçu et utilisé selon les mêmes patterns capability-based.
Le sandboxing capability-based (cf. ADR-010) introduit en couche 5 (via air-launchd et les entitlements) s’appuie sur AirCom en couche 2 et sur les primitives de sandboxing (Landlock, namespaces, seccomp) en couche 0. Une application reçoit au démarrage un ensemble initial de capabilities AirCom et un environnement isolé par namespaces. Elle ne peut consommer que les services dont elle a déclaré la capability dans son manifeste, et ne peut accéder qu’aux ressources filesystem que son entitlement Landlock autorise. Aucune autorité ambiante. Aucun moyen pour une app malveillante de “découvrir” des services qu’elle n’a pas le droit de consommer.
Cohérence horizontale : les services système comme citoyens de première classe
Outre la cohérence verticale, Air a une cohérence horizontale au niveau de la couche 5 : tous les services système d’Air (.airservice) suivent le même pattern. Ils sont packagés en bundles signés avec entitlements (cf. ADR-010), supervisés par systemd (cf. ADR-005), cloisonnés en utilisateurs dédiés non-privilégiés (cf. Principe d’ingénierie 10), exposent leurs APIs via AirCom, et sont éventuellement remplaçables par l’utilisateur dans les limites du raisonnable.
Cette uniformité a des conséquences pratiques importantes. Un développeur qui sait écrire un service Air sait écrire n’importe lequel. Un administrateur système qui sait inspecter un service Air sait inspecter tout l’éco-système. Un utilisateur qui voit la liste des services tournants comprend que chacun a un périmètre limité et auditable. Pas de “service magique” qui aurait des privilèges spéciaux invisibles.
Apps natives, apps tierces, apps console : trois publics distincts
Air accueille trois catégories d’applications avec des niveaux d’intégration différents.
Les applications natives .airapp sont écrites avec le SDK Air (couches 1, 2, 4) et distribuées comme bundles signés (cf. ADR-010). Elles consomment intégralement le modèle d’objet C-ABI, AirCom, et les frameworks Air. Elles bénéficient de l’accessibilité par construction (cf. ADR-017), de l’i18n native (cf. ADR-016), de la sandbox capability-based, et de l’intégration enrichie avec air-wm via le protocole privé AirCom (cf. ADR à produire — protocole privé AirCom air-wm ↔ apps). Elles tournent aussi sous d’autres compositeurs Wayland avec qualité de base élevée mais sans les enrichissements.
Les applications tierces (Flatpak, GTK, Qt) tournent sous air-wm comme clients Wayland standards. Elles bénéficient des standards freedesktop (xdg-shell, xdg-desktop-portal pour les capacités) mais n’ont pas accès aux services AirCom privés d’Air. Air les supporte de première classe comme stratégie transitoire pendant la phase d’incubation où l’écosystème natif .airapp se construit (cf. ADR-013). Elles cohabitent avec les apps natives sans conflit.
Les applications console tournent en air-base (ou en mode console sous air-desktop). Si elles sont écrites avec le SDK Air et air-tui, elles bénéficient de l’intégration native (modèle d’objet, AirCom, sandbox, capability-gating). Si elles sont des programmes Unix classiques (vim, htop, ssh), elles tournent normalement dans un terminal Air ou sur un TTY géré par air-console, sans intégration spécifique mais sans non plus aucun obstacle.
La discipline d’isolation entre couches
Une couche supérieure ne peut consommer une couche inférieure que via les contrats publics exposés par celle-ci. Les détails d’implémentation d’une couche basse ne sont jamais exposés vers le haut. Cette discipline a plusieurs conséquences concrètes.
Pas d’accès direct au kernel depuis les couches supérieures à 0. Une application native n’appelle pas directement open(), read(), mmap(). Elle utilise les APIs de la couche 1 (AirFile, AirMemory), qui elles-mêmes utilisent la couche 0. Si un jour Air doit changer la façon dont la couche 0 parle au kernel (par exemple, basculer entièrement sur io_uring là où aujourd’hui il y a encore des syscalls bloquants), les couches supérieures ne le voient pas.
Pas d’invocation directe de systemd ou de D-Bus depuis les couches supérieures à 2. Une application native n’utilise pas sd-bus directement. Elle consomme AirCom, qui en interne peut parler à systemd via sd-bus pour ses besoins propres. Si Air doit un jour réduire sa dépendance à systemd ou la remplacer (peu probable mais pas impossible sur 20 ans), les couches supérieures ne le voient pas.
Pas de couplage applicatif au compositeur spécifique. Une application native parle à air-ui qui dialogue avec air-wm via Wayland et AirCom. L’application ne sait pas qu’il s’agit d’air-wm plutôt que de Mutter — elle consomme les capabilities exposées par air.wm.session (cf. ADR à produire — protocole privé AirCom air-wm ↔ apps) et dégrade gracieusement quand elles ne sont pas présentes.
Les exceptions sont nommées et justifiées. Certains composants couche 5 (notamment les .airservice système comme air-power) ont des besoins qui les amènent à consommer la couche 0 directement (accès sysfs, netlink pour la gestion énergie ou réseau, perf_event pour la mesure de consommation). Ces accès directs sont autorisés mais explicités dans la documentation du composant et soumis à audit. Ils ne sont jamais le cas général.
Les artefacts livrés par chaque couche
Pour conclure cette vue d’ensemble, voici ce que chaque couche produit comme artefacts livrables. Le détail figure dans les sections suivantes.
| Couche | Crates Rust principales | Bibliothèques C exposées | Daemons / services | Outils CLI |
|---|---|---|---|---|
| 0 | air-sys-types, air-sys-syscall | — | — | — |
| 1 | air-base-core/air-base-lib, air-socket, air-crypto, air-filesystem, air-process, air-thread, air-memory, air-alloc, air-terminal, air-runtime (ADR-052)… + Managers de domaine (ADR-077) | — (interne, pas d’ABI publique) | — | air-fs, air-network-tools |
| Toits /L1 | air-libc-* (libc) · PAL std::sys | libair_c (libc C, Linux-conforme, zéro glibc) | — | — |
| 2 | air-object, air-com, air-event, air-systemd, air-collections | libair (surface Rust pur) · libair-object.so, libair-com.so, libair-event.so | air-registry | air-call |
| 3 | air-wm, air-console, air-render, air-audio, air-display, air-seat | libair-render.so | air-wm, air-console, air-pipewire-bridge | — |
| 4 | air-ui, air-tui, air-ui-core, air-view-core, air-ui-widgets, air-ui-views | libair-ui.so, libair-tui.so | — | — |
| 5 | air-launchd-lib, air-power-lib, etc. | — | air-launchd, air-notifyd, air-firewalld, air-power, air-printd, air-bluetoothd, air-share, air-nfcd, air-trust, air-appdb, air-shell, xdg-desktop-portal-air | air-launch, air-trust-tool, divers |
Surfaces publiques, en un coup d’œil. Les couches 0 et 1 sont internes (elles suivent le kernel, ne sont pas un contrat pour les développeurs tiers — Section 10). Ce que les développeurs consomment :
libair— la lib Rust pur d’Air, surface publique stable de la couche 2 (le contrat d’API app/service, garanti 10 ans, ADR-012).libair_c— la libc C (cratesair-libc-*), Linux-conforme (pas POSIX), que lient le C et lestdRust de*-linux-air— c’est un toit sur la couche 1, zéro glibc.- les ABI C du modèle d’objet de la couche 2 (
libair-object.so,libair-com.so,libair-event.so) pour les bindings polyglottes — livrées en librairies indépendantes (granularité, taille d’exécutable), jamais une méga-lib unique.
Les sections 2 à 7 détaillent chaque couche, ses composants internes, ses contrats publics, ses choix techniques structurants, et ses choix laissés ouverts pour la spec future.
Section 2 — Couche 0 : Abstraction kernel Linux
Rôle et périmètre
La couche 0 est la frontière entre Air et le kernel Linux. Son rôle est de fournir aux couches supérieures un accès aux services du kernel via une façade Rust propre, sûre, et testable. Elle abstrait sans cacher : un développeur qui connaît Linux retrouve ses repères dans les noms et les concepts, mais bénéficie des garanties de typage et de sûreté mémoire de Rust.
La couche 0 ne fait pas de logique applicative. Elle ne décide rien au-delà de ce qui est nécessaire pour offrir un binding propre. Elle ne maintient pas d’état global. Elle ne consomme aucune autre couche d’Air — par définition, elle est la plus basse. Elle n’a pas de libc externe pour dépendance : depuis ADR-048, la couche 0 est no_std et émet ses appels système directement (instruction syscall/svc 0 en assembleur, pas de binding glibc/musl). C’est ce qui permet à Air de tourner sans aucune glibc (jalon M5 : std s’exécute sur notre libc libair, elle-même bâtie sur cette couche 0). Ses rares dépendances externes se limitent à quelques crates Rust très matures de wrapping, sous la règle des 80 % du Principe d’ingénierie 6.
Son périmètre couvre :
- Appels système vers le kernel : ouverture/lecture/écriture de fichiers, gestion de processus, signaux, manipulation de la mémoire, gestion du temps.
- I/O asynchrone moderne via io_uring (ADR-004 acte Linux tier-1 exclusif, donc io_uring assumé).
- Sandboxing : namespaces, seccomp-bpf, Landlock, cgroups v2.
- Communication inter-processus bas niveau : Unix sockets (AF_UNIX), pipes, eventfd, signalfd, memfd_create.
- Accès aux devices : netlink pour
NETLINK_KOBJECT_UEVENT(notification hotplug), sysfs (lecture d’attributs devices), evdev (/dev/input/eventXpour les entrées clavier/souris/touch). - Observabilité kernel : eBPF pour l’instrumentation système (consommé par les outils d’audit et de profiling Air, pas exposé tel quel aux apps),
perf_event_openpour la collecte d’événements de performance (consommé notamment parair-power, cf. ADR à produire —air-power).
Tout ce qui n’est pas dans ce périmètre relève des couches supérieures. Notamment : la gestion de processus politique (priorisation, isolation décidée selon entitlements) relève d’air-launchd en couche 5 ; la couche 0 fournit juste fork, exec, setns, unshare, etc., en bindings sûrs.
Crates de la couche 0 : air-sys-types et air-sys-syscall
La couche 0 est livrée en deux crates Rust complémentaires. Ce découpage a été acté pendant l’instruction des spécifications phase 0 (cf. les specs détaillées dans docs/specs/layer-0/).
air-sys-types: crate des types fondamentaux partagés par toute la couche 0 et exposés vers les couches supérieures. Newtypes pour les ressources kernel (OwnedFd,BorrowedFd,Pid,Tid,Uid,Gid, etc.), types d’erreur (Errno), structures de données partagées entre familles. Aucune dépendance versair-sys-syscall(la direction est inverse). Périmètre détaillé dansdocs/specs/layer-0/air-sys-types.md.air-sys-syscall: crate des wrappers de syscalls. Consommeair-sys-types. Organisée en sous-modules par famille fonctionnelle, chaque famille étant spécifiée dans son propre documentdocs/specs/layer-0/family-*.md.
Les familles actuellement spécifiées dans la phase 0 sont :
air-sys-syscall/
├── process/ — getpid, clone3, waitid, pidfd_*, prctl par opération, capabilities (capget/capset)
├── fs/ — openat2 et variantes *at, read/write, statx, mkdirat, fcntl par opération, watchers (inotify/fanotify)
├── mem/ — mmap, mprotect, madvise, memfd_create, mlock/mlock2
├── signal/ — signalfd (par défaut), masques, sigaction restreint aux 4 signaux synchrones fatals (cf. ADR-020)
├── time/ — clock_gettime, timerfd, clock_nanosleep (sans notion de fuseau horaire — relève d'icu4x, consommé dès la COUCHE 1 par air-base-lib ET la libc Air, cf. ADR-053 ; surfacé plus haut aussi)
├── net/ — socket, accept4, send/recv, sockopt typés (pas d'API "Connection" haut niveau — relève du futur framework networking couche 2)
├── ipc/ — eventfd, pipe2, splice/tee/vmsplice
├── security/ — seccomp_load_filter, Landlock (filtres déclaratifs compilés en BPF en interne)
├── system/ — uname, sysinfo, getrandom, gethostname (`getrandom` est la primitive cryptographique exposée)
└── io_uring/ — voir ci-après, fait l'objet d'un module dédié et d'un ADR propre (ADR-022)
Trois périmètres mentionnés dans les esquisses early-stage de la couche 0 ne font pas encore l’objet de specs phase 0 et sont en attente d’instruction :
- Énumération et hotplug devices (netlink uevent, sysfs, evdev) : périmètre couche 0 par nature, à spécifier ultérieurement. Le compositeur graphique
air-wmet le compositeur consoleair-console(cf. ADR-007) consommeront ce module quand il sera produit. - eBPF et
perf_event_open: périmètre couche 0 par nature, à spécifier ultérieurement. Consommé par les services système d’observabilité et de monitoring d’énergie (cf. couche 5). - Bibliothèque cryptographique applicative : hors couche 0 par décision (RustCrypto et équivalents arrivent en couche 1+).
Le module io_uring
io_uring est traité comme un module à part entière de air-sys-syscall, distinct des autres familles à cause de son importance stratégique et de la complexité de son API. L’ADR-022 consigne 10 décisions structurantes sur ce module, et la spec détaillée est découpée en 4 Temps (Temps 1 cœur API, Temps 2 opérations FS/réseau/async, Temps 3 registration/linked/multishot/shared, Temps 4 raw). Voir docs/specs/layer-0/io-uring-overview.md et l’ADR-022.
Pour les opérations couvertes à la fois par les wrappers synchrones (net, fs, process…) et par io_uring, les deux chemins coexistent avec types et conventions partagés (Décision 2 d’ADR-022). Les couches supérieures choisissent selon le contexte (sync pour les chemins simples, io_uring pour la performance async).
Socle de bindings : syscalls directs (décision actée), rustix écarté
L’écosystème Rust dispose de plusieurs crates pour parler à la libc et au kernel Linux : libc (bindings bruts, peu sûrs), nix (bindings sûrs mais avec des choix de design vieillissants), et rustix (bindings sûrs, modernes, optionnellement sans dépendance à la libc, soutenu activement par l’équipe Bytecode Alliance).
Trace historique. rustix avait été évoqué comme candidat principal lors de la rédaction early-stage de ce document (API moderne, mode linux_raw, large couverture). Cette piste a depuis été abandonnée.
Décision actée (2026-05-31) : pas de socle de bindings externe. La couche 0 (air-sys-types + air-sys-syscall) appelle les syscalls Linux directement via core::arch::asm!, sur x86_64 et aarch64, sans aucune dépendance externe (ni rustix, ni nix, ni libc dans le chemin d’appel). Ce choix maximise le contrôle, le déterminisme de build (ADR-025) et la compatibilité no_std future, au prix d’un wrapping manuel assumé. Les opérations avancées de bas niveau (io_uring, Landlock) suivent la même règle : wrapping manuel des syscalls, pas de crate de bindings tierce. Le choix d’un éventuel binding eBPF haut niveau (libbpf-rs vs aya) reste une décision séparée, à instruire quand la famille ebpf sera spécifiée (cf. « Choix laissés ouverts »).
Gestion d’erreur : modèle hybride à deux niveaux (ADR-019 ; face ABI C : ADR-045)
Conformément à l’ADR-019, la couche 0 utilise un type d’erreur minimaliste Errno :
#[repr(transparent)]surNonZeroI32— sans allocation, sans contexte, sans chaîne de causalité.- Énumération des codes errno standards de Linux (~140 constantes :
EBADF,ENOENT,EINTR, etc.). - Implémente
core::error::Errorpour permettre la chaîne viasource()aux couches supérieures.
Aucun type d’erreur enrichi en couche 0. L’enrichissement (contexte, chemins, chaînes de causalité) est l’affaire des couches 1+ qui définissent leurs propres types Error spécifiques au domaine, encapsulant l’Errno source via #[from] pour permettre la propagation idiomatique avec ?.
Aucun panic!() dans le code public (cf. Principe d’ingénierie 4).
Conventions transverses (ADR-021)
L’ADR-021 acte cinq conventions de design qui s’appliquent à toutes les familles de la couche 0 :
- Préférer les variantes
*atavec base explicite (openat2, mkdirat, unlinkat, etc.) plutôt que les variantes implicites. La base de chemin est unDirFd::Cwdou un FD ; cela élimine les courses TOCTOU et clarifie les contextes d’accès. - Remonter EINTR à l’appelant plutôt que de re-tenter en interne. La décision de re-tenter ou non relève des couches supérieures qui ont le contexte sémantique.
- Décomposer les syscalls multiplexés (
prctl,fcntl,ioctl) en fonctions dédiées par opération avec types appropriés. Pas de wrapper générique qui prend un code d’opération entier. - Sentinelle
Nonepour les patterns kernel à valeur magique (signal 0 pour test d’existence, PID 0 pour processus courant, etc.).Option<T>plutôt que valeur entière sentinelle. - Constantes typées et bitflags pour les drapeaux de syscalls. Pas de constantes
i32bare exposées dans l’API publique.
Ces cinq conventions structurent le design de chaque famille de wrappers.
Sûreté mémoire et zones unsafe
Doctrine — l’unsafe vit en couche 0, et nulle part ailleurs (étoile polaire). La couche 0
est l’interface avec le kernel : c’est la barrière de sécurité du userland, et donc le seul
endroit où du unsafe Rust doit exister. air-sys-syscall contient du code unsafe par
nécessité, sous discipline stricte : tout bloc unsafe documenté avec un commentaire
// SAFETY: (vérifié en CI), API publique sans unsafe exposé sauf cas justifié (par exemple
unsafe fn from_raw_fd), code unsafe concentré dans des modules dédiés audités plus
strictement. Au-dessus de la couche 0, le code Rust est safe et garanti safe.
Cette règle est une étoile polaire, pas un état déjà atteint partout : quelques sites
au-dessus de la couche 0 portent encore du unsafe par construction — le bootstrap /
TCB / TLS / relocation d’air-runtime (couche 1), l’implémentation de GlobalAlloc par
air-alloc (couche 1), et la frontière C (extern "C" / #[no_mangle]) des toits libc
(le C n’offre aucune sûreté ; la logique derrière le shim reste du Rust safe couche 1).
Chacun de ces sites est nommé, audité, minimal, et traité comme une dette à faire
descendre en couche 0 ou à éliminer. Dette explicite ouverte : rendre GlobalAlloc safe
dans le cas d’Air, en travaillant avec l’équipe libs de Rust — au même titre que le retrait
de l’escape hatch raw_syscall (ADR-087).
Tests : coverage 100 % obligatoire
Cf. Principe d’ingénierie 1. Stratégies : tests unitaires par API, tests d’intégration en environnement contrôlé (containers/sandboxes), property-based testing via proptest pour APIs à paramètres complexes (notamment seccomp), fuzzing via cargo-fuzz pour parseurs (notamment quand les modules device et ebpf seront spécifiés), tests d’injection de fautes via simulateur de syscall, CI sur machines de référence (Raspberry Pi 4 ARM64 + Mac Intel x86_64 à chaque commit, cf. ADR-014).
Choix laissés ouverts pour la suite
- Version kernel Linux minimale requise : 6.12 (LTS) — décidée, pas ouverte. io_uring mature, Landlock présent, cgroups v2 standard, eBPF complet. Aucun fallback vers les kernels antérieurs.
- Spécification des familles
device(uevent/sysfs/evdev) etebpf(perf_event_openinclus) restant à produire. - Choix entre
libbpf-rsetayapour le binding eBPF haut niveau quand la familleebpfsera spécifiée (cf. ADR-024 workflow dépendances). - Organisation précise des feature flags pour dégradation gracieuse selon fonctionnalités kernel disponibles.
Notes pour suite
Virtualisation (KVM, libvirt, qemu) : pas tier-1, mais à traiter ultérieurement en ADR futur dédié quand le projet aura mûri (utilisation de containers/VMs sur Air OS pour développement, isolation, compatibilité).
Section 3 — Couche 1 : Primitives système
Rôle et périmètre
La couche 1 fournit les briques fondamentales que toutes les couches supérieures consomment pour faire de la programmation système. Là où la couche 0 expose des syscalls et des primitives kernel, la couche 1 expose des concepts applicatifs : fichiers ouvrables, sockets connectables, processus contrôlables, données cryptographiques, devices interrogeables. C’est l’équivalent de la bibliothèque libSystem sur d’autres systèmes Unix : la couche que tout le monde utilise sans même y penser.
La couche 1 est écrite en Rust pur et exposée également en ABI C pour les consommateurs polyglottes. Elle vise une couverture de tests de 100 % (cf. Principe d’ingénierie 1) et constitue avec la couche 0 le socle dont la fiabilité conditionne tout le reste.
Son périmètre couvre :
- Gestion de processus de haut niveau : lancement, supervision, communication parent/enfant, signaux applicatifs.
- Filesystem : chemins, opérations atomiques, watchers, gestion temporaire, opérations sécurisées.
- Sockets et networking bas niveau (
air-socket) : connexions TCP/UDP/Unix, écoute, manipulation d’adresses. La stack réseau de haut niveauair-networkau sens du futur ADR « framework networking couche 2 » vient en couche 2. - Primitives cryptographiques : hash, signatures, symétrique, asymétrique, dérivation de clés.
- Devices via
libudev: énumération, propriétés, surveillance. - Time et timers : horloges, deadlines, intervalles.
- Threading et synchronisation : threads, mutex, channels, atomics, primitives de coordination.
- Allocation et structures mémoire : pools, arenas, allocateurs custom pour cas spécifiques.
Tout ce qui est sémantiquement applicatif (le runtime d’objets C-ABI, l’IPC AirCom, l’event loop unifié, les services système) est en couche 2 ou au-dessus.
Les crates de la couche 1
air-base-core/ — cœur sans i18n : erreurs, chaînes/chemins/octets, temps (ADR-054)
air-base-lib/ — fondations universelles sur air-base-core (Unicode/i18n via icu4x, services)
air-filesystem/ — opérations filesystem haut niveau (AirFile, watcher)
air-process/ — gestion processus applicative (fork/exec, privilege separation, spawn)
air-thread/ — threading et synchronisation (futex, mutex/cond/rwlock)
air-memory/ — pools, arenas, mappings partageables (MmapRegion)
air-alloc/ — allocateur global possédé (inspiré de dlmalloc, ADR-056)
air-socket/ — sockets et networking bas niveau (ex air-network)
air-device/ — énumération et surveillance devices via libudev
air-crypto/ — primitives cryptographiques (RustCrypto)
air-terminal/ — AirTerminalManager : terminaux Linux (termios, PTY, codec ANSI, ADR-060/061)
air-signal/ — face signal générique (signalfd, masques, ADR-064)
air-poll/ — multiplexeur d'événements générique (ppoll)
air-stdio/ — brique stdio/console bas niveau
air-env/ — environnement no_std (variables, args, octets)
air-account/ — comptes /etc/passwd·shadow·group, bindé par la libc (ADR-067)
air-handle/ — registre de handles fd-général + duplicateur (ADR-069)
air-config/ — compilateur de configuration + artefact Cap'n Proto (ADR-040)
air-libm/ — math flottante no_std (fondation libc/i18n, ADR-057)
air-runtime/ — runtime userland : AirRuntime, TCB, TLS, relocation (ADR-052)
Toutes exposent une API Rust idiomatique. Cette API est interne au projet Air : elle
outille les toits (la libc libair_c et le PAL std) et n’est pas un contrat public pour
les développeurs d’applications (cf. la doctrine de stabilité, Section 10) — elle évolue avec
le kernel. Les toits ne consomment jamais la couche 0 en direct : ils passent par les
Managers de domaine de la couche 1 (voir ci-dessous).
Managers de domaine (ADR-077)
Le rôle de la couche 1 est de transformer les « objets » fonctionnels du kernel en objets
Rust, et de les faire manipuler par des Managers de domaine : des objets médiateurs, un
par domaine — AirFileManager, AirTaskManager, AirNetworkManager, AirSystemManager,
AirSignalManager, AirEnvironmentManager, AirMemoryManager, AirTerminalManager. C’est
la surface que consomment les toits (la libc libair_c et le PAL std) : jamais la
couche 0 en direct, jamais des fonctions libres éparses. Les Managers évoluent avec les
features du kernel — ils adoptent les variantes modernes et déprécient, voire abandonnent,
le legacy. Air se réserve le droit de faire évoluer leur comportement pour coller à la fois
aux idiomes Rust et à ceux du kernel Linux : ils ne sont pas un contrat public pour les
développeurs d’applications (cf. Section 10).
air-base-lib : fondations universelles
La crate la plus consommée du système. Contient :
Types d’erreur et result. AirError est le type d’erreur générique, conçu pour absorber facilement les erreurs des couches plus basses (AirSysError) et exposer une chaîne d’erreurs traçable. Cohérent avec Principe d’ingénierie 4.
Strings et paths. AirString Unicode-aware (enrobe String Rust avec opérations Unicode normalisées via icu4x, cf. ADR-016). Méthodes : normalisation NFC/NFD, casing locale-aware, segmentation par grapheme, comparaison locale-aware. AirPath représente un chemin de fichier, distinct d’AirString parce qu’un chemin Unix peut contenir des octets non-UTF8 (cohérent avec Principe d’ingénierie 3). Conversion explicite entre les deux.
Time. AirInstant (point dans le temps monotone), AirDuration (durée), AirDateTime (date et heure dans un calendrier donné, via icu4x). Distinction nette entre temps physique monotone et temps calendaire.
Logging structuré. AirLog est l’API de logging unifiée. Pousse vers journald via sd-journal (cohérent ADR-005). Format structuré (clés/valeurs typées), niveaux standard, contexte attachable. Intégration avec tracing standard Rust.
Identifiants. AirUuid (UUID v7 préféré), AirId128 (générique, utilisé pour machine-id via sd-id128), AirMonotonicId (compteur monotone).
Configuration. AirConfig API standard pour lire la config d’un composant. Format TOML par défaut. Recherche selon conventions XDG et Air. Validation par schéma (typage Rust + serde).
Pas de macros dérivées ici. L’objectif d’air-base-lib est d’être consommable depuis le C aussi simplement que possible. Les macros idiomatiques Rust (comme #[derive(AirObject)]) sont en couche 2.
air-filesystem : opérations filesystem
Au-delà des primitives couche 0 :
- Opérations atomiques :
AirFs::write_atomic(path, data)(tmpfile + fsync + rename). - Watchers :
AirFsWatcherenrobant inotify, avec callback, filtrage, debouncing. - Répertoires temporaires :
AirTempDiravec cleanup automatique au drop. - Chemins canoniques : résolution symlinks, normalisation, vérification de confinement.
- Copie efficace :
AirFs::copyviacopy_file_rangezero-copy sur FS qui le supportent. - Recherche : globs, regex, bornée pour éviter explosions.
air-process : gestion de processus applicative
AirProcesstypé : arguments, environnement, redirections, wait, signaux.- Pipes typés :
AirPipewrapspipe2()avec flags. - Pattern privilege separation :
AirProc::drop_privileges()helper atomique (retire capabilities, change UID/GID, applique seccomp en une opération). Utilisé partout dans les services Air, cohérent avec Principe d’ingénierie 10. - Pas de supervision longue durée : confiée à systemd via
air-launchdcouche 5.
air-socket : sockets et networking bas niveau
Renommage validé : la crate sockets bas niveau de la couche 1 s’appelle air-socket, pour éviter la collision avec air-network (couche 2 — ADR à produire — framework networking).
- Sockets typés :
AirTcpSocket,AirUdpSocket,AirUnixSocket,AirNetlinkSocket. - Adresses :
AirIpAddress(v4 ou v6),AirSocketAddress,AirUnixAddress. - Helpers DNS : résolution synchrone et asynchrone via resolver système. Pas de DNS Air spécifique (cohérent futur ADR « framework networking couche 2 »).
- Pas de framework de connexion sémantique : les notions
AirConnection,AirHttpRequest, etc. prévues par le futur ADR « framework networking couche 2 » sont en couche 2.
air-crypto : primitives cryptographiques
- Choix de socle : RustCrypto. Hash (SHA-2, SHA-3, BLAKE3), MAC (HMAC), symétrique (AES, ChaCha20), asymétrique (Ed25519, X25519, RSA), KDF (HKDF, Argon2), AEAD (AES-GCM, ChaCha20-Poly1305). Maintenu, audité, performant.
- TLS via
rustls: pas d’OpenSSL. Air n’a pas besoin de la dette historique d’OpenSSL. - Accélération matérielle : AES-NI sur Intel x86_64, Crypto Extensions ARM v8 récents (automatique via RustCrypto).
- Random haute qualité :
AirRandomenrobantgetrandom(toujours via kernel, jamais PRNG userspace). - Stockage de secrets pas ici : keychain en couche 5 (futur ADR).
- Capabilities pour usage avancé : primitives disponibles à tous, mais utilisation pour signer bundles
.airappréservée àair-trust(couche 5).
air-device : énumération et surveillance via libudev
- Choix de socle : libudev (cohérent ADR-005).
- Énumération :
AirDevice::enumerate(filter)avec filtres (subsystem, sysname, propriétés). - Propriétés : exposition des propriétés device, typage pour propriétés courantes (vendor/product ID, MAC).
- Surveillance hotplug :
AirDeviceMonitorconsommant uevents via libudev, stream typé d’événements. - Pas de logique politique :
air-deviceénumère et notifie. Les décisions sont en couche 5.
air-thread : threading et synchronisation
AirThread: lancement avec config (nom, taille stack, affinité CPU).- Synchronisation :
AirMutex,AirRwLock,AirSemaphoreau-dessus de parking_lot. - Channels :
AirChannelMPSC. - Atomics : ré-export des types stdlib pour cohérence.
- Pas d’async runtime ici : le runtime async (
air-event) est en couche 2 — natif io_uring, sans tokio (ADR-038).
air-memory : allocations spécialisées
- Arenas :
AirArenapour patterns alloue-massivement-libère-d’un-coup. - Object pools :
AirObjectPool<T>pour types fréquemment alloués/libérés. - Slab allocators : pour structures à taille fixe.
- Memory tracking : API pour mesurer consommation par composant (audits fin de phase, validation cibles modestes Principe 9).
- Pas de GC : Rust gère via ownership.
air-runtime : runtime userland (AirRuntime)
Le runtime userland d’Air (ADR-049, placement corrigé par ADR-052) est un objet de
couche 1, pas un étage à part : un programme a besoin, au démarrage, d’un runtime (crt0) qui
n’est pas que de la gestion de thread. air-runtime héberge les objets Rust sûrs qui le composent —
tous couche 1, donc consommant la couche 0 comme n’importe quel autre objet couche 1 :
ThreadControlBlock: bloc de contrôle de thread au registre TLS (errnothread-local, self-pointer, canari de pile issu d’AT_RANDOM, DTV,tid, mot futex de join).ThreadLocalStorage: mise en place TLS ELF (Variant II x86_64 / Variant I aarch64), lecture du gabaritPT_TLS, programmation du registre TLS (set_fscouche 0 /msr tpidr_el0).- Outils de relocation : la cible
*-linux-airétant PIE/static-PIE (ADR-050), le runtime applique lui-même ses relocationsR_*_RELATIVE(parcoursauxv/phdrs/.dynamic) — outillage ELF en Rust pur, sous contrainte de bootstrap. AirRuntime: compose les trois ci-dessus avec le décodage de la pile initiale (argc/argv/envp/auxv), l’exposition d’environ/args, lespawnstd-free et la terminaison de processus — en s’appuyant surair-thread(spawn/futex),air-memory(mmap),air-process(exit).
Invariant (ADR-052) : AirRuntime ne consomme que la couche 1 ; il ne dépend jamais de
air-sys-syscall (la surface kernel ; air-sys-types — types transverses — reste autorisé partout,
cf. ADR-052 D6). La libc Air et le std de *-linux-air reposent sur AirRuntime. Seul le point
d’entrée ELF _start (trampoline asm minimal, spécifique à la cible) vit hors-arbre dans rt/
(ADR-050, nightly + build-std) et appelle AirRuntime.
Pas d’ABI C publique en couche 1 — les toits libair_c et le PAL
L’ancienne ABI C de la couche 1 (libair-base.so) est retirée : la couche 1 n’expose pas
de surface publique stable — elle est interne, non contractuelle, et évolue avec le kernel
(cf. la doctrine de stabilité, Section 10). Elle alimente en revanche, via ses Managers, les
deux toits construits au-dessus d’elle :
libair_c— la libc C d’Air (cratesair-libc-*), conforme au kernel Linux, pas à POSIX (cf. ADR-046/047). C’est ce que lient le C et lestdRust de la cible*-linux-air(zéro glibc). Elle donne au développeur C une base attendue et sûre ; pour faciliter le portage d’outils Unix elle expose des symboles approchant POSIX, mais dont le comportement suit Linux — un symbole hérité néfaste (p. ex.pthread_cancel) peut être fourni déprécié / inerte pour permettre le linkage, sans jamais recevoir de vraie implémentation.- Le PAL (
std::sysde la cible*-linux-air) — le backend qui fait tourner lestdRust natif sur Air.
La surface publique stable d’Air pour les développeurs vit en couche 2 : la lib Rust
libair (Rust pur) et les ABI C du modèle d’objet (libair-object.so, etc.), zone
air-stable, garantie 10 ans (cf. ADR-012, Section 4).
Tests : coverage 100 % obligatoire
Stratégies similaires couche 0 + tests d’ABI C de la libc libair_c (compilation C + appels), tests d’intégration multi-processus, property-based testing pour types complexes (AirString Unicode, AirPath normalisation, AirDateTime calendriers), fuzzing pour parseurs (TOML, paths, propriétés udev).
Choix laissés ouverts pour la spec de composant
- Primitives de synchronisation bloquantes :
std::sync/parking_lot(cf. specair-thread). L’async (couche 2) repose sur le runtime io_uring natifair-event, pas sur tokio (ADR-038). - Politique précise de logging : format des messages structurés, conventions de nommage, niveaux par défaut. À standardiser début phase 0.
- Bindings polyglottes auto-générés vs écrits manuellement :
cbindgen,uniffi(Mozilla). À évaluer phase 1. - Politique d’allocation par défaut : system allocator, jemalloc, mimalloc. Probablement system allocator sur Air OS.
Section 4 — Couche 2 : Modèle d’objet, AirCom, services fondamentaux
Rôle et périmètre
La couche 2 est le cœur conceptuel d’Air. Elle introduit les abstractions qui rendent le système cohérent verticalement (du kernel aux applications) et horizontalement (entre tous les composants). Trois piliers la composent :
- Le runtime d’objets C-ABI (
air-object) qui donne à toute classe d’Air une identité runtime, des propriétés observables, et la bindabilité polyglotte. Décidé par ADR-002. - AirCom (
air-com) qui fournit l’IPC capability-based, schema-first, performant, qui remplace D-Bus pour les communications internes. Décidé par ADR-001. - Les services fondamentaux que toute couche supérieure consomme : event loop unifié, intégration systemd, collections Unicode-aware via
icu4x, notification, observation de propriétés, registre de services.
Cette couche est écrite en Rust et exposée intégralement en ABI C : c’est précisément ici que se matérialise la propriété “Air est polyglotte dès le départ”. Une application en Swift, Python ou Ruby consomme libair-object.so et libair-com.so directement, sans glue spécifique par classe.
La couche 2 ne contient pas de logique applicative spécifique (pas de compositeur, pas de framework UI, pas de service système au sens fonctionnel). Elle contient les mécaniques qui permettent à ces composants existants d’être cohérents.
Partie A — Le runtime d’objets air-object
Le problème à résoudre
Rust, par design, n’a pas de runtime d’objets dynamique. C’est une force pour la performance et la sûreté, mais insuffisant pour quatre besoins :
- Observation de propriétés : fondement du data binding, des frameworks réactifs (
air-ui), de l’accessibilité (lecteur d’écran), du débogage (inspecteur). - Introspection runtime : pour les outils (debugger, inspecteur d’UI, lecteur d’écran) qui doivent interroger un objet sans connaître son type concret.
- Bindings polyglottes sans glue par classe : pour qu’un script Python ou Swift consomme Air uniformément.
- Comptage de références cross-langage : pour qu’un objet créé en Rust et référencé par Swift/C/Python vive correctement.
ADR-002 a tranché : Air a un runtime d’objets C-ABI pour le périmètre “CoreFoundation/AppKit” (collections, strings, URLs, services, vues, contrôleurs, propriétés observables). Tout le reste reste Rust pur.
Architecture du runtime d’objets
L’unité fondamentale est AirObject, structure C opaque :
AirObject (taille variable selon classe)
├── header (24 octets sur 64-bit)
│ ├── classe — pointeur vers la AirClass
│ ├── refcount — compteur de référence atomique
│ └── flags — frozen, observed, traits accessibility, etc.
└── payload (taille variable, dépend de la classe)
└── champs de la classe, dont propriétés observables
Toute AirClass est elle-même un AirObject (méta-classe). Une classe porte :
- Sa table virtuelle : pointeurs vers les fonctions méthodes.
- Ses métadonnées d’introspection : nom, parente, propriétés, méthodes.
- Ses traits accessibility : role par défaut, attributs universels.
API publique C-ABI :
air_object_alloc(class) — allocation
air_object_retain(obj) / release(obj) — refcount
air_object_class(obj) / is_kind_of — type info
air_object_get_property / set_property — propriétés par nom
air_object_observe / unobserve — abonnement aux changements
air_object_send_message — invocation de méthode dynamique
AirValue est un type union typé : entier, flottant, booléen, string, bytes, autre AirObject, null. Équivalent NSObject/id macOS ou GValue GObject.
Définition des classes en Rust
Via macros :
#![allow(unused)]
fn main() {
#[air_class]
pub struct AirButton {
#[observable]
pub label: AirString,
#[observable]
pub enabled: bool,
#[accessibility(role = "button")]
_accessibility: AccessibilityMarker,
on_click: Option<AirCallback<()>>,
}
}
Les macros génèrent : structure C opaque avec layout C-ABI, AirClass métadonnée enregistrée au runtime, implémentations standard (retain/release/getProperty/etc.), fonctions C exportées (ABI stable), annotations accessibility consommées par air.wm.accessibility (ADR-017).
AirHandle<T> : smart pointer Rust avec sémantique Arc interopérant avec refcount AirObject (Drop = release, Clone = retain).
Bindings polyglottes
Pattern A : binding générique (Python, Ruby, dynamiques) — consomme uniquement les fonctions universelles air_object_*. Aucun code spécifique par classe.
Pattern B : binding typé (Swift, statiques avancés) — génère wrappers typés à partir des métadonnées. Swift via @dynamicMemberLookup pour syntaxe quasi-native.
Pont avec le monde Rust pur
Toutes les structures Air ne sont pas des AirObject. Pour structures internes, parseurs, algorithmes : Rust pur (pas de surcoût refcount atomique, pas de table virtuelle). Frontière explicite, jamais implicite (Principe d’ingénierie 7).
Mémoire et threads
Refcount atomique : objet détenu par plusieurs threads. Mais contenu pas thread-safe par défaut. Politique explicite obligatoire :
Immutable: créée une fois, jamais modifiée (thread-safe trivialement, modèleAirString).MainThreadOnly: thread principal uniquement (modèleAirView, vérifié en debug).ThreadSafe: accès concurrent autorisé (implémentation protège invariants).
Classe sans politique déclarée ne compile pas.
Introspection et debugging
air_class_list_all() / air_class_properties / air_class_methods / air_class_parent
air_object_describe / air_object_children
Activée en production. Permet à air-screenreader (cf. ADR-017) de fonctionner sur n’importe quelle app sans plugin.
Crates concernées
air-object/ — runtime principal, ABI C
air-object-macros/ — macros procédurales
air-value/ — AirValue
air-collections/ — AirArray, AirDictionary, AirSet (observables)
(AirString — chaîne UTF-8 Unicode-aware sur icu4x — est un type de la couche 1
[air-base-lib], re-exporté/consommé ici, pas une crate de la couche 2.)
ABI C via libair-object.so, zone air-stable.
Partie B — AirCom air-com
Rappel des décisions structurantes (ADR-001)
Unix sockets pour control plane, memfd_create + mmap pour data plane bulk, io_uring pour async, modèle capability-based inspiré XPC/Cap’n Proto/FIDL, encodage schema-first avec Cap’n Proto, pas de broker central, registre léger pour découverte.
Couche 2 implémente AirCom dans deux crates : air-com (transport et runtime) et air-com-schema (compilateur de schémas).
Le modèle capability-based
Une capability AirCom est un handle opaque non-falsifiable. Techniquement : FD vers une connexion AirCom, plus métadonnées de type.
Distribution initiale. air-launchd (couche 5) crée les connexions selon les entitlements et passe les FDs via SCM_RIGHTS :
fn main() {
let cx = AirContext::from_environment();
let notifications = cx.capability::<NotificationService>()?;
// obtenu uniquement si entitlement air.system.notifications déclaré
notifications.send(Notification::new("Hello")).await?;
}
Pas d’autorité ambiante : un processus ne peut parler qu’aux services dont il a reçu une capability.
Délégation. Capability passable à un autre processus via AirCom lui-même (encodée comme SCM_RIGHTS). Composition : un service peut déléguer une sous-capability sans passer par air-launchd. Modèle seL4 / Mach ports macOS.
Révocation. Par fermeture FD côté serveur, invalide immédiatement côté client.
Schémas et typage
Schema-first via Cap’n Proto. Chaque service décrit par un schéma définissant méthodes, types, versioning :
@0x9f5b87a3c0b46d3e;
interface NotificationService @0 {
send @0 (notification :Notification) -> (id :UInt64);
cancel @1 (id :UInt64) -> ();
struct Notification {
title @0 :Text;
body @1 :Text;
# ...
}
}
Compilateur air-com-compile génère : module Rust avec types et traits, header C correspondant, optionnellement bindings polyglottes.
Évolution des schémas (cohérent ADR-012)
Règles strictes Cap’n Proto : ajout champ OK (ignoré par ancien client), ajout méthode OK, suppression jamais, renumérotation jamais, changement de type jamais.
Versions d’interface dans les noms (NotificationServiceV2 coexistant avec V1). Registre AirCom présente les deux, l’app choisit.
Transport et performance
- Control plane : Unix sockets SEQPACKET (ordering garanti, frontières préservées, FD passing).
- Data plane bulk : memfd + mmap pour payloads volumineux (images, frames vidéo, buffers GPU). Zero-copy entre processus.
- Async via io_uring : opérations AirCom async par défaut, batchées quand possible. Performance comparable XPC ou Cap’n Proto natif.
- Back-pressure explicite : modèle reactive streams. Pas de mémoire qui gonfle silencieusement.
Registre de services : air-registry
Seul service système “fondamental” : fournit la découverte. Quand un service expose une interface, il s’enregistre auprès d’air-registry. Clients qui ne connaissent pas l’emplacement le demandent.
Pas un broker : ne relaie pas. Fournit juste la résolution. Une fois connexion établie, dialogue direct.
Capability initiale universelle. Tout processus Air reçoit au démarrage la capability vers air-registry. Seule capability implicite. Toutes les autres explicitement déclarées.
Crates concernées
air-com/ — transport, runtime
air-com-schema/ — compilateur de schémas
air-com-codegen/ — génération bindings
air-registry/ — service registre (lib + daemon)
ABI C via libair-com.so, zone air-stable.
Partie C — Services fondamentaux et orchestration
Event loop unifié : air-event
Runtime asynchrone natif sur io_uring (ADR-023, ADR-038) — sans tokio. Le moteur est le crate air-runtime (couche 1, consomme io_uring) ; air-event en est la façade C-ABI (couche 2). Cf. ADR-039. (tokio avait été envisagé au tout début, avant le support intégral d’io_uring ; abandonné depuis.)
Pourquoi air-event. Pour exposer un event loop async consommable depuis C/Swift/Python, il faut une API C-ABI propre bâtie directement sur io_uring. air-event est ce runtime et cette API.
Concepts exposés : AirEventLoop, AirFuture (polymorphique exposable en C), AirTimer, AirSignal (via signalfd couche 0), AirChannel (MPSC async).
Integration AirCom : toute opération AirCom retourne AirFuture. Coordination naturelle AirCom + timers + FDs.
Integration sd-event : peut consommer source sd-event quand composant Air doit s’intégrer systemd (watchdog, sd-notify).
Runtime maison sur io_uring : Air maîtrise entièrement son runtime async (aucune dépendance externe type tokio à encapsuler ou remplacer). Cf. ADR-038.
Intégration systemd : air-systemd
Encapsule intégration systemd (cohérent ADR-005).
- sd-bus peer-to-peer : client
sd-busconfigurable pour parler à systemd directement via socket privé sans dbus-daemon. Mécanisme principal pour logind,systemd --user,systemd-resolved, etc. - sd-event integration : pont vers
air-event. - sd-notify : API pour qu’un service Air notifie systemd (READY, RELOADING, STOPPING, WATCHDOG).
- sd-journal : API logging structuré, consommée par
AirLogcouche 1. - sd-id128 : identifiants 128-bit, consommés par
AirId128couche 1. - Pont D-Bus optionnel :
air-dbus-bridgechargeable à la demande. Pas requis.
Collections Unicode-aware
AirArray<T>, AirDictionary<K, V>, AirSet<T>, AirOrderedSet<T> — observables. Tous AirObject, donc C-ABI et bindables.
Pour usages internes performants : Vec, HashMap stdlib Rust. Conversion explicite vers collections C-ABI.
Observation et notification
AirObservable<T>: wrapper rendant un champ observable. Get/set/observe.AirNotificationCenter: bus de notifications nommées intra-processus. ModèleNSNotificationCentermacOS. Distinct de AirCom (qui est inter-processus).
Préférences utilisateur : air-prefs (ADR à produire — préférences utilisateur)
Détaillé dans le futur ADR — air-prefs. Service AirCom fondamental en couche 2 (par sa nature de service consommé par toutes les couches supérieures). Trois domaines hiérarchiques (système / utilisateur global / app) avec cascade de fallback. Format TOML. Schémas typés déclaratifs (modèle GSettings simplifié). Capabilities air.preferences.read/write.{own-app,global,other-app}. Observation async via AirCom. Versioning explicite des schémas avec migrations déclaratives. Compatibilité freedesktop via xdg-desktop-portal-air (Settings portal).
Architecture d’ensemble de la couche 2
┌──────────────────────────────────┐
│ Couches 3, 4, 5 consomment │
│ les APIs publiques de la 2 │
└─────────────┬────────────────────┘
│
┌────────────────────────────┼─────────────────────────────┐
│ │ │
▼ ▼ ▼
┌────────┐ ┌───────────┐ ┌──────────────┐
│Runtime │◄─────────────►│ AirCom │◄──────────────►│ Event loop │
│d'objets│ métadonnées │ IPC │ intégration │ air-event │
│ C-ABI │ classes │ │ async │ │
└────┬───┘ └─────┬─────┘ └──────┬───────┘
│ │ │
│ ┌───────────┐ │ ┌──────────────┐ │
├────►│Collections│ ├─────►│ air-systemd │ │
│ │Unicode │ │ │ sd-* │◄──────┤
│ │AirString │ │ └──────┬───────┘ │
│ └───────────┘ │ │ │
│ │ ▼ │
│ ┌──────▼──┐ ┌──────────────┐ │
│ │air- │ │ systemd │ │
│ │registry │ │ (couche │ │
│ └─────────┘ │ externe) │ │
│ └──────────────┘ │
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ Couches 0 et 1 (air-sys-*, air-base-lib, air-socket...)│
└─────────────────────────────────────────────────────────┘
Crates de la couche 2 — récapitulatif
air-object/ — runtime d'objets C-ABI
air-object-macros/ — macros procédurales
air-value/ — AirValue
air-collections/ — AirArray, AirDictionary, AirSet
air-com/ — transport IPC
air-com-schema/ — schémas Cap'n Proto
air-com-codegen/ — génération bindings
air-registry/ — service registre
air-event/ — event loop unifié
air-systemd/ — intégration systemd
air-dbus-bridge/ — pont D-Bus optionnel
air-prefs/ — préférences (spec ultérieure)
air-notification/ — AirNotificationCenter intra-processus
ABI C via libair-object.so, libair-com.so, libair-event.so, zone air-stable.
Tests : coverage 100 % obligatoire
Stratégies couches 0-1 + tests ABI C runtime depuis C/Swift/Python, tests AirCom cross-process (lancer service et client en processus séparés), tests d’évolution de schéma (version N parle à N+1), fuzzing transport AirCom (parsing messages externes), tests multi-thread (refcount atomique, race conditions).
Choix laissés ouverts pour la spec de composant
- Compilateur Cap’n Proto :
capnpcrate Rust officiel au départ, règle des 80 % à vérifier. - Stratégie de génération de bindings polyglottes :
cbindgenheaders C,uniffiSwift/Kotlin/Python. À évaluer phase 1. - Politique de garbage collection : refcount strict au départ, cycles évités par discipline (modèle ARC macOS), GC cyclique reporté.
- Format final des macros
#[air_class]: exemple indicatif, détail à l’implémentation. - Politique précise de
MainThreadOnlyenforcement : debug uniquement ou release avec overhead minimal. À mesurer.
Section 5 — Couche 3 : Composition, rendu, audio, input
Rôle et périmètre
La couche 3 est l’interface physique d’Air avec le matériel d’affichage et de son. Elle prend en entrée des descriptions logiques et produit des artefacts matériels.
Son périmètre couvre :
- Composition d’écran : deux compositeurs jumeaux partageant leur backend.
air-wmWayland graphique (ADR-003),air-consolemode texte bypass VT (ADR-007). - Rendu 2D : GPU-first via Vello pour graphique, grille de cellules pour console. Texte bidirectionnel via Cosmic Text + HarfBuzz.
- Audio : intégration PipeWire.
- Input : evdev partagé, gestion seats via logind, traduction événements bruts en logiques.
- Gestion matériel d’affichage : DRM/KMS, hotplug, multi-écran, propriétés étendues (HDR, espace colorimétrique, refresh rates, DPI).
Tout ce qui est décision applicative sur quoi afficher est en couche 4. La couche 3 n’est présente que dans air-desktop (cf. ADR-006), à l’exception d’air-console qui peut être inclus dans air-base.
Vue d’ensemble
┌─────────────────────────────────────────────────────────────────┐
│ Couches 4 et 5 consomment la couche 3 │
└────────────┬────────────────────────────────┬───────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ air-wm │ │ air-console │
│ compositeur │ │ compositeur │
│ Wayland │ │ texte │
└────────┬─────────┘ └────────┬─────────┘
│ │
└──────────┬──────────────────────┘
│
▼
┌─────────────────────┐ ┌──────────────────┐
│ air-display │ │ air-audio │
│ (backend partagé) │ │ PipeWire bridge │
│ DRM/KMS + evdev │ └────────┬─────────┘
│ + seats + glyph │ ▼
└──────────┬──────────┘ ┌──────────────────┐
│ │ PipeWire │
│ └──────────────────┘
▼
┌─────────────────────┐ ┌──────────────────┐
│ Couches 0, 1, 2 │ │ air-render │
│ + air-com │ │ (Vello 2D) │
└─────────────────────┘ └──────────────────┘
Partie A — Backend partagé air-display
ADR-007 a posé la symétrie : air-wm et air-console bypassent les abstractions historiques pour parler directement au matériel. Conséquence : ils partagent une couche d’abstraction matérielle commune, air-display.
air-display est une bibliothèque, pas un compositeur à lui seul. Elle gère le matériel ; les compositeurs gèrent la politique.
DRM/KMS backend. Bindings haut niveau sur l’API Direct Rendering Manager. Types : AirDrmDevice (GPU disponible), AirOutput (sortie physique connectée), AirCrtc (contrôleur scan-out), AirFramebuffer (buffer GPU prêt à afficher), AirPlane (plan de composition matériel). Programmation atomique de DRM exposée (multi-écran cohérent). Choix de socle : crate drm Rust à évaluer phase 0.
evdev backend. Bindings haut niveau sur input_event. Types : AirInputDevice (clavier, souris, touchpad, tactile, gamepad — énuméré via libudev couche 1), AirInputEvent (événement brut typé), AirSeat (groupe de devices via logind). Acquisition EVIOCGRAB exposée pour mode exclusif.
Gestion des seats via logind. air-display consomme logind via air-systemd (couche 2). Permet à air-wm ou air-console de tourner sans être root, en utilisant leur place dans le seat.
Glyph rasterizer. Rendu de glyphes texte centralisé. Stack : cosmic-text pour layout (shaping, line breaking, bidi), HarfBuzz pour shaping spécifique (latin, arabe avec ligatures, devanagari avec diacritiques), Swash ou équivalent pour rasterisation, cache de glyphes partagé indexé par (police, taille, glyph_id, sub-pixel). Polices Noto par défaut (cohérent ADR-016).
Partie B — air-wm : compositeur Wayland
Cf. ADR-003 (compositeur Wayland) et ADR à produire — protocole privé AirCom (services privés).
Sous-systèmes :
- Cœur Wayland : implémentation protocole Wayland + extensions standards (xdg-shell, xdg-decoration, linux-dmabuf, text-input-v3). Smithay comme base candidate.
- Backend
air-display: tout accès matériel. - Scene graph : représentation hiérarchique avec damage tracking. Performance critique sur matériel modeste.
- Renderer : composition GPU via Vello, composition matérielle directe via DRM planes pour cas simples.
- Window manager logic : placement, workspaces (par écran selon le futur ADR « protocole privé AirCom »), modes multi-écran, raccourcis système, focus.
- Services AirCom privés : les sept catégories prévues par le futur ADR « protocole privé AirCom » (accessibility, animation, drag-drop, clipboard, notifications, devtools, displays) +
air.wm.session(négociation initiale),air.wm.screencapture(ADR à produire —air-screencapture/air-screenrecord),air.system.appearance(ADR à produire —air.system.appearance/ theming).
Shell intégré ou externe ? Décision validée : air-shell est un processus séparé en couche 5, pour respecter la discipline d’isolation et permettre modularité. Réévaluation possible si overhead trop important sur petites configurations.
Protocole Wayland natif + extension AirCom. Pour apps clientes Wayland standards : qualité de base élevée. Pour apps Air natives : services AirCom privés en plus. Pas de protocole Wayland privé inventé (cohérent avec le futur ADR « protocole privé AirCom »).
Multi-écran professionnel (ADR à produire — protocole privé AirCom (catégorie displays)). Modes miroir/étendu/indépendants exposés sémantiquement, fullscreen ciblé par écran soigné, workspaces par écran configurables, hotplug à chaud sans casser session, propriétés étendues (HDR, espace colorimétrique, refresh rates, DPI réel) via air.wm.displays.
Performance. Cible 60 fps stable sur Raspberry Pi 4, jusqu’à 120/144 Hz sur matériel plus puissant. Latence input-to-photon < 30 ms. Stratégies : composition matérielle (DRM planes), damage tracking strict, VRR quand supporté, adaptation au mode énergie (cf. ADR à produire — air-power).
Partie C — air-console : compositeur de mode texte
Cf. ADR-007 et ADR-008.
Partage massivement de code avec air-wm via air-display. Différences sur le modèle de rendu et le modèle d’événement.
Modèle de rendu : grille de cellules. Chaque cellule porte : codepoints Unicode (graphèmes complexes — émojis, ligatures arabes, diacritiques), attributs de style (couleur fg/bg, gras, italique, souligné, etc.), largeur double pour CJK. Buffer 2D rastérisé via glyph rasterizer partagé. Rendu final = framebuffer pixel via DRM/KMS. La grille est une abstraction logicielle au-dessus d’un rendu pixel, pas une dépendance hérité.
Modèle d’événement. Événements typés aux apps connectées (vs flux ANSI à un terminal classique) : clavier complets (press/release/repeat, modifiers exacts, layout-aware), souris haute précision, multi-touch, redimensionnement grille, focus. Via protocole AirCom privé air-tui (mode 1 d’ADR-008, plein bypass).
Gestion des espaces de travail. Affichage simultané possible : tiling, full-screen avec switching, splits. À affiner en spec.
Bascule TTY classique ↔ air-console. Plusieurs TTYs cohabitent. Ctrl+Alt+F1..F12 historique. logind gère qui a accès au seat.
Cohabitation apps Unix classiques. Bash/vim/htop tournent dans un terminal Air qui est lui-même app air-tui. Le terminal Air émule un PTY pour les programmes legacy. Apps air-tui natives tournent directement sans émulateur.
Partie D — Rendu : air-render
Choix de socle : Vello (validé).
Référence moderne pour rendu 2D GPU-first en Rust : approche compute-shader, qualité graphique excellente, performances sur matériel modeste, alignement avec direction Rust d’Air. Cohérent avec ADR-009 qui cite l’écosystème Linebender.
API. Scene graph immédiate : AirScene, AirPath (formes vectorielles), AirBrush (remplissages), AirTextRun (texte). air-ui compile ses vues déclaratives vers AirScene que air-render rasterise.
Backend GPU. Vello compile compute shaders Vulkan via wgpu. Sur machines de référence : Vulkan disponible Mac Intel, sur Raspberry Pi 4 (driver v3d, Vulkan partiel — à valider en conditions réelles).
Cache de glyphes partagé avec compositeur via air-display.
Partie E — Audio : air-audio
Intégration PipeWire (cohérent ADR-005). Bibliothèque, pas service.
PipeWire = serveur audio Linux moderne, remplace PulseAudio et JACK. Mature, performant, supporte audio bas-latence pour production musicale comme audio desktop.
API exposée aux apps Air :
AirAudioStream: flux audio lecture/capture. Format, sample rate, latence cible.AirAudioDevice: device physique ou virtuel via PipeWire. Énumération, propriétés.AirAudioPolicy: abstraction “à quoi sert ce flux” — musique, voix d’app, notification, communication temps réel.
Audio Bluetooth délégué à PipeWire (gère A2DP, HFP/HSP). air-bluetoothd configure profils, PipeWire route.
Latence et qualité. PipeWire permet sub-10ms en mode pro. Cible standard Air : 20-30 ms. Mode pro audio disponible pour apps spécialisées.
Couche transverse : événements et compositeur de session
air-seat : bibliothèque commune pour air-wm et air-console. Maintient connexion logind, gère acquisition/libération du seat lors des bascules TTY, distribue FDs des devices aux compositeurs actifs.
Coordination énergétique. air-power (couche 5, ADR à produire — air-power) interagit avec couche 3 pour : refresh rate selon mode (60 Hz normal, 30 Hz low_power, max performance), qualité de rendu réduite en low_power (animations off, blur réduit), VRR quand bénéfique, throttling scan-out sur inactivité. Via AirCom (air-wm expose capabilities consommées par air-power).
Crates de la couche 3 — récapitulatif
air-display/ — backend matériel partagé (DRM, evdev, seats, glyph)
air-seat/ — gestion seats via logind
air-wm/ — compositeur Wayland
air-wm-protocols/ — implémentations protocoles Wayland étendus
air-console/ — compositeur mode texte
air-render/ — bibliothèque rendu 2D (Vello-based)
air-audio/ — intégration PipeWire
Services AirCom exposés par air-wm et air-console font partie de leurs binaires respectifs.
Tests
Plus difficile à tester exhaustivement que couches 0-2. Stratégies adaptées :
- Tests unitaires sur logique pure (damage tracking, layout multi-écran, transformations).
- Tests d’intégration avec headless backend (
vkmskernel module ou équivalent pour DRM virtuel). - Tests sur matériel de référence ADR-014 : screenshots comparés, FPS, latence, multi-écran, hotplug.
- Tests d’interopérabilité Wayland : weston-clients, Smithay examples.
- Fuzzing parseur protocole Wayland.
Coverage cible : >90% mais pas 100% obligatoire (chemins matériel-dépendants).
Choix laissés ouverts pour la spec de composant
- Smithay comme base candidate à confirmer en phase 3 selon Principe 6 (règle des 80 %).
drm-rsvs wrapping manuel pour bindings DRM (phase 0).- Position d’
air-shell(couche 5 séparé validé, réévaluation possible si overhead). - Cosmic-text vs alternatives pour layout texte (phase 0).
- Stratégie fallback Vulkan sur GPU sans support complet (Raspberry Pi 4, phase 3 en conditions réelles).
- API précise de
air-render(scene graph immédiat directionnel, détail final à l’implémentation). - Politique précise cohabitation
air-console↔ TTY kernel (acquisition/libération devices).
Section 6 — Couche 4 : Frameworks applicatifs air-ui et air-tui
Rôle et périmètre
La couche 4 est l’interface qu’Air offre aux développeurs d’applications. Elle fournit les outils pour construire des UIs déclaratives, qu’elles soient graphiques (consommant air-wm en couche 3) ou textuelles (consommant air-console ou un terminal en couche 3).
Périmètre :
- Modèle de composition de vues déclaratif, partagé entre les deux modes.
- Substrat impératif
air-view-corevivant dans le runtime d’objets C-ABI (couche 2). - Framework graphique
air-uiavec renderer Vello. - Framework console
air-tuiavec renderer cellules et trois backends de transport (ADR-008). - Widgets fondamentaux : labels, boutons, listes, tables, formulaires, layouts.
- Vues composites de haut niveau : panneaux de paramètres, calendriers, alertes, lecteurs média.
- Animation déclarative first-class (ADR-009).
- Accessibilité par construction via modèle d’objet (ADR-017).
- API intermédiaire
air-ui-controllerpour mélanger déclaratif et impératif.
Vue d’ensemble
┌─────────────────────────────────────────────────────────────────┐
│ Applications .airapp / .airservice (consommateurs) │
└────────┬──────────────────────────────────────┬─────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ air-ui │ │ air-tui │
│ graphique │ │ texte │
└────────┬─────────┘ └────────┬─────────┘
│ │
└─────────────────┬───────────────────┘
│
▼
┌──────────────────────────┐
│ air-ui-core │
│ modèle déclaratif │
│ State / Observable / │
│ Environment │
└────────────┬─────────────┘
│
▼
┌──────────────────────────┐
│ air-view-core │
│ substrat impératif │
│ (vit dans air-object │
│ C-ABI, couche 2) │
└────────────┬─────────────┘
│
┌───────────────────┴──────────────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ air-ui-render- │ │ air-ui-render- │
│ gpu (Vello) │ │ tui (cellules) │
└────────┬─────────┘ └────────┬─────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ air-wm │ │ air-console │
│ (couche 3) │ │ (couche 3) │
└──────────────────┘ └──────────────────┘
Partie A — air-view-core : substrat impératif
ADR-009 a posé : modèle déclaratif au-dessus, substrat impératif en-dessous vivant dans runtime C-ABI couche 2. Permet : introspection runtime universelle (lecteur d’écran), consommation polyglotte, interopérabilité avec outils dev (air-inspector), mélange déclaratif/impératif via air-ui-controller.
Développeur d’app standard ne touche jamais air-view-core directement.
Classes fondamentales (toutes AirObject héritant propriétés accessibility universelles ADR-017) :
AirView: classe racine. Géométrie, visibilité, état, parent, enfants. Propriétés observables (frame, visible, enabled, children, accessibility_*).AirControl: sous-classe pour éléments interactifs (boutons, champs, sliders). Gestion focus, événements d’activation.AirContainer: sous-classe pour conteneurs purs (layouts). Logique de placement.AirWindow: fenêtre top-level. Wayland sousair-ui, tile/session sousair-tui.AirImage,AirText,AirCanvas: vues de contenu standard.
Hiérarchie de coordonnées : logiques (indépendantes du DPI), scale factor géré au rendu. Leading/trailing plutôt que left/right pour locales RTL (cohérent ADR-016, ADR-009).
Cycle de vie observable : created, attached, appeared, disappeared, detached, deallocated.
Partie B — air-ui-core : modèle déclaratif partagé
ADR-009 : air-ui et air-tui partagent le modèle de composition mais diffèrent par le renderer.
Les view descriptions : descriptions immutables produites à chaque cycle. Le framework compare avec la précédente et applique les diffs au substrat impératif.
#![allow(unused)]
fn main() {
fn counter_view(cx: &mut Cx) -> impl View {
let count = cx.state(|| 0);
column((
text(format!("Count: {}", count.get()))
.font_size(24)
.accessibility_label("Compteur"),
button("Increment")
.on_press(move |_| count.set(count.get() + 1))
.accessibility_hint("Incrémente le compteur de 1"),
))
.padding(16)
.spacing(8)
}
}
Les trois mécanismes de state (ADR-009) :
State<T>: état local par vue. Identifié par position d’appel surCx. Accédé viacx.state(|| initial).Observable<T>: objets observables partagés vivant dans runtime C-ABI couche 2. Abonnement explicitecx.observe(&obj, callback).Environment<T>: injection par contexte d’arbre. Typage par TypeId. Modèle SwiftUI environment.
Tous les accès explicites (Principe d’ingénierie 7). Pas de magie de traçage automatique.
Diff incrémental. Algorithme inspiré React reconciliation, adapté Rust et matériel modeste. Clés (view.key("item-3")) pour stabiliser identité dans listes dynamiques.
Composition par fonction, pas par classe. Modèle SwiftUI / Xilem / Iced / React function components transposé Rust avec passage explicite du Cx.
Macro view! optionnelle pour cas où syntaxe Rust devient verbeuse :
#![allow(unused)]
fn main() {
view! {
column padding=16 spacing=8 {
text("Count: {count}") font_size=24
if count > 10 {
text("Big number!") color=red
}
for item in items {
list_row(item)
}
}
}
}
Strictement du sucre syntaxique. Mélange possible des deux styles.
Partie C — air-ui : framework graphique
air-ui-render-gpu : renderer prenant l’arbre de view descriptions, produisant frames GPU via Vello (via air-render couche 3). Gère rasterisation widgets, animation (interpolation entre frames, intégration vsync), compositing final (fenêtre Wayland reçoit buffer GPU).
Communication avec air-wm : Wayland standard pour opérations de base + AirCom privé pour enrichissements (ADR à produire — protocole privé AirCom). Sous compositeur tiers : Wayland fonctionne, AirCom privé indisponible, dégradation selon air.wm.session.
Widgets atomiques (air-ui-widgets)
Texte et contenu :
text(content): texte avec style.image(source): image bitmap ou vectorielle (atomique, bas niveau).icon(name): icône système symbolique.
Contrôles :
button(label): bouton avec action.text_field(binding): saisie single-line.text_area(binding): saisie multi-ligne.toggle(binding): switch on/off.slider(binding, range): curseur.picker(binding, options): sélection non-éditable.combo_box(binding, options, allow_custom): sélection éditable.radio_button(binding, value): sélection mutuellement exclusive.color_picker(binding): sélecteur couleur (palette du futur ADR « theming » + picker visuel).progress(value): variantesprogress_linear,progress_circular,progress_indeterminate.scroll_bar(orientation, binding): barre standalone (intégrée automatiquement àscroll_viewpar défaut).date_picker(binding): sélection date inline locale-aware.
Conteneurs et layouts :
column(children): empilement vertical.row(children): empilement horizontal.grid(children): grille 2D.stack(children): superposition z-order.scroll_view(content): zone scrollable (scroll bars automatiques selonair.system.appearance).toolbar(items): barre d’outils spécialisée.
Navigation :
tab_view(tabs): onglets.navigation_view(stack): navigation hiérarchique.
Listes et collections :
list(items, row): liste virtuelle (rendu items visibles seulement).table(items, columns): table de données.outline(items, children): arbre hiérarchique.
Modifiers : .font, .color, .padding, .background, .animation, .transition, .accessibility_label, .accessibility_hint, .accessibility_role, .on_press, .on_hover, .draggable, .droppable.
Vues composites de haut niveau (air-ui-views)
Au-dessus des widgets atomiques, air-ui fournit un set de vues composites : composants de granularité grosse implémentant des patterns d’UI standards et fréquents. Le développeur ne réinvente pas un calendrier ou un panneau de paramètres à chaque application : il consomme la vue composite, la configure, profite des conventions cohérentes du système (modèle macOS NSAlert, NSDatePicker, iOS UIAlertController).
Ces vues sont implémentées au-dessus des widgets atomiques et du modèle déclaratif. Elles sont remplaçables (une app avec besoin spécifique écrit le sien avec les briques), mais pour 90 % des cas elles suffisent et garantissent la cohérence.
calendar_view(binding, options): calendrier navigable mois/année, sélection dates. Intègreicu4xpour calendriers locale-aware (cf. ADR-016). Modes : unique, plage, multiple.alert_view(title, message, actions): dialogue modal alerte/confirmation. Titre, message, icône optionnelle (info/warning/error), 1-3 boutons, modal overlay, theming système (ADR à produire —air.system.appearance/ theming).settings_view(sections): panneau paramètres structuré, modèle macOS System Settings / iOS Settings. Hiérarchie sections/items avec bindings vers préférences (consommées viaair-prefs). Layout automatique (sidebar+content sur écran large, navigation hiérarchique sur étroit). Recherche intégrée.help_view(content_source): visualiseur d’aide intégrée. Navigation hiérarchique, recherche, Markdown ou arbre structuré. Intégration optionnelle avec manuel système global.carousel_view(items, options): carrousel d’éléments. Navigation swipe/clic, indicateurs de page, animations, défilement auto optionnel. Variantimage_carouselpré-configurée.form_view(sections): formulaire structuré avec validation, modèle iOS form / macOS preference pane. Sections, items typés, validation par item et globale, focus et tab order automatiques.split_view(leading, trailing, options): vue divisée 2-3 colonnes redimensionnable. Modèle macOS sidebar+content+détail. Adaptation automatique au format d’écran.navigation_split_view(sidebar, content, detail): extension avec gestion automatique de la navigation hiérarchique en mode compact.media_player_view(source, options): lecteur multimédia avec contrôles standards (play/pause, scrub, volume, fullscreen). Audio via PipeWire couche 3, vidéo via accélération matérielle VAAPI/V4L2 m2m (cf. ADR à produire —air-screencapture/air-screenrecord).image_view(source, mode): affichage image enrichi (zoom, pan, loading state, placeholder, error state, lazy loading). Distinct du widget atomique bas niveauimage.
Liste indicative amenée à grossir selon besoins observés. Crate air-ui-views distincte d’air-ui-widgets.
Animation déclarative
Cohérent ADR-009, citoyenne de première classe.
#![allow(unused)]
fn main() {
let scale = cx.state(|| 1.0);
button("Click me")
.scale(scale.get())
.on_press(move |_| { scale.set(1.2); })
.animation(.spring(damping: 0.8))
}
Coordination avec air-wm via air.wm.animation (ADR à produire — protocole privé AirCom (catégorie animation)). Mode économie d’énergie peut désactiver/simplifier (cf. futur ADR « theming » reduce_motion, futur ADR « air-power » — mode low_power).
Transitions de vue : fade, slide, scale, personnalisables.
Theming
Cohérent avec le futur ADR « theming ». Consomme air.system.appearance et adapte automatiquement :
color_scheme: light/dark.accent_color: couleur d’accent.text_size: facteur taille (Dynamic Type).reduce_motion,reduce_transparency,increase_contrast.
Surcharge possible par l’app : .preferred_color_scheme(.dark).
Accessibilité par construction
Cohérent ADR-017. Toute UI air-ui accessible grâce au substrat impératif air-view-core portant les propriétés universelles.
Génération automatique : button("Save") a automatiquement role=button, label="Save". text("Hello") a automatiquement role=text, value="Hello".
Modifiers explicites pour cas avancés : .accessibility_label, .accessibility_hint, .accessibility_role.
Arbre consommé par air-screenreader via air.wm.accessibility (ADR à produire — protocole privé AirCom (catégorie accessibility), ADR-017).
Crates
air-ui/ — framework graphique (re-exporte air-ui-core + widgets + vues composites)
air-ui-render-gpu/ — renderer Vello
air-ui-widgets/ — widgets atomiques
air-ui-views/ — vues composites
air-ui-animation/ — primitives d'animation
Partie D — air-tui : framework console
air-ui-render-tui : renderer prenant l’arbre de view descriptions et rastérisant en grille de cellules. Layout cellulaire, couleurs (256 ou 24-bit selon backend), attributs (gras, italique, souligné, inversé), clipping et scrolling.
Les trois backends de transport (ADR-008) :
- Backend 1 : TTY réel avec
air-console(plein bypass). Événements via protocole privé AirCom d’air-console. Tous événements bruts (key press/release, modifiers complets, souris, multi-touch). Rendu via grille envoyée àair-console. - Backend 2 : Terminal Air sur compositeur graphique (plein bypass équivalent). Terminal Air implémente protocole AirCom privé équivalent. Mêmes capacités, même API.
- Backend 3 : Terminal tiers ou SSH (mode dégradé). Kitty keyboard protocol si supporté, ANSI standard sinon. Perd events release et souris haute précision.
API Capabilities : l’app interroge ce qui est disponible et dégrade silencieusement selon le contexte.
#![allow(unused)]
fn main() {
if cx.capabilities().has_real_mouse() { mouse_indicator() } else { empty() }
}
Widgets air-tui compatibles air-ui (rendu différent, même API) : text, button, text_field, text_area, toggle, picker, column, row, grid, stack, scroll_view, tab_view, navigation_view, split_view, list, table, outline. Code identique pour app both.
Widgets spécifiques air-tui : ascii_art, progress_bar_text, box(border_style).
Widgets exclus en air-tui : image (placeholder textuel), canvas (impossible — variante canvas_cells pour dessin cellulaire), slider haute précision (version cellulaire à granularité réduite).
Vues composites disponibles en air-tui : alert_view, settings_view, help_view, form_view, split_view, navigation_split_view, calendar_view (variante ASCII type cal). Exclus : carousel_view, media_player_view, image_view (intrinsèquement graphiques).
Animation en TUI : supportée avec contraintes (cellule = unité minimale, framerate typique 30 fps). Transitions par changement progressif de couleur, slide cellulaire. reduce_motion respecté.
I18n et accessibilité en TUI : Unicode complet (CJK double largeur, émojis, scripts complexes), bidi limité (alignement par cellule, pas de mirror automatique de mise en page, mais bidi dans une cellule via cosmic-text), IME tier-1 en backend 1-2 / tier-2 en backend 3, accessibilité via arbre impératif consommé par air-screenreader.
Crates :
air-tui/ — framework console
air-ui-render-tui/ — renderer cellules
air-tui-widgets/ — widgets standards TUI
air-tui-backend-console/ — backend 1
air-tui-backend-terminal/ — backend 2
air-tui-backend-ansi/ — backend 3
Partie E — air-ui-controller : mélange déclaratif/impératif
ADR-009 : API intermédiaire pour cas nécessitant accès impératif fin (éditeurs graphiques, tableurs, apps média avec timeline, intégration contenu externe).
Conçu citoyen de première classe, pas échappatoire.
#![allow(unused)]
fn main() {
fn editor_view(cx: &mut Cx) -> impl View {
let canvas_controller = cx.state(|| CanvasController::new());
column((
toolbar(cx, &canvas_controller), // déclaratif standard
air_view_controller(canvas_controller.air_view()) // pont vers impératif
.min_size(800, 600),
status_bar(cx, &canvas_controller),
))
}
}
Le contrôleur impératif maintient son propre état et gère ses événements indépendamment du cycle déclaratif. La vue est insérée dans l’arbre déclaratif. Interface explicite, localisée, compatible avec le reste (canvas = AirView standard, accessible, themée, observable).
Partie F — Apps multi-modes : graphique ET console
Manifeste de l’app :
[application.ui]
profile = "both" # ou "desktop", ou "console"
Pour app both : widgets compatibles partout, capability-gate des zones spécifiques.
fn main_view(cx: &mut Cx) -> impl View {
column((
text("Mon application"),
if cx.capabilities().has_graphical_rendering() {
image("logo.png")
} else {
text("[LOGO]").bold()
},
list(cx.state(|| vec![...]).get(), |item| item_row(item)),
))
}
L’app, packagée .airapp, contient binaire lançable dans les deux contextes. air-launchd détecte le mode et configure le backend.
Cas typiques both : gestionnaires fichiers, lecteurs mail, clients IRC/messagerie, monitors système, configurateurs, éditeurs texte.
Tests
- Tests unitaires sur logique pure (diff, layout, transformations d’arbres).
- Snapshot tests : rendu en buffer pixel/cellules comparé avec référence. Détection régressions visuelles.
- Tests d’accessibilité automatisés : propriétés universelles correctement renseignées pour chaque widget.
- Tests interopérabilité backends
air-tui: trois backends testés sur même app. - Tests cross-platform sur machines ADR-014 : rendu identique entre architectures.
Coverage cible : >90% mais pas 100% obligatoire (rendu se valide mieux par intégration que coverage exhaustif).
Choix laissés ouverts pour la spec de composant
- Algorithme de diff précis (React, Solid, custom) : à évaluer phase 4 selon performance.
- Set précis des widgets standards (liste indicative, affinement à l’usage).
- Conventions de nommage des modificateurs (
.font_size(24)vs.text_size(24)vs.size_pt(24)) : à standardiser. - Profilage runtime de rendu : Raspberry Pi 4 60 fps demande optimisation. Spec détaillera budgets par opération.
- Stratégie cache view descriptions (pools, arenas).
- Comportement précis mode “both” en bascule contextuelle (probablement : pas de migration en cours, relance requise).
Section 7 — Couche 5 : Cycle de vie applicatif et services transverses
Rôle et périmètre
La couche 5 est la couche fonctionnelle d’Air visible côté utilisateur. Elle englobe tout ce qui orchestre la vie des applications (lancement, sandboxing, supervision, mise à jour) et tous les services système transverses (notifications, énergie, impression, Bluetooth, partage, réseau, FireWall, etc.). Pattern commun : tous ses composants sont des services système packagés sous forme de bundles .airservice, supervisés par systemd, cloisonnés selon le Principe 10, exposant leurs APIs via AirCom.
Périmètre, trois grandes familles :
- Cycle de vie applicatif :
air-launchd,air-trust,air-appdb,air-update(futur ADR). - Services système transverses :
air-notifyd,air-power(ADR à produire),air-firewalld(ADR à produire),air-share(ADR à produire —air-share),air-screencapture/air-screenrecord(ADR à produire —air-screencapture/air-screenrecord),air-prefs(ADR à produire — préférences utilisateur),air-keychain(ADR à produire — stockage de secrets). - Subsystèmes hardware :
air-printd,air-bluetoothd,air-nfcd(ADR à produire — sous-systèmes hardware).
Shell graphique : air-shell, interface visible utilisateur en mode air-desktop.
Vue d’ensemble
┌──────────────────────────────────────────────────────────────────┐
│ Applications utilisateur (.airapp, Flatpak, Unix legacy) │
└──────────────────────────────────────────────────────────────────┘
▲
│ AirCom + xdg-desktop-portal-air
│
┌──────────────────────────┴──────────────────────────────────┐
│ │
▼ ▼
┌──────────────────────────────────┐ ┌──────────────────────────┐
│ CYCLE DE VIE APPLICATIF │ │ SHELL GRAPHIQUE │
│ air-launchd, air-trust, │ │ air-shell │
│ air-appdb, air-update │ │ (air-desktop only) │
└──────────────────────────────────┘ └──────────────────────────┘
▲ ▲
│ supervision systemd │
│ │
┌──────────────────────────┴──────────────────────────────────┐
│ │
▼ ▼
┌──────────────────────────────────┐ ┌──────────────────────────┐
│ SERVICES SYSTÈME TRANSVERSES │ │ SUBSYSTÈMES HARDWARE │
│ air-notifyd, air-power, │ │ air-printd (CUPS) │
│ air-firewalld, air-share, │ │ air-bluetoothd (BlueZ) │
│ air-keychain, air-prefs, │ │ air-nfcd (neard) │
│ air-screencapture/record │ │ │
└──────────────────────────────────┘ └──────────────────────────┘
▲
│
▼
┌─────────────────────────────────┐
│ Couches 0-4 + systemd │
└─────────────────────────────────┘
Partie A — Cycle de vie applicatif
air-launchd : le launcher central
Cohérent ADR-010 et ADR-005.
Rôle. Quand demande de lancement d’une .airapp :
- Lit manifeste et entitlements.
- Vérifie signature via
air-trust. - Crée namespaces (mount, network, PID, user, IPC, UTS).
- Applique Landlock pour filesystem selon entitlements.
- Applique seccomp-bpf.
- Demande à
air-registryles capabilities AirCom initiales selon entitlements. - Lance le binaire dans namespace contraint, passe FDs des capabilities via
SCM_RIGHTS. - Génère/met à jour unit file systemd user pour supervision.
Modes utilisateur vs système : .airapp standards en systemd --user, .airservice en systemd system avec entitlements étendus.
Activation à la demande : activation = "on-demand" dans manifeste → socket activation systemd. Économise mémoire, accélère boot.
Watchdog et supervision : units systemd avec watchdogs, sd_notify(WATCHDOG=1) attendu des services longue durée.
Cohérence Principe 10 : toutes unit files avec User= dédié (DynamicUser=yes quand applicable), NoNewPrivileges=true, PrivateTmp=true, ProtectSystem=strict, RestrictAddressFamilies= strict, seccomp calculé, capabilities Linux seulement si entitlement le déclare.
API : service AirCom air.system.launcher (launch, running_apps, terminate, status).
air-trust : signatures et notarization
Cohérent ADR-010 (trois niveaux) et ADR-015 (politique ouverte).
- Stockage local des autorités reconnues.
- Vérification au lancement : signature développeur, notarization si demandée, conformité politique locale.
- Mode développeur assouplissant pendant développement.
- API AirCom
air.system.trust.
air-appdb : registre des applications installées
Index local des .airapp et .airservice installées :
- Scan
/Applications,~/Applications, chemins configurés. - Surveillance continue via watcher
air-filesystem(couche 1). - Métadonnées indexées (identifier, version, icône, locales, services exposés, entitlements).
- Stockage SQLite local ou TOML indexé (à valider).
- API AirCom
air.system.appdb(find,search,list,observe). - Intégration freedesktop : génération
.desktopstandard pour visibilité depuis GNOME/KDE.
air-update : mise à jour (mention)
ADR futur. Stratégies possibles : OSTree-like (atomique, rollback), Sparkle-like (par app), Flatpak-like.
Partie B — Services système transverses
air-notifyd : notifications système
Premier service livré (ADR-011, phase 2).
- API AirCom
air.system.notifications(send,cancel,observe). - Notifications enrichies via
air.wm.notifications(ADR à produire — protocole privé AirCom (catégorie notifications)) enair-desktop. - Fallback basique en
air-baseou sous compositeur tiers. - Compatibilité freedesktop via
xdg-desktop-portal-air(org.freedesktop.Notifications). - Cloisonnement Principe 10.
air-power : gestion énergie (ADR à produire)
Cf. futur ADR — air-power :
- Service
.airservicecoordonnant kernel, compositeurs, devices, apps. - Trois modes : normal / low_power / performance + mode critique automatique.
- API AirCom
air.system.power. - Capabilities
air.power.inhibit.sleep,air.power.inhibit.idle,air.power.background_running,air.power.observe. - Panneau utilisateur (battery, consommation, historique) dans
air-shell.
air-firewalld : FireWall (ADR à produire)
Cf. futur ADR — air-firewalld :
- Service au-dessus de nftables.
- API AirCom
air.system.firewall. - Trois niveaux : entitlements automatiques apps standard, service utilisateur, accès direct nftables (apps
.airserviceprivilégiées avec capabilityair.system.firewall.admin).
air-share : partage entre appareils (ADR à produire — air-share)
Cf. futur ADR — air-share :
- Service implémentant AirShare ouvert.
- Découverte mDNS/DNS-SD + BLE + QR code.
- Transfert P2P chiffré bout en bout.
- Authentification TOFU.
- API AirCom
air.system.sharevia Share Sheet système. - Capabilities
air.share.send,air.share.receive.
air-screencapture et air-screenrecord (ADR à produire — air-screencapture/air-screenrecord)
Cf. ADR à produire — air-screencapture/air-screenrecord :
- Capture statique et enregistrement vidéo.
- Encodage matériel VAAPI/V4L2 m2m.
- Consentement TCC-style.
- Indicateur système toujours visible pendant opération.
- Backend
xdg-desktop-portal-airpour Flatpak.
air-prefs : préférences utilisateur (ADR à produire)
Cf. futur ADR — air-prefs :
- Service AirCom
air.system.preferences. - Trois domaines hiérarchiques : système (
/etc/air/prefs.d/), utilisateur global (~/.config/air/prefs.toml), app (~/.config/air/apps/<app-id>/prefs.toml). - Format TOML, schémas typés déclaratifs.
- Capabilities
air.preferences.read.{own-app,global,other-app}+ écriture. - Observation async, debouncing 100ms par défaut.
- Versioning des schémas avec migrations déclaratives au lancement.
- Compatibilité freedesktop via
xdg-desktop-portal-air(Settings portal). - Cloisonnement Principe 10.
air-keychain : stockage de secrets (ADR à produire)
Cf. futur ADR — air-keychain :
- Service AirCom
air.system.keychain(couche 5.airservice). - Chiffrement Argon2id + ChaCha20-Poly1305/AES-256-GCM via RustCrypto.
- Plusieurs collections : login (auto-déverrouillée via PAM), high-security (manuelle), session (en mémoire), custom.
- ACL fines par item (différenciateur vs GNOME Keyring) :
allow-always,allow-with-prompt,deny. - Capabilities
air.keychain.{access.own-items, access.specific-item, manage, admin}. - Audit local complet sans transmission externe.
- Secret Service API exposée via
xdg-desktop-portal-airpour Flatpak. - Phasage tier-1 (mot de passe + ACL + Secret Service) / tier-2 (biométrie via fprintd) / tier-3 (TPM2, YubiKey FIDO2, smartcards PKCS#11).
- Cloisonnement strict Principe 10 avec
mlocksur master keys déverrouillées.
Partie C — Subsystèmes hardware (ADR à produire — sous-systèmes hardware)
air-printd, air-bluetoothd, air-nfcd : à détailler dans un futur ADR — sous-systèmes hardware.
air-printd(tier-1) au-dessus de CUPS, préférence IPP Everywhere driverless, drivers propriétaires HPLIP intégrés Air OS, dialogue d’impression viaair-ui, capabilityair.print.sendnon sensible.air-bluetoothd(tier-1) au-dessus de BlueZ, UI intégrée, audio Bluetooth délégué PipeWire, HID transparent via evdev, LE Privacy par défaut, capabilitiesair.bluetooth.{scan,connect,le,admin}.air-nfcd(tier-2) au-dessus de neard, API minimaliste, use case principal authentification, capabilitiesair.nfc.{read,write}.
Tous respectent Principe 10.
Partie D — Shell graphique air-shell
Interface visible utilisateur en mode air-desktop. Processus séparé d’air-wm (cohérent discipline d’isolation, réévaluation possible si overhead).
Composants visuels :
- Panneau système : horloge, batterie, réseau, son, Bluetooth, accessibilité active, capture en cours (ADR à produire —
air-screencapture/air-screenrecord). Menu utilisateur, recherche système (Spotlight-like). - Lanceur d’applications : vue grille (consomme
air-appdb), recherche, lancement. - Dock (optionnel) : apps épinglées + en cours.
- Gestionnaire de notifications : affichage entrant (consomme
air-notifydetair.wm.notifications), centre de notifications. - Panneau de préférences système : sections (Apparence, Accessibilité, Énergie, Réseau, Bluetooth, etc.). Construit avec
air-ui+settings_view(vue composite).
Gestion de session : verrouillage écran (manuel ou auto), authentification au déverrouillage (mot de passe, biométrie si matériel), déconnexion/suspension/arrêt/redémarrage (coordination air-power et systemd-logind), bascule entre sessions multi-utilisateur.
Style et theming : consomme air.system.appearance (ADR à produire — air.system.appearance / theming). Modes clair/sombre/auto, accent color, mode économie d’énergie. Personnalisation via panneau préférences.
Modularité : implémenté comme .airapp particulière avec entitlements étendus (capability air.wm.shell). Théoriquement remplaçable par shell alternatif. En pratique : un shell de référence livré, alternatives possibles communauté.
Partie E — xdg-desktop-portal-air : pont avec freedesktop
Backend Air du portal freedesktop pour apps Flatpak.
APIs portal exposées (backend Air) : FileChooser, ScreenCast/Screenshot, Notifications, Print, Settings, NetworkMonitor, OpenURI, Background, RemoteDesktop (à étudier).
Architecture : daemon D-Bus (exceptionnellement, parce que standard xdg-desktop-portal est D-Bus-based). En interne traduit vers AirCom. L’une des rares utilisations du broker D-Bus par Air, cohérent ADR-005 (D-Bus optionnel mais activé en air-desktop pour compat freedesktop).
Tests
- Tests unitaires sur logique pure (parsing manifeste, vérification signature).
- Tests d’intégration en environnement contrôlé (containers avec systemd minimal).
- Tests interaction inter-services (
air-launchdlance app, consommeair-notifyd, notifieair-shell). - Tests sur matériel de référence (imprimantes, Bluetooth, mesures consommation).
- Tests compatibilité freedesktop :
xdg-desktop-portal-airtesté avec Firefox/LibreOffice/Signal en Flatpak. - Audit cloisonnement en CI : vérification automatique unit files respectent Principe 10.
Coverage cible : >85%, pas 100% obligatoire (chemins testables seulement en intégration).
Crates et binaires — récapitulatif
# Cycle de vie applicatif
air-launchd, air-trust, air-appdb, air-update
# Services système transverses
air-notifyd, air-power, air-firewalld, air-share,
air-screencapture, air-screenrecord, air-prefs, air-keychain
# Subsystèmes hardware
air-printd, air-bluetoothd, air-nfcd
# Shell et portail
air-shell (.airapp), xdg-desktop-portal-air
# Outils CLI
air-launch, air-trust-tool, air-share-cli, etc.
Choix laissés ouverts pour la spec de composant
- Format précis stockage
air-appdb(SQLite vs TOML indexé). - Politique précise de scan des bundles (fréquence, profondeur, montages externes).
- Stratégie MAJ
.airapp(Sparkle-like, centralisé, ou distribution-driven). Futur ADR. - Limites et seuils
air-launchd(apps max simultanées, mémoire max par défaut, surcharge).
Section 8 — Aspects transverses
Pourquoi cette section existe
Certaines propriétés d’Air ne tiennent pas dans une couche unique. La sécurité traverse couche 0 (Landlock, seccomp, namespaces), couche 2 (capabilities AirCom), couche 5 (entitlements, sandbox au lancement). L’accessibilité traverse couche 2 (propriétés universelles dans AirObject), couche 4 (génération automatique par frameworks), couche 5 (air-screenreader). Pour comprendre comment ces propriétés se tiennent, regard vertical nécessaire.
Cette section synthétise sept aspects transverses. Pour chacun, rappel de matérialisation à travers les couches, sans répéter le détail.
Sécurité
Propriété émergente de mécanismes consistants à travers toutes les couches.
La sûreté mémoire est le socle. Le userland Rust d’Air est la barrière de sécurité du
kernel : le unsafe est concentré en couche 0 (l’interface kernel) et le reste du stack est
Rust safe, garanti safe (doctrine Section 1). Les couches sont en outre un rempart qui
filtre et détecte au plus tôt (validation en amont, Principe 4) ce qui serait transmis au kernel
comme non conforme. Dette de sûreté ouverte : rendre GlobalAlloc (air-alloc) safe dans
le cas d’Air, avec l’équipe libs de Rust — voir la Section 2 (« zones unsafe ») et le suivi.
- Couche 0 : primitives kernel (namespaces, seccomp-bpf, Landlock, cgroups v2, capabilities Linux). Air consomme correctement les mécanismes mûrs du kernel. Seul lieu de
unsafedu userland (barrière kernel). - Couche 1 : primitives applicatives (
air-cryptoRustCrypto+rustls,air-filesystemopérations atomiques, pattern privilege separation dansair-process). - Couche 2 : modèle capability-based via AirCom (capabilities non-falsifiables FDs Unix sockets, pas d’autorité ambiante, distribution explicite).
- Couche 3 : compositeurs isolés (
air-wmetair-consolenon-root, accès matériel via logind+seats, capabilities sensibles protégées par entitlements). - Couche 4 : apps consomment AirCom, pas kernel directement. Pas d’accès direct matériel/filesystem global/réseau sauf via capabilities accordées.
- Couche 5 :
air-launchdmatérialise sandbox au lancement (namespaces, Landlock, seccomp, capabilities AirCom distribuées). Pas d’application possible après lancement.
Principe 10 appliqué à tous services (utilisateurs dédiés, capabilities Linux minimales, hardening systemd, seccomp restrictif, audit CI).
Charte principe 3 (Confiance par défaut) techniquement matérialisée : aucune télémétrie utilisateur, aucun compte obligatoire, aucun cloud forcé. ADR-012-bis durcit télémétrie au strict minimum.
Notions clés : POLA (Principle Of Least Authority), Defense in depth (plusieurs couches d’isolation cumulées).
Internationalisation et localisation (ADR-016)
Traverse toutes les couches car conditionne rendu texte, formatage dates/nombres, saisie, organisation UIs.
- Couche 1 :
AirStringUnicode-aware (consommeicu4x),AirPathdistinct pour chemins potentiellement non-UTF8. - Couche 2 :
AirLocale(BCP 47),AirDateTime,AirNumber,AirCurrencylocale-aware viaicu4x. Cascade fallback macOS-style. - Couche 3 : rendu texte bidirectionnel UAX#9 via cosmic-text + HarfBuzz, partagé entre
air-wmetair-console. IME viatext-input-v3Wayland. Polices Noto par défaut. - Couche 4 : coordonnées logiques leading/trailing avec mirror RTL automatique. Formatage automatique dans widgets
text,date_picker, etc. - Couche 5 : traductions Fluent (
.ftl), plateforme communautaire (Weblate-like) à prévoir.
Langues prioritaires (ADR-016) : anglais, français, allemand, espagnol, italien, portugais, chinois simplifié, arabe, japonais. Couvre 4 systèmes d’écriture distincts qui stressent l’architecture.
Position neutre genre/inclusivité : formes standard + variantes communautaires.
Accessibilité (ADR-017)
Propriété émergente du modèle d’objet C-ABI, pas module ajouté.
- Couche 2 : modèle d’objet porte propriétés accessibility universelles (label, role, value, state, hierarchy, traits) sur toute
AirObject. Serviceair.accessibility.observercapability-sensible. - Couche 3 :
air-wmexposeair.wm.accessibilityagrégeant arbreAirViewdes apps natives. Pont AT-SPI ↔ AirCom pour compat Orca pendant incubation. - Couche 4 :
air-uietair-tuigénèrent automatiquement propriétés accessibility depuis structure déclarative. Modifiersreduce_motion,reduce_transparency,increase_contrastconsommés automatiquement. - Couche 5 :
air-screenreader(planifié post-phase 4),air-voice-control(tier-2, Whisper.cpp), magnifier intégré, polices dyslexie-friendly, mode interface simplifiée.
Moteur TTS : piper privilégié, espeak-ng fallback.
Engagement WCAG 2.2 AA pour toutes UIs Air. Tests CI, audit externe avant Air OS 1.0.
Propriété fondamentale : toute UI Air accessible par construction. Distingue Air des écosystèmes où l’accessibilité est rajoutée après coup.
Observabilité et logging
Repose sur intégration native avec journald (cohérent ADR-005) et mécanismes additionnels.
- Logging structuré universel :
AirLog(couche 1) →journaldviasd-journal. Format structuré, niveaux standard, contexte attachable. Namespace dédié par service. - Audit logs : opérations sensibles (lancement app, octroi capability, capture écran, partage AirShare, NFC, Bluetooth, FireWall) loguées dans journald sous namespaces dédiés.
- Tracing distribué (AirCom) : propagation trace IDs entre processus. Modèle OpenTelemetry compatible, sans transmission externe (consultation locale via
air-trace). - Métriques performance :
air-powercollecte consommation par app viaperf_event_open. Panneau utilisateur, aucune transmission. - Pas de télémétrie utilisateur : cohérent Charte principe 3 et ADR-012-bis. Aucune remontée externe sauf
air-deprecation-trackerstrictement opt-in développeur.
ADR futur : stratégie logging et observabilité au-delà journald.
Énergie et performance (futur ADR — air-power, Principe 9)
Coordination verticale par air-power.
- Couche 0 : configuration CPU governors, états P et C, autosuspend devices via sysfs/netlink.
- Couche 1 : respect des budgets mémoire et CPU (Principe 9).
- Couche 2 : AirCom prolonge timeouts en low_power, batch messages non urgents, polling réduit. Transparent pour apps.
- Couche 3 :
air-wmadapte refresh rate (60/30/max selon mode), VRR, animations réduites.air-consoleadapte similaire. - Couche 4 : apps natives consomment
AirPowerStateobservable et réagissent. - Couche 5 :
air-powerorchestre.air-launchdapplique politiques énergie déclarées dans manifestes.
Validation continue sur machines modestes (Principe 9) : Raspberry Pi 4 4 Go/8 Go, Mac Intel 8 Go/16 Go.
Principe transversal : Air fluide sur matériel modeste, pas optimisé pour matériel premium. Performance émergeant de sobriété, pas d’optimisation locale.
Confidentialité et données utilisateur
Application concrète Charte (Confiance par défaut).
- Pas de phone home : aucun composant Air ne fait de connexion sortante par défaut.
- Pas de cloud forcé : aucun service Air ne nécessite compte cloud. Données utilisateur locales sauf choix explicite de partage.
- Sandboxing strict : app voit seulement ce que ses entitlements accordent. Pas d’accès home par défaut. Pas d’accès contacts/calendrier/micro/caméra sans capability + confirmation TCC-style.
- Audit local : opérations sensibles loguées (journald), pas transmises.
- Pas de tracking inter-app : une app ne peut savoir quelles autres apps installées (sauf entitlement explicite). Pas d’identifiant utilisateur partagé.
- Données chiffrées au repos : keychain (
air-keychain) chiffre secrets. Disque entier chiffrable (LUKS au niveau Air OS).
Stabilité contractuelle (ADR-012, Principe 8)
Outillée et testée en continu.
- Trois zones de stabilité :
air-stable(10 ans ABI),air-internal(SemVer source),air-experimental. - Outils mainteneurs :
air-abi-check,air-symver,air-deprecation-tracker. Intégrés dès phase 0. - Tests de conformité ABI : binaires de référence testés en CI contre versions postérieures.
- Format
.airappstable :manifest-versionlu de v1 à actuel. - Schémas AirCom stables : règles d’évolution Cap’n Proto strictes.
- Noms d’entitlements stables : jamais renommés ni retirés.
- Période 0.x exploratoire de 12-24 mois après phase 4 avant fixation
air-stabledéfinitive (modèle Rust).
Synthèse : ce qui fait la cohérence d’Air
La cohérence d’Air ne vient pas d’un composant central magique. Elle vient de disciplines consistantes appliquées dans toutes les couches :
- Le modèle d’objet C-ABI (ADR-002) — toute structure inspectable, bindable polyglottement.
- AirCom (ADR-001) — capability-based partout.
- Les frameworks
air-uietair-tui(ADR-008, ADR-009) — conventions système (theming, accessibility, i18n) automatiques. - systemd (ADR-005) — supervision unifiée pour tous les services.
- Les Principes d’ingénierie — appliqués à tout le code.
- La Charte — valeurs vérifiables techniquement.
Aucune partie d’Air n’est cohérente toute seule. Toutes le sont parce qu’elles partagent les mêmes disciplines. C’est cette consistance qui doit faire d’Air un système crédible plutôt qu’un assemblage de bonnes idées.
Section 9 — Contrats inter-couches et règles d’isolation
Pourquoi cette section existe
Les couches d’Air sont matérialisées par des contrats publics stables entre voisines et par des règles d’isolation strictes. Cette section formalise ces contrats et règles. Leur respect n’est pas qu’une bonne pratique : c’est ce qui permet à Air d’évoluer sans casser les contrats sur 10 ans (cohérent ADR-012, Principe 8), de remplacer une couche si nécessaire sans toucher aux autres, et de garder la complexité localisée.
Principe général : consommation strictement descendante
Une couche N ne peut consommer que les contrats publics de la couche N-1.
Implications :
- Pas d’accès direct au kernel depuis couches ≥ 2. Apps consomment
AirFile,AirMemoryde la couche 1. - Pas d’appel direct systemd ou D-Bus depuis couches ≥ 3. Consommation via
air-systemdcouche 2. - Pas de couplage applicatif au compositeur spécifique. Apps consomment
air.wm.session(ADR à produire — protocole privé AirCom) et s’adaptent. - Pas d’accès direct matériel depuis couches ≥ 4. Passage par couche 3 ou primitives couche 1.
Le saut de couches : autorisé exceptionnellement
Un service couche 5 peut consommer directement couche 0 ou 1 sans passer par intermédiaires, dans cas justifiés :
air-powerconsommeair-sys-syscall(couche 0) pour CPU governors via sysfs etperf_event_open.air-firewalldconsommeair-sys-syscallpour netlink/nftables.air-bluetoothdconsommelibudev(couche 1) pour énumérer adaptateurs avant BlueZ.
Règle d’usage : nommé explicitement en documentation du composant, justifié techniquement, soumis à audit. Pas le cas général. Trop de sauts = signal qu’il manque une abstraction couche intermédiaire.
Le passage transverse : interdit
Une couche N ne peut consommer une autre couche N sans passer par une couche inférieure commune. Exemple : air-bluetoothd ne consomme pas directement air-printd. S’ils ont besoin de coordonner, passage par AirCom (couche 2) et exposition mutuelle de services.
Évite les couplages horizontaux fragiles. Force à exposer des APIs propres.
Le passage ascendant : strictement interdit
Une couche N ne peut jamais consommer N+1 ou au-dessus.
- Couches basses ignorent qui les consomme.
- Inversion de dépendance par callbacks : couche basse expose API d’abonnement (
observe), couche haute s’abonne.
Contrats inter-couches : formalisation
Pour chaque frontière :
- APIs publiques exposées : types, fonctions, services AirCom déclarés stables. Documentés.
- Garanties de comportement : pré-conditions, post-conditions, invariants, sémantiques.
- Engagement de stabilité : selon zone (
air-stable,air-internal,air-experimental). Majoritéair-stable(10 ans). - Mécanismes d’évolution : ajout sans casse (nouveaux champs, méthodes, capabilities), retrait via deprecation 5 ans + alternative.
Synthèse des frontières principales
- Couche 0 → Couche 1 : API Rust idiomatique sans
unsafeexposé. Interne : seule la couche 1 (et les développeurs du système Air) s’y appuie ; instable, elle suit le kernel. - Couche 1 → toits : API Rust idiomatique via les Managers de domaine (ADR-077). Interne, non contractuelle pour les apps. Consommée par les toits
libair_c(libc C) et PALstd. L’ex-libair-base.soest retirée. - Couche 2 → Couches 3-5 et développeurs : la lib Rust
libair(surface publique stable) + les ABI C du modèle d’objet (libair-object.so), AirCom (libair-com.so), event loop (libair-event.so). Stable 10 ans. Contrat le plus important du système. - Couche 3 → Couches 4-5 : Wayland standard + AirCom privé (
air.wm.*ADR à produire — protocole privé AirCom) graphique. Protocole privéair-tuiADR-008 console. PipeWire audio. - Couche 4 → Couche 5 : API frameworks consommée par apps. Pas frontière au sens strict (app
inclutframework), mais contrat conceptuel. - Couche 5 → utilisateurs et apps externes : services AirCom (
air.system.*), backendxdg-desktop-portal-air, format.airappet.airservice.
Cas particuliers
Interface avec systemd : systemd pas couche Air. Composant externe. Encapsulé dans air-systemd (couche 2). Toute autre couche passe par air-systemd. Si Air doit modifier dépendance systemd un jour, point unique.
Interface avec D-Bus : optionnel pour Air (ADR-005). Encapsulé dans air-dbus-bridge (couche 2). xdg-desktop-portal-air (couche 5) est l’exception qui parle D-Bus de bout en bout (parce que standard xdg-desktop-portal est D-Bus-based).
Audit du respect des règles
En CI :
- Analyse dépendances de crates : aucune crate couche N ne dépend de couche supérieure. Vérifié via
cargo metadata. - Analyse appels AirCom : capabilities consommées correspondent aux contrats exposés par couches inférieures autorisées.
- Audit manuel sauts de couches : à chaque revue de code, auteur justifie explicitement tout saut dans commit message.
Section 10 — Évolution et stabilité
Pourquoi cette section existe
Air conçu pour exister sur 10 ans minimum (Charte principes 4 et 5). Stabilité contractuelle (ADR-012, Principe 8) outillée, testée en continu, matérialisée par disciplines de versioning. Cette section synthétise comment Air évolue dans le temps en gardant ses promesses.
Les trois zones de stabilité (rappel ADR-012)
air-stable: APIs publiques engagées sur 10 ans à partir d’Air 1.0. ABI C stable via versioned symbols GNU/Linux. Schémas AirCom stables avec règles d’évolution Cap’n Proto. Format.airappversionné (v1 à actuel pour toujours). Noms d’entitlements stables. Majorité des contrats inter-couches.air-internal: APIs internes au stack, partagées entre composants Air mais pas consommées par apps. SemVer source dans un release majeur. Évolution plus libre à chaque majeur.air-experimental: aucune stabilité. Marqué#[experimental]ou cratesair-experimental-*. Pour nouvelles fonctionnalités en exploration.
Correspondance couche ↔ zone (structurante). Les couches 0 et 1 sont air-internal : la
couche 0 suit le kernel (elle ajoute/déprécie au rythme de Linux et n’a pas vocation à
implémenter tous les syscalls, surtout le legacy), et la couche 1 — ses Managers — évolue
avec lui (adopte les features modernes, déprécie/abandonne le legacy). Aucune des deux n’est un
contrat pour les développeurs d’applications, qui ne doivent pas s’y appuyer ; Air se
réserve d’en faire évoluer le comportement. Le sceau couche-0-v1.x/couche-1-v1.x garantit
une stabilité interne (les toits d’Air lient une couche 1 figée), pas un contrat public. La
zone air-stable — le contrat public 10 ans — est portée par la couche 2 : la lib
Rust libair et les ABI C du modèle d’objet. La libc libair_c (toit) vise, elle, la
conformité au kernel Linux (pas POSIX) et sa propre discipline de dépréciation des symboles
hérités.
Le phasage long terme (ADR-011)
- Phase 0 :
air-sys-types+air-sys-syscall+air-base-lib(couches 0-1 partielles). Coverage 100%, tests cross-architecture. Apprentissage. - Phase 1 : modèle d’objet + AirCom + event loop (couche 2). Bindings polyglottes émergent.
- Phase 2 : premier livrable publiable
air-base1.0. Profil console, services système basiques. - Phase 3 : composition + rendu + audio + input (couche 3).
air-wmetair-consoleopérationnels. - Phase 4 : frameworks UI (couche 4) + services desktop.
air-desktop1.0 publiable. - Phase 5+ : enrichissement progressif, écosystème, communauté, Air OS distribution.
Où en est-on (2026-07-10). Les phases 0-1 sont largement réalisées : couches 0 et 1
scellées (couche-0-v1.12 / couche-1-v1.9), et le jalon toolchain M5 est atteint — le
std Rust tourne sur la libc d’Air (libair_c), zéro glibc, sur les 2 arches. La production
courante vise air-sshd (ADR-074) et la consolidation des toits (libair_c, PAL std) et de
la surface publique Rust libair (couche 2).
Période 0.x exploratoire de 12-24 mois après phase 4 avant fixation air-stable définitive.
Mécanismes d’évolution sans rupture
- Ajout : toujours autorisé (nouvelle méthode, champ optionnel, capability, widget, propriété observable).
- Suppression : deprecation period minimum 5 ans. Annonce publique, marquage
#[deprecated], alternative documentée,air-deprecation-trackermesure l’usage réel. - Modification comportement : traité comme suppression + ajout. Pas de “fix silencieux”.
- Versioning sémantique schémas AirCom : nouvelle version d’interface (V2) coexistant avec ancienne (V1). Migration progressive.
- Migration formats données : champ
versionlu dès le début, migration automatique. Anciens formats lisibles.
Outils mainteneurs
air-abi-check: analyse ABI C, compare à référence, détecte ruptures. CI à chaque commit.air-symver: gère versioned symbols GNU/Linux pourlibair-*.so.air-deprecation-tracker: collecte usages APIs deprecated via télémétrie strictement opt-in développeur (cohérent ADR-012-bis). Permet de quantifier objectivement quand retirer une API. Données auditables, jamais identifiantes.air-com-schema-check: valide qu’évolution schéma respecte règles Cap’n Proto.air-manifest-check: valide manifestes.airapp/.airservicesyntaxiquement et entitlements déclarés existants.
Développés dès phase 0, raffinés au fil du temps. Disponibles à la communauté.
Gouvernance de l’évolution (ADR-015)
- Phases 0-2 (BDFL) : fondateur tranche. Décisions structurantes consignées comme ADRs.
- Phases 3-4 (TC émergent) : Technical Committee émerge. Nouveaux ADRs revus par TC avant ratification BDFL.
- Phase 5+ (gouvernance distribuée) : Fondation indépendante (modèle Blender). Évolutions contrats
air-stablevia RFC publique avec période de commentaires.
Communication des évolutions
- Release notes structurées : par catégorie (nouveautés stable, changements internal, expérimentations, deprecations annoncées, deprecations retirées).
- Calendrier de deprecations public : table avec APIs deprecated, date d’annonce, date prévue retrait minimum, alternative.
- Migrations documentées : pour chaque deprecation, document step-by-step.
- Pas de surprises : utilisateurs et développeurs anticipent plusieurs années à l’avance.
Engagement temporel concret
À partir d’Air 1.0 (phase 4 + 12-24 mois période 0.x) :
- Contrats
air-stablegarantis sur 10 ans minimum. - ABI C versioned symbols : aucune rupture sans deprecation 5 ans + version majeure suivante.
- Format
.airapp: lisible v1 à actuel pour toujours. - Services AirCom
air.system.*: compatibles ascendant pour toujours dans la branche d’interface. - Entitlements : pas de renommage, pas de retrait sans deprecation 5 ans.
Engagements publics consultables. Violer = échec public connu. Pression que le projet s’impose.
Cas particulier : période avant 1.0
Pendant phases 0 à 4 et période 0.x exploratoire, Air pas stable. Explicite. Développeurs adoptant tôt acceptent ruptures occasionnelles, peuvent influencer les choix avant fixation.
À 1.0, ce qui est dans air-stable se fige pour 10 ans. Commit fort qu’il faut être prêt à tenir.
Synthèse : la stabilité comme propriété émergente
Stabilité 10 ans pas un vœu pieux. Propriété émergente :
- Trois zones différenciées rendant explicite ce qui est figé.
- Outils mainteneurs automatisant vérifications.
- Mécanismes d’évolution sans rupture (ajout OK, retrait via deprecation longue).
- Gouvernance évoluant avec le projet sans casser engagements.
- Communication transparente.
- Engagement public consultable.
Air ne peut pas se permettre de casser ses contrats. Sa cible (utilisateurs migrant macOS/Windows, communautés institutionnelles long horizon) attend une plateforme professionnelle. La stabilité est un investissement, pas une option.
Licence du document : MPL 2.0
Statut : Document fondateur immuable. Toute évolution requiert un RFC dédié.
Registre des ADRs d’Air
Document fondateur — Version 3.42 (2026-07-13 — ADR-093 couche transport SSH-2 d’air-sshd (phase 1 d’ADR-074, directive BDFL v1/v2 intégrée) : topologie de crates figée — cœur sans-IO air-ssh-proto ([ADR-091], invariant v1↔v2, réutilisé verbatim en v2) + pilote air-sshd scindé (pilote d’octets swappable SshByteStream sur air-async en v1 / stack « ssh » d’air-network en v2 ; couche service invariante) + facilité partagée air-service (trait ServiceHost). v1 lancé par systemd (socket-activation + sd_notify, a minima), v2 par air-launchd. air-sshd publie ses événements de session sur AirCom (pub-sub §7 + objet-service interrogeable list_sessions, inc.7) → air-com dépendance v1. Algos moderne only (curve25519-sha256/ssh-ed25519/chacha20-poly1305@openssh.com/aes256-gcm@openssh.com AEAD, pas de downgrade) ; crypto air-crypto ([ADR-034], aléa injecté) ; conf binaire air-config ([ADR-073], + conf par-utilisateur HomeDirectory en phase 2). Cible wire-compat OpenSSH. 2 ADR compagnons v2 (air-launchd ; stack « ssh » air-network). 4 incréments T.1–T.4. Précédent : ADR-092 air-async.)
Document fondateur — Version 3.41 (2026-07-11 — ADR-092 air-async : découpage réacteur/exécuteur : la runloop io_uring d’ADR-038 se scinde en réacteur air-uring (couche 1) + exécuteur air-async (couche 2) — contrainte check-layers (couche 2 ↛ couche 0) ; air-async remplace air-event (ADR-038 amendé) ; modèle owned-buffer déjà en couche 0 ; squelette prouvé host. Précédent : ADR-091 motif réseau sans-IO.)
Version 3.40 (2026-07-11 — ADR-091 motif réseau sans-IO obligatoire : rend normative la §2 de reseau-architecture-crates-fr.md — tout crate réseau maison = cœur pur sans-IO (anatomie canonique à 9 composants : Framer/Codec/StateMachine/Handshaker/Flow/Mux/Timer/Session/Ext, 8 purs, fuzzés/testés en isolation) + pilote I/O mince ; seul motif testant la pile indépendamment ; neutre sync/async, composable, zéro-C sauf exception TLS ADR-042. Précédent : ADR-090 binding couche 1→std.)
Version 3.39 (2026-07-11 — ADR-090 binding couche 1 dans std par rustc-dep-of-std : découplage plutôt que fork, sans descellement couche 0 (mesuré : build-config seul) ; discipline air-base-core jamais air-base-lib ; exceptions 80 % (icu4x/RustCrypto) hors fermeture std ; frontière no_std = relation à std ; industrialisation par vagues. Met en œuvre ADR-088. Précédent : ADR-089 réservation SecurityManager ; ADR-088 pivot std sur Air safe sans libc C ; jalon M5 [ADR-076/086/087] ; Managers [ADR-077].)
Rôle
ADR — Architecture Decision Record (enregistrement de décision d’architecture). Document court et daté qui consigne une décision structurante : son contexte, la décision prise, et ses conséquences. Les ADRs sont numérotés, immuables une fois acceptés (sauf RFC), et font autorité (cf.
CLAUDE.md).
Les ADRs (Architecture Decision Records) consignent les décisions structurantes prises pour Air. À partir de la version 2.0 du registre, chaque ADR vit dans son propre fichier afin de faciliter l’édition, la revue, et la future évolution via le mécanisme RFC public (cf. ADR-015). Ce document est l’index pointant vers ces fichiers.
Le registre se décompose en trois groupes :
- ADRs fondateurs (001 à 017) — instruits avant le démarrage de la phase 0. Constituent le socle architectural et organisationnel d’Air.
- ADR fondateur instruit en phase de design (018) — modèle d’imagerie, ajouté en phase pré-ouverture publique selon la politique d’édition directe.
- ADRs phase 0 (019 à 025) — instruits pendant les sessions de spécification de la couche 0, du runtime async, et de l’infrastructure (workflow dépendances, builds reproductibles). Acceptés et appliqués.
- ADRs post-phase 0 (à partir de 026) — instruits pour préciser les contrats structurants des couches hautes (couche 4
air-ui/air-tui, puis couche 5air-base), au-delà du périmètre phase 0.
Tous ces ADRs sont immuables après ouverture publique sauf erreur factuelle ou clarification mineure ; toute évolution structurante passera alors par un nouveau RFC amendant explicitement.
Index des ADRs
ADRs fondateurs et de design pré-phase 0
| N° | Titre | Catégorie | Fichier |
|---|---|---|---|
| 001 | IPC AirCom, refus de D-Bus comme transport principal | Architecture | ADR-001-aircom-fr.md |
| 002 | Modèle d’objet hybride asymétrique, C-ABI | Architecture | ADR-002-modele-objet-fr.md |
| 003 | Air compositeur Wayland | Architecture | ADR-003-compositeur-wayland-fr.md |
| 004 | Linux tier-1, FreeBSD/Windows tier-3 | Portabilité | ADR-004-linux-tier-1-fr.md |
| 005 | Intégration systemd, D-Bus optionnel | Architecture | ADR-005-systemd-fr.md |
| 006 | Profils air-base et air-desktop | Architecture | ADR-006-profils-fr.md |
| 007 | Compositeur de console air-console | Architecture | ADR-007-air-console-fr.md |
| 008 | Framework TUI air-tui trois backends | Architecture | ADR-008-air-tui-fr.md |
| 009 | Framework de vues air-view / air-ui / air-tui, API exclusivement déclarative | Architecture | ADR-009-framework-vues-fr.md |
| 010 | Format .airapp et .airservice, entitlements | Architecture | ADR-010-airapp-fr.md |
| 011 | Phasage bottom-up, premier livrable air-base 1.0 | Méthode | ADR-011-phasage-fr.md |
| 012 | Stratégie de versionnement et stabilité ABI | Méthode | ADR-012-versionnement-fr.md |
| 012-bis | Politique de télémétrie durcie | Politique | ADR-012-bis-telemetrie-fr.md |
| 013 | Distribution Modèle C → B, troisième voie qualitative | Distribution | ADR-013-distribution-fr.md |
| 014 | Catalogue de matériel « Air long-term » | Méthode | ADR-014-catalogue-materiel-fr.md |
| 015 | Licence MPL 2.0, DCO, gouvernance évolutive | Gouvernance | ADR-015-licence-gouvernance-fr.md |
| 016 | Internationalisation native, icu4x, Fluent | Architecture | ADR-016-i18n-fr.md |
| 017 | Accessibilité citoyen de première classe, WCAG 2.2 AA | Architecture | ADR-017-accessibilite-fr.md |
| 018 | Modèle d’imagerie : vectoriel pour mode graphique, cellulaire pour mode console | Architecture | ADR-018-modele-imagerie-fr.md |
ADRs phase 0 (sessions de spécification couche 0 et runtime async)
| N° | Titre | Catégorie | Fichier |
|---|---|---|---|
| 019 | Modèle d’erreurs hybride à deux niveaux | Architecture (couches 0/1) | ADR-019-modele-erreurs-fr.md |
| 020 | Stratégie signaux : signalfd par défaut, sigaction restreint | Architecture (couche 0) | ADR-020-strategie-signaux-fr.md |
| 021 | Conventions transverses de la couche 0 | Méthode | ADR-021-conventions-couche-0-fr.md |
| 022 | Architecture du module io_uring | Architecture (couche 0) | ADR-022-architecture-io-uring-fr.md |
| 023 | Runtime asynchrone Air construit sur io_uring | Architecture (couche 1) | ADR-023-runtime-air-fr.md |
| 024 | Workflow de gestion des dépendances | Méthode | ADR-024-workflow-dependances-fr.md |
| 025 | Stratégie de builds reproductibles | Méthode | ADR-025-builds-reproductibles-fr.md |
ADRs post-phase 0 (couches hautes)
| N° | Titre | Catégorie | Fichier |
|---|---|---|---|
| 026 | Contrat 1.0 d’air-tui : UI texte riche, cellulaire, capability-gated | Architecture (couche 4) | ADR-026-contrat-air-tui-fr.md |
| 027 | Stratégie de documentation polyglotte (ABI C et bindings multi-langages) | Documentation / Architecture (couche 2) | ADR-027-doc-polyglotte-fr.md |
| 028 | Soundness et téardown du module io_uring (S1 slab, S2 téardown, S3 sandbox) — complète ADR-022 | Architecture (couche 0) | ADR-028-soundness-io-uring-fr.md |
| 029 | Nommage de la surface publique : explicite, sans abréviation ; noms d’autorité conservés à l’interface | Méthode (transverse) | ADR-029-nommage-surface-publique-fr.md |
| 030 | Périmètre de la règle des 80 % : production vs test-only (companion ADR-024) | Méthode (gouvernance) | ADR-030-dependances-test-only-fr.md |
| 031 | Mesure de couverture en root sur les runners self-hosted (familles privilégiées : ebpf, uinput) | Méthode (gouvernance / CI) | ADR-031-couverture-root-ci-fr.md |
| 032 | Préservation des données confiées : zéro discard silencieux | Méthode (transverse) | ADR-032-zero-discard-donnees-fr.md |
| 033 | Modèle de configuration : source typée, compilation validée, artefact binaire | Architecture (transverse) | ADR-033-modele-configuration-fr.md |
| 034 | Discipline des dépendances cryptographiques (companion ADR-024) | Méthode (gouvernance) | ADR-034-discipline-dependances-crypto-fr.md |
| 035 | Taxonomie des exceptions de couverture (companion ADR-031) — amendée 2026-06-29 : +catégorie TARGET-ONLY (code sound seulement sur *-linux-air, validé par selftest, companion ADR-052) | Méthode (gouvernance / CI) | ADR-035-taxonomie-exceptions-couverture-fr.md |
| 036 | Filtrage par chemin de la re-vérification d’une couche scellée (companion ADR-031/035) | Méthode (gouvernance / CI) | ADR-036-filtrage-reverification-couche-scellee-fr.md |
| 037 | CI ARM seule (raspi-srv-2) + validation x86 par barrière pré-merge (companion ADR-031/036) | Méthode (gouvernance / CI) | ADR-037-ci-arm-x86-premerge-fr.md |
| 038 | Modèle d’exécution : runtime async natif io_uring (sans tokio), couches basses synchrones, pas d’epoll (amende ADR-023) | Architecture (couches 1/2) | ADR-038-modele-execution-async-io-uring-fr.md |
| 039 | Nommage & placement : runtime async air-runtime (L1), modèle d’objet air-object (L2), ordonnancement single-thread/thread-per-core (amende ADR-002, précise ADR-023/038) | Architecture / Nommage | ADR-039-nommage-runtime-objet-ordonnancement-fr.md |
| 040 | Format de l’artefact binaire de configuration : Cap’n Proto (companion ADR-033, cohérent ADR-001) | Architecture (air-config, L1) | ADR-040-format-artefact-config-capnproto-fr.md |
| 041 | Coexistence /etc : projection générée, lecture seule sélective, air-config seul écrivain (companion ADR-033) | Architecture (air-config L1 + intégration OS L5) | ADR-041-coexistence-etc-fr.md |
| 042 | Pile TLS : rustls + aws-lc-rs (exception C nommée, étroite), TLS 1.3, air-tls maison en contingence | Architecture (couche 2 / Sécurité) | ADR-042-pile-tls-rustls-awslc-fr.md |
| 043 | Pile SSH : OpenSSH système en incubation, air-ssh maison à terme (client avant serveur) | Architecture (couche 2 / Sécurité) | ADR-043-pile-ssh-fr.md |
| 044 | Extension couche 0 : famille poll (ppoll) pour l’attente synchrone bornée/interruptible (re-sceau couche-0-v1.5, RFC ADR-015) | Couche 0 (extension socle) | ADR-044-extension-couche-0-famille-poll-fr.md |
| 045 | Modèle d’erreurs de l’ABI C : AirStatus in-band, sans errno, panic = abort (companion ADR-019/027/012) | Architecture (ABI C / contrat) | ADR-045-modele-erreurs-abi-c-fr.md |
| 046 | La libc Air (Rust pur) : périmètre (~25-30 familles, cible OpenSSH), layering couche-1-only impératif, POSIX = objectif non conformité, doctrine d’exécution par provenance | Architecture (libc / fondateur) | ADR-046-libc-air-fr.md |
| 047 | Principes de construction de la libc Air : gabarit par famille, regard sécurité (4 modes de défaillance), sœurs SAFE air_*, errno thread-local (companion ADR-046) | Méthode (libc) | ADR-047-principes-construction-libc-fr.md |
| 048 | Descellement unique de la couche 0 : std-free certifié (#![no_std]) + famille futex(2) + types FD natifs + clone3 spawn de thread (re-sceau couche-0-v1.6, RFC ADR-015) | Couche 0 (descellement / re-sceau) | ADR-048-descellement-couche0-std-free-futex-fr.md |
| 049 | Runtime Air : TCB/TLS/démarrage (crt0), errno-in-TCB, environ, spawn std-free — fondation *-linux-air (option (a) : cible complète, programme cohérent) | Architecture (runtime / fondateur) | ADR-049-runtime-tcb-tls-fr.md |
| 050 | Spécification de la cible *-linux-air (phase 1) : triple env=air, llvm-target musl découplé, statique panic=abort, dév. hors-arbre (build-std/nightly, carve-out ADR-030), squelette air-rt (companion ADR-049) | Architecture (toolchain / cible) | ADR-050-cible-linux-air-spec-fr.md |
| 051 | Descellement couche-0-v1.7 : 7 syscalls pour le runtime et la libc — arch_prctl (SET_FS/GET_FS, x86_64, nouvelle famille arch) + set_tid_address/getcwd/umask/getppid/sched_yield/getrusage (RFC ADR-015 ; companion ADR-049/046) | Couche 0 (descellement / re-sceau) | ADR-051-descellement-couche0-v1.7-libc-fr.md |
| 052 | air-rt est un objet couche 1 (AirRuntime), jamais un consommateur direct de la couche 0 — briques couche 1 ThreadControlBlock/ThreadLocalStorage/relocation ; corrige ADR-049 D8 (« reposant sur couche 0 ») et reformule le cadrage « affaire d’air-rt » d’ADR-051 (RFC ADR-015) | Architecture (layering / fondateur) | ADR-052-air-rt-objet-couche1-fr.md |
| 053 | La libc Air s’appuie sur icu4x pour ctype/locale/i18n (caractères, casse, collation, normalisation, segmentation, calendriers) — i18n correcte par défaut, BCP-47, écarts POSIX documentés ; précise ADR-046 D7 (« libm exclu v1 » et « hors wide-char lourd » → OBSOLÈTES : libm requis, le wcs* i18n EST la surface icu4x) ; companion ADR-016 (RFC ADR-015) | Architecture (libc / i18n) | ADR-053-libc-sur-icu4x-fr.md |
| 054 | Scission d’air-base-lib : un cœur air-base-core sans i18n (erreurs/id/chemins-octets/temps-monotone/encodages/log) extrait pour débloquer la fermeture runtime *-linux-air (l’i18n d’icu4x tirait std) — air-base-lib garde l’i18n et ré-exporte le cœur (compat) ; runtime (air-memory/air-thread/air-process) bascule sur air-base-core ; air-process::Command passe args/env en octets (AirPath) ; rectifie l’en-tête erroné « icu sans std » ; companion ADR-052/053 (RFC ADR-015) | Architecture (layering / fondateur) | ADR-054-air-base-core-scission-i18n-fr.md |
| 055 | air-thread/air-process passent #![no_std] (2ᵉ mur de la fermeture runtime *-linux-air après ADR-054) : nouvelle crate air-env (couche 1, no_std, sans dépendance) porte l’environnement (set_environ/get/vars, octets) sous air-runtime ET air-process ⇒ cycle évité (air-runtime::env ré-exporte, air-process::build_envp lit air_env) ; air-process → core/alloc::ffi, plus de std::env/std::os::unix ; le spawn ergonomique std::thread d’air-thread (thread.rs) est gardé #[cfg(not(target_env="air"))] (hôte seulement — sound sous glibc ; le spawn Air-natif relève d’air-runtime + allocateur, différé) ; portée compile ≠ run (allocateur AbortOnAlloc) ; companion ADR-048/052/054 (RFC ADR-015) | Architecture (layering / no_std) | ADR-055-nostd-thread-process-air-env-fr.md |
| 056 | Allocateur global air-alloc (couche 1, #![no_std]) : inspiré de dlmalloc, simple, possédé — ni vendoring boîte-noire ni réinvention ; étiquettes de frontière + fusion O(1), smallbins + table de bits, liste triée des grands blocs (pas de treemap), dv/top, segments 64 Kio + cartographie dédiée ≥ 256 Kio ; verrou futex maison (pas AirMutex), GlobalAllocator, gardien post-fork ; remplace AbortOnAlloc, maillon de la libc Air ; companion ADR-049/052/046/047 (RFC ADR-015) | Architecture (couche 1, fondateur) | ADR-056-allocateur-air-alloc-fr.md |
| 057 | libm Air (air-libm, couche 1, #![no_std]) : vendoring de libm (rust-lang) en exception nommée (ADR-024, modèle icu4x) — math flottante no_std (le seul primitif système manquant, audit icu4x §3/§8 : calendrical_calculations + LSTM segmenter) ; crate qui ré-exporte la surface de libm (pur Rust, zéro dép transitive, fusionné dans compiler-builtins, qualité std) ; aucun consommateur encore (fondation libc/i18n) ; companion ADR-053/ADR-024/ADR-016 (RFC ADR-015) | Architecture (couche 1, fondateur) | ADR-057-libm-air-fr.md |
| 058 | loom — activation du modèle de concurrence (outil de vérification test-only, tiré sous --cfg loom seulement, jamais livré, ADR-030) : sous loom, exploration exhaustive des entrelacements pour prouver la SÛRETÉ (exclusion mutuelle) des primitives futex ; 1ᵉʳ modèle = AirMutex d’air-thread (protocole 3-états) via alias d’atomique core/loom + shim futex (yield/no-op) ; tests réguliers gardés cfg(all(test, not(loom))). Pas une exception 80 % (comme proptest). Garde-fou : loom prouve la sûreté, pas la vivacité (réveil futex validé on-target). Attaque la variance de couverture (#189) ; companion ADR-030/021/031 (RFC ADR-015) | Outillage de vérification (test-only) | ADR-058-loom-modele-concurrence-fr.md |
| 059 | Stratégie i18n — Option V : Air UTILISE icu4x (pas de réimplémentation du socle). Vérification empirique (2026-07-01) : la fermeture icu4x compile pour *-linux-air (--release + default-features = false ; LSTM inclus ; libm OK) — deux conditions triviales (feature std par défaut évitée ; repli logging debug-only d’icu_provider inactif en release). ⇒ réécrire le socle (~15 crates ULP-correctes) est inutile. Air utilise icu4x (extension exception ADR-016), possède l’API air-string dual-face + le pipeline de données ; réécriture réservée par composant si besoin doctrinal. Rétrécit la roadmap §2.A en chantier no_std (air-base-lib #![no_std] + default-features=false). Companion ADR-053 (affirmé)/016/024/050 (RFC ADR-015) | Architecture (couche 1, i18n) | ADR-059-i18n-strategie-option-v-icu4x-fr.md |
| 060 | Descellement couche-0-vX : famille termios/tty (comme ADR-051). Clore la libc exige termios (POSIX-1988, terminal-agnostique) + isatty (pour stdio) — absents du socle scellé. Ajoute toute la famille en fonctions dédiées typées (jamais d’ioctl générique, ADR-021 conv. 3), module air-sys-syscall/src/terminal.rs sur le type Termios (air-sys-types, #[repr(C)], x86/arm identiques) : attributs (tcgetattr/tcsetattr), contrôle-ligne (tcdrain/tcflush/tcflow/tcsendbreak), winsize (TIOCGWINSZ/S), PTY (/dev/ptmx+TIOCGPTN/TIOCSPTLCK/TIOCGPTPEER), session/job-control (tc[gs]etpgrp/TIOCSCTTY/TIOCNOTTY/TIOCGSID). Option<T>, EINTR remonté, RAII FD, 100 % couv. isatty/ttyname restent couche 1. Re-sceau à l’atterrissage. Companion ADR-051/021/046/047, prépare ADR-061 (RFC ADR-015) | Couche 0 (extension / re-sceau) | ADR-060-descellement-couche0-termios-tty-fr.md |
| 069 | Registre de handles fd-général (couche 1, air-handle) + moteur socket : registre process-global unique (clé = vrai fd kernel) qui possède l’OwnedFd (RAII central → détection de fuites/double-close) ; points de passage uniques create/duplicate/close/set_fd/set_status ; duplicateur unique (alias = description partagée refcomptée + éviction-sur-silent-close) résolvant la cohérence dup2/fcntl ; tous les objets fd de couche 1 re-domiciliés (API préservée) ; AirSocketEngine = couche de politique (couture de sécurité réseau + résolveur réutilisé) au-dessus ; une impl, deux façades (PAL Rust + libc C) ⇒ vue unique Rust/C dans un même process. Déclenché par M4 réseau ; descellement additif couche 1. Companion ADR-065/068/046/047 (RFC ADR-015) | Architecture (couche 1, fondateur / libc) | ADR-069-registre-handles-fd-moteur-socket-fr.md |
| 084 | Ratification air-url : couche 4 élargie (« frameworks développeur : UI + fondation ») + nom air-url. Tranche les deux points laissés ouverts par la spec air-url/la note api-reseau-strategie. air-url (≈ URLSession : sessions/tâches/cache/cookies/WebSocket sur air-http) est une commodité applicative de fondation, pas un mécanisme système — comme URLSession vit dans Foundation (≠ socle réseau). Décision : couche 4, dont le périmètre passe de « UI seulement » (air-ui/air-tui) à « UI + fondation » (amende la macro-archi) ; nom air-url (descriptif, vocabulaire Air, jamais « URLSession » ; alternatives air-web/air-fetch révisables sans rouvrir la couche). Alternative « haut de couche 2 » rejetée (mélange confort applicatif / frameworks système). Ratification BDFL. Companion note api-reseau-strategie, ADR-009/008 (RFC ADR-015) | Architecture (couche/nommage) / amendement macro-archi | ADR-084-ratification-air-url-couche4-elargie-fr.md |
| 087 | syscall générique (raw_syscall) : escape hatch TEMPORAIRE pour le pal std (M5, Lot D) — À RETIRER — Accepté (ratification BDFL 2026-07-09), explicitement temporaire. Dernier symbole indéfini de M5 = syscall : la libc DOIT fournir long syscall(long, ...) car le pal unix de std l’appelle EN DIRECT pour futex (aucune fonction libc n’a jamais existé ; base Mutex/Condvar/park) et gettid (pas de wrapper avant glibc 2.30). syscall(2) = trappe non typée par nature (numéro + 6 mots bruts). Décision BDFL : l’implémenter « comme std l’attend » = additif couche 0 raw_syscall (asm syscall/svc 0 brut, 2 arches) + shim libc air-libc-capi::syscall cible-only qui le binde EN DIRECT (convention __syscall_ret). ENTORSE ASSUMÉE à ADR-021 conv.3 (pas de générique) + « libc binde couche 1 jamais couche 0 » → EXCEPTION consignée (EXCEPTIONS.md). STATUT TEMPORAIRE : le BDFL refuse cet appel générique permanent → ACTION ITEM AMONT équipe Rust (pal std sans syscall générique sur *-linux-air → router futex/gettid typés) → raw_syscall + shim disparaissent. Périmètre M5 quasi nul (futex jamais appelé mono-thread). Alternatives rejetées : router numéros (fragile flags futex), ENOSYS partout (ne prépare pas OpenSSH). Re-sceau couche-0-v1.12 (délégation ADR-065). Companion ADR-076/021/077 (RFC ADR-015) | Additif couche 0 + exception (à retirer) | ADR-087-syscall-escape-hatch-a-retirer-fr.md |
| 086 | Introspection thread cible pour la face libc : TLS dynamique, bornes de pile, auxv (M5, Lot C) — Accepté (ratification BDFL 2026-07-09). Additif couche 1 débloquant 7 symboles std indéfinis sur la cible (pthread_key_create/key_delete/setspecific, pthread_self, pthread_getattr_np/attr_getstack/attr_getguardsize), gardés jusque-là #[cfg(not(target_vendor="air"))] (hôte, sur std), version cible « REMONTÉE » (ADR-055 D3). Additifs : AirProcessManager::resource_limit (binde prlimit couche 0) + module air-runtime::process_context (capture au bootstrap de l’auxv + bornes de pile principale via RLIMIT_STACK ; expose auxval=socle getauxval et main_thread_stack=socle détection de pile — fusionne l’ex-Lot E getauxval). TLS dynamique per-thread = #[thread_local] natif (modèle errno cible), PAS un slot TCB (rejeté : sur aarch64 Variant I les champs TCB ≥ 0x10 recouvrent le TLS). pthread_self=current_tid (zéro additif). Bornes approximatives assumées (std raffine via /proc/self/maps). Threads spawnés (destructeurs TLS à la sortie, pile per-thread) câblés mais validation différée (mono-thread hello-std, spawn aarch64 buggé). Re-sceau couche-1-v1.9 DIFFÉRÉ en fin de Lot C. Délégation additive (ADR-065). Companion ADR-076/077/052/055 (RFC ADR-015) | Additif couche 1 (neutre) | ADR-086-introspection-thread-cible-libc-fr.md |
| 085 | Descellement additif couche 0 CUMULÉ pour la face libc (chantier B — std+OpenSSH) — Accepté (ratification BDFL 2026-07-08, périmètre implémenté, re-sceau couche-0-v1.11 posé). Cumule en un ADR tout le manque couche 0 restant (directive BDFL : un ADR / un descellement / une production / un re-sceau), au lieu de descellements par-symbole (ADR-070/071). Additifs (conventions ADR-021, Result/Option/BorrowedFd/EINTR) : munmap_raw(addr,len) (mémoire — le C libère par addr/len, pas par l’objet RAII Mapping) ; fchmod/fchown/futimens (par fd) ; chroot ; sched_getaffinity/sched_setaffinity (CpuSet typé) ; pause (portable 2 arches, rt_sigsuspend aarch64) ; sigaltstack (AltStack typé, requis par std) ; sendfile. ioctl = dispatcher LIBC (route les request connus vers les ops typées, TIOCGWINSZ→termios/FIONBIO→fcntl ; inconnu→ENOTTY) — pas de couche-0 générique (ADR-021 conv. 3 préservé). Hors périmètre = couche 1 (descellée) : getauxval (auxv, TARGET-ONLY), sysconf, strerror_r, __tls_get_addr. Débloque le lot mémoire libc (critique pour lier std). Re-sceau couche-0-v1.11 DIFFÉRÉ jusqu’à impl complète 2 arches. Companion ADR-051/070/071/060/076/077 (RFC ADR-015) | Descellement additif couche 0 (cumulé) | ADR-085-descellement-couche0-cumule-libc-std-fr.md |
| 083 | Additifs couche 0 pour la plomberie réseau : TUN (TUNSETIFF) + adjtimex/clock_adjtime — descellement additif couche-0-v1.x. Vérification faite : la plupart des « additifs couche 0 » flagués par la plomberie existent déjà — clock_settime (step) présent, SocketType::Raw + socket générique (ICMPv6 brut) présents, sockopt entier (SO_BROADCAST) présent (ADR-071). Seuls deux manquent : adjtimex (slew, discipline fine que NTP préfère au step) et TUN (/dev/net/tun + TUNSETIFF, ioctl dédié typé ADR-021 conv. 3, pour WireGuard). Types #[repr(C)] 2 arches, Option<T>, EINTR remonté, RAII FD ; privilèges (CAP_SYS_TIME/CAP_NET_ADMIN) appliqués par le kernel. ICMPv6/SO_BROADCAST = wrappers couche 1 (air-socket), pas couche 0. Corrige air-dhcp/air-ntp/air-wireguard. Délégation additive neutre (ADR-065). Companion ADR-060/070/071 (RFC ADR-015) | Additif couche 0 (neutre) | ADR-083-additifs-couche0-tun-adjtimex-fr.md |
| 082 | Additifs crypto : AEAD à nonce explicite (record/packet) + BLAKE2s (WireGuard) + PBKDF2 (SASL/SCRAM) — descellement additif couche-1-v1.x, complète ADR-078/081. Point important : la façade AirAead v1 génère un nonce aléatoire interne, or toutes les piles de session (TLS record RFC 8446 §5.3, QUIC packet RFC 9001 §5.3, WireGuard, SSH aes-gcm) protègent avec un nonce déterministe IV⊕compteur, non préfixé → ajout de lowlevel::AirAeadExplicitNonce (backends aes-gcm/chacha20poly1305 déjà présents ; opt-in expert-only, contrat d’unicité de nonce ; façade à nonce géré reste le défaut). blake2 (BLAKE2s hash/MAC/HMAC, WireGuard Noise — pur Rust zéro-C ≠ blake3-tire-cc) + pbkdf2 (SCRAM-SHA-256, air-mail). Correction rétroactive : air-tls/air-quic/air-ssh/air-wireguard notent l’usage de l’AEAD à nonce explicite. Pur Rust, zéro-C. Ratification BDFL (surface crypto). KAT-gated (ADR-034). Companion ADR-078/081/034 (RFC ADR-015) | Descellement additif couche 1 / Sécurité | ADR-082-additifs-crypto-wireguard-mail-aead-nonce-fr.md |
| 081 | Additifs crypto air-quic/air-ssh : primitives bas niveau + RSA PKCS#1 v1.5 vérif. — descellement additif couche-1-v1.x, complète ADR-078. Révélés par air-quic (header protection RFC 9001 §5.4 = bloc AES brut + keystream ChaCha20, sous la couche AEAD) et air-ssh (rsa-sha2 = RSASSA-PKCS#1 v1.5 vérif., ≠ le PSS d’ADR-078 ; chacha20-poly1305@openssh = ChaCha20 + Poly1305 séparés). Décision : module air_crypto::lowlevel (bloc AES single-block, ChaCha20 keystream, Poly1305 one-shot — opt-in expert-only, façade AEAD reste le défaut ; crates aes/chacha20/poly1305 déjà transitives → promues directes) + AirRsaPkcs1VerifyingKey (vérif. seule, hors Marvin). Kex PQ SSH = ML-KEM-768 réutilisé (mlkem768x25519, ADR-078) ; sntrup761 différé. Construction (header protection/SSH-AEAD) reste en couche 2, pas dans air-crypto. Pur Rust, zéro-C. Ratification BDFL requise (surface crypto, hors délégation ADR-065). KAT-gated (ADR-034). Companion ADR-078/034/042/043 (RFC ADR-015) | Descellement additif couche 1 / Sécurité | ADR-081-additifs-crypto-quic-ssh-fr.md |
| 080 | Additifs air-socket : multicast UDP typé + codec DNS étendu (SRV/TXT/PTR) — descellement additif couche-1-v1.x + couche-0-v1.12 (setsockopt structuré). Révélé par air-mdns (L2) : mDNS exige (1) joindre un groupe multicast = setsockopt(IP_ADD_MEMBERSHIP, ip_mreq) — option à structure, hors du sockopt entier d’ADR-071 (limitation D-M4.1) → additif couche 0 setsockopt_bytes (aucun nouveau syscall) ; (2) le codec DNS (noms compressés bornés + SRV/TXT/PTR) — promu public sans-IO plutôt que dupliqué dans air-mdns (surface hostile décodée une fois, règle 80 %). Fonctions multicast dédiées typées (ADR-021 conv. 3). Zéro-C. Délégation additive neutre (ADR-065). Companion ADR-070/071/062 (RFC ADR-015) | Additifs couche 1 + couche 0 (neutres) | ADR-080-additifs-air-socket-multicast-dns-fr.md |
| 079 | air-netlink (couche 1) : transport netlink générique + descellement additif couche-0-v1.11 (AF_NETLINK/sockaddr_nl). Décision BDFL — option B, couche 1. Le path-monitor d’air-network (L2) exige rtnetlink (plan de contrôle : liens/adresses/routes). Deux options analysées : (A) étendre air-device — rejetée (conflate uevent-broadcast-texte vs rtnetlink-TLV, descellement lourd, disperse un transport multi-consommateurs) ; (B) crate air-netlink dédiée — responsabilité unique, transport réutilisable (path-monitor, WireGuard, DHCP, air-config routes, wifi nl80211), TLV hostile fuzzé une fois, patron sans-IO. Placée couche 1 (pair d’air-socket, révise la mention « L2 » d’air-socket). Additif couche 0 modéré : SocketDomain::Netlink=16 + SocketAddrNetlink (syscalls socket/bind/sendmsg/recvmsg génériques déjà présents → aucun nouveau syscall). air-device inchangé (migration future possible). Companion ADR-069/051/070/071 (RFC ADR-015) | Nouvelle crate couche 1 + descellement couche 0 | ADR-079-air-netlink-couche1-fr.md |
| 078 | Descellement additif couche-1-v1.7 : additifs crypto pour air-tls (AES-128-GCM, ECDSA/ECDH P-256/P-384, RSA-PSS vérif. seule, ML-KEM-768 hybride). Révélé par la contingence air-tls maison (ADR-042 §Contingence) + l’audit s2n-tls : le cœur v1 d’air-crypto (Ed25519/X25519/AES-256/ChaCha20) ne valide pas le WebPKI réel. Ajoute 5 primitives (crates p256/p384/rsa/ml-kem + variante aes-gcm), purement additives, pur Rust, zéro-C, KAT-gated (ADR-034). Sécurité gravée : RSA vérif. seule (aucune op. privée → hors Marvin RUSTSEC-2023-0071), ECDSA RFC 6979, ML-KEM hybride only. Hors délégation additive automatique (ADR-065) : surface crypto ⇒ ratification BDFL requise avant tag. Companion ADR-042/034/062/065 (RFC ADR-015) | Descellement additif couche 1 / Sécurité | ADR-078-descellement-couche1-v1.7-crypto-air-tls-fr.md |
| 061 | Architecture AirTerminal (vision) : objet couche 1 = tag (Legacy/Air) + codec + modèle canonique interne (lingua franca). Les codecs (ansi/termios, kitty, air-codec-1) traduisent tous ce modèle → nouveau terminal = un codec de plus, jamais une refonte ; codec legacy = adaptateur dégradant. Discipline unique « Annoncer → Sélectionner → dégrader-sur-inconnu » (registre à IDs stables + #[non_exhaustive] + variante Unknown + schéma capnp ADR-040). TermIn = enum 2 étages extensible ; AttachSurface (énumérer→sélectionner→lier) = la clé « vraie surface Air via compositeur » au lieu de smuggler du graphique en séquences ; termios = affaire du codec legacy seul, chemin natif termios-free ; passerelle legacy↔natif = composer 2 codecs. Vision, zéro impl. au-delà du trait : le sceau couche 1 n’exige que termios (L0) + mince air-terminal (L1, codec ansi) ; codecs riches/air-codec-1/AttachSurface = couche 2 différés. termcap hors libc (ncurses/L2, xterm-only). Companion ADR-060/046/001/003/007/008/040 (RFC ADR-015) | Architecture (vision terminal, couche 1→2) | ADR-061-architecture-air-terminal-fr.md |
| 062 | Sceau couche-1-v1.0 : gel de l’API Rust publique de la couche 1 (17 crates). Campagne de clôture (4 P0 AirFile/air-poll/termios-PTY/air-signal + remodel os_str env/args) ; évolution ultérieure par descellement additif v1.x uniquement (RFC). Companion ADR-051/054/055/063/064 (RFC ADR-015) | Sceau (couche 1) | ADR-062-sceau-couche-1-fr.md |
| 063 | Instrumentation IO couche 1 (debug) : registre de handles & cohérence post-fork, LIB_AIR_DEBUG (fd non fermés, doubles-close). Doctrine gravée au sceau ; implémentation en additif v1.x. Companion ADR-069/062 (RFC ADR-015) | Doctrine (couche 1) | ADR-063-instrumentation-io-couche-1-fr.md |
| 064 | Doctrine des signaux couche 1 : signalfd primaire ; les 4 fautes → disposition par défaut (zéro unsafe exposé) ; §7 barrière de poison / fault_state(). Companion ADR-020/066 (RFC ADR-015) | Doctrine (couche 1) | ADR-064-signaux-doctrine-couche-1-fr.md |
| 065 | Descellement additif couche-1-v1.1 + délégation de re-sceau : AirRuntime::errno_location (socle de __errno_location de la libc) ; instaure la délégation (le superviseur pose les tags de re-sceau additifs neutres sans rouvrir l’ADR-062). Companion ADR-062 (RFC ADR-015) | Descellement additif couche 1 / gouvernance | ADR-065-descellement-couche1-v1.1-fr.md |
| 066 | Descellement additif couche 0 : rt_sigaction non-faute (async réel) + trampoline rt_sigreturn (x86_64) + rt_sigpending. Fondation de l’install de handler async pour les signaux non-faute ; les 4 fautes restent inertes (ADR-064). Companion ADR-020/064 (RFC ADR-015) | Descellement additif couche 0 | ADR-066-descellement-couche0-sigaction-async-fr.md |
| 067 | air-account (couche 1) : codecs des comptes /etc/passwd + /etc/shadow + /etc/group, brique bindée par la libc (getpwnam_r/getgrnam_r/getspnam_r…). Une impl Rust, deux faces (libc + réutilisable Rust). Companion ADR-046/068 (RFC ADR-015) | Nouvelle crate couche 1 / libc | ADR-067-air-account-couche1-fr.md |
| 068 | Surface fine credentials (uid/gid) en couche 1 (air-process) : getuid/setuid/setresuid/setgroups/initgroups + convenance drop_to_user (résolveur AirPrivilegeDrop) ; sémantique POSIX setuid ratifiée. Bindée par la libc. Companion ADR-067 (RFC ADR-015) | Descellement additif couche 1 / libc | ADR-068-credentials-couche1-fr.md |
| 070 | Descellement additif couche 0 : codec sockaddr public pour la face libc (inet_pton/inet_ntop, sockaddr_in/in6/un, byteorder). Companion ADR-069/071 (RFC ADR-015) | Descellement additif couche 0 | ADR-070-descellement-couche0-sockaddr-libc-fr.md |
| 071 | Descellement additif couche 0 : setsockopt/getsockopt entiers publics pour la face libc (limitation D-M4.1 sur les options à structure → levée par ADR-080 setsockopt_bytes). Companion ADR-069/070/080 (RFC ADR-015) | Descellement additif couche 0 | ADR-071-descellement-couche0-sockopt-libc-fr.md |
| 072 | CI : x86_64 runner primaire + aarch64 en cross-check (compile, chaque PR) + aarch64-natif sur main/hebdo (révise ADR-037) → PR ~3-7 min au lieu de ~30. Companion ADR-031/037 (RFC ADR-015) | Infrastructure / CI | ADR-072-ci-x86-primaire-aarch64-crosscheck-fr.md |
| 073 | Doctrine de configuration binaire : confs Air binaires (pas de texte clair), CLI exigeant le mot de passe administrateur, sérialiseur/désérialiseur pour la compat. Modèle de menace = attaquant avec shell/sudo modifiant une conf texte. Companion ADR-033/040 (RFC ADR-015) | Doctrine (sécurité / configuration) | ADR-073-configuration-binaire-doctrine-fr.md |
| 074 | Vision air-sshd : démon SSH natif async io_uring, wire-compat OpenSSH, conf binaire, profil crypto moderne — la vraie cible produit (OpenSSH-compile n’était qu’une fonction de forçage de la libc/couche 1). air-crypto couvre déjà le profil SSH moderne. Companion ADR-043/073/076 (RFC ADR-015) | Architecture (vision / cible produit) | ADR-074-air-sshd-vision-fr.md |
| 075 | Fourniture du PAL : std::sys::pal::air fourni via rust-src patché + build-std, upstream Tier-3 visé à terme. Companion ADR-050/076 (RFC ADR-015) | Architecture (toolchain / PAL) | ADR-075-fourniture-pal-rust-src-patche-fr.md |
| 076 | std natif sur la libc d’Air : cible env=musl + vendor=air (chantier B, option A) — std réutilise le pal unix et lie libair en direct (zéro glibc, mais pas zéro libc : la nôtre). LA recette du jalon M5. Companion ADR-050/075/077 (RFC ADR-015) | Architecture (toolchain / PAL) | ADR-076-std-sur-air-libc-env-musl-fr.md |
| 077 | Managers de domaine couche 1 : surface objet médiatrice (AirFileManager/AirProcessManager/AirNetworkManager/AirSystemManager/AirSignalManager/AirEnvironmentManager/AirMemoryManager) consommée par les toits libc et PAL — jamais la couche 0 en direct (ADR-052). Companion ADR-069/052/046 (RFC ADR-015) | Architecture (couche 1 / toits) | ADR-077-managers-domaine-couche1-fr.md |
| 089 | Réservation du SecurityManager : sécurité active per-process (couche 1). ADR de réservation (moteur = ADR ultérieur). Air secure-first : les Managers sensibles consultent un SecurityManager avant une op sensible → Allow/Deny + log de la raison (ex. flood connexions → règle eBPF drop ; fork-bomb/huge-alloc → refus). PAS un daemon (goulot) : per-process, embarqué par libair, sollicité par les autres Managers ; décision locale, enforcement via kernel partagé (eBPF/cgroups/seccomp/Landlock) ; règles depuis la config binaire (ADR-073). Seam posé maintenant (interface + défaut permissif allow-all+log, câblé au fil de l’eau) ; moteur (règles/détection/rate-limiting) différé, aligné air-sshd. Companion ADR-032/077/073/074/088 (RFC ADR-015) | Architecture (sécurité, couche 1) — réservation | ADR-089-reservation-security-manager-fr.md |
| 088 | std Rust sur Air safe : PAL custom sur la couche 1, sans libc C. Amende ADR-076 (abandonne l’option A « std sur la libc C libair_c » comme état-cible) : std repose sur un PAL Air custom et safe (std::sys::pal::air + modules sys/<domaine>/air.rs) bindant les Managers couche 1 en Rust safe, zéro FFI — Air serait le premier backend std safe de bout en bout (l’unsafe confiné couche 0). Cible target_os="air" (Air reste Linux ; hermit/xous = précédents structurels non-Linux, pas un clone ; revient sur env="musl"). libair_c survit mais découplée de std (libc pour devs C, Linux-conforme). AirTaskManager (task + AirThreadGroup/tgid, fidèle au kernel, produit process/thread) remplace AirProcessManager (raffine ADR-077). Résorbe raw_syscall (ADR-087) + traite la dette GlobalAlloc-safe. Prototypage host-first (rust-src patché + build-std) ; RFC amont Tier-3 après preuve. Instruit par la note etude-std-pal-air-safe-fr.md. Companion ADR-050/075/076/077/087/046 (RFC ADR-015) | Décision de toolchain (toit std) + raffinement Manager couche 1 | ADR-088-std-pal-safe-couche1-sans-c-fr.md |
| 093 | Couche transport SSH-2 d’air-sshd : cœur sans-IO air-ssh-proto, topologie de crates v1/v2, algorithmes modernes, wire-compat OpenSSH. Instruit la phase 1 d’ADR-074 selon ADR-091 ; intègre la directive BDFL v1/v2. Topologie (fige tout air-sshd) : air-ssh-proto (couche 2, cœur PUR no_std+alloc, #![forbid(unsafe_code)], zéro fd/I/O — Framer [RFC 4253 §6]/Codec SSH_MSG_*/StateMachine transport+rekey/Handshaker/Timer ; Flow/Mux/Session réservés P3 ; invariant, réutilisé verbatim en v2) + air-sshd scindé : pilote d’octets swappable (trait SshByteStream, v1 air-async/air-socket, v2 stack « ssh » d’air-network enveloppant le même cœur) + couche service invariante (SessionRegistry, publication AirCom, air-config, hôte de service) + air-service (trait ServiceHost réutilisable, v1 systemd socket-activation+sd_notify a minima / v2 air-launchd). Observabilité (directive BDFL) : air-sshd publie les événements de session sur AirCom (pub-sub §7 : ConnectionRequested/Kex*/Auth*/SessionOpened/Closed) + objet-service interrogeable (list_sessions/session_info via call, inc.7) → air-com = dépendance v1 (supersede ADR-074 §4.3). Algos MODERNE uniquement (pas de downgrade) : curve25519-sha256 [RFC 8731], ssh-ed25519 [RFC 8709], AEAD chacha20-poly1305@openssh.com/aes256-gcm@openssh.com, compression none. Frontière crypto : cœur→air-crypto (2→1) mais aléa injecté (SshRng) → fuzzing déterministe. Conf binaire air-config (existe ; + conf par-utilisateur HomeDirectory en P2, ADR-073). Cible : un vrai client ssh OpenSSH traverse le transport. 4 incréments T.1 (3 crates+seams+schéma events+identification+Framer+fuzz) → T.2 (KEXINIT+négo+event) → T.3 (KEX X25519+H+sig Ed25519+dérivation) → T.4 (NEWKEYS+chiffré+rekey+wire-compat). 2 ADR compagnons v2 : air-launchd ; stack « ssh » air-network. Companion ADR-074/091/092/034/069/073/001/005/033/040 (RFC ADR-015) | Architecture (couche 2, premier service réseau) | ADR-093-air-sshd-transport-ssh2-fr.md |
| 092 | air-async : découpage réacteur (couche 1) / exécuteur (couche 2) de la runloop io_uring. Raffine et renomme ADR-038. Le runtime async, placé « couche 2 » par ADR-038 sous le nom air-event, se scinde (contrainte check-layers : couche 2 ↛ couche 0, car io_uring est couche 0) : air-uring (couche 1) = réacteur, rempart safe sur io_uring (comme air-poll/ppoll), possède tous les types io_uring + mappe token→Waker ; air-async (couche 2) = exécuteur (futures/block_on/timers), le pilote I/O async du motif sans-IO (ADR-091), zéro type io_uring nommé. air-async remplace air-event (ADR-038 amendé). Modèle owned-buffer déjà en couche 0 (rien à réinventer) ; v1 mono-thread, 1 CQE→1 Waker, timers IORING_OP_TIMEOUT. Squelette prouvé host (block_on(timeout) traverse L2→L1→L0→CQE→Ready), check-layers vert. Questions ouvertes : annulation-au-drop, token→tâche O(1), spawn, éveil inter-thread. Companion ADR-038/091/022/028/044 (RFC ADR-015) | Architecture (couche 2 / couche 1 — la runloop) | ADR-092-air-async-decoupage-reacteur-executeur-fr.md |
| 091 | Motif d’architecture réseau sans-IO : anatomie canonique à 9 composants, OBLIGATOIRE. Rend normative la §2 de la note reseau-architecture-crates-fr.md. Tout crate réseau maison = cœur sans-IO pur (machine à états octets → événements + octets, zéro socket/async/horloge/alloc cachée) + pilote I/O mince (seul à toucher air-socket/air-runtime). Anatomie uniforme à 9 composants (Framer/Codec/StateMachine/Handshaker/Flow Controller/Multiplexer/Timer Manager/Session Context/Extension hooks — 8 purs) : « on sait toujours où regarder », chaque composant testable en isolation ; composant absent = marqué explicitement, jamais un trou. Régime de test drastique : fuzz obligatoire par parseur, model-based sur StateMachine, virtual clock (jamais de sleep réel), zeroize secrets, 100 % couverture, interop. Neutre sync/async (ADR-038), composable (cœurs empilés, I/O au bas de la pile), zéro-C sauf exception TLS étroite (ADR-042). Portée : air-tls/air-quic/air-http/air-ssh/mDNS/WireGuard/DHCP/NTP ; air-socket+DNS = précurseurs conformes. Prérequis de la couche 2 (avant air-async/air-sshd). Companion ADR-019/024/038/042/043 (RFC ADR-015) | Architecture (réseau, couches 1–2, fondateur) | ADR-091-motif-reseau-sans-io-fr.md |
| 090 | Binding de la couche 1 dans std par rustc-dep-of-std : découplage plutôt que fork, sans descellement de la couche 0. Met en œuvre ADR-088. Mesuré (incréments 0→3) : un Manager entre dans la fermeture de std par une feature rustc-dep-of-std (build-config, reste no_std) — zéro source couche 0/1, zéro sémantique, pas de descellement. Discipline absolue : bras PAL & Managers de la fermeture std importent leurs types transverses depuis air-base-core (icu-free), JAMAIS air-base-lib (tire icu4x) ; interdiction de tirer une exception 80 % lourde (icu4x ADR-016/059, RustCrypto ADR-034) dans la fermeture de std — elles restent au niveau consommateur (couche 2/apps). AirRandom sur chemin sans crypto. Frontière no_std = relation à std (composant dont std est bâtie → no_std ; consommateur → std complet) ; String = type alloc déjà dispo couche 1. Politique de fork des deps externes (règle 80 %, ADR-024) : minimiser, préférer découplage/retrait au fork, ne PAS forker icu4x/RustCrypto. Industrialisation par vagues (0/1/2), jalon hello-std sur PAL safe 2 arches. S’appuie sur ADR-054 (scission air-base-core). Companion ADR-088/054/016/059/034/024 (RFC ADR-015) | Décision de toolchain (binding couche 1 → std) | ADR-090-binding-couche1-std-rustc-dep-of-std-fr.md |
Documents fondateurs apparentés
- Vision : ../vision/vision-fr.md (FR) / ../vision/vision-en.md (EN)
- Charte (5 principes de valeurs) : ../charte/charte-fr.md (FR) / ../charte/charter-en.md (EN)
- Principes d’ingénierie (9 principes méthodologiques) : ../principes-ingenierie/principes-ingenierie-fr.md (FR) / ../principes-ingenierie/engineering-principles-en.md (EN)
- Macro-architecture des 6 couches : ../architecture/macro-architecture-fr.md
- Vue d’ensemble du projet : ../../README.md
ADRs futurs identifiés (à instruire ultérieurement)
- Stratégie de logging et observabilité au-delà de
journald. - Stratégie de mise à jour atomique du système (vers Modèle B de distribution).
- App Store Air (présentation Flathub +
.airapp). - Protocole privé AirCom entre
air-wmet apps natives (rich content, animations, capture vectorielle scene-graph). - Politique d’accès des agents IA à l’arbre sémantique et aux captures vectorielles (dans la continuité de
air.accessibility.observerd’ADR-017 et des conséquences d’ADR-018). - Services système couche 5 (screencapture, appearance, share, firewalld, power, printd/bluetooth/nfc, prefs, keychain) — actuellement référencés à titre prospectif dans
../architecture/macro-architecture-fr.md; leurs numéros définitifs seront assignés au moment de leur instruction (numéros ≥ 029 ; 027 est pris par la documentation polyglotte, 028 par la soundness io_uring). - Cas spécifiques émergeant de l’implémentation.
Historique
-
v3.41 — Ajout d’ADR-092 («
air-async: découpage réacteur (couche 1) / exécuteur (couche 2) de la runloop io_uring »). Accepté (décision BDFL 2026-07-11). Raffine et renomme ADR-038 : la runloop se scinde enair-uring(couche 1, réacteur safe sur io_uring) +air-async(couche 2, exécuteur) — forcé parcheck-layers(couche 2 ↛ couche 0).air-asyncremplaceair-event. Modèle owned-buffer déjà en couche 0. Squelette prouvé host,check-layersvert. Companion ADR-038/091/022/028/044. Propagationair-event→air-async(specs/macro-archi) en suivi doc. -
v3.40 — Ajout d’ADR-091 (« Motif d’architecture réseau sans-IO : anatomie canonique à 9 composants, obligatoire »). Accepté (décision BDFL 2026-07-11). Rend normative la §2 de la note
reseau-architecture-crates-fr.md: tout crate réseau maison = cœur sans-IO pur (9 composants uniformes, 8 purs, fuzzés/testés en isolation) + pilote I/O mince. Seul motif testant la pile réseau indépendamment (surface hostile n°1 fuzzée sans réseau). Neutre sync/async (ADR-038), composable, zéro-C sauf exception TLS étroite (ADR-042). Prérequis de la couche 2 — gravé avantair-async/air-sshd. Le reste de la note (protocoles/priorités) reste exploratoire. Companion ADR-019/024/038/042/043. -
v3.39 — Ajout d’ADR-090 (« Binding de la couche 1 dans
stdparrustc-dep-of-std: découplage plutôt que fork, sans descellement de la couche 0 »). Accepté (décision BDFL 2026-07-11). Met en œuvre ADR-088. Mesuré (incréments 0→3, branchefeat/pal-safe-air-target) : binding par featurerustc-dep-of-std(build-config,no_std) — zéro source couche 0/1, pas de descellement ; le fardeau de fork (~71 naïf : 33 icu4x + 37 RustCrypto +bitflags) est accidentel, évité par découplage (imports →air-base-core, jamaisair-base-lib;AirRandomhors crypto), pas par fork. Exceptions 80 % hors fermeturestd(niveau consommateur). Frontièreno_std= relation àstd. Industrialisation par vagues (0/1/2). Companion ADR-088/054/016/059/034/024. -
v3.38 — Ajout d’ADR-089 (« Réservation du
SecurityManager: sécurité active per-process, couche 1 »). Accepté (décision BDFL 2026-07-10) — ADR de réservation (moteur = ADR ultérieur). Air secure-first : les Managers sensibles consultent unSecurityManageravant une op sensible →Allow/Deny+ log de la raison ; PAS un daemon (goulot) mais per-process embarqué parlibair; décision locale, enforcement via kernel partagé (eBPF/cgroups/seccomp/Landlock) ; règles depuis la config binaire (ADR-073). Seam posé (interface + défaut permissif, câblé au fil de l’eau) ; moteur différé, alignéair-sshd. Companion ADR-032/077/073/074/088. -
v3.37 — Ajout d’ADR-088 («
stdRust sur Air safe : PAL custom sur la couche 1, sans libc C »). Accepté (décision BDFL 2026-07-10). Pivot d’objectif premier : la toolchain Rust dont lastdrepose uniquement sur Air, par un backendstd::sysbindant la couche 1 Rust safe, sans lib C ni bindings C. Amende ADR-076 (abandonne l’option A «stdsur libc C » comme état-cible ; M5 devient un marchepied de faisabilité), re-amende ADR-050 (cibletarget_os="air", plus « déguisée » en unix), raffine ADR-077 (AirProcessManager→AirTaskManager: task + thread group, fidèle au kernel), résorbe ADR-087 (raw_syscalldisparaît).libair_csurvit mais découplée destd. Instruit par la note d’étudeetude-std-pal-air-safe-fr.md. Companion ADR-050/075/076/077/087/046. -
v3.36 — Jalon M5 atteint (2026-07-10) :
stdtourne sur la libc d’Air, zéro glibc, 2 arches. Ajout d’ADR-087 («syscallgénérique (raw_syscall) : escape hatch temporaire À RETIRER pour le palstd»). Accepté (ratification BDFL 2026-07-09), explicitement temporaire : dernier symbole indéfini de M5,syscall(2)brut requis par le palunixdestd(futex/gettid), impl. via additif couche 0raw_syscall+ shim libc cible-only. Entorse assumée (ADR-021 conv. 3 + « libc binde couche 1 ») ⇒ exception consignée + action item amont équipe Rust. Re-sceaucouche-0-v1.12(délégation ADR-065). Companion ADR-076/021/077. Backfill de l’index du registre : réintégration des lignes ADR-062→068 et ADR-070→077 (dérive admise à v3.26, jamais résorbée). En-tête porté à v3.36. -
v3.35 — Ajout d’ADR-086 (« Introspection thread cible pour la face libc : TLS dynamique, bornes de pile,
auxv»). Accepté (ratification BDFL 2026-07-09). Additif couche 1 débloquant 7 symbolesstdcible (pthread_key_*,pthread_self,pthread_getattr_np/attr_getstack/attr_getguardsize) :AirProcessManager::resource_limit(bindeprlimit) +air-runtime::process_context(captureauxv+ bornes de pile au bootstrap ; soclegetauxval+ détection de pile), TLS dynamique via#[thread_local]natif. Re-sceaucouche-1-v1.9. Companion ADR-076/077/052/055. -
v3.34 — Ajout d’ADR-085 (« Descellement additif couche 0 cumulé pour la face libc — chantier B »). Accepté (ratification BDFL 2026-07-08, périmètre implémenté). Cumule en un ADR tout le manque couche 0 restant de la campagne libc/
std:munmap_raw(mémoire),fchmod/fchown/futimens(fd),chroot,sched_[gs]etaffinity,pause,sigaltstack,sendfile;ioctlrésolu en dispatcher libc (ADR-021 préservé) ;getauxval/sysconf/strerror_r/__tls_get_addrrenvoyés à la couche 1 (descellée). Re-sceaucouche-0-v1.11posé. Companion ADR-051/070/071/076/077. -
v3.33 — Ajout d’ADR-084 (« Ratification
air-url: couche 4 élargie (UI + fondation) + nomair-url»). Ratification BDFL. Tranche les deux points ouverts de la specair-url(noteapi-reseau-strategie) :air-url(≈ URLSession — sessions/tâches/cache/cookies/WebSocket, surair-http) est une commodité applicative de fondation, pas un mécanisme système ⇒ couche 4, dont le périmètre est élargi de « frameworks UI » à « frameworks développeur : UI + fondation » (amende la macro-architecture, modèle Foundation ⊕ UIKit d’Apple). Nomair-url(descriptif, vocabulaire Air, jamais « URLSession » ;air-web/air-fetchrévisables sans rouvrir la couche). Alternatives rejetées : haut de couche 2 (mélange de registres), nouvelle couche dédiée (sur-ingénierie), nom copié d’Apple. Macro-archi (§couche 4) + specair-url+ INDEX/SUMMARY mis à jour. (Ratification pilotée par directive ; le nom reste ajustable par le BDFL.) -
v3.32 — Ajout d’ADR-083 (« Additifs couche 0 pour la plomberie réseau : TUN (
TUNSETIFF) +adjtimex/clock_adjtime»). Délégation additive (ADR-065, neutre). Vérification sur le socle réel : la plupart des additifs couche 0 flagués parair-dhcp/air-ntp/air-wireguardexistent déjà —clock_settime(step),SocketType::Raw+socket(domain, ty, protocol)générique (ICMPv6 brut), sockopt entierSO_BROADCAST(ADR-071). Seuls deux manquent réellement :adjtimex(slew — discipline fine que NTP préfère au saut brut) et TUN (/dev/net/tun+TUNSETIFF, ioctl dédié typé ADR-021 conv. 3, pour l’interface tunnel WireGuard). Types#[repr(C)]identiques 2 arches,Option<T>/EINTR/RAII FD ; privilèges (CAP_SYS_TIME/CAP_NET_ADMIN) appliqués par le kernel (wrapper neutre). ICMPv6 brut (SLAAC) +SO_BROADCAST= wrappers couche 1 (air-socket), pas de couche 0. Corrige les « Travail à reprendre » deair-dhcp/air-ntp/air-wireguard(sur-estimation). Tagscouche-0-v1.xaprès barrière 2 arches. SUMMARY à jour. -
v3.31 — Ajout d’ADR-082 (« Additifs crypto : AEAD à nonce explicite (record/packet) + BLAKE2s (WireGuard) + PBKDF2 (SASL/SCRAM) »). Proposé — ratification BDFL (surface crypto). Complète ADR-078/081. Révélé par
air-wireguard/air-mailet une relecture des piles TLS/QUIC/SSH : (1) le point important — la façadeAirAeadv1 génère un nonce aléatoire préfixé, mais tous les protocoles de session (TLS 1.3 record §5.3, QUIC packet RFC 9001 §5.3, WireGuard, SSH aes-gcm) chiffrent avec un nonce déterministeIV⊕compteur, non préfixé → ajout delowlevel::AirAeadExplicitNonce(backendsaes-gcm/chacha20poly1305déjà présents ; opt-in expert-only, contrat d’unicité de nonce documenté ; la façade à nonce géré reste le défaut applicatif). (2)blake2(BLAKE2s hash/MAC-keyed/HMAC pour le handshake Noise de WireGuard — pur Rust ZÉRO-C, contrairement àblake3qui tirecc). (3)pbkdf2(PBKDF2-HMAC-SHA256 pourSCRAM-SHA-256d’air-mail; optionnel —OAUTHBEARERl’évite). Correction rétroactive :air-tls/air-quic/air-ssh/air-wireguardnotent que la protection d’enregistrement/paquet passe par l’AEAD à nonce explicite. Pur Rust, zéro-C, KAT-gated. Section « Additifs planifiés » d’air-cryptoétendue. SUMMARY + INDEX + EXCEPTIONS à jour. -
v3.30 — Ajout d’ADR-081 (« Additifs crypto
air-quic/air-ssh: primitives bas niveau + RSA PKCS#1 v1.5 vérif. »). Proposé — ratification BDFL (surface crypto, comme ADR-078). Complète ADR-078 avec les additifs révélés par les specs QUIC/SSH (postérieures) : (1) moduleair_crypto::lowlevel— bloc AES single-block, keystream ChaCha20, Poly1305 one-shot (QUIC header protection RFC 9001 §5.4 + SSHchacha20-poly1305@openssh) ; cratesaes/chacha20/poly1305déjà transitives → promues directes ; opt-in expert-only, la façade AEAD à nonce géré reste le défaut (la construction reste en couche 2). (2)AirRsaPkcs1VerifyingKey— RSASSA-PKCS#1 v1.5 vérif. seule (SSHrsa-sha2-256/512), hors Marvin (opération publique ; chiffrement v1.5 toujours omis). (3) Kex PQ SSH = ML-KEM-768 réutilisé (mlkem768x25519-sha256, défaut OpenSSH ≥ 9.9) → aucun additif nouveau ;sntrup761différé (pas de crate pure-Rust vettée). Pur Rust, zéro-C, KAT-gated (ADR-034). Corrige la specair-ssh(PQ). Section « Additifs planifiés » ajoutée àair-crypto. SUMMARY + INDEX + EXCEPTIONS à jour. -
v3.29 — Ajout d’ADR-080 (« Additifs
air-socket: multicast UDP typé + codec DNS étendu SRV/TXT/PTR — descellement additifcouche-1-v1.x+couche-0-v1.12»). Délégation additive (ADR-065, neutre non-sécurité). Révélé par la specair-mdns(L2) : (1) multicast UDP — rejoindre un groupe =setsockopt(IP_ADD_MEMBERSHIP, ip_mreq), une option à structure que la couche 0 ne sait pas poser (ADR-071 = sockopts entiers seulement, limitation D-M4.1) → additifsetsockopt_bytes(aucun nouveau syscall, variante à buffer dusetsockoptdéjà appelé) ; fonctions multicast dédiées typées surAirUdpSocket(ADR-021 conv. 3). (2) Codec DNS — promu public sans-IO (codec de noms compressés borné/fuzzé existant) + types SRV/TXT/PTR (DNS-SD), plutôt que dupliqué dansair-mdns(surface de parsing hostile décodée une fois, règle 80 % ADR-024). Zéro-C, zérounsafe. Tagscouche-0-v1.12puiscouche-1-v1.xaprès barrière 2 arches. Section « Additifs planifiés » ajoutée à la specair-socket. SUMMARY + INDEX à jour. -
v3.28 — Ajout d’ADR-079 («
air-netlink(couche 1) : transport netlink générique + descellement additifcouche-0-v1.11(AF_NETLINK/sockaddr_nl) »). Décision BDFL (2026-07-07) — option B, couche 1. La conscience du chemin d’air-network(L2,AirNetworkPathMonitor) exige rtnetlink (plan de contrôle : liens/adresses/routes) ; la couche 0 n’a qu’un socketNETLINK_KOBJECT_UEVENTspécifique (air-device). Trade-off tranché : crateair-netlinkdédiée (responsabilité unique, transport réutilisable par path-monitor + WireGuard/DHCP +air-configroutes + wifi nl80211 ; surface TLV hostile fuzzée une fois ; patron sans-IO 9-composants) plutôt qu’étendreair-device(rejeté : conflate deux protocoles netlink très différents, descellement lourd, dispersion). Placée couche 1 (pair d’air-socket; révise la mention prospective « couche 2 » d’air-socket). Additif couche 0 modéré :SocketDomain::Netlink=16 +SocketAddrNetlink(sockaddr_nl), les syscalls génériquessocket/bind/sendmsg/recvmsgétant déjà présents ⇒ aucun nouveau syscall.air-deviceinchangé (migration future possible, non requise). Specdocs/specs/layer-1/air-netlink.md. Tagscouche-0-v1.11puiscouche-1-v1.xaprès barrière 2 arches. SUMMARY + INDEX à jour. -
v3.27 — Ajout d’ADR-078 (« Descellement additif
couche-1-v1.7: additifs crypto pourair-tls»). Proposé — ratification BDFL requise (surface crypto, hors délégation additive automatique d’ADR-065). Révélé par la contingenceair-tlsmaison (ADR-042 §Contingence, specdocs/specs/layer-2/air-tls.md) et l’audit s2n-tls (docs/notes/audit-s2n-tls-fr.md) : le cœur v1 d’air-crypto(Ed25519/X25519/AES-256-GCM/ChaCha20) ne valide pas le WebPKI réel. Ajoute 5 primitives àair-crypto(scellée) — AES-128-GCM (MTI TLS 1.3), ECDSA + ECDH P-256/P-384 (RFC 6979 déterministe), RSA-PSS vérification seule, ML-KEM-768 hybride — via crates RustCryptop256/p384/rsa/ml-kem(+ variante d’aes-gcmdéjà présent), purement additives, pur Rust, zéro-C,no_std-strict, KAT-gated (ADR-034). Décisions de sécurité gravées : RSA vérif. seule (aucune opération privée RSA ⇒ hors Marvin RUSTSEC-2023-0071 ; PKCS#1 v1.5 chiffrement omis), ECDSA à nonce déterministe RFC 6979, ML-KEM en hybrideX25519MLKEM768only (sécurité ≥ max des deux). N’impacte pas la pile de production rustls + aws-lc-rs (ADR-042). Exceptions 80 % à nommer (EXCEPTIONS.md— déjà instruites). Tagcouche-1-v1.7après ratification BDFL + implémentation KAT-gated + barrière 2 arches. SUMMARY + INDEX à jour. -
v3.26 — Ajout d’ADR-069 (« Registre de handles fd-général (couche 1,
air-handle) + moteur socket, façades PAL & libc »). Ratifié BDFL (2026-07-05). Déclenché par le jalon M4 réseau : la question « comment la libc socket se pose sur la couche 1 » a révélé un enjeu plus large — la cohérence de l’état des descripteurs entre Rust et C au sein d’un même processus (pas de « chemin Rust » vs « chemin C »). Décision : une crateair-handle(couche 1 basse, dont dépendent tous les producteurs de fd) porte un registre process-global unique (clé = vrai fd kernel, rendu au C pour l’interop) qui possède l’OwnedFd; points de passage uniques (create/duplicate/close/set_fd/set_status) ; duplicateur unique modélisant l’alias (description partagée refcomptée) + éviction-sur-silent-close → cohérencedup2/fcntl(F_DUPFD/F_SETFL). Tous les objets fd de couche 1 (AirFile/pipes/AirTcpSocket/AirUdp/AirUnix*) re-domiciliés (API préservée) ;AirSocketEngine= couche de politique (couture de sécurité réseau + résolveur réutilisé) au-dessus du registre ; une impl, deux façades (objets PAL Rust + libc C). Alternatives rejetées : libc→couche 0 direct (pas de couture), registre socket-only (2 régimes), handle synthétique (cassepoll/SCM_RIGHTS), libc sans état (= musl). Descellement additif couche 1 (re-sceau au lot 0). SUMMARY à jour. (Constat au passage : l’index du registre a dérivé — ADR-062→068 absents ; backfill à traiter hors périmètre de cette PR.) -
v3.25 — Ajout d’ADR-060 (« Descellement
couche-0-vX: familletermios/tty») et ADR-061 (« ArchitectureAirTerminal: modèle canonique + codecs — vision »). RFC de structure (ADR-015), par délégation d’autonomie BDFL (gravés pour revue a posteriori). ADR-060 : clore la libc exigetermios(interface POSIX-1988 de discipline de ligne, terminal-agnostique) +isatty(pourstdio), absents de la couche 0 scellée → descellement additif (mécanique ADR-051), toute la famille en une passe : attributs/contrôle-ligne/winsize/PTY/session, en fonctions dédiées typées (ADR-021 conv. 3, jamais d’ioctl générique), sur le typeTermiosexistant (branchefeat/termios-couche0) ;Option<T>/EINTR-remonté/RAII/100 %. ADR-061 (vision, sans impl.) : issu d’une réflexion de fond — le PTY est un canal d’octets non typé où mouse/négociation sont greffés en séquences (kitty & co. faute d’autre canal) ; Air, possédant le stack, sépare terminal-texte (termios + ansi, borné, compat) et interaction riche (device/compositeur,AttachSurface).AirTerminal= tag + codec + modèle canonique ; discipline « Annoncer→Sélectionner→dégrader-sur-inconnu » (#[non_exhaustive]+Unknown+capnp) pour évoluer sans casser ;termcap= ncurses/couche 2 (hors libc), xterm-only. Portée de sceau : couche 1 n’exige que termios (L0, ADR-060) + minceair-terminal(codecansi) ; le reste (codecs riches,air-codec-1, surfaces) = couche 2 différée. Registre/SUMMARY à jour. -
v3.24 — Ajout d’ADR-059 (« Stratégie i18n — Option V : Air utilise icu4x, pas de réimplémentation »). RFC de structure (ADR-015), ratifié BDFL en session sur décision data-backée, companion ADR-053 (affirmé : libc sur icu4x) + ADR-016 (exception i18n) + ADR-024 + ADR-050 (cible). Vérification empirique (sonde jetable, carbon) : la fermeture icu4x compile intégralement pour
x86_64-unknown-linux-air(--release, 33 s) — 6 composants (normalizer/casemap/segmenter avec LSTMauto/collator/locale_core/calendar) + socle +calendrical_calculations/core_maths/libm. 2 conditions triviales : (1)default-features = falsesur lesicu_*(évite la featurestdpar défaut — cause du « mur std ») ; (2) build--release(évite un repli loggingstd::eprintlndebug-only d’icu_provider). ⇒ réimplémenter le socle n’est PAS une nécessité (des mois pour reproduire ~15 crates ULP-correctes déjàno_std-cible). Décision Option V : Air utilise icu4x (extension exception ADR-016) + possède l’APIair-stringdual-face + le pipeline de données ; réécriture par composant seulement si justifiée. Rétrécit la roadmap §2.A (le « long pole » disparaît) en chantier no_std :air-base-lib#![no_std]+default-features=false, face C-ABI d’air-string, datagen différé. Abandonne le vendoring/port du socle.air-libm(ADR-057) reste surface math publique mais non requis par icu. Auditdocs/notes/audit-icu4x-rust-pur-fr.md§9 tranché. Roadmap/registre/SUMMARY à jour. -
v3.23 — Ajout d’ADR-058 («
loom: activation du modèle de concurrence, outil de vérification test-only »). RFC de structure (ADR-015), par délégation d’autonomie BDFL pour ce lot (gravé pour revue a posteriori), companion ADR-030 (dépendances test-only) + ADR-021 (EINTR/boucle) + ADR-031 (couverture). Tranche l’attente de gouvernance signalée dans leCargo.tomldu workspace (« décision Principe 6 / ADR-024 en attente »). Décision : activerloomcomme outil de modèle de concurrence, dépendance test-only tirée uniquement sous--cfg loom([target.'cfg(loom)'.dependencies], hors graphe normal, jamais livrée) — pas une exception 80 % (ADR-024 vise la production ;loomest un outil de vérification commeproptest, donc pas dansEXCEPTIONS.md), licenceMIT. Premier modèle : l’AirMutexd’air-thread(protocole futex à 3 états) via alias d’atomique commutécore/loom(même code exercé sous les deux) + shim de blocage (loom::thread::yield_now/no-op remplacefutex_wait/futex_wake— loom ne modélise pasfutex, mais la boucle relit l’état, ADR-021 §2). loom explore tous les entrelacements et prouve l’exclusion mutuelle (2 modèles :exclusion_mutuelle_deux_threads,try_lock_n_empiete_jamais— validés on carbon x86). Garde-fou : loom prouve la SÛRETÉ, pas la VIVACITÉ (réveil futex réel validé on-target). Tests réguliers gardéscfg(all(test, not(loom))). Attaque la source de la variance de couverture (#189, plancher 96→94). Invocation documentée ; job CI loom optionnel différé. Roadmap §3.1 (+ correction §2.B.1 : la migration futex d’air-threadétait déjà faite via le passage no_std #182). Registre/SUMMARY à jour. -
v3.22 — Ajout d’ADR-057 («
libmAir (air-libm) : vendoring delibm(rust-lang) en surface math couche 1 »). RFC de structure (ADR-015), par délégation d’autonomie BDFL pour ce lot (gravé pour revue a posteriori), companion ADR-053 (libc/i18n sur icu4x) + ADR-024/ADR-016 (règle des 80 % / exceptions nommées). Décision (auditdocs/notes/audit-icu4x-rust-pur-fr.md§3/§8) : le seul primitif système qui manque à la réimplémentation i18n/libc d’Air est une math flottanteno_std— unlibm(consommateurs incontournables :calendrical_calculationsviaicu_calendar, non contournable, + le modèle LSTM d’icu_segmenter, tous deux viacore_maths→libm). On vendore la cratelibm(rust-lang) comme exception nommée (modèleicu4x) — ni réécriture from-scratch (énorme, risque ULP) ni dépendance non nommée :libmest pur Rustno_std, zéro dépendance transitive, fusionné danscompiler-builtins(lelibmque lastdutilise pour wasm/no_std), licenceMIT. Exposé via une crate couche 1air-libm(#![no_std],pub use libm::*;) — frontière nommée, pinnée (=0.2.16), auditée. Aucun consommateur aujourd’hui : fondation posée pour la libc/i18n future (ADR-053). Couvertureair-libm= 100 % (ré-export sans logique propre + tests de fumée à valeurs connues, tolérance epsilon). Preuve de build*-linux-air(crate-sonde jetablert/). Back-fill : la rangée ADR-056 (absente du tableau d’index, omission de la PR air-alloc) est ajoutée par la même occasion. EXCEPTIONS/DEPENDENCIES/registre/SUMMARY/INDEX à jour. -
v3.21 — Ajout d’ADR-055 («
air-thread/air-processen#![no_std]: crateair-env+ spawn ergonomique gardé per-cible »). RFC de structure (ADR-015), ratifié BDFL, companion ADR-048 (no_std couche 1) + ADR-052 (D7.3) + ADR-054. Constat (re-sonde de build*-linux-airpost-ADR-054) : le 2ᵉ mur de la fermeture runtime est queair-thread/air-processne sont pas#![no_std]et fuientstd(air-process:std::ffi/std::os::unix/std::env::vars_osdansbuild_envp;air-thread:thread.rsenrobestd::thread, délibérément — une closure arbitraire sur un thread Air sans TCB glibc seraitunsound). Deux obstacles :air-processa besoin de l’environnement mais ne peut pas dépendre d’air-runtime(cycleair-runtime → air-processviaexit_process) ; le spawn ergonomique Air-natif relève d’air-runtime(TCB/TLS) et d’un allocateur réel (boxer la closure), indisponible. Décision : (D1) nouvelle crateair-env(couche 1, no_std, zéro dépendance) porte l’environnement sous les deux ⇒ cycle évité ; (D2)air-process→# ; (D3)air-thread→#![no_std],thread.rs(spawn std) gardé#[cfg(not(target_env="air"))]— hôte seulement ; (D4) portée « compile ≠ run » : la fermeture compile no_std pour la cible (re-sonde), mais exécuterCommand/spawn reste bloqué par l’allocateurAbortOnAlloc(chantier suivant). Couvertureair-env= 100 %. Registre/SUMMARY/INDEX/DEPENDENCIES à jour. -
v3.20 — Ajout d’ADR-054 (« Scission d’
air-base-lib: un cœurair-base-coresans i18n pour la fermeture runtime »). RFC de correction d’architecture (ADR-015), ratifié BDFL, companion ADR-052 (layering air-runtime) + ADR-053 (libc/icu4x). Constat empirique (sonde de build*-linux-air, étape 6) :air-memory/air-thread/air-processne consomment d’air-base-libqu’AirError(+ horloge monotone etAirPathpour deux d’entre eux) mais compilent tout l’i18n (icu_*), et ledefaultd’icu tirestd→ le build no_std meurt surextern crate stdd’icu_provider, avant d’atteindrelibm(donclibmhors du chemin critique de l’étape 6). Décision : extraire un cœur sans icuair-base-core(erreurs/id/chemins-octets/temps-monotone/encodages/log) ;air-base-libgarde l’i18n (AirString/AirLocale,AirDateTime/AirCalendar) + ré-exporte tout le cœur (compat — 7 consommateurs non-runtime + ABI C inchangés) ; le runtime bascule surair-base-core(fermeture sans icu ni libm pour l’étape 6) ;air-process::Commandprend args/env en octets (AirPath,execve=octets non garantis UTF-8, Principe 3). Rectifie l’en-tête erroné d’air-base-lib(« icu4x sans featurestd» — faux : ledefaultd’icu tirestd). La mise enno_stdd’icu4x lui-même (+libm) est déférée au chantier libc/i18n (ADR-053). Registre/SUMMARY/INDEX/DEPENDENCIES à jour. -
v3.19 — Ajout d’ADR-053 (« La libc Air s’appuie sur icu4x pour ctype/locale/i18n »). RFC (ADR-015), ratifié BDFL, companion ADR-046 (libc) + ADR-016 (i18n). Doctrine : la surface C de traitement de caractères/locales/i18n (
isX/towX/strcoll/strxfrm/wcs*+ extensions) repose sur icu4x (couche 1) → i18n correcte par défaut, le développeur C n’embarque plus icu4x, et là où POSIX est faible Air donne le standard Unicode (cf. ADR-046 D3/D5 : POSIX fonctionnel non conforme, kernel=bible). Mapping POSIX↔icu4x (mono-char/locale-globale ↔ chaîne/locale-explicite/sans-état :isX→icu_properties, casse simple 1→1 vs pleine niveau-chaîne, locale courante thread-local réconciliant le « zéro globale »), byte-level gardé rapide (icu4x seulement pour la correction i18n), écarts POSIX documentés (transparence). Précise/rend obsolète ADR-046 D7 : « libm exclu v1 » (icu4x exige libm : calendriers + LSTM) et « hors wide-char lourd » (lewcs*i18n EST la surface icu4x). Un socle icu4x couche 1, 3 consommateurs (libc C, futurstd*-linux-air, pal). Constat (notedocs/notes/audit-icu4x-rust-pur-fr.md) : Ruststdne repose **pas** non plus sur icu4x (mini-tables propres) → même bolt-on que C ; icu4x runtime = no_std calcul-pur (zéro syscall/horloge/thread) → seul manque Air =libm` no_std + petites crates utilitaires + pipeline de données CLDR/UCD. Renvois posés (bannières « obsolète ») dans ADR-046 (D7) + ADR-016 (extension) + macro-architecture (icu4x dès couche 1). Questions ouvertes : libm (réécrit vs vendoré), LSTM (oui/dico), datagen (réutiliser vs refaire). -
v3.18 — Amendement d’ADR-035 : 5ᵉ catégorie de couverture « TARGET-ONLY » (RFC ADR-015, companion ADR-052/049). Code dont la sémantique n’est correcte que sur
*-linux-air(programmer le registre TLS, threadCLONE_SETTLS, relocations static-PIE, self-pointer TCB) → ni exécuté ni mesuré par le gate (*-linux-gnu, glibc — unsound, #151), prouvé par le selftestrt/on-target (exit code, 2 arches : carbon x86 + raspi aarch64). Granularité fichier (--ignore-filename-regex, précédent_capnp.rs) : on retire--exclude air-runtime(mesure des modules host-testables env/args/reloc-parser/fork à 100 %) et on ignore les fichiers target-only (thread_control_block/thread_local_storage/thread/start+ primitives air-thread + applier reloc). Distincte de STRUCTURAL/CHILD-EXIT/DEFERRED-TOOLING ; réversible (toolchain Air-native mesurant on-target). Anti-complaisance : réservé à l’insondable-sur-cible-du-gate, pas au « dur à tester ». -
v3.17 — Ajout d’ADR-052 («
air-rtest un objet couche 1 (AirRuntime), jamais un consommateur direct de la couche 0 »). RFC de correction d’architecture (ADR-015), ratifié BDFL. Constat : le cratert/crates/air-rt(phase 2) appelle la couche 0 en direct (arch::set_fs,mem::mmap,process::clone_thread,futex,exit_group) — violation du layering (seule la couche 1 consomme la couche 0). Doctrine gravée :air-rtest un objet couche 1 (AirRuntime) composé de nouvelles briques couche 1 à créer —ThreadControlBlock,ThreadLocalStorage, outils de relocation (cible PIE/static-PIE, ADR-050) — et s’appuyant surair-thread/air-mem/air-proc; il ne dépend jamais deair-sys-syscall(la surface kernel) —air-sys-types(types transverses :AirError/Errno/Pid/Tid…, D6) reste autorisé à toutes les couches. Les wrappers de syscalls (dontset_fsd’ADR-051) restent en couche 0, mais leur consommateur devient un objet couche 1. Le cratert/ne garde que le point d’entrée ELF_start+ la plomberie cible. Corrige ADR-049 D8 (tag obsolète + bannière) et reformule le « affaire d’air-rt » d’ADR-051 (note layering). Conséquence : la logique de runtime, devenue couche 1, retombe sous le 100 % couverture + barrière 2-arches (gain de rigueur vs carve-outrt/). macro-architecture (§Section 3 + tableau d’artefacts),rt/README.md/rt/DECISIONS.md, SUMMARY/INDEX à jour. Code à refactorer (suivi séparé). -
v3.16 — Ajout d’ADR-051 (« Descellement
couche-0-v1.7: 7 syscalls pour le runtime et la libc »). RFC d’extension d’une couche scellée (ADR-015), ratifié BDFL, livré en une seule PR (CI ARM longue → on minimise les cycles de gate). Ajoute, en un re-sceau unique (éviter les cycles répétés), 7 wrappers typés :arch_prctl(set_fs/get_fs, x86_64-only, nouvelle famillearch— débloque le TLS du thread principal x86 de la phase 2*-linux-air; aarch64 =tpidr_el0en asm côté air-rt) + processset_tid_address/getcwd(octets)/umask/getppid(→Option<Pid>)/sched_yield/getrusage(+ typesRusage/RusageWho/Timeval). Conventions ADR-021 (fns dédiées,Option/newtypes).check-syscallsPASS vs uapi (2 arches), couvrable VIDE (2 exceptions STRUCTURAL/EFAULT-SAFE : brasErrdeget_fs/getrusage),audit-exceptionscohérent (77). Reporté encouche-0-v1.8(phase 5, OpenSSH) : termios/PTY/rt_sigaction/sigaltstack(+ décision ADR-020). SUMMARY/INDEX/specs (family-arch.md,family-process.md§7) à jour. -
v3.15 — Ajout d’ADR-050 (« Spécification de la cible
*-linux-air(phase 1) ») et réconciliation du tableau avec les ADR-046→049 (acceptés/mergés mais jamais portés au registre). ADR-050 ouvre l’exécution du programme*-linux-air(ADR-049) : triple{x86_64,aarch64}-unknown-linux-air(env=air, 3ᵉ saveur de libc à côté de gnu/musl),llvm-targetmusl découplé du nom Rust (LLVM ignoreair), cible statiquepanic=abort+has-thread-local, champs codegen repris verbatim de la base musl ; développement hors-arbre (rt/: workspace+toolchain nightly séparés,-Z build-std core,alloc,-Z json-target-spec) exclu de la barrière stable (ADR-037) et de la couverture — carve-out de même nature quefuzz/(esprit ADR-030) ; squeletteair-rt(modulestcb/tls/start/env/spawnmappés sur D1–D9). Faisabilité prouvée 2 arches (core+alloc+air-rt compilent pour x86_64 ET aarch64). Stratégie ratifiée (ADR-049) : hors-arbre jusqu’à la démo phase 4, upstream Tier-3 après. Cible PIE/static-PIE affirmée (codegen PIC dès la phase 1, vérifié 2 arches ; ASLR préservé) ; seul l’_startauto-relocalisant (relocationsR_*_RELATIVE) reste un livrable de phase 2 (avec asm TLS/spawn, 2 arches,// SAFETY:). Les rangées 046→049 sont ajoutées au tableau (back-fill) : ADR-046 (libc Air : périmètre/layering/provenance), ADR-047 (principes de construction libc), ADR-048 (descellement couche 0 std-free + futex, re-sceau v1.6), ADR-049 (runtime TCB/TLS, fondation*-linux-air). -
v3.14 — Ajout d’ADR-045 (« Modèle d’erreurs de l’ABI C »). Première passe ABI C (
air-base-capi→libair-base.so) : erreurs in-band viaAirStatus(#[repr(C)],AIR_OK=0, sur-ensemble d’AirErrorKind+NULL_ARGUMENT/BUFFER_TOO_SMALL), sanserrnoni globale (fidèle au kernel, doctrine libc) ; message viaair_status_messagestatique ;panic = "abort"(panic = bug Air → fail-fast, pas decatch_unwind/AIR_PANIC). Réalise « pas de globale libc / dit ce qu’elle fait » au niveau C. Spec :air-base-capi.md. Alternatives écartées :errno-style, objetAirErroropaque dès T1,catch_unwind→AIR_PANIC. -
v3.13 — Ajout d’ADR-044 (« Extension couche 0 : famille
poll/ppoll»). RFC d’extension d’une couche scellée (ADR-015) — ≠ complément additifErrno. Ajoute le wrapperppoll(variante moderne : ns,timeoutnon muté, sigmask) +PollFd(emprunté +0,reventsrestitués, ADR-032) + promotion dePollEventshors d’io_uring(ré-export, zéro rupture) ; conversionDuration→timespecdéfensive (clampi64::MAX, jamaisas). Débloque l’attente synchrone bornée/interruptible deair-process(wait_timeout/wait_until/wait_forever) sans io_uring (footgun de hang de l’incident fork #128). Re-sceaucouche-0-v1.5;check-syscallsppoll(271/73, 2 arches) ; gate barrière + CI ARM. Spec :family-poll.md; consommateur :air-process.md§4. -
v3.12 — Amendement d’ADR-027 (addendum « doc ABI C »). Clôt le « Doxygen ou équivalent » : rendu = Doxygen (HTML + pages
man) sur le headercbindgencommitté (contrat ABI diffé en CI), versions pinnées (ADR-025), intégré au mdBook FR+EN. Grave l’exigence de contenu orientée dev C : guides obligatoires sur les obligations de l’appelant — mise en route/lien, modèle d’erreursAirError, et surtout la propriété mémoire & cycle de vie (pour chaque type complexe : qui alloue/libère et avec quelle fonction ; retour possédé→libérer / emprunté→ne pas libérer ; arguments empruntés +0 ; chaînes/buffers/out-params ; refcompté retain/release ; secrets zeroize) + thread-safety par type + exemples C. Chaquepub extern "C"porte sa clause de propriété dans son commentaire///(source unique → cbindgen → Doxygen). Mise en œuvre à la 1ʳᵉ passe ABI C (air-base-lib). -
v3.11 — Ajout d’ADR-043 (« Pile SSH »). Pendant d’ADR-042 mais calcul différent : pas d’option Rust mature (russh ≪ rustls), surface SSH bien plus grande (
sshd= surface distante critique, classe regreSSHion), SSH hors chemin critique (applicatif couche 2+). Décision : court termeair-sshdifféré, OpenSSH système en incubation (ADR-013) ; à termeair-sshmaison (memory-safe Rust, anatomie 9-composants, strict-kex anti-Terrapin, moderne-only, RustCrypto, homologation interop OpenSSH+RFCs, audit externe du sshd), PAS russh (immature pour un daemon distant ; gardé comme référence/base auditée possible) ; client (ssh/scp/sftp) avant serveur (sshd). Zéro exception C (maison+RustCrypto). Implémentation au cadrage réseau couche 2. -
v3.10 — Ajout d’ADR-042 (« Pile TLS : rustls + aws-lc-rs »). Après analyse explicite (frontière primitives/protocole ; classes de CVE TLS, sûreté mémoire dominante ; comparaison piles dont s2n-tls) : la pile TLS = rustls (protocole pur Rust, memory-safe, TLS 1.3) + aws-lc-rs (provider crypto AWS-LC : asm formellement vérifié, FIPS, maintenu) + rustls-webpki (X.509 pur Rust). Exception C NOMMÉE et ÉTROITE : le C ne concerne que les primitives (
aws-lc-sys), jamais le protocole ni le parseur X.509 (memory-safe) ;ringreste banni. = la combinaison la plus sûre disponible (memory-safety + vérif formelle + 1.3 + maintenance financée).air-tlsmaison gardé en contingence (si rustls devient une faille/un passif de maintenance). deny.toml/EXCEPTIONS.md/check-c-surface + pinning toolchain C (ADR-025) appliqués à l’implémentation (pas avant). Corrige la note réseau (ligneair-tls). -
v3.9 — Ajout d’ADR-041 (« Coexistence
/etc», companion d’ADR-033, détaille la règle 7 au-delà de systemd). Les fichiers/etcpossédés par Air sont des projections générées parair-config, en lecture seule sélective (jamais globale — casseraitpasswd/ldconfig/installs ; même Silverblue/MicroOS gardent/etcinscriptible),air-configseul écrivain (swap atomique) ; mutables-runtime (resolv.conf,machine-id) redirigés vers/run;/etc/systemd/systempossédé par le backend systemd d’air-config(systemd lit, n’écrit pas ;start/stop/restartintacts). Garde-fou (root peut remonter rw), pas frontière de sécurité — celle-ci viendra avec air-launchd/Landlock. Génération =air-config(L1) ; protection (montage RO sélectif) = intégration OS (L5), réalisable avant air-launchd. Périmètre progressif ; bootstrap bidirectionnel (import/etc→ source). Détail → specair-config. -
v3.8 — Ajout d’ADR-040 (« Format de l’artefact binaire de configuration : Cap’n Proto », companion d’ADR-033). Tranche le format de l’artefact
air-config: Cap’n Proto — pour la cohérence projet (AirCom encode déjà en Cap’n Proto, ADR-001 → un seul langage de schéma / codegen / dépendance à auditer, exception règle-des-80 % partagée) et le zéro-copie/mmap (ADR-033 §4, Principe 9, boot Pi). protobuf écarté (pas zéro-copie), FlatBuffers écarté (second écosystème superflu). ExceptionEXCEPTIONS.mdpourcapnpà acter avec AirCom ; placementair-configen crate dédiée (vsair-base-lib) à trancher au cadrage. Hors périmètre : backends d’émission/etc(coexistence libc/Unix, ADR-033 §7) → specair-config. Amendé 2026-06-25 (addendum) :capnpruntime = zéro dépendance / pur Rust (propre), maiscapnpcexige le binaire C++capnpau build (vérifié empiriquement ; pas propre à Cap’n Proto —flatc/protocaussi). Politique gravée : code généré committé (schémas.capnpversionnés ; mainteneur régénère le.rsau changement de schéma, le commite ; builds/CI pur-Rust + reproductibles sans tool C++). Vaut aussi pour AirCom (ADR-001). -
v3.7 — Ajout d’ADR-039 (« Nommage & placement du runtime et du modèle d’objet + ordonnancement », amende ADR-002, précise ADR-023/038). Le modèle d’objet C-ABI est renommé
air-object(couche 2 ;libair-object.so) ; le runtime async gardeair-runtime(couche 1, consomme io_uring directement), multi-crateair-runtime-core/io/time/signal/sync+ façade ;air-event(couche 2) est la façade C-ABI par-dessus le runtime L1. Ordonnancement : exécuteur single-thread comme unité (empreinte minimale Pi 4B), scalable en thread-per-core shared-nothing (réutilise io_uring Temps 3e :RingPool/msg_ring/LockedIoUring/SqpollIoUring), sans work-stealing v1 ; CPU-bound déporté versair-thread. Deux familles de synchro (bloquantestd::sync/ async runtime). Sweep macro-architecture (air-runtime→air-objectdu modèle d’objet) appliqué. -
v3.6 — Ajout d’ADR-038 (« Modèle d’exécution : runtime async natif io_uring, sans tokio, couches basses synchrones, pas d’epoll », amende ADR-023). Plus aucune dépendance tokio :
air-event(couche 2) est le runloop async natif sur io_uring. Posture synchrone-first (couches 0/1 : wrappers bloquants + API sync avec seamas_fd()) + async opt-in (couche 2). Le synchrone est de première classe pour : init/sécurité (drop_privileges/seccomp/Landlock), outils one-shot/CLI, compute-bound (crypto/memory), embarqueurs avec leur propre boucle, RAII, poignées systemd. io_uring est le seul moteur de readiness+complétion — pas d’epoll (porte laissée ouverte par RFC si besoin réel mesuré ;ppollplutôt qu’epoll pour un éventuel multiplex sync). Met à jour la macro-architecture (mentions « au-dessus de tokio » corrigées). -
v3.5 — Ajout d’ADR-037 (« CI ARM seule + validation x86 par barrière pré-merge », companion d’ADR-031/036). La CI GitHub ne tourne plus que sur le runner ARM (raspi-srv-2) ; l’x86 est validé hors CI par
cargo xtask barriersur speedy ET carbon avant chaque merge (mêmes contrôles et planchers 96/78 que l’ancien runner x86). speedy reste enregistré mais retiré des workflows (réactivation à l’ouverture publique) ; raspi-srv-2, lent, est réservé au seul rôle CI ARM. Invariant ADR-014 (x86_64 ET aarch64) préservé : ARM par CI, x86 par barrière pré-merge. Conséquences assumées : raspi-srv-2 devient le SPOF de la CI ; le gate x86 n’est plus imposé par GitHub (acceptable mono-mainteneur, ADR-031). Câblé dans.github/workflows/ci.yml+docs/CI.md. -
v3.4 — Ajout d’ADR-036 (« Filtrage par chemin de la re-vérification d’une couche scellée », companion d’ADR-031/035). La mesure de couverture (et le fuzz) de la couche 0 scellée ne se relance pas sur les PR qui ne la touchent pas ; re-vérification complète toujours garantie : (a) PR touchant couche 0 / toolchain / CI / politique, (b) merge vers
main(mainjamais non vérifié — dérive de toolchain bloquée au merge), (c) planifié hebdomadaire. Câblé dans.github/workflows/ci.yml(jobchangespargit diff, variableFULL). N’amende pas le standard 100 % de la couche 0 ; le tagcouche-0-v1reste la référence. -
v3.3 — Ajout d’ADR-035 (« Taxonomie des exceptions de couverture », companion d’ADR-031). Grave 4 catégories + 1 étiquette de dette pour
docs/COVERAGE-EXCEPTIONS.md: STRUCTURAL (inatteignable par construction — absorbeEFAULT-SAFEetVALUE-UNREACHABLEpermanente), PRIVILEGE, FEATURE-KERNEL, CHILD-EXIT (code exécuté dans un enfant forké mais profil LLVM non flushable — preuve par le code de sortie viawaitid), et DEFERRED-TOOLING (branche réellement atteignable non encore outillée → à couvrir, jamais exemption permanente). Règle : les bras de code de test ne figurent pas au registre production (annexe). Invariant « couvrable VIDE ». N’amende pas ADR-031 ni le Principe 1. Appliqué immédiatement (055) : 3 candidates atteignables couvertes ⇒DEFERRED-TOOLINGvide. -
v3.2 — Ajout d’ADR-034 (« Discipline des dépendances cryptographiques », companion d’ADR-024). Grave la discipline des crates crypto auditées d’Air (exceptions structurantes à la règle des 80 %, comme
icu4x) : exemption de vendoring (cohérence cryptographique + sécurité par adoption massive primant sur l’auto-hébergement), veille des trois upstreams (RustCrypto, dalek-cryptography, équipe BLAKE3), fast-lane sécurité (un bump correctif de sécurité passe malgré le pinning des builds reproductibles ADR-025), KAT-gating des montées de version (vecteurs de test connus rejouéss avant tout bump), et option Apache pourblake3(dualCC0-1.0 OR Apache-2.0) dansdeny.toml.air-crypto(couche 1) renvoie à cet ADR pour sa discipline de dépendances. Aucun amendement aux autres ADRs. -
v3.1 — Ajout d’ADR-033 (« Modèle de configuration : source typée, compilation validée, artefact binaire »). Source typée canonique → compilateur validant (barre couches 0/1, fuzzé) → artefact binaire reproductible schema-first (version + checksum) consommé par le runtime ; projections texte JSON/YAML/TOML en import/export ; sûreté par générations + switch atomique + rollback ; backends de compilation enfichables (joint d’indépendance — unit files systemd générés et validés pour V1). L’intégrité des binaires est explicitement renvoyée à un futur ADR. Aucun amendement aux autres ADRs. (NB : ADR-032 figure déjà au tableau ci-dessus — rattrapage du prompt non nécessaire.)
-
v3.0 — Ajout d’ADR-032 (« Préservation des données confiées : zéro discard silencieux »). Règle transverse : une API Air ne discarde jamais une donnée confiée — restitution intégrale (tous les buffers), ressources reçues matérialisées (
OwnedFd) et troncatures signalées, chemins d’erreur compris ; la consommation explicite par contrat (submit_close(OwnedFd)) reste permise. Émergé du Temps 2b io_uring (into_zero_copy_buffers). Aucun amendement aux autres ADRs. -
v1.0 — Registre consolidé initial regroupant les 17 ADRs fondateurs en un seul fichier.
-
v2.0 — Restructuration en un fichier par ADR. Ce document devient un index. ADR-018 ajouté en phase de design. ADR-009 révisé (intégration de l’amendement déclaratif, anciennement envisagé sous le nom ADR-009-bis ; intégration directe conforme à la politique d’édition design-phase).
-
v2.1 — Intégration des 6 ADRs phase 0 (ADR-019 à ADR-024). Renumérotation : la série phase 0 telle qu’instruite (018-erreurs, 019-signaux, 020-conventions, 021-io_uring, 022-runtime, 023-workflow-deps) est décalée de +1 pour résoudre le conflit avec l’ADR-018 fondateur « Modèle d’imagerie » déjà acté en v2.0. Réorganisation physique : tous les ADRs vivent désormais dans
docs/adrs/, et l’ensemble de la documentation est restructurée selon le plan consigné dans../INDEX.md. -
v2.2 — Ajout d’ADR-025 (« Stratégie de builds reproductibles »). Clôt la série de design phase 0. Aucun amendement aux ADRs antérieurs.
-
v2.3 — Enrichissement substantiel d’ADR-001 (« IPC AirCom, refus de D-Bus comme transport principal »). Passage de 12 lignes à 197 lignes pour rendre la décision défendable face à des contributeurs venant de l’écosystème D-Bus traditionnel. La décision elle-même est inchangée ; seuls la pédagogie, la documentation des sources, et l’articulation avec les autres ADRs et principes Air sont développées. Aucun amendement aux autres ADRs.
-
v2.4 — Ajout d’ADR-026 (« Contrat 1.0 d’
air-tui») pour cadrer le modèle d’interaction, les capacités, les dégradations acceptées, et le noyau de primitives de l’interface texte riche d’Air. -
v2.5 — Ajout d’ADR-027 (« Stratégie de documentation polyglotte — ABI C et bindings multi-langages »). Décide de générer la doc C/polyglotte depuis le code Rust (source unique de vérité), mise en œuvre différée à la couche 2. Ajout en tête d’une définition canonique du sigle ADR. Aucun amendement aux ADRs antérieurs.
-
v2.6 — Ajout d’ADR-028 (« Soundness et téardown du module io_uring »). Grave les trois décisions S1 (slab pré-alloué, zéro alloc happy path), S2 (
Dropquiescent +shutdown()), S3 (confinement par restrictions) émergées de la spécification détaillée du module io_uring. Complète ADR-022 sans modifier ses 10 décisions. Aucun amendement aux autres ADRs. -
v2.9 — Ajout d’ADR-031 (« Mesure de couverture en root sur les runners self-hosted »). Le job CI
test-coveragemesure la couverture en root (sudo -n) pour que les tests d’intégration privilégiés (bpf()/CAP_BPF,uinput, perf, cgroup, bpffs) s’exécutent réellement sous instrumentation —ebpf, quasi-tout-privilégiée, est sinon plafonnée ~74 % non-root. Plancher inchangé (96 % lignes / 78 % branches). Prérequis :NOPASSWDsur les deux runners ; modèle de confiance dépôt privé / mono-mainteneur à revisiter avant l’ouverture publique. Aucun amendement aux autres ADRs. -
v2.8 — Ajout d’ADR-030 (« Périmètre de la règle des 80 % : production vs test-only »), companion d’ADR-024. Précise (sans amender ADR-024) que la règle des 80 % et le vendoring du Principe 6 visent les dépendances de production (
[dependencies]) ; les deps dev/test-only,cfg-gated (loom), et la cratefuzz/séparée en sont exemptées, mais restent soumises àdeny.toml(licences, arbre transitif complet) etcargo audit. Débloque proptest/loom/cargo-fuzz pour les tests du module io_uring sans violer le Principe 6. -
v2.7 — Ajout d’ADR-029 (« Nommage de la surface publique »). Érige en règle, pour le nommage de l’API, la déclinaison du Principe 7 : surface conçue par Air = noms explicites sans abréviation (Zone 1) ; noms hérités d’une autorité consultable — ABI kernel,
stdRust, termes POSIX — conservés verbatim (Zone 2). Test de décision et nuance des types miroir. Transverse à tout le framework. Aucun amendement aux autres ADRs.
Licence du document : MPL 2.0 Statut : Document fondateur. Édition directe autorisée en phase de design pré-ouverture publique ; après ouverture publique, l’ajout ou la modification d’un ADR passe par RFC.
ADR-001 — IPC AirCom, refus de D-Bus comme transport principal
Statut : Accepté. Document fondateur immuable après ouverture publique.
Catégorie : Architecture (couche 2 — IPC).
Note de nomenclature (2026-05-31) : l’IPC d’Air, initialement nommé Conduit lors de la rédaction early-stage, a été renommé AirCom. Le contenu de cet ADR a été aligné sur ce nom, et le fichier lui-même a été renommé de
ADR-001-conduit-fr.mdenADR-001-aircom-fr.md(avec mise à jour de tous les liens entrants) afin de ne pas laisser subsister l’ancien nom, source de confusion. Le numéro d’ADR (001) reste l’identifiant stable.
Contexte
D-Bus est, depuis sa première spécification en 2003, le mécanisme d’IPC standard de l’écosystème Linux desktop et, dans une large mesure, de l’écosystème serveur. Il est utilisé par systemd, NetworkManager, BlueZ, UPower, polkit, ModemManager, geoclue, accountsservice, les environnements GNOME et KDE, et la quasi-totalité des services freedesktop. Son rôle historique dans l’unification des communications inter-services sur Linux est réel et durable.
Cette décision n’est donc pas une critique générale de D-Bus. C’est un choix de transport pour les services Air natifs entre eux, qui s’inscrit dans un constat précis : les besoins d’Air en matière de sécurité capability-based, de performance sur matériel modeste, et de stabilité contractuelle sur dix ans (cf. ADR-012) ne sont pas couverts par D-Bus tel que conçu. Air reconnaît la place de D-Bus dans l’écosystème et continue à l’utiliser pour interopérer avec lui ; en revanche, Air n’utilise pas D-Bus comme transport interne pour ses propres services natifs.
Cette décision a été prise après examen des évolutions tentées de D-Bus (kdbus, bus1, dbus-broker) et après évaluation des IPC contemporains qui ont influencé la conception de AirCom (XPC sur macOS, Cap’n Proto, FIDL sur Fuchsia, Mojo sur Chromium, Binder sur Android).
Décision
Air adopte un IPC propre nommé AirCom pour la communication entre ses propres services natifs. D-Bus reste utilisé exclusivement pour interopérer avec l’écosystème Linux existant, jamais comme transport interne d’Air.
Cette décision se décompose en quatre points fermes :
-
AirCom est le transport interne des services Air entre eux. Tout appel d’un composant Air vers un autre composant Air (compositeur, services système, applications natives
.airapp, etc.) passe par AirCom. Aucun service Air natif n’expose son API principale via D-Bus. -
systemd est consommé via sd-bus en mode peer-to-peer. Air parle à
systemd,systemd-logind,systemd-resolved,systemd-journald,systemd --userviasd-busconfiguré pour parler directement aux sockets privés des services systemd, sans passer par le brokerdbus-daemon. Le wire format D-Bus est utilisé sur ces sockets, mais sans dépendance au broker. Cohérent avec ADR-005. -
Un pont D-Bus optionnel (
air-dbus-bridge) est disponible pour interopérer avec le reste de l’écosystème. NetworkManager, BlueZ, UPower, ModemManager et les autres services D-Bus traditionnels sont accessibles via ce pont, chargé à la demande dans les profils où il est utile (typiquementair-desktop). Il n’est pas requis pourair-base. -
xdg-desktop-portal-airest une exception assumée. Ce composant parle D-Bus de bout en bout parce que le standardxdg-desktop-portalest D-Bus-based par construction. Cette exception est documentée comme telle, ne s’étend à aucun autre composant Air, et préserve la compatibilité avec les applications tierces (Flatpak, GTK, Qt) qui consomment les portals.
Pourquoi pas D-Bus comme transport principal
Les arguments qui suivent sont des propriétés architecturales de D-Bus, pas des opinions. Ils sont vérifiables dans la spécification D-Bus elle-même (D-Bus Specification) et dans la documentation des projets qui ont tenté de faire évoluer D-Bus (kdbus, bus1, dbus-broker). Chaque propriété est ensuite mise en regard d’une exigence Air précise.
Le coût architectural d’un broker centralisé
D-Bus, dans sa forme déployée standard (dbus-daemon ou dbus-broker), fait transiter tout message à travers un processus broker. Chaque message émis par un client traverse donc deux frontières kernel/userspace : l’émetteur écrit dans le socket vers le broker, le broker lit, route, écrit dans le socket vers le destinataire, le destinataire lit. Sur un message de N octets, c’est deux copies kernel.
Pour les messages de contrôle de petite taille, ce coût est négligeable. Pour les charges de données plus volumineuses (transfert d’images, audio, vidéo, structures de données binaires partagées), il devient significatif. Le projet dbus-broker a publié des benchmarks montrant des améliorations d’un ordre de grandeur par rapport à dbus-daemon (dbus-broker repository, README et discussions associées) sans pour autant remettre en cause le modèle conceptuel à double copie.
Cohérence avec Air : la Charte principe 4 (longévité du matériel) et l’ADR-014 (catalogue matériel incluant Raspberry Pi 4 comme machine de référence) imposent qu’Air fonctionne avec fluidité sur du matériel modeste. Le Principe d’ingénierie 5 (optimiser après mesure) ne dispense pas de choisir des architectures qui n’imposent pas un surcoût intrinsèque. La double copie systématique d’un broker centralisé est un surcoût intrinsèque que la performance applicative ne peut pas compenser.
Le typage et le versioning des interfaces
D-Bus définit un système de types signature-based (s, u, a{sv}, etc.) sans schéma versionné. Une interface D-Bus n’a pas de mécanisme natif pour exprimer une évolution compatible ou incompatible. La compatibilité ascendante repose sur des conventions (introspection XML), pas sur une discipline outillée. Quand une signature change, la rupture est silencieuse côté wire format ; elle peut n’apparaître qu’au moment où un client tombe sur un type inattendu.
À titre de comparaison, les IPC schema-first contemporains (Cap’n Proto, Protobuf, FIDL) imposent un fichier de schéma compilé qui définit l’ABI/API et permet de vérifier en build et en CI que les évolutions respectent les règles de compatibilité (champs réservés, ordres canoniques, etc.).
Cohérence avec Air : le Principe d’ingénierie 8 (stabilité contractuelle) et l’ADR-012 (stratégie de versionnement, zones air-stable / air-internal / air-experimental, garantie 10 ans) imposent que tout contrat public soit testable en CI pour sa stabilité ABI. Construire cette infrastructure de tests sur D-Bus est possible mais demanderait de reconstruire une couche schema-first par-dessus. À ce stade, autant adopter un IPC qui est schema-first par construction.
Le FD passing
D-Bus permet de passer des descripteurs de fichiers entre processus via le type h (UNIX_FD). Cette fonctionnalité a été ajoutée tardivement (D-Bus 1.3, 2010), n’est pas supportée sur tous les transports, et reste limitée par les permissions et le routage du broker.
Le passage de FDs est un mécanisme fondamental pour Air : il permet le partage de mémoire zero-copy (via memfd_create), le transfert efficace de buffers GPU (via DMA-BUF), et l’établissement de canaux directs entre processus sans passer par le broker à chaque message. Air a besoin que le FD passing soit un mécanisme de première classe, intégré au wire format dès la conception.
Cohérence avec Air : ADR-002 (modèle d’objet C-ABI, services Air entre eux), ADR-003 (compositeur Wayland natif, transferts GPU), et les besoins de performance sur matériel modeste demandent un FD passing rapide et systématique, pas réservé à des cas particuliers.
Zero-copy et back-pressure
D-Bus n’a pas de mécanisme natif de zero-copy pour les charges volumineuses. Tout payload est sérialisé/désérialisé dans le format de wire D-Bus, et copié à travers le broker. Pour transférer un Mo d’image, c’est un Mo sérialisé, copié vers le broker, copié vers le destinataire, désérialisé.
D-Bus n’a pas non plus de mécanisme natif de back-pressure : si un client lent ne consomme pas ses messages, ils s’accumulent dans le broker jusqu’à atteindre des limites configurables, après quoi la connexion est tuée. Il n’y a pas de signal de saturation propre transmis à l’émetteur.
Cohérence avec Air : ADR-009 (framework de vues déclaratif) et l’architecture en couches d’Air supposent un flux de données entre services capable de traiter du contenu volumineux (images vectorielles riches, captures vectorielles selon ADR-018, audio/vidéo selon le futur ADR « framework networking couche 2 »). Le zero-copy via memfd_create partagé en mémoire est la solution standard pour ces cas, et elle n’est pas accessible naturellement via D-Bus. Le back-pressure explicite est nécessaire pour le streaming, et son absence native dans D-Bus serait à reconstruire par-dessus.
Le modèle de sécurité par policy XML
D-Bus contrôle l’accès aux services via des fichiers de policy XML (/etc/dbus-1/system.d/*.conf notamment) qui définissent qui peut s’adresser à qui. Ces policies sont statiques au chargement du broker, gérées par la distribution, et combinées en pratique avec polkit pour les autorisations dynamiques.
Ce modèle a deux conséquences notables :
- L’autorisation est ambient. Un processus qui peut s’adresser au service D-Bus
org.freedesktop.NetworkManagerpeut le faire par défaut tant que la policy le permet. Il n’y a pas de capability unforgeable transmise au démarrage de l’application qui matérialise cette autorisation. - L’évolution des policies se fait hors processus applicatif. Ajouter une autorisation pour une application passe par un fichier de configuration administratif, pas par un manifeste applicatif signé.
Cohérence avec Air : ADR-010 (.airapp / .airservice avec entitlements déclaratifs signés, sandbox capability-based) impose un modèle où chaque application reçoit au démarrage un ensemble explicite de capabilities, transportées de manière non falsifiable, vérifiables à la signature du bundle, révoquables. Ce modèle est radicalement différent de l’autorisation par policy XML. Le construire au-dessus de D-Bus reviendrait à ignorer son mécanisme de sécurité natif pour en bâtir un nouveau — ce qui défait l’argument d’usage de D-Bus.
Alternatives évaluées
kdbus (2013-2017)
kdbus était une réimplémentation de D-Bus dans le kernel Linux, proposée par Greg Kroah-Hartman et l’équipe systemd entre 2013 et 2015. L’objectif était de résoudre les problèmes de performance et de sécurité du broker userspace en faisant transiter les messages via un mécanisme kernel dédié. Après plusieurs cycles de revue, kdbus a été refusé par la communauté kernel pour des raisons d’architecture et de complexité (discussions LWN sur kdbus, 2014-2015 ; cf. notamment lwn.net/Articles/580194, lwn.net/Articles/641275). Le code n’a jamais été mergé dans le mainline.
Conclusion : la voie d’évolution in-kernel de D-Bus a été explorée et n’a pas abouti. Air n’a pas à parier sur sa résurrection.
bus1 (2016-2018)
bus1 était le successeur conceptuel de kdbus, une couche IPC capability-based en kernel, plus générique que kdbus. Présentée en 2017 (présentation linux.conf.au 2017, lwn.net/Articles/707925). Le projet n’a pas non plus été mergé dans le mainline et son développement public s’est essoufflé.
Conclusion : la voie d’un IPC capability-based standardisé en kernel pour Linux n’existe pas aujourd’hui.
dbus-broker
dbus-broker est une réimplémentation moderne du broker D-Bus userspace, par Tom Gundersen et David Herrmann (github.com/bus1/dbus-broker). Il améliore significativement les performances par rapport à dbus-daemon et corrige certains défauts d’implémentation. Il devient progressivement le broker par défaut sur plusieurs distributions.
dbus-broker adresse les problèmes d’implémentation du broker, pas les contraintes architecturales du modèle D-Bus (broker centralisé, typage par signatures, modèle de sécurité par policy, etc.). Air reconnaît dbus-broker comme une bonne implémentation de D-Bus mais ne le retient pas comme transport principal.
Conclusion : un meilleur broker reste un broker. Les exigences d’Air sont sur le modèle conceptuel, pas sur la performance du broker actuel.
Évolution incrémentale de D-Bus
Étendre D-Bus pour qu’il devienne schema-first, capability-based, zero-copy, etc. est techniquement possible mais reviendrait à le réinventer en cassant la compatibilité avec son écosystème. La valeur de D-Bus tient en grande partie à sa stabilité et à son ubiquité ; toute évolution qui le rendrait incompatible avec les services existants détruirait cette valeur sans bénéfice net.
Conclusion : l’option « D-Bus 2.0 incompatible » serait moins utile à l’écosystème que le maintien de D-Bus dans son rôle actuel et la coexistence avec un IPC distinct pour les besoins qu’il ne couvre pas.
AirCom en synthèse
AirCom est l’IPC interne d’Air. Sa spécification détaillée fera l’objet de documents et d’ADRs ultérieurs, mais ses caractéristiques structurantes, telles qu’elles découlent du présent ADR, sont les suivantes :
- Transport : Unix sockets pour le control plane (messages courts, signalisation),
memfd_create+mmappour le data plane bulk (charges volumineuses zero-copy),io_uringpour les opérations asynchrones (cf. ADR-022). - Pas de broker central. Les services Air se découvrent via un registre léger (
air-registry, couche 5) qui fournit les adresses de socket ; toute communication ultérieure est peer-to-peer entre client et service. - Modèle capability-based. Une application reçoit au démarrage les capabilities AirCom que son manifeste signé (
.airapp, cf. ADR-010) déclare. Une capability est un handle non falsifiable transporté via FD passing. Aucune autorité ambient ; aucune découverte de services non autorisés. - Encodage schema-first. Les interfaces AirCom sont définies dans des fichiers de schéma compilés, avec versioning explicite et règles de compatibilité testées en CI (cf. Principe d’ingénierie 8 et ADR-012).
- Inspirations. AirCom s’inspire ouvertement de XPC sur macOS (modèle capability-based, NSXPCConnection), de Cap’n Proto (sérialisation schema-first sans copies, support natif des FDs), de FIDL sur Fuchsia (IPC capability-based avec channels typés), et dans une moindre mesure de Mojo sur Chromium et de Binder sur Android. Cap’n Proto est candidat comme base technique pour l’encodage du wire format.
- Pas un service système universel. AirCom ne vise pas à remplacer D-Bus dans l’écosystème Linux. Il est l’IPC d’Air pour Air. Les services D-Bus existants restent accessibles via les ponts décrits ci-après.
Le pont D-Bus dans Air
Le périmètre exact d’usage de D-Bus par Air est triple :
sd-bus peer-to-peer pour systemd
sd-bus, fourni par libsystemd, peut être configuré pour parler directement à un service systemd sans passer par le broker dbus-daemon. Le wire format reste D-Bus, le transport reste un socket Unix, mais le broker n’est pas dans le chemin. Cette technique est utilisée par Air pour parler à systemd, systemd-logind, systemd-resolved, systemd-journald, systemd --user. Aucune dépendance fonctionnelle à dbus-daemon ou dbus-broker. Cohérent avec ADR-005.
air-dbus-bridge (couche 2, optionnel)
Un composant chargeable à la demande, présent dans le profil air-desktop, qui expose les services D-Bus traditionnels (NetworkManager, BlueZ, UPower, ModemManager, accountsservice, etc.) sous forme de services AirCom consommables par les applications Air natives. La traduction AirCom ↔ D-Bus est gérée par air-dbus-bridge qui :
- Consomme D-Bus côté éco-système (broker classique).
- Expose une API AirCom côté Air, avec schémas typés et capabilities.
Une application Air native n’invoque jamais D-Bus directement. Elle consomme air.system.network, air.system.bluetooth, etc., via AirCom ; air-dbus-bridge fait la traduction. Si l’utilisateur n’utilise jamais ces services, le pont n’est pas chargé.
Pour air-base (profil console), le pont n’est pas requis. Les services d’écosystème pertinents pour air-base (typiquement systemd) sont accessibles via sd-bus peer-to-peer.
xdg-desktop-portal-air (exception assumée)
xdg-desktop-portal-air est l’implémentation Air du standard xdg-desktop-portal, qui définit comment les applications sandboxées (Flatpak, Snap, et les .airapp cibles freedesktop) demandent l’accès à des ressources protégées (fichiers, capture d’écran, impression, etc.). Le standard est D-Bus-based par construction. xdg-desktop-portal-air parle donc D-Bus à ses clients et à ses backends, et expose en interne une bridge vers AirCom pour la suite du flux.
Cette exception est explicitement documentée, ne s’étend à aucun autre composant Air, et permet à Air de respecter pleinement le standard freedesktop sans renoncer à AirCom en interne.
Articulation avec les autres ADRs
- ADR-002 (modèle d’objet hybride asymétrique C-ABI) : AirCom transporte des invocations de méthodes sur des
AirObject. Le modèle d’objet et l’IPC sont conçus conjointement, ce qui n’est pas le cas avec D-Bus. - ADR-005 (intégration systemd dure, D-Bus optionnel) : ADR-005 acte la dépendance dure à systemd et l’optionalité de
dbus-daemon. ADR-001 précise la décision IPC qui en découle pour les services Air natifs. - ADR-010 (
.airapp/.airservice, entitlements déclaratifs signés) : le modèle capability-based de AirCom est la conséquence directe du modèle d’entitlements d’ADR-010. Les deux ADRs sont complémentaires : ADR-010 acte les entitlements, ADR-001 acte le transport qui matérialise les capabilities correspondantes. - ADR-012 (versionnement, stabilité ABI 10 ans) : AirCom schema-first rend la stabilité ABI testable en CI, condition nécessaire à l’engagement de 10 ans d’ADR-012.
- ADR-013 (distribution Modèle C → B) : pendant la phase d’incubation, les apps natives
.airappcoexistent avec des apps tierces (Flatpak, GTK, Qt) qui consomment D-Bus. Le pont assure la cohabitation. - ADR-022 (architecture du module io_uring) : AirCom s’appuie sur io_uring pour ses opérations asynchrones de soumission/complétion, ce qui n’est pas naturel avec un broker D-Bus.
Questions courantes anticipées
« Pourquoi pas étendre D-Bus pour qu’il fasse ce qu’Air veut ? » — La voie a été explorée (kdbus, bus1) sans aboutir. Étendre D-Bus de manière incompatible briserait son écosystème. Étendre D-Bus de manière compatible reconstruirait par-dessus ce que AirCom construit en partant des bonnes primitives.
« Est-ce qu’Air refuse de parler à NetworkManager, BlueZ, etc. ? » — Non. Ces services restent accessibles via air-dbus-bridge qui les expose en AirCom aux applications Air. Air respecte et utilise l’écosystème D-Bus pour interopérer.
« Est-ce que c’est du Not-Invented-Here ? » — AirCom s’inspire ouvertement de XPC, Cap’n Proto, FIDL, Mojo, Binder. Le but n’est pas l’originalité, c’est la cohérence interne du SDK Air et la prise en charge native de besoins (capability-based, zero-copy, schema-first) que D-Bus ne couvre pas.
« Pourquoi pas s’aligner sur ce que fait Flatpak / Snap / etc. ? » — Flatpak et Snap s’appuient sur D-Bus parce qu’ils ciblent l’interopérabilité avec l’écosystème existant. Air a le même besoin pour les apps tierces (et c’est pour cela qu’il maintient air-dbus-bridge et xdg-desktop-portal-air), mais pour ses applications natives .airapp Air vise un modèle plus rigoureux que ce que D-Bus permet.
« AirCom va-t-il un jour devenir un standard freedesktop ? » — Ce n’est pas un objectif d’Air. AirCom est l’IPC interne d’Air. Si des projets externes y trouvent un intérêt, ils sont libres de l’adopter ou de s’en inspirer, mais Air ne cherche pas à pousser AirCom comme remplaçant de D-Bus dans l’écosystème.
« Le pont D-Bus n’est-il pas un single point of failure ? » — Le pont est optionnel et concerne uniquement l’interop avec les services tiers. Sa défaillance désactive l’accès à ces services tiers, mais les services Air natifs restent fonctionnels via AirCom. Pour systemd, le canal sd-bus peer-to-peer ne dépend pas du pont. Pour air-base, le pont n’est pas chargé du tout.
Conséquences
Bénéfices
- Cohérence interne du SDK Air. Un seul modèle de communication entre services Air, conçu conjointement avec le modèle d’objet (ADR-002) et le modèle d’entitlements (ADR-010).
- Stabilité ABI testable. Schema-first → tests automatisés de compatibilité en CI, cohérent avec ADR-012 et le Principe d’ingénierie 8.
- Performance prévisible. Pas de double copie par broker, FD passing de première classe, zero-copy via
memfd_createpour les charges volumineuses. Cohérent avec ADR-014 et la Charte principe 4. - Modèle de sécurité unifié. Capabilities AirCom = capabilities applicatives = entitlements signés. Cohérent avec ADR-010.
Coûts
- Double stack d’IPC. Air maintient AirCom pour son cœur et un pont D-Bus pour l’interop. C’est plus à construire et à maintenir qu’une stack unique. Ce coût est jugé acceptable parce qu’aucune stack unique ne couvre les deux besoins (besoins internes Air + interop écosystème).
- Outillage à construire. AirCom demande son propre runtime, son compilateur de schémas, ses outils de tests, son inspecteur de tronc. C’est du travail concentré en phase 2 (cf. ADR-011), avec un livrable lisible (
air-notifydcomme premier service AirCom) en fin de phase 2. - Pédagogie nécessaire. Le choix peut surprendre les contributeurs venant de l’écosystème D-Bus traditionnel. Le présent ADR vise précisément à rendre la décision compréhensible et défendable, sans posture évangéliste ni rejet de D-Bus.
Risques et mitigations
- Risque : AirCom reste obscur et adoption faible par les développeurs tiers. Mitigation : qualité d’outillage (introspection, debugger, bindings polyglottes via le modèle d’objet C-ABI d’ADR-002), documentation claire, ergonomie soignée. La valeur principale est d’abord pour les apps natives
.airapp, le reste suit. - Risque : l’écosystème D-Bus évolue (par exemple un futur dbus2 schema-first capability-based) et rend AirCom redondant. Mitigation : la décision est révisable comme tout ADR (mécanisme RFC après ouverture publique, cf. ADR-015). Une telle évolution serait sérieusement évaluée.
- Risque : interop incomplète avec un service D-Bus tiers important. Mitigation :
air-dbus-bridgeest conçu pour rendre n’importe quel service D-Bus consommable depuis Air ; les cas pathologiques (services D-Bus reposant sur des particularités du broker) sont traités au cas par cas.
Statut futur
ADR fondateur immuable dans son principe (AirCom comme transport interne, D-Bus pour interop). Les détails de AirCom (wire format exact, choix Cap’n Proto vs alternative, format du registre, etc.) feront l’objet d’ADRs et de specs ultérieurs au moment de l’implémentation en phase 2 (cf. ADR-011).
ADRs ultérieurs identifiés :
- Spécification détaillée du wire format AirCom (probablement basé sur Cap’n Proto, à confirmer en phase 2).
- Protocole privé AirCom entre
air-wmet les apps natives (rich content, animations, capture vectorielle scene-graph). - Politique de versioning des schémas AirCom (compatibilité ascendante, champs réservés, etc.) et outillage
air-abi-checkcorrespondant.
Licence du document : MPL 2.0 Statut : Document fondateur immuable.
ADR-002 — Modèle d’objet hybride asymétrique, C-ABI pour le périmètre CoreFoundation/AppKit
La couche 2 expose un modèle d’objet C-ABI pour tout ce qui est susceptible d’être observé, bindé depuis d’autres langages, ou inspecté par des outils : collections, strings Unicode, URLs, services, vues, contrôleurs, propriétés observables. Pour tout le reste — algorithmique interne, parseurs, structures de données privées — on reste en Rust pur.
Frontière explicite : un type qui dérive #[derive(AirObject)] ou équivalent rejoint le monde C-ABI et accepte ses contraintes (compteur de référence atomique, classe = vtable + métadonnées, propriétés observables, introspection runtime). Pont entre les deux mondes par conversion explicite quand nécessaire.
Conséquence stratégique : Air est conçu polyglotte dès le départ. Un binding Python générique appelle air_object_get_property pour tout, sans code spécifique par classe. Idem Swift via @dynamicMemberLookup. Modèle qui fait que PyObjC marche sans glue par classe sur macOS.
Licence du document : MPL 2.0 Statut : Document fondateur. Édition directe autorisée en phase de design pré-ouverture publique ; immuable après ouverture publique (toute évolution via RFC, cf. ADR-015).
ADR-003 — Air compositeur Wayland
Air est compositeur Wayland, pas client. Notre serveur, notre shell, notre WM. Smithay comme base candidate. Apps externes Wayland-compliant peuvent tourner ; apps natives utilisent en plus un protocole privé pour le rich content (animations, accessibilité, drag & drop typé, échange de buffers GPU).
Les apps .airapp sont clients Wayland standards de premier rang, fonctionnelles sous tout compositeur Wayland avec qualité maximale, expérience enrichie sous air-wm via protocoles privés AirCom.
Licence du document : MPL 2.0 Statut : Document fondateur. Édition directe autorisée en phase de design pré-ouverture publique ; immuable après ouverture publique (toute évolution via RFC, cf. ADR-015).
ADR-004 — Linux tier-1 exclusif, FreeBSD et Windows tier-3 documentés
L’architecture est conçue pour Linux. Choix structurants assumés Linux-only : io_uring, Landlock, seccomp-bpf, eBPF, namespaces/cgroups, systemd, PipeWire.
Couche 0 a une façade Rust propre qui pourrait recevoir d’autres backends sans que ce soit un objectif. Aucun compromis design pour préserver une portabilité théorique. Documentation des « portes » : on liste explicitement par couche quelles parties sont portables et quelles parties ne le sont pas.
Mac Intel tier-1 (architecture x86_64 standard). Apple Silicon tier-2, supporté via collaboration avec Asahi Linux. ARM64 hors Apple Silicon tier-3 selon contributions communautaires.
Licence du document : MPL 2.0 Statut : Document fondateur. Édition directe autorisée en phase de design pré-ouverture publique ; immuable après ouverture publique (toute évolution via RFC, cf. ADR-015).
ADR-005 — Intégration systemd comme socle système, D-Bus optionnel
systemd est dépendance dure d’Air (tier-1) : sd-bus en mode peer-to-peer, sd-event, sd-notify, sd-journal, sd-id128, libudev, logind, systemd --user.
D-Bus (dbus-daemon broker) est strictement optionnel. Aucun composant Air ne le requiert pour fonctionner.
udev via libudev est la source de vérité pour la découverte de devices, intégrée en couche 1 (façade Rust) et consommée par toutes les couches supérieures qui en ont besoin.
Licence du document : MPL 2.0 Statut : Document fondateur. Édition directe autorisée en phase de design pré-ouverture publique ; immuable après ouverture publique (toute évolution via RFC, cf. ADR-015).
ADR-006 — Profils de déploiement air-base (console) et air-desktop (graphique)
L’architecture est conçue pour être fonctionnelle et utile dès air-base. Aucune couche supérieure n’est requise par une couche inférieure.
Profil air-base : couches 0, 1, 2 complètes ; couche 3 absente (pas de compositeur, pas d’audio graphique) ; couche 4 absente (pas de framework GUI) ; couche 5 présente sauf composants liés au desktop. Pas de dbus-daemon requis. Apps Air « console » possibles : CLI tools, TUI apps, daemons.
Profil air-desktop : air-base + couches 3 et 4. Compositeur Wayland Air, framework déclaratif, shell graphique. Pont D-Bus complet chargeable. PipeWire requis pour audio.
Cette séparation est un invariant d’architecture, pas un détail de packaging. Console first-class, pas dégradé.
Licence du document : MPL 2.0 Statut : Document fondateur. Édition directe autorisée en phase de design pré-ouverture publique ; immuable après ouverture publique (toute évolution via RFC, cf. ADR-015).
ADR-007 — Compositeur de console air-console, symétrie avec air-wm
Air fournit un compositeur de mode texte qui bypasse la VT kernel via DRM/KMS pour le rendu et evdev pour l’input. Backend partagé (DRM, evdev, seats, xkbcommon, glyph rasterizer) avec le compositeur Wayland air-wm. Permet aux apps TUI d’accéder à l’état réel des inputs (modifiers complets, key release, souris, multi-touch) là où la VT et les terminaux classiques l’interdisent.
Symétrie architecturale forte : tout comme Air est compositeur Wayland pour le graphique au lieu d’être client d’un compositeur existant, Air est compositeur de console pour le texte au lieu d’être client de la VT kernel.
Licence du document : MPL 2.0 Statut : Document fondateur. Édition directe autorisée en phase de design pré-ouverture publique ; immuable après ouverture publique (toute évolution via RFC, cf. ADR-015).
ADR-008 — Framework TUI air-tui avec trois backends de transport
Le framework TUI d’Air abstrait trois modes d’exécution : (1) TTY réel avec air-console (mode plein bypass) ; (2) terminal Air avec protocole privé AirCom (équivalent plein bypass) ; (3) terminal tiers ou SSH avec fallback Kitty keyboard protocol ou ANSI standard (mode dégradé).
API Capabilities pour que l’app interroge ce qui est disponible et dégrade ses fonctionnalités sans crash. Le développeur d’app écrit son code une fois ; les capacités dégradent silencieusement selon le contexte.
Licence du document : MPL 2.0 Statut : Document fondateur. Édition directe autorisée en phase de design pré-ouverture publique ; immuable après ouverture publique (toute évolution via RFC, cf. ADR-015).
ADR-009 — Framework de vues d’Air : tronc déclaratif air-view, bindings air-ui (graphique) et air-tui (texte)
Statut : draft de version révisée, en attente de revue BDFL. Remplace l’ADR-009 actuel du registre.
Note de révision : cette version intègre l’amendement déclaratif (anciennement envisagé sous le nom ADR-009-bis) directement dans le corps d’ADR-009, conformément à la politique d’édition en phase de design pré-ouverture publique.
Paradigme
Paradigme déclaratif/réactif en pure Rust, sans DSL séparé. Une macro view! optionnelle peut être fournie pour les cas de syntaxe condensée, mais l’API principale est composée d’appels de fonctions et de méthodes Rust standard. Verbosité assumée au profit de la clarté du data-flow et de la performance runtime, conformément au principe d’ingénierie 7.
API publique exclusivement déclarative
L’API exposée au développeur d’application n’expose aucune coordonnée absolue, ni en pixels, ni en cellules, ni en aucune autre unité physique. Le positionnement et le dimensionnement s’expriment exclusivement par :
- des conteneurs de mise en page déclaratifs :
HStack,VStack,ZStack,Grid,LazyStacket variantes, qui composent leurs enfants selon des règles déclaratives ; - des modificateurs déclaratifs :
padding,spacing,alignment,frame(en termes contraints),weight, etc. ; - des unités logiques :
.small,.medium,.largeet variantes proportionnelles, jamais en pixels ou cellules.
La typographie est exprimée comme rôle sémantique (.title, .body, .caption, .code, etc.), jamais comme triplet (famille, taille, graisse). Le renderer cible résout le rôle vers une police concrète selon son contexte : police par défaut système (Helvetica ou équivalent) sous air-ui-render-gpu, police monospace de la console sous air-ui-render-tui.
Ce choix est structurant et non négociable parce que la promesse « code dual-mode » de la Vision ne peut tenir que si les APIs publiques utilisées par le développeur ont une sémantique commune dans les deux modes. Les coordonnées absolues n’ont pas de sémantique commune entre les deux modes : le mode graphique opère sur un écran à DPI variable, le mode console opère sur une grille de cellules dont la taille pixel varie selon la police et le DPI (sur air-console) ou n’est même pas connue (sur terminal tiers selon ADR-008). L’arrondi naïf à la cellule n’est pas une réponse acceptable (chevauchements, gaps, casse sur texte non-ASCII type CJK/emoji).
Cette posture est par ailleurs la norme des frameworks UI modernes (SwiftUI, Xilem, Compose, Flutter), corrigeant l’erreur des SDKs UI antérieurs (Win32, AppKit pre-Auto Layout, GTK 2) qui exposaient des coordonnées absolues et obligeaient à des recalculs manuels pour chaque résolution cible.
Mécanismes de state
Trois mécanismes inspirés de SwiftUI mais transposés en idiome explicite :
State<T>: state local par vue, identifié par position d’appel surCx.Observable<T>: objets observables vivant dans le runtime C-ABI de la couche 2, abonnement explicite viacx.observe().Environment<T>: injection par contexte d’arbre.
State local par vue ; pas de state global imposé ; partage via Observable. Animation déclarative first-class.
Architecture des crates
-
air-object-view— substrat impératif vivant dans le runtime d’objets C-ABI de la couche 2. Vues élémentaires, contexte de composition, scene-graph abstrait, types primitifs partagés. C’est le bas niveau sur lequel tout repose. -
air-view— tronc déclaratif commun en couche 4. Expose les conteneurs de mise en page, les modificateurs, les mécanismes de state, l’APICapabilities. Consommable directement par les applications dual-mode qui n’ont besoin que du vocabulaire commun. -
air-ui— bindings idiomatiques + widget set graphique en couche 4. Embarqueair-viewetair-ui-render-gpu. Utilisé par les apps purement graphiques ou par les apps dual-mode qui veulent les widgets idiomatiques pour leur partie graphique. -
air-tui— bindings idiomatiques + widget set TUI en couche 4. Embarqueair-viewetair-ui-render-tui. Utilisé par les apps purement TUI ou par les apps dual-mode qui veulent les widgets idiomatiques pour leur partie texte. -
air-ui-render-gpu— renderer graphique via Vello/GPU surair-wm(cf. ADR-018 pour le modèle d’imagerie sous-jacent). -
air-ui-render-tui— renderer cellulaire surair-consoleou terminal selon les trois backends d’ADR-008. -
air-ui-controller— API intermédiaire pour les cas qui mélangent paradigmes déclaratif et impératif (par exemple intégration progressive d’un composant impératif legacy, ou besoin d’un contrôle fin du cycle de rendu).
Le partage du modèle de composition entre les deux renderers passe par air-view : un widget sémantique commun (Button, Text, List, TextField, Form, etc.) a une implémentation dans chaque renderer, et l’app développeur l’utilise sans savoir lequel des deux tournera.
Capability-gating des primitives mode-specific
Certaines fonctionnalités n’ont de sens que dans un mode. Elles existent dans les bindings idiomatiques air-ui et air-tui mais sont explicitement isolées dans des namespaces séparés (nommage à arrêter au moment de la spécification framework, candidats air_ui::gfx::* et air_tui::cells::*) et capability-gated : leur usage est détecté à la compilation ou au runtime comme un usage qui sort du périmètre dual-mode portable, et signalé clairement au développeur via l’API Capabilities (étendue de celle d’ADR-008).
Primitives spécifiques au mode graphique : gradients, shaders custom, blur, transformations 3D, animations sub-pixel, blending stack avancé.
Primitives spécifiques au mode texte : box-drawing characters, attributs cellulaires (blink, reverse, underline-style), modes alternatifs d’écran, hints de couleur ANSI 256.
Escape hatches pour taille exacte
Il existe des cas légitimes où un développeur veut une taille exacte : visualisation scientifique pixel-perfect, app de design graphique, jeu. Ces cas sont autorisés via des modificateurs explicitement marqués comme non-portables, par exemple .frameExact(width: 800, height: 600, unit: .pixels). L’utilisation de ces modificateurs déclenche le même capability-gating que les primitives mode-specific : l’app sort du périmètre dual-mode portable, le système le détecte et le signale.
Ces escape hatches sont conservés dans l’API standard plutôt que renvoyés à air-experimental parce que les cas d’usage légitimes existent vraiment et qu’on ne veut pas pousser les développeurs concernés à écrire leur propre couche en contournant le framework. Le capability-gating suffit à protéger l’invariant dual-mode pour les apps qui s’en revendiquent.
Inspirations techniques
Xilem, Iced, SwiftUI. L’architecture en trois étages (substrat C-ABI impératif air-object-view / tronc déclaratif commun air-view / bindings idiomatiques air-ui et air-tui) est inspirée de la stratification Xilem mais transposée à la dualité graphique/texte propre à Air et à la contrainte C-ABI propre à la couche 2.
Cross-références
- ADR-002 (modèle d’objet C-ABI) —
air-object-viewvit dans ce runtime, lesObservable<T>sont desAirObject. - ADR-008 (
air-tuitrois backends) — laCapabilitiesAPI y est introduite et étendue ici pour couvrir le capability-gating des primitives mode-specific et des escape hatches. - ADR-017 (accessibilité) — l’API déclarative permet aussi l’accessibilité par construction et la compatibilité avec l’agrandisseur intégré ; le rejet des coordonnées absolues est une condition de l’accessibilité par zoom.
- ADR-018 (modèle d’imagerie) — vit sous cette API déclarative ; le développeur d’app ne voit ni les commandes vectorielles ni les commandes cellulaires.
- Principe d’ingénierie 7 (verbosité au service de la clarté) — informe l’arbitrage en faveur du déclaratif explicite contre l’impératif laconique.
Licence du document : MPL 2.0 Statut : draft de révision en attente de validation BDFL. Une fois validé, ce texte remplace l’ADR-009 actuel dans le registre canonique.
ADR-010 — Format .airapp et .airservice, entitlements déclaratifs signés, sandbox capability-based
Format standard de distribution : bundle .airapp (répertoire structuré : Manifest.toml, Entitlements.toml, signatures détachées, binaires, frameworks embarqués, ressources, schémas AirCom publiés). TOML pour lisibilité et idiome Rust. Autonome et déplaçable.
Format secondaire .airservice pour les composants système (drivers utilisateur, services réseau, agents système avec accès eBPF, etc.) avec modèle d’entitlements étendu et installation nécessitant privilèges administrateur. Distinction visuelle dans le shell.
Entitlements déclaratifs en deny-by-default, signés. Modèle hybride : entitlements par chemins explicites pour le filesystem + entitlements thématiques pour les patterns courants. Quatre familles : filesystem (mappés sur Landlock), network (mappés sur namespace réseau + filtrage), AirCom (capabilities aux services), devices.
air-launchd (couche 5) applique les entitlements au lancement : namespaces, Landlock, seccomp, distribution des capabilities AirCom initiales par FD passing. Pas d’autorité ambiante (POLA strict).
Trois niveaux de signature : développeur (toujours, self-signed possible), notarization (modèle ouvert : plusieurs autorités possibles), politique locale (Gatekeeper-like).
Licence du document : MPL 2.0 Statut : Document fondateur. Édition directe autorisée en phase de design pré-ouverture publique ; immuable après ouverture publique (toute évolution via RFC, cf. ADR-015).
ADR-011 — Phasage bottom-up piloté par jalons démontrables
Air est construit en phases successives, chacune produisant un livrable démontrable de bout en bout, conformément aux Principes d’ingénierie d’Air.
Phase préliminaire — Infrastructure de qualité : CI, coverage, fuzzing, benchmarking, conventions, templates de PR.
Cinq phases initiales menant à air-base 1.0 :
- Phase 0 (couche 0 + noyau couche 1) — coverage 100 % obligatoire.
- Phase 1 (modèle d’objet C-ABI + IPC AirCom transport) — coverage 100 % obligatoire.
- Phase 2 (AirCom typé + premier service système
air-notifyd) — premier livrable publiable. - Phase 3 (compositeur texte
air-console). - Phase 4 (framework
air-tui+ app démonstrative) —air-base1.0.
Le phasage est un objectif, pas un contrat. Si une phase révèle une complexité non anticipée, elle est redécoupée sans pression de date.
Chaque fin de phase : audit de performance + audit de dépendances + revue de coverage. Décisions de dégraissage éventuelles documentées avec justification écrite.
Licence du document : MPL 2.0 Statut : Document fondateur. Édition directe autorisée en phase de design pré-ouverture publique ; immuable après ouverture publique (toute évolution via RFC, cf. ADR-015).
ADR-012 — Stratégie de versionnement et stabilité ABI
Trois zones de stabilité différenciées :
-
air-stable— APIs C-ABI publiques (couches 1, 2, partie consommable de 3 et 4). Stabilité ABI garantie sur 10 ans (5 ans release + 5 ans support étendu). Mécanisme : versioned symbols GNU/Linux. Deprecation : 5 ans minimum entre annonce et retrait. Modèle : glibc. -
air-internal— APIs Rust entre crates Air. Stabilité source SemVer dans un release majeur. Pas de stabilité ABI. Refactorisation libre entre majeurs. -
air-experimental— APIs nouvelles en évaluation. Aucune stabilité. Préfixées explicitement. Promotion àair-stableaprès un release majeur d’usage et RetEx.
Versionnement majeur.mineur.patch. Majeur tous les 5-10 ans. Mineurs ajoutent des APIs sans casse. Patches : bug fixes.
Support en parallèle : releases majeurs N et N-1 supportés simultanément. Cohabitation Side-by-Side sur une même machine (chemins versionnés, dispatch au runtime selon air-runtime demandé par l’app).
Cas spéciaux : AirCom (schémas Cap’n Proto avec règles d’évolution strictes), entitlements (noms jamais renommés ni retirés), format .airapp (manifest-version lu depuis v1).
Période 0.x exploratoire de 12-24 mois après phase 4 (air-base 1.0) avant fixation air-stable définitive. Modèle inspiré de Rust.
Outils mainteneurs : air-abi-check, air-symver, air-deprecation-tracker à intégrer dès la phase 0.
Licence du document : MPL 2.0 Statut : Document fondateur. Édition directe autorisée en phase de design pré-ouverture publique ; immuable après ouverture publique (toute évolution via RFC, cf. ADR-015).
ADR-012-bis — Politique de télémétrie Air
Périmètre strictement limité : air-deprecation-tracker côté développeur uniquement. Aucun autre composant Air ne collecte de télémétrie. Le moindre composant qui aurait besoin de télémétrie nécessite un ADR explicite.
Opt-in strict : désactivée par défaut. Activation via configuration explicite du développeur (variable d’environnement, fichier de config dédié, commande SDK). Jamais d’activation transparente par installation d’un IDE ou outil tiers.
Données collectées : exclusivement les usages des APIs marquées deprecated. Pas d’identifiants persistants. Pas de géolocalisation. Pas de fingerprinting machine. Pas de noms de projet, de chemins de fichiers, de contenu d’apps. Le schéma de données est public, versionné comme une API air-stable, et auditable.
Transport : via AirCom vers un service de collecte minimaliste opéré par le projet Air (ou son équivalent organisationnel). Service en open source, déployable par tiers (les organisations peuvent rediriger vers leur propre instance).
Transparence : tableau de bord public des données agrégées. Pas de tableau de bord privé.
Possibilité de désactivation : à tout moment, sans conditions, sans dégradation de fonctionnalité.
Licence du document : MPL 2.0 Statut : Document fondateur (amendement-politique d’ADR-012). Édition directe autorisée en phase de design pré-ouverture publique ; immuable après ouverture publique (toute évolution via RFC, cf. ADR-015).
ADR-013 — Distribution Air, posture stratégique de troisième voie qualitative
Air ne se positionne pas en concurrent frontal de GNOME et KDE mais en troisième voie qualitative. Coopération technique active avec freedesktop.org. Pas de marketing agressif. Respect mutuel.
Phasage de distribution :
- Phase d’incubation — Modèle C : repositories Air officiels ajoutables aux distributions existantes + image OS de référence basée sur Debian stable. Cible : développeurs et power-users.
- Phase de pré-grand-public — construction d’Air OS comme distribution dédiée basée sur Debian stable.
- Phase grand public (Air OS 1.0) — Modèle B livré : téléchargement direct, installation guidée, expérience cohérente, support communautaire et/ou commercial.
À terme, transmission progressive aux distributeurs Linux majeurs (Red Hat/Fedora, Canonical/Ubuntu, Debian, SUSE).
Base technique : Debian stable. Images Arch et Fedora possibles ultérieurement.
Écosystème applicatif transitoire : support de premier ordre de Flatpak. App Store Air unifie Flathub et catalogue .airapp.
Stratégie matérielle : x86_64/Mac Intel tier-1 ; Apple Silicon tier-2 (via Asahi Linux) ; ARM64 hors Apple Silicon tier-3 ; drivers propriétaires et firmwares non-libres intégrés (politique pragmatique).
Licence du document : MPL 2.0 Statut : Document fondateur. Édition directe autorisée en phase de design pré-ouverture publique ; immuable après ouverture publique (toute évolution via RFC, cf. ADR-015).
ADR-014 — Catalogue de matériel « Air long-term »
Air maintient un catalogue de machines explicitement testées et garanties supportées pour au moins 10 ans à partir de leur sortie commerciale ou de leur abandon par le fabricant d’origine, le plus tardif des deux. Tests automatisés en CI sur chaque machine du catalogue à chaque version.
Première version du catalogue (Phase 0) :
- Raspberry Pi 4 Model B 4 Go (ARM64) — référence
air-base. - Raspberry Pi 4 Model B 8 Go (ARM64) — référence
air-desktop. - Mac mini Intel i5 8 Go (x86_64, GPU Intel intégré) — référence
air-desktopbureautique. - MacBook Pro 17“ Intel i7 16 Go (x86_64, GPU Intel + discret) — référence
air-desktoplaptop.
Diversité couverte : ARM64 + x86_64, profils mémoire 4 à 16 Go, GPU variés, écrans externes et intégrés, classes SBC à laptop puissant.
Versions ultérieures candidates : MacBook Pro Intel (2015, 2017, 2019), ThinkPad T-series, Framework Laptop, Intel NUC, Dell XPS, Raspberry Pi 5.
Critères d’inclusion : disponibilité raisonnable, support kernel mainline, contributeur engagé pour maintenir les tests.
Engagement : machine entrée ne sort qu’en cas de force majeure, avec préavis 2 ans et décision motivée.
Licence du document : MPL 2.0 Statut : Document fondateur. Édition directe autorisée en phase de design pré-ouverture publique ; immuable après ouverture publique (toute évolution via RFC, cf. ADR-015).
ADR-015 — Licence MPL 2.0, contribution DCO, gouvernance évolutive, marque modèle Linux
Licence : Mozilla Public License 2.0 (MPL 2.0) pour l’intégralité du code source d’Air et de sa documentation. Copyleft par fichier protégeant le cœur d’Air contre les forks propriétaires non-libérés, sans contaminer les apps .airapp qui peuvent rester propriétaires. Compatible avec Apache 2.0 et GPL.
Contribution : Developer Certificate of Origin (DCO). Tout commit doit contenir un Signed-off-by valide. Vérification automatique en CI. Pas de CLA. Conséquence : impossibilité de changement de licence rétroactif sans consentement des contributeurs.
Gouvernance technique évolutive :
- BDFL (phase 0 et début phase 1) : le fondateur décide, valide RFCs, merge PRs.
- Comité Technique (déclenché à ~5 contributeurs réguliers) : fondateur + contributeurs cooptés puis élus. Décisions par lazy consensus, vote si désaccord, veto BDFL transitoire.
- Fondation (vers Air 1.0) : séparation CA/TC, formalisation juridique. Modèle Blender Foundation.
Mécanisme RFC pour évolutions significatives. Repository dédié air-rfcs, template structuré, période de commentaires 2-4 semaines, décision BDFL/TC, archivage public. Modèle Rust RFCs.
Code de conduite : Contributor Covenant 2.1.
Marque « Air » modèle Linux : usage libre pour la communauté, distributions, éditeurs d’apps, sans autorisation préalable formelle. Action juridique uniquement contre abus manifestes.
Structure juridique évolutive :
- Phase A (0-12 mois) : individuel, propriété personnelle du fondateur.
- Phase B (12-36 mois) : association loi 1901.
- Phase C (vers Air 1.0) : Fondation à but non lucratif.
Licence du document : MPL 2.0 Statut : Document fondateur. Édition directe autorisée en phase de design pré-ouverture publique ; immuable après ouverture publique (toute évolution via RFC).
ADR-016 — Internationalisation native et localisation progressive
🔗 EXTENSION (2026-06-30, ADR-053).
icu4xne sert pas qu’aux couches hautes (AirString,air-ui, formats) : il sous-tend aussi la libc C d’Air (ctype/locale/i18n — caractères, casse, collation, normalisation, segmentation, calendriers). La libc Air donne donc l’i18n correcte par défaut au C, sans bolt-on. Locales BCP-47 (et non POSIX) y compris au niveau libc, les noms POSIX étant mappés. Voir ADR-053.
Unicode 16+ supporté nativement. UTF-8 partout, pas de codages legacy en interne. Type AirString Unicode-aware (normalisation, casing locale-aware, segmentation par grapheme, comparaison locale-aware).
Locales identifiées par BCP 47, pas POSIX. Trois niveaux : système, utilisateur, application. Cascade de fallback macOS-style.
Socle technique : icu4x adopté comme dépendance structurante, exception explicite à la règle des 80 % du Principe d’ingénierie 6. Justification : référence Unicode mondiale, reproduction impossible, sécurité par adoption massive, modularité native.
Formats culturels via APIs air-runtime consommant icu4x. Direction du texte RTL via cosmic-text + HarfBuzz. air-ui utilise des coordonnées logiques (leading/trailing).
IME via text-input-v3 Wayland standard dans air-wm. Compatible Fcitx5 et IBus. Support IME en mode console tier-2.
Traductions : format Fluent (Mozilla). Fichiers .ftl par composant et par langue.
Polices : famille Noto comme défaut. Mécanisme de fallback automatique.
Calendriers : grégorien par défaut, autres calendriers disponibles via API AirCalendar.
Genre et inclusivité : traductions officielles en formes standard, variantes communautaires possibles. Position politiquement neutre.
Langues prioritaires initiales (9 langues) : anglais, français, allemand, espagnol, italien, portugais, chinois mandarin simplifié, arabe, japonais.
Licence du document : MPL 2.0 Statut : Document fondateur. Édition directe autorisée en phase de design pré-ouverture publique ; immuable après ouverture publique (toute évolution via RFC, cf. ADR-015).
ADR-017 — Accessibilité citoyen de première classe, fondée sur le modèle d’objet C-ABI
L’accessibilité est un fondement d’Air. Toute UI Air est accessible par construction grâce au modèle d’objet C-ABI (ADR-002) qui porte les propriétés accessibility universelles (label, role, value, state, hierarchy, traits) pour toute AirObject. Les développeurs d’apps Air n’ont pas à coder spécifiquement l’accessibilité : le framework la génère depuis la structure déclarative de l’app.
Technologies d’assistance comme services AirCom. Capability air.accessibility.observer (sensible, confirmation utilisateur au premier usage). Pont bidirectionnel AT-SPI ↔ AirCom en couche 2 pour compatibilité avec écosystème existant (Orca notamment) pendant la phase d’incubation.
Catégories couvertes :
- Malvoyance/cécité : lecteur d’écran natif
air-screenreader(planifié post-phase 4, alternative initiale Orca via pont AT-SPI), agrandisseur intégréair-wm, thèmes contraste, taille de texte ajustable, filtres daltonisme. - Handicap moteur : navigation clavier intégrale obligatoire, sticky/slow/bounce keys via evdev, commande vocale
air-voice-control(tier-2, basée Whisper.cpp ou équivalent local), switch control (tier-3). - Handicap auditif : signaux visuels pour alertes audio, sous-titres système (tier-2).
- Handicap cognitif : mode interface simplifiée, polices dyslexie-friendly, réduction du mouvement.
Moteur TTS : piper (qualité moderne, open source) en priorité, espeak-ng en fallback.
Engagement public WCAG 2.2 AA pour toutes les UIs Air. Tests automatisés CI, tests manuels avec AT lors des releases, audit externe avant Air OS 1.0 grand public, documentation publique de l’état d’accessibilité.
Licence du document : MPL 2.0 Statut : Document fondateur. Édition directe autorisée en phase de design pré-ouverture publique ; immuable après ouverture publique (toute évolution via RFC, cf. ADR-015).
ADR-018 — Modèle d’imagerie d’Air : vectoriel pour le mode graphique, cellulaire pour le mode console
Statut : draft, en attente de revue BDFL. À intégrer au registre des ADRs après validation.
Décision
Air adopte deux modèles d’imagerie distincts, chacun natif à sa cible :
-
Mode graphique (couche 3
air-wm+ couche 4air-ui-render-gpu) : modèle d’imagerie vectoriel inspiré de Quartz 2D / Display PDF. Primitives : paths, fills, strokes, transformations affines, blend modes, transparence composée selon des règles explicites, espaces colorimétriques gérés. Vello est retenu comme moteur de rendu candidat, en cohérence avec ADR-009. -
Mode console (couche 3
air-console+ couche 4air-ui-render-tui) : modèle d’imagerie cellulaire natif. Primitives : cellules indexées par (colonne, ligne), glyphes stylés, attributs cellulaires, dirty regions. Aucun modèle vectoriel imposé au-dessus.
Aucune unification des deux modèles n’est tentée à ce niveau. L’unification se fait exclusivement au-dessus, dans l’API déclarative de composition de vues (cf. ADR-009 et ADR-009-bis).
Justification
Le modèle vectoriel pour le mode graphique apporte cinq bénéfices structurants, dans l’ordre où ils pèsent :
Indépendance de résolution. Le rendu vectoriel s’adapte sans aliasing à n’importe quel DPI, HiDPI, ou combinaison d’écrans hétérogènes. Critique pour le principe 4 de la Charte (longévité du matériel) : un écran de 2015 et un de 2025 partagent la même API et la même fidélité.
Accessibilité par agrandissement. L’agrandisseur intégré prévu par ADR-017 peut re-rendre à n’importe quelle échelle sans perte. Avec un buffer raster, on serait condamné à l’interpolation et donc à la dégradation. Le modèle vectoriel rend l’agrandissement parfait par construction.
Capture vectorielle native. Une capture d’écran ou de fenêtre peut être produite directement en PDF vectoriel, et non en raster. Utilisable pour documentation, archivage, partage avec des outils de traitement de texte, comparaison de versions. C’est une propriété émergente du choix du modèle d’imagerie, pas une feature à ajouter.
Impression native. Pas de pipeline d’export séparée : on imprime la même scène qu’on affiche, sans conversion intermédiaire. Cohérent avec l’approche macOS historique.
Analyse par agents IA. Les agents IA actuels analysent les UIs par OCR sur captures raster, avec une qualité variable (erreurs de lecture, perte de structure). Une capture vectorielle leur donne le texte exact, les positions exactes, les couleurs précises. Cette utilité est complémentaire de l’arbre sémantique fourni par ADR-002 + ADR-017 : l’arbre sémantique reste le canal principal pour qu’un agent comprenne ce que fait l’UI et puisse agir dessus ; la capture vectorielle est un complément précieux pour le raisonnement visuel (revue de design, debug visuel, comparaison de captures).
Le modèle cellulaire pour le mode console est conservé parce que le rendu console est intrinsèquement discret : cellules adressables, glyphes par cellule, dimensions variables selon contexte d’exécution (DRM/KMS sur air-console, ou simple grille colonnes × lignes sur terminal tiers selon ADR-008). Forcer un modèle vectoriel par-dessus serait sur-ingénierer sans bénéfice — la capture/sérialisation en mode console se fait nativement comme un dump structuré de la grille, plus efficient et portable qu’un PDF pour ce cas.
Conséquences
L’API publique du framework de vues (couche 4) ne parle ni en commandes vectorielles ni en commandes cellulaires : elle parle un vocabulaire de composition déclarative et de widgets sémantiques. Le choix du modèle d’imagerie est une décision interne aux renderers de la couche 4 et à la pipeline de rendu de la couche 3. Cette frontière est durcie par ADR-009 (révision intégrant l’amendement déclaratif).
Le pont entre le scene-graph déclaratif et le moteur de rendu vectoriel est interne à air-ui-render-gpu. Le pont entre le scene-graph déclaratif et le moteur cellulaire est interne à air-ui-render-tui. Ces deux ponts sont des composants critiques de la couche 4, à concevoir avec le même niveau de soin que le runtime de composition lui-même.
La capture vectorielle au niveau du compositeur air-wm suppose que le compositeur ait accès à la représentation vectorielle des fenêtres qu’il compose. C’est trivial pour les apps natives Air dont le rendu se fait en local puis transmet un buffer Wayland — la capture vectorielle nécessite alors une coopération entre compositeur et app (le compositeur demande à l’app de re-rendre vers PDF). Pour les apps Wayland tierces qui n’envoient que des buffers raster, la capture est dégradée en raster. C’est acceptable et documenté.
Non-décisions explicites
air-console ne reçoit pas de modèle vectoriel. Toute proposition future de « vectorialiser » le mode console doit faire l’objet d’un nouvel ADR amendant celui-ci.
Le format exact de capture vectorielle (PDF strict, PDF tagged, PostScript, SVG, format Air interne) n’est pas tranché. Il fera l’objet d’une décision lors de l’implémentation de la fonctionnalité de capture, probablement en phase post-air-base 1.0. Le PDF tagged (avec marqueurs accessibilité) est le candidat naturel parce qu’il préserve une partie de la structure sémantique.
Questions ouvertes renvoyées à des ADRs futurs
Rendu côté client vs scene-graph transporté. Deux options pour faire profiter air-wm du modèle vectoriel : (i) les apps rendent en local et envoient un buffer Wayland standard — air-wm ne voit que des pixels, sauf coopération explicite pour la capture ; (ii) les apps natives Air envoient leur scene-graph au compositeur via un protocole privé AirCom, et air-wm rend lui-même — capture vectorielle universelle de toutes les apps natives, mais surface du compositeur élargie et fossé avec les apps Wayland tierces. À trancher dans l’ADR « Protocole privé AirCom entre air-wm et apps natives » déjà identifié dans le README.
Politique d’accès des agents IA à la capture vectorielle et à l’arbre sémantique. Dans la continuité de la capability air.accessibility.observer (ADR-017), il faudra définir comment un agent IA obtient le consentement de l’utilisateur pour accéder à ces canaux. À traiter dans un ADR de politique d’accès aux APIs d’observation.
Références croisées
- ADR-001 (IPC AirCom) — le transport éventuel d’un scene-graph vers le compositeur passerait par AirCom.
- ADR-002 (modèle d’objet C-ABI) — porte les types primitifs (Path, Color, AffineTransform) du modèle vectoriel, observables et bindables.
- ADR-003 (compositeur Wayland) —
air-wmest le compositeur qui bénéficie du modèle vectoriel. - ADR-007 (
air-console) — conserve son modèle cellulaire, non remis en cause. - ADR-008 (
air-tuitrois backends) — la capability API mentionnée ici permet déjà aux apps de connaître leur contexte d’exécution ; étendue ici à la déclaration de fonctionnalités vectorielles indisponibles en console. - ADR-009 (framework de vues :
air-view/air-ui/air-tui) — Vello était déjà candidat ; ADR-018 acte le choix architectural sous-jacent. ADR-018 vit sous l’API déclarative d’ADR-009. - ADR-017 (accessibilité) — la capture vectorielle complète l’arbre sémantique ; l’agrandisseur intégré bénéficie directement du modèle vectoriel.
Licence du document : MPL 2.0 Statut : draft, en attente de revue BDFL.
ADR-019 — Modèle d’erreurs hybride à deux niveaux
Statut : Accepté. Document fondateur de la phase 0.
Catégorie : Architecture (couche 0 et couche 1).
Contexte
Tout au long de la spécification de la couche 0, la question du type d’erreur retourné par les fonctions wrapper s’est posée. Deux préoccupations contradictoires sont apparues :
D’un côté, la couche 0 doit rester minimaliste, performante, et proche des conventions kernel. Un type d’erreur enrichi avec contexte, allocation, et chaîne de causalité serait disproportionné pour une fonction qui wrappe un syscall en quelques instructions assembleur. Le coût d’allocation seul peut être supérieur au coût du syscall lui-même.
De l’autre côté, les couches supérieures qui consomment la couche 0 ont besoin d’erreurs riches en contexte : “impossible d’ouvrir tel fichier à tel chemin pour telle raison” est beaucoup plus utile que “ENOENT”. Sans contexte attaché, l’erreur est difficile à propager utilement vers l’utilisateur final.
Décision
Air adopte un modèle d’erreurs hybride à deux niveaux :
Couche 0 utilise un type Errno minimaliste. Ce type est :
#[repr(transparent)]surNonZeroI32.- Sans allocation, sans contexte, sans chaîne de causalité.
- Une simple énumération des codes d’erreur kernel (
EBADF,ENOENT,EINTR, etc.). - ~140 constantes correspondant aux codes errno standards de Linux.
- Implémente
core::error::Errorpour permettre la chaîne viasource()aux couches supérieures.
Couches 1 et au-delà utilisent un type Error enrichi, propre à chaque crate, qui :
- Capture les erreurs spécifiques au domaine de la crate.
- Inclut le contexte pertinent (chemins, identifiants, etc.).
- Encapsule l’
Errnosource via#[from]pour permettre la propagation avec?. - Est dérivé via
thiserrorpour réduire le boilerplate.
Conversion entre les deux niveaux
La conversion d’Errno vers Error de la couche supérieure se fait :
- Automatiquement via
#[from]quand le contexte n’est pas pertinent. - Explicitement via
.map_err(...)quand on attache du contexte (chemin, identifiant).
#![allow(unused)]
fn main() {
// Conversion automatique
fn read_byte(fd: BorrowedFd<'_>) -> Result<u8, IoError> {
let mut buf = [0u8; 1];
air_sys_syscall::fs::read(fd, &mut buf)?; // Errno -> IoError automatique
Ok(buf[0])
}
// Conversion explicite avec contexte
fn open_config(path: &Path) -> Result<Config, ConfigError> {
let fd = openat2(DirFd::Cwd, path.as_cstr(), OpenHow::read_only())
.map_err(|errno| ConfigError::OpenFailed {
path: path.to_path_buf(),
source: errno,
})?;
// ...
}
}
Justifications
Performance préservée en couche 0. Errno est un NonZeroI32 ; pas d’allocation, pas de heap, taille fixe. Le coût d’une erreur est identique à un return statement classique.
Ergonomie en couches supérieures. Les développeurs d’application Air manipulent des Error riches qui donnent un diagnostic clair. Pas de “ENOENT” lapidaire remonté à l’utilisateur final.
Pas de double système d’erreurs. Un seul type kernel-level (Errno), un type par crate de haut niveau. La hiérarchie est claire et linéaire.
Composition naturelle via ?. Les conversions via #[from] permettent au développeur de propager les erreurs sans cérémonie quand le contexte n’est pas nécessaire.
Conformité au Principe d’ingénierie 4. La validation amont retourne Errno directement pour les violations d’invariants en couche 0, sans surcoût.
Implémentation
Le type Errno est défini dans la crate air-sys-types :
#![allow(unused)]
fn main() {
#[repr(transparent)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Errno(NonZeroI32);
impl Errno {
pub const EPERM: Self = Self(unsafe { NonZeroI32::new_unchecked(1) });
pub const ENOENT: Self = Self(unsafe { NonZeroI32::new_unchecked(2) });
pub const ESRCH: Self = Self(unsafe { NonZeroI32::new_unchecked(3) });
pub const EINTR: Self = Self(unsafe { NonZeroI32::new_unchecked(4) });
pub const EIO: Self = Self(unsafe { NonZeroI32::new_unchecked(5) });
// ... ~140 constantes au total
pub const fn as_raw(self) -> i32 {
self.0.get()
}
pub const fn name(self) -> &'static str {
match self.as_raw() {
1 => "EPERM",
2 => "ENOENT",
// ...
_ => "EUNKNOWN",
}
}
pub const fn description(self) -> &'static str {
match self.as_raw() {
1 => "Operation not permitted",
2 => "No such file or directory",
// ...
_ => "Unknown error",
}
}
}
impl core::fmt::Display for Errno {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "{} ({})", self.name(), self.description())
}
}
impl core::error::Error for Errno {}
}
Les types d’erreur des couches supérieures suivent ce pattern (exemple pour air-runtime-io) :
#![allow(unused)]
fn main() {
use thiserror::Error;
use air_sys_types::Errno;
#[derive(Debug, Error)]
pub enum IoError {
#[error("syscall failed: {source}")]
Syscall {
#[from]
source: Errno,
},
#[error("io_uring submission queue full")]
SubmissionQueueFull,
#[error("operation not supported on this kernel")]
OperationNotSupported,
}
}
Alternatives considérées et rejetées
Alternative 1 : Errno enrichi avec contexte en couche 0.
Rejetée parce que le coût d’allocation est disproportionné par rapport au coût d’un syscall qui échoue. Pour des fonctions appelées des millions de fois par seconde (lectures, écritures), le surcoût est mesurable.
Alternative 2 : Type d’erreur dynamique (Box<dyn Error>) partout.
Rejetée parce que Box<dyn Error> empêche le pattern matching exhaustif sur les variants d’erreur. Les couches supérieures perdent la capacité de réagir spécifiquement à EAGAIN ou EINTR.
Alternative 3 : Pas de type Errno, juste des i32 bruts.
Rejetée parce que cela perd toute information de typage. Confusion possible entre codes d’erreur et autres valeurs entières, pas de Display formatté, pas d’implémentation de Error.
Conséquences
Conséquence positive principale : la couche 0 reste performante et minimaliste. Les couches supérieures peuvent enrichir les erreurs sans contrainte.
Conséquence négative à accepter : deux types d’erreurs coexistent, ce qui ajoute une étape conceptuelle pour les contributeurs (comprendre quand passer de Errno à Error enrichi). Mitigation : documentation claire dans ERROR_HANDLING.md et exemples canoniques.
Statut futur
ADR immuable dans ses principes. L’évolution des types d’erreur enrichis spécifiques aux crates supérieures se fait normalement, sans amendement de cet ADR.
Licence du document : MPL 2.0 Statut : Document fondateur immuable.
ADR-020 — Stratégie signaux : signalfd par défaut, sigaction restreint
Statut : Accepté. Document fondateur de la phase 0.
Catégorie : Architecture (couche 0).
Contexte
La gestion des signaux Unix est notoirement difficile. Les handlers signaux installés via sigaction doivent respecter la contrainte d’être “async-signal-safe” : ne pas appeler de fonctions non listées comme telles dans man 7 signal-safety. La liste est très restreinte (pas de malloc, pas de printf, pas de lock), et il est facile de la violer involontairement.
Les conséquences d’une violation peuvent être catastrophiques : interblocages, corruption de mémoire, crashes intermittents difficiles à diagnostiquer. Beaucoup de bugs critiques dans les logiciels systèmes proviennent de handlers signaux mal écrits.
Le mécanisme moderne Linux signalfd permet de transformer les signaux en événements lisibles sur un file descriptor. L’application les lit comme n’importe quel autre FD, sans handler installé. Cela élimine structurellement la classe entière de bugs liés aux handlers async-signal-unsafe.
Cependant, signalfd ne couvre pas tous les cas. Les signaux synchrones fatals (SIGSEGV, SIGBUS, SIGFPE, SIGILL) sont délivrés directement à l’instruction fautive et ne peuvent pas être différés via signalfd. Pour ces signaux, un handler sigaction reste nécessaire (typiquement pour des crash reporters qui capturent l’état du processus avant terminaison).
Décision
Air adopte une stratégie en deux temps pour la gestion des signaux :
Mécanisme par défaut : signalfd pour tous les signaux différables.
Tous les signaux que l’application veut gérer (SIGINT, SIGTERM, SIGUSR1, SIGCHLD, etc.) sont gérés via signalfd. Le module air-sys-syscall::signal expose :
signalfd_create: création d’un FD signalfd.signalfd_create_blocking: helper qui combine création + blocage des signaux dans le masque (pattern recommandé).block_signals,unblock_signals,set_signal_mask,current_signal_mask: gestion du masque de signaux.wait_for_signal: helper pour les patterns simples “attendre un signal et faire quelque chose”.kill,tgkill,rt_sigqueueinfo: envoi de signaux.
Mécanisme restreint : sigaction uniquement pour les signaux synchrones fatals.
Un sous-module air-sys-syscall::signal::synchronous_handler expose sigaction mais uniquement pour les 4 signaux synchrones non-différables :
SIGSEGV(segmentation fault).SIGBUS(bus error).SIGFPE(floating point exception).SIGILL(illegal instruction).
Le type FatalSignal est un enum restreint à ces 4 variants. Pas moyen de passer un autre signal à cette API. La barrière est par construction, pas par discipline.
Justifications
Élimination structurelle des bugs async-signal-unsafe. Les développeurs Air n’écrivent pas de handlers signaux pour 99% des cas. Ils lisent un FD comme ils liraient un socket. Pas de risque d’appeler malloc ou printf dans un handler.
Intégration naturelle avec io_uring. Un FD signalfd peut être lu via une opération io_uring. Cela permet à un reactor io_uring d’attendre des signaux dans le même mécanisme que les autres événements asynchrones. Cohérent avec la philosophie d’Air où io_uring est central.
Intégration avec le runtime async (ADR-023). Le runtime asynchrone Air consomme signalfd directement pour la gestion des signaux. Pas besoin de pattern complexe self-pipe trick ou d’intermédiaire en thread dédié.
Conformité au Principe d’ingénierie 7 (verbosité au service de la clarté). Le code qui gère les signaux est explicite : “je lis ce FD, j’obtiens cette structure SignalFdInfo”. Pas de magie cachée dans un handler asynchrone.
Conservation de la capacité à gérer les crashes. Le sous-module synchronous_handler reste disponible pour les cas légitimes (crash reporters, stack guards via sigaltstack). Mais l’API est explicitement étroite et fortement documentée sur ses contraintes.
Implémentation
Le pattern canonique en signalfd :
#![allow(unused)]
fn main() {
use air_sys_syscall::signal::{
signalfd_create_blocking, SignalFdFlags, SignalMask,
};
use air_sys_types::Signal;
let mask = SignalMask::from_signals(&[
Signal::SIGTERM,
Signal::SIGINT,
Signal::SIGUSR1,
]);
let sfd = signalfd_create_blocking(&mask, SignalFdFlags::empty())?;
loop {
let info = sfd.read()?;
match info.signal {
Signal::SIGTERM | Signal::SIGINT => {
println!("Shutdown signal received");
break;
}
Signal::SIGUSR1 => {
println!("Custom signal received");
}
_ => unreachable!(),
}
}
}
Le pattern pour les signaux fatals (cas avancé) :
#![allow(unused)]
fn main() {
use air_sys_syscall::signal::synchronous_handler::{
install_fatal_handler, FatalSignal, SignalInfo,
};
unsafe extern "C" fn crash_handler(
signum: c_int,
info: *mut SignalInfo,
context: *mut c_void,
) {
// Code TRES restreint : uniquement async-signal-safe.
// Typiquement : écrire un mini-dump puis _exit.
}
// SAFETY: handler is async-signal-safe (only calls write and _exit).
unsafe {
install_fatal_handler(FatalSignal::Segv, crash_handler)?;
}
}
L’API unsafe du sous-module est obligatoire et la documentation # Safety est extensive.
Alternatives considérées et rejetées
Alternative 1 : sigaction pour tout, comme la convention Unix historique.
Rejetée. Productrice de bugs subtils, incompatible avec une intégration io_uring propre, demande une discipline soutenue que les développeurs perdent inévitablement.
Alternative 2 : signalfd pour tout, y compris les fatals.
Impossible. Les signaux synchrones fatals ne sont pas différables : ils sont délivrés au moment de l’instruction fautive, à l’endroit de cette instruction. Ils ne peuvent pas être attrapés via signalfd.
Alternative 3 : self-pipe trick (pattern classique).
Le self-pipe trick consiste à installer un handler signal minimal qui écrit dans un pipe, puis à lire le pipe dans le reactor. Fonctionnel mais plus complexe que signalfd (qui fait essentiellement la même chose en interne avec plus de garanties).
Conséquences
Conséquence positive principale : élimination d’une classe entière de bugs systèmes critiques. Les futurs développeurs Air n’auront jamais à débugger un handler signal qui appelle indirectement malloc.
Conséquence à accepter : un sous-module unsafe reste exposé pour les fatals. Mitigation : périmètre étroit (4 signaux seulement), API explicite, documentation # Safety substantielle, revue obligatoire pour toute utilisation.
Statut futur
ADR immuable dans ses principes. Des ajouts au sous-module synchronous_handler (par exemple, support de sigaltstack pour les stack guards) peuvent se faire sans amendement, tant qu’ils respectent la discipline (périmètre étroit, sécurité par construction).
Licence du document : MPL 2.0 Statut : Document fondateur immuable.
ADR-021 — Conventions transverses de la couche 0
Statut : Accepté. Document fondateur de la phase 0.
Catégorie : Méthode (conventions de design).
Contexte
Au fil de la spécification des différentes familles de la couche 0 (process, fs, mem, signal, time, net, ipc, security, system, et io_uring), plusieurs conventions transverses ont émergé. Ces conventions ne sont pas spécifiques à une famille ; elles s’appliquent uniformément à toute l’API de la couche 0.
Plutôt que les redocumenter dans chaque spec de famille, cet ADR les consigne au niveau du projet. Toute future addition à la couche 0 (ou aux couches qui suivent les mêmes conventions) doit s’y conformer.
Conventions
Convention 1 : Option<T> pour remplacer les sentinelles kernel
Les API kernel utilisent fréquemment des valeurs spéciales pour signifier “défaut”, “self”, ou “aucun”. Par exemple :
0danssetpgid(0, ...)signifie “le processus courant”.0danskill(pid, 0)signifie “tester l’existence sans envoyer de signal”.nullptrdans diverses structures kernel signifie “champ absent”.
Le wrapper Air remplace ces sentinelles par Option<T> typé :
#![allow(unused)]
fn main() {
// Kernel : setpgid(0, 0)
// Air : setpgid(None, None)
pub fn setpgid(pid: Option<Pid>, pgid: Option<Pid>) -> Result<(), Errno>;
// Kernel : kill(pid, 0)
// Air : kill(pid, None)
pub fn kill(pid: Pid, signal: Option<Signal>) -> Result<(), Errno>;
}
Justification. L’Option<T> est explicitement typé, lisible, et empêche la confusion entre “PID 0” (qui n’existe pas) et “PID courant” (sentinelle). Application du Principe d’ingénierie 7 (verbosité au service de la clarté).
Convention 2 : EINTR remonté à l’appelant, jamais retried automatiquement
Le syscall EINTR indique qu’une opération bloquante a été interrompue par un signal avant complétion. La convention historique est ambiguë : certaines bibliothèques retentent automatiquement, d’autres remontent à l’appelant.
Air remonte systématiquement EINTR à l’appelant en couche 0. Pas de retry automatique.
#![allow(unused)]
fn main() {
// Si l'appelant veut retry, il le fait explicitement
loop {
match fs::read(fd, &mut buf) {
Ok(n) => return Ok(n),
Err(Errno::EINTR) => continue,
Err(e) => return Err(e),
}
}
}
Justification. L’appelant a parfois besoin de savoir qu’un signal est arrivé pour réagir (par exemple, vérifier un drapeau de shutdown). Un retry automatique masquerait cette information. Pour les patterns où le retry est désiré, des helpers en couche 1 fourniront la boucle classique.
Convention 3 : Refus des wrappers génériques pour syscalls multiplexés
Plusieurs syscalls Linux sont multiplexés : un seul nom de syscall couvre 60+ opérations distinctes selon le premier argument. Exemples : prctl, fcntl, ioctl.
Air refuse d’exposer ces syscalls via un wrapper générique. À la place, chaque opération est exposée comme une fonction dédiée typée :
#![allow(unused)]
fn main() {
// PAS d'exposition générique comme :
// pub unsafe fn prctl(op: i32, arg2: u64, ...) -> Result<i32, Errno>;
// MAIS des fonctions individuelles typées :
pub fn set_no_new_privs() -> Result<(), Errno>;
pub fn get_no_new_privs() -> Result<bool, Errno>;
pub fn set_thread_name(name: &CStr) -> Result<(), Errno>;
pub fn get_thread_name() -> Result<CString, Errno>;
// ... etc
}
Justification. Les wrappers génériques prctl(op, arg2, arg3, ...) sont impossibles à utiliser safely : les types des arguments dépendent de l’opération, certaines opérations retournent des valeurs encodées différemment, certaines demandent des préconditions spécifiques. Exposer une API typée par opération concentre la complexité au point de définition (côté Air) plutôt qu’au point d’usage (côté appelant). Le coût en volume de code est compensé par la clarté et la sûreté.
Convention 4 : Pas d’allocation heap dans le happy path sauf nécessité documentée
La couche 0 vise à être performante et utilisable depuis tous les contextes, y compris des contextes contraints (pas de heap disponible, allocator custom, etc.).
Convention : pas d’allocation heap dans le happy path des fonctions wrapper sauf si :
- L’opération kernel demande intrinsèquement un buffer dynamique (par exemple,
getdents64qui lit un nombre variable d’entrées). - La sémantique exige un type owned (par exemple,
gethostnamequi retourne uneCString).
Dans ces cas, l’allocation est explicite et documentée.
Cas typique : les fonctions qui retournent un Result<Errno> n’allouent pas, même en cas d’erreur. L’Errno lui-même est NonZeroI32, sans allocation.
Justification. Performances prévisibles. Compatibilité future avec des allocateurs custom ou des contextes no_std. Respect du Principe d’ingénierie 5 (optimiser après mesure, mais sans dégrader gratuitement).
Convention 5 : Infallible pour les fonctions qui ne retournent jamais en succès
Certaines opérations Unix ne retournent jamais en cas de succès. Exemples :
execve: remplace l’image du processus, ne retourne que si échec.exit_group: termine le processus.
Pour exit_group, le type de retour est ! (never type) :
#![allow(unused)]
fn main() {
pub fn exit_group(status: i32) -> !;
}
Pour execve, qui peut échouer, le type est Result<Infallible, Errno> :
#![allow(unused)]
fn main() {
pub fn execve(
path: &CStr,
argv: &[&CStr],
envp: &[&CStr],
) -> Result<Infallible, Errno>;
}
Justification. Le Result<Infallible, Errno> est l’idiome Rust pour exprimer “soit ça retourne une erreur, soit ça ne retourne pas”. L’utilisateur peut écrire :
#![allow(unused)]
fn main() {
let _: Infallible = execve(path, &argv, &envp)?;
unreachable!("execve should not return on success");
}
Le type signale clairement que le succès est impossible dans le retour normal de la fonction.
Application
Ces 5 conventions s’appliquent à toute la couche 0, déjà spécifiée et future. Elles s’étendent par cohérence aux couches supérieures quand les patterns équivalents apparaissent.
Tout PR qui introduit une nouvelle fonction wrapper doit respecter ces conventions. Le reviewer vérifie en particulier :
- Sentinelles kernel converties en
Option<T>? - EINTR remonté tel quel (ou retry justifié) ?
- Pas d’API multiplexée générique ajoutée ?
- Pas d’allocation heap injustifiée ?
Infallibleou!utilisé quand approprié ?
Conséquences
Conséquence positive principale : cohérence transversale de l’API Air. Un développeur qui connaît une famille reconnaît immédiatement les patterns dans une autre famille.
Conséquence à accepter : volume de code accru par rapport à des wrappers génériques. Compensé par la clarté et la sûreté.
Statut futur
ADR immuable dans ses 5 conventions. Des conventions additionnelles peuvent émerger au fil du développement et seraient consignées dans un ADR successeur (ADR-021-bis) plutôt qu’amender celui-ci.
Licence du document : MPL 2.0 Statut : Document fondateur immuable.
ADR-022 — Architecture du module io_uring
Statut : Accepté. Document fondateur de la phase 0.
Catégorie : Architecture (couche 0).
Contexte
io_uring est le mécanisme d’I/O asynchrone moderne de Linux, introduit en kernel 5.1 et considérablement étendu dans les versions suivantes. Il offre des performances supérieures à epoll pour les workloads I/O-bound et permet l’expression d’opérations qui n’étaient pas possibles avec les API précédentes (linked operations, multishot, registered buffers).
io_uring est central dans l’architecture d’Air : il est le mécanisme principal d’I/O asynchrone, intégré directement à la couche 0 et consommé par le runtime async (ADR-023) en couche 1. La qualité du wrapper io_uring détermine en grande partie la performance et l’ergonomie du stack Air.
La conception du wrapper soulève plusieurs questions structurantes :
- Quel niveau d’abstraction adopter ? Bas niveau (très proche du kernel) ou haut niveau (encapsulant la complexité) ?
- Comment coexister avec les syscalls synchrones équivalents ?
- Comment gérer les buffers (qui doivent rester valides pendant la durée de l’opération) ?
- Comment exposer les fonctionnalités avancées (multishot, linked, registered) sans alourdir l’API basique ?
Cet ADR consigne les 10 décisions structurantes prises pour le module air-sys-syscall::io_uring.
Décisions
Décision 1 : Niveau d’abstraction 2 (soumission/complétion typée)
Le wrapper io_uring d’Air opère au niveau d’abstraction 2 : soumission d’opérations typées et récupération de complétions typées, sans exposer les ring buffers kernel directement.
#![allow(unused)]
fn main() {
// Niveau 2 (Air par défaut) :
let token = ring.submit_read(fd, buffer, offset)?;
let completion = ring.wait_completion()?;
let bytes_read = completion.bytes_read()?;
}
Le niveau 1 (manipulation directe des SQE/CQE) est exposé dans un sous-module air-sys-syscall::io_uring::raw pour les cas avancés.
Justification. Le niveau 2 offre l’ergonomie et la sûreté nécessaires pour 95% des usages. Le niveau 1 reste accessible pour les optimisations extrêmes et les usages que l’API typée ne couvre pas.
Décision 2 : Coexistence avec les syscalls synchrones, types partagés
Les opérations qui ont un équivalent synchrone (read, write, openat2, accept, connect, send, recv, etc.) sont exposées à la fois dans le module synchrone (air-sys-syscall::fs, ::net, etc.) et dans le module io_uring.
Les types utilisés sont partagés : SocketAddr, MessageFlags, OpenHow, etc. sont les mêmes types dans les deux mondes. Un développeur qui apprend l’API synchrone retrouve les mêmes types en io_uring.
Justification. Permet de choisir le mode selon le contexte (script de démarrage en synchrone, hot path en io_uring) sans réapprendre l’API. Les couches supérieures peuvent migrer progressivement entre les deux mondes.
Décision 3 : Trois mécanismes de buffers, ownership transfert par défaut
Pour les opérations io_uring qui lisent ou écrivent des données, le buffer doit rester valide pendant toute la durée de l’opération (jusqu’à la complétion). Trois mécanismes sont exposés :
Mécanisme 1 : Transfert d’ownership (défaut). L’opération prend le buffer en argument et le retourne dans la complétion. Le buffer est physiquement détenu par le ring entre la soumission et la complétion.
#![allow(unused)]
fn main() {
let buffer = vec![0u8; 1024];
let token = ring.submit_read(fd, buffer, 0)?;
let completion = ring.wait_completion()?;
let (buffer, bytes_read) = completion.into_read_result()?;
}
Mécanisme 2 : Registered buffers (performance). Les buffers sont enregistrés à l’avance via register_buffers, puis référencés par index dans les opérations. Évite la traduction d’adresses virtuelles à chaque opération.
Mécanisme 3 : Raw unsafe (cas extrême). Le développeur passe un pointeur brut et garantit la validité du buffer. Réservé aux optimisations qui ne peuvent pas s’exprimer autrement.
Justification. Le transfert d’ownership est sûr par construction (le compilateur empêche d’utiliser le buffer pendant que le ring le détient). Les modes avancés sont disponibles pour les cas où la performance brute prime sur l’ergonomie.
Décision 4 : Registration explicite, pas d’automatisation
L’enregistrement de FDs ou de buffers (register_files, register_buffers) est une décision explicite de l’application. Le wrapper Air ne tente pas d’enregistrer automatiquement.
Justification. L’automatisation cacherait des coûts et compliquerait le modèle mental. Le développeur qui veut le bénéfice de la registration le demande explicitement.
Décision 5 : Multishot et linked dans sous-modules dédiés
Les opérations multishot (accept multishot, poll multishot) et les chaînes d’opérations liées (linked operations) ont des sémantiques distinctes des opérations one-shot classiques. Elles sont exposées dans des sous-modules dédiés :
air-sys-syscall::io_uring::multishotpour les opérations qui produisent plusieurs complétions.air-sys-syscall::io_uring::linkedpour les chaînes d’opérations.
Justification. Séparation conceptuelle claire. L’API de base reste simple, les API avancées sont disponibles quand on en a besoin.
Décision 6 : IoUring Send mais pas Sync, SharedIoUring séparé
Le type principal IoUring est Send (peut être déplacé entre threads) mais pas Sync (ne peut pas être partagé par référence entre threads). Pour les usages multi-thread, un type séparé LockedIoUring ou SharedIoUring est exposé dans le sous-module air-sys-syscall::io_uring::shared.
Justification. La majorité des usages sont mono-thread (un reactor par thread, thread-per-core). Imposer Sync sur le type principal aurait un coût (lock interne) pour la majorité des cas. Les patterns multi-thread sont disponibles explicitement.
Décision 7 : Opérations sans équivalent syscall exposées individuellement
Certaines opérations io_uring n’ont pas d’équivalent syscall direct (par exemple, IORING_OP_NOP, IORING_OP_TIMEOUT, IORING_OP_LINK_TIMEOUT, IORING_OP_FILES_UPDATE). Elles sont exposées comme méthodes du IoUring.
Justification. Ces opérations sont spécifiques à io_uring et n’ont pas vocation à apparaître dans une famille de syscalls classique. Leur place est dans le module io_uring.
Décision 8 : Détection runtime via IORING_REGISTER_PROBE
io_uring évolue rapidement : chaque version kernel ajoute des opérations. Le wrapper Air utilise IORING_REGISTER_PROBE au démarrage pour détecter ce que le kernel courant supporte.
#![allow(unused)]
fn main() {
let ring = IoUring::new(256)?;
if ring.supports_op(IoUringOpcode::OpenAt2) {
// utiliser openat2 via io_uring
} else {
// fallback sur syscall synchrone
}
}
Justification. Air doit fonctionner sur Linux 5.15 LTS (kernel cible de référence pour Debian stable, Ubuntu LTS) tout en permettant l’usage des features plus récentes quand disponibles. La détection runtime est le mécanisme propre.
Décision 9 : Type Completion unique, méthodes d’interprétation typées
Les complétions io_uring contiennent un result (entier signé) dont la sémantique dépend de l’opération qui a produit la complétion. Plutôt qu’exposer un enum de toutes les complétions possibles (qui serait énorme), Air expose un type Completion unique avec des méthodes d’interprétation typées :
#![allow(unused)]
fn main() {
let completion = ring.wait_completion()?;
// Selon le type d'opération qu'on attend :
let bytes_read = completion.bytes_read()?; // pour read
let fd = completion.accepted_fd()?; // pour accept
let _ = completion.completed()?; // pour close
}
Le développeur sait quelle opération il a soumise et appelle la méthode appropriée. Si l’opération a échoué, la méthode retourne Err(Errno).
Justification. Évite l’explosion combinatoire d’un enum de complétions. L’API reste typée et claire, le développeur garde le contrôle.
Décision 10 : Gestion gracieuse de la disponibilité d’io_uring
Si le kernel ne supporte pas io_uring (très ancien) ou si l’environnement l’a désactivé (sandbox, container), IoUring::new() retourne une erreur explicite (Errno::ENOSYS ou équivalent).
L’application peut alors choisir de basculer sur les syscalls synchrones, ou de refuser de démarrer.
Justification. Pas d’échec silencieux. Pas de tentative cachée de fallback automatique. L’application décide.
Architecture résultante
Le module air-sys-syscall::io_uring est organisé en plusieurs sous-modules :
air-sys-syscall::io_uring::
├── (racine) -- API niveau 2 principale
├── ::registration -- register_files, register_buffers
├── ::linked -- chaînes d'opérations liées
├── ::multishot -- opérations multishot
├── ::shared -- variantes thread-safe (LockedIoUring, etc.)
└── ::raw -- accès niveau 1 (SQE/CQE bruts)
Cette organisation permet de découvrir progressivement les fonctionnalités : un développeur démarre avec l’API racine, et explore les sous-modules quand il a un besoin spécifique.
Phasage de spécification
La spécification du module io_uring est découpée en 4 Temps successifs :
- Temps 1 : cœur API (
IoUring,Completion, soumission/complétion de base). - Temps 2a : opérations filesystem (21 opérations : read, write, openat2, etc.).
- Temps 2b : opérations réseau (8 opérations : accept, connect, send, recv, etc.).
- Temps 2c : opérations async-spécifiques (9 opérations : nop, timeout, etc.).
- Temps 3a : registration (FdPool, RegisteredBuffer, ProvidedBuffers).
- Temps 3b : linked (LinkedChainBuilder).
- Temps 3c : multishot (accept/poll/recv multishot).
- Temps 3d : shared (LockedIoUring, RingPool, SqpollIoUring).
- Temps 4 : raw (RawSubmissionQueueEntry, RawCompletionQueueEntry, accès direct ring buffers).
Cette spec en 4 Temps est cohérente avec le phasage bottom-up de l’ADR-011.
Note (postérieure, non normative — n’altère aucune des 10 décisions). Les noms de types cités ci-dessus à titre illustratif (
FdPool,RegisteredBuffer,ProvidedBuffers…) datent d’avant la spécification détaillée. Les noms définitifs, alignés sur ADR-029 (nommage explicite), figurent dans les specs par Temps et le document maître :FixedFdTable,RegisteredBuffers,ProvidedBufferRing, etc. De même, le découpage et les comptes d’opérations (2a, 2b, 2c…) ont été révisés et étendus (ajout des Temps 2d, 3b, 3e, 3f) dans le document maître../specs/layer-0/io-uring-0-inventaire.md, qui fait foi pour l’inventaire. Les soundness S1/S2/S3 sont gravées en ADR-028.
Statut futur
ADR immuable dans ses 10 décisions. Des extensions au module io_uring (nouvelles opérations kernel) sont ajoutées normalement sans amendement, tant qu’elles respectent les conventions établies.
Licence du document : MPL 2.0 Statut : Document fondateur immuable.
ADR-022 — Architecture of the io_uring module
Status: Accepted. Founding document of phase 0.
Category: Architecture (layer 0).
Context
io_uring is Linux’s modern asynchronous I/O mechanism, introduced in kernel 5.1 and substantially extended in subsequent versions. It offers superior performance to epoll for I/O-bound workloads and enables the expression of operations that were not possible with previous APIs (linked operations, multishot, registered buffers).
io_uring is central to Air’s architecture: it is the primary asynchronous I/O mechanism, integrated directly into layer 0 and consumed by the async runtime (ADR-023) in layer 1. The quality of the io_uring wrapper largely determines the performance and ergonomics of the Air stack.
The design of the wrapper raises several structural questions:
- What abstraction level to adopt? Low-level (very close to the kernel) or high-level (encapsulating complexity)?
- How to coexist with the equivalent synchronous syscalls?
- How to manage buffers (which must remain valid for the duration of the operation)?
- How to expose advanced features (multishot, linked, registered) without burdening the basic API?
This ADR records the 10 structural decisions made for the air-sys-syscall::io_uring module.
Decisions
Decision 1: Abstraction level 2 (typed submission/completion)
Air’s io_uring wrapper operates at abstraction level 2: submission of typed operations and retrieval of typed completions, without directly exposing the kernel ring buffers.
#![allow(unused)]
fn main() {
// Level 2 (Air default):
let token = ring.submit_read(fd, buffer, offset)?;
let completion = ring.wait_completion()?;
let bytes_read = completion.bytes_read()?;
}
Level 1 (direct manipulation of SQE/CQE) is exposed in a submodule air-sys-syscall::io_uring::raw for advanced use cases.
Rationale. Level 2 provides the ergonomics and safety required for 95% of use cases. Level 1 remains accessible for extreme optimisations and use cases that the typed API does not cover.
Decision 2: Coexistence with synchronous syscalls, shared types
Operations that have a synchronous equivalent (read, write, openat2, accept, connect, send, recv, etc.) are exposed both in the synchronous module (air-sys-syscall::fs, ::net, etc.) and in the io_uring module.
The types used are shared: SocketAddr, MessageFlags, OpenHow, etc. are the same types in both worlds. A developer who learns the synchronous API will find the same types in io_uring.
Rationale. Allows choosing the mode according to context (startup script in synchronous mode, hot path in io_uring) without relearning the API. Higher layers can progressively migrate between the two worlds.
Decision 3: Three buffer mechanisms, ownership transfer by default
For io_uring operations that read or write data, the buffer must remain valid for the entire duration of the operation (until completion). Three mechanisms are exposed:
Mechanism 1: Ownership transfer (default). The operation takes the buffer as an argument and returns it in the completion. The buffer is physically held by the ring between submission and completion.
#![allow(unused)]
fn main() {
let buffer = vec![0u8; 1024];
let token = ring.submit_read(fd, buffer, 0)?;
let completion = ring.wait_completion()?;
let (buffer, bytes_read) = completion.into_read_result()?;
}
Mechanism 2: Registered buffers (performance). Buffers are registered in advance via register_buffers, then referenced by index in operations. Avoids virtual address translation on each operation.
Mechanism 3: Raw unsafe (extreme case). The developer passes a raw pointer and guarantees the validity of the buffer. Reserved for optimisations that cannot be expressed otherwise.
Rationale. Ownership transfer is safe by construction (the compiler prevents using the buffer while the ring holds it). Advanced modes are available for cases where raw performance takes precedence over ergonomics.
Decision 4: Explicit registration, no automation
Registering FDs or buffers (register_files, register_buffers) is an explicit decision of the application. The Air wrapper makes no attempt to register automatically.
Rationale. Automation would hide costs and complicate the mental model. The developer who wants the benefit of registration requests it explicitly.
Decision 5: Multishot and linked in dedicated submodules
Multishot operations (accept multishot, poll multishot) and linked operation chains have semantics distinct from classic one-shot operations. They are exposed in dedicated submodules:
air-sys-syscall::io_uring::multishotfor operations that produce multiple completions.air-sys-syscall::io_uring::linkedfor operation chains.
Rationale. Clear conceptual separation. The base API remains simple; advanced APIs are available when needed.
Decision 6: IoUring is Send but not Sync, separate SharedIoUring
The primary type IoUring is Send (can be moved between threads) but not Sync (cannot be shared by reference between threads). For multi-thread use cases, a separate type LockedIoUring or SharedIoUring is exposed in the submodule air-sys-syscall::io_uring::shared.
Rationale. The majority of use cases are single-threaded (one reactor per thread, thread-per-core). Imposing Sync on the primary type would have a cost (internal lock) for the majority of cases. Multi-thread patterns are available explicitly.
Decision 7: Operations with no syscall equivalent exposed individually
Some io_uring operations have no direct syscall equivalent (for example, IORING_OP_NOP, IORING_OP_TIMEOUT, IORING_OP_LINK_TIMEOUT, IORING_OP_FILES_UPDATE). They are exposed as methods of IoUring.
Rationale. These operations are specific to io_uring and are not intended to appear in a classic syscall family. Their place is in the io_uring module.
Decision 8: Runtime detection via IORING_REGISTER_PROBE
io_uring evolves rapidly: each kernel version adds operations. The Air wrapper uses IORING_REGISTER_PROBE at startup to detect what the current kernel supports.
#![allow(unused)]
fn main() {
let ring = IoUring::new(256)?;
if ring.supports_op(IoUringOpcode::OpenAt2) {
// use openat2 via io_uring
} else {
// fall back to synchronous syscall
}
}
Rationale. Air must run on Linux 5.15 LTS (the reference target kernel for Debian stable, Ubuntu LTS) while allowing use of more recent features when available. Runtime detection is the proper mechanism.
Decision 9: Single Completion type, typed interpretation methods
io_uring completions contain a result (signed integer) whose semantics depend on the operation that produced the completion. Rather than exposing an enum of all possible completions (which would be enormous), Air exposes a single Completion type with typed interpretation methods:
#![allow(unused)]
fn main() {
let completion = ring.wait_completion()?;
// Depending on the type of operation being awaited:
let bytes_read = completion.bytes_read()?; // for read
let fd = completion.accepted_fd()?; // for accept
let _ = completion.completed()?; // for close
}
The developer knows which operation they submitted and calls the appropriate method. If the operation failed, the method returns Err(Errno).
Rationale. Avoids the combinatorial explosion of a completions enum. The API remains typed and clear; the developer retains control.
Decision 10: Graceful handling of io_uring availability
If the kernel does not support io_uring (very old kernel) or if the environment has disabled it (sandbox, container), IoUring::new() returns an explicit error (Errno::ENOSYS or equivalent).
The application can then choose to fall back to synchronous syscalls, or to refuse to start.
Rationale. No silent failure. No hidden attempt at automatic fallback. The application decides.
Resulting architecture
The air-sys-syscall::io_uring module is organised into several submodules:
air-sys-syscall::io_uring::
├── (root) -- primary level-2 API
├── ::registration -- register_files, register_buffers
├── ::linked -- linked operation chains
├── ::multishot -- multishot operations
├── ::shared -- thread-safe variants (LockedIoUring, etc.)
└── ::raw -- level-1 access (raw SQE/CQE)
This organisation allows progressive discovery of features: a developer starts with the root API and explores submodules when they have a specific need.
Specification phasing
The specification of the io_uring module is divided into 4 successive phases:
- Phase 1: core API (
IoUring,Completion, basic submission/completion). - Phase 2a: filesystem operations (21 operations: read, write, openat2, etc.).
- Phase 2b: network operations (8 operations: accept, connect, send, recv, etc.).
- Phase 2c: async-specific operations (9 operations: nop, timeout, etc.).
- Phase 3a: registration (FdPool, RegisteredBuffer, ProvidedBuffers).
- Phase 3b: linked (LinkedChainBuilder).
- Phase 3c: multishot (accept/poll/recv multishot).
- Phase 3d: shared (LockedIoUring, RingPool, SqpollIoUring).
- Phase 4: raw (RawSubmissionQueueEntry, RawCompletionQueueEntry, direct ring buffer access).
This 4-phase spec is consistent with the bottom-up phasing of ADR-011.
Note (later, non-normative — does not alter any of the 10 decisions). The type names cited above for illustration (
FdPool,RegisteredBuffer,ProvidedBuffers…) predate the detailed specification. The final names, aligned with ADR-029 (explicit naming), appear in the per-Stage specs and the master document:FixedFdTable,RegisteredBuffers,ProvidedBufferRing, etc. Likewise, the breakdown and operation counts (2a, 2b, 2c…) were revised and extended (adding Stages 2d, 3b, 3e, 3f) in the master document../specs/layer-0/io-uring-0-inventaire-en.md, which is authoritative for the inventory. The soundness decisions S1/S2/S3 are recorded in ADR-028.
Future status
This ADR is immutable in its 10 decisions. Extensions to the io_uring module (new kernel operations) are added normally without amendment, as long as they respect the established conventions.
Document license: MPL 2.0 Status: Immutable founding document.
ADR-023 — Runtime asynchrone Air construit sur io_uring
Statut : Accepté. Document fondateur de la phase 1.
Catégorie : Architecture (couche 1).
Contexte
Air a besoin d’un runtime asynchrone pour orchestrer les opérations io_uring, gérer les futures Rust, scheduler les tâches. La couche 0 fournit les primitives kernel (io_uring, signalfd, timerfd) ; la couche 1 doit fournir une abstraction async ergonomique au-dessus.
Plusieurs options existent dans l’écosystème Rust :
Tokio : runtime de référence, très mature, écosystème énorme. Conçu initialement pour epoll, support io_uring via tokio-uring (crate annexe encore expérimentale). Très volumineux.
glommio : runtime conçu spécifiquement pour io_uring, architecture thread-per-core. Performances excellentes mais audience plus restreinte. Maintenu par DataDog.
smol : runtime minimaliste, écosystème async-std-compatible. Pas de support io_uring natif.
Runtime maison : construire un runtime spécifique à Air, intégré nativement avec la couche 0.
Décision
Air construit son propre runtime asynchrone, air-runtime, plutôt que d’adopter un runtime existant. Le runtime est conçu nativement sur io_uring (couche 0) et exclusif à Linux. Il vit en couche 1 et consomme directement l’API de air-sys-syscall::io_uring.
Le runtime n’est pas construit en phase 0 (qui se concentre sur la couche 0 et les types fondamentaux). Il est construit en phase 1, en parallèle du modèle d’objet C-ABI.
Justifications
Cinq raisons principales motivent la construction d’un runtime maison plutôt que l’adoption de Tokio, glommio, ou smol.
Premièrement, l’alignement avec io_uring. La couche 0 d’Air est entièrement spécifiée autour d’io_uring : signal handling via signalfd, timers via timerfd, sockets et fichiers via io_uring natif. Les runtimes existants ont été conçus pour epoll et portent cette dette dans leur design. Tokio supporte io_uring via une crate annexe encore expérimentale qui ne couvre qu’une fraction des opérations ; glommio est io_uring-natif mais avec une architecture thread-per-core qui ne correspond pas à tous les patterns Air ; smol n’a pas de support io_uring. Construire le runtime maison permet une cohérence native impossible à obtenir autrement.
Deuxièmement, le Principe d’ingénierie 6 (règle des 80 %). Tokio est massif : Air n’utiliserait au mieux que 20 à 30 % de sa surface API. La règle des 80 % impose soit le vendoring partiel (très coûteux pour un runtime async, qui est interconnecté), soit une exception explicite (qui serait substantielle pour un composant aussi structurant). Glommio et smol sont mieux placés mais imposent quand même des conventions et des dépendances qui ne sont pas les nôtres.
Troisièmement, le Principe d’ingénierie 7 (verbosité au service de la clarté). Tokio cache beaucoup de complexité derrière des macros (#[tokio::main], select!) et des conventions implicites (auto-wake, work-stealing, etc.). Un développeur Air doit pouvoir lire et comprendre tout le code qui exécute son application. Un runtime maison construit explicitement satisfait cette exigence par construction.
Quatrièmement, la leçon de l’expérience Tokio. L’écosystème Rust async a maintenant plusieurs années de recul sur Tokio. Plusieurs choix initiaux se sont révélés problématiques avec le temps : pinned futures qui demandent des hacks ergonomiques, ABI instable entre versions majeures, intégration io_uring difficile à rétrofitter. Construire un runtime à la lumière de cette expérience permet d’éviter ces écueils dès le début.
Cinquièmement, l’alignement philosophique avec le projet. Air construit son stack du bas vers le haut, avec ses propres conventions et son propre vocabulaire. Intégrer Tokio (ou tout runtime tiers majeur) en couche 1 introduirait une dissonance : la couche 0 est pure Air, la couche 1 emprunterait massivement, les couches 2+ reviendraient sur les conventions Air. La cohérence verticale du stack est un atout important pour la durée de vie du projet.
Périmètre du runtime
Le runtime maison se concentre sur ce dont Air a besoin, ni plus ni moins. Périmètre cible pour la phase 1 :
- Un scheduler de tâches asynchrones (
TaskAir). - Un mécanisme d’attente de complétions io_uring intégré au scheduler.
- Des primitives async pour les opérations courantes (sleep, timeout, select).
- Des primitives de synchronisation async (mutex, channel, notify).
- Une intégration avec signalfd pour la gestion des signaux async.
- Une intégration avec timerfd pour les timers async.
Périmètre explicitement exclu (au moins pour la phase 1) :
- Work-stealing scheduler complexe. Le modèle initial est thread-per-core ou simple round-robin selon les besoins.
- Support multi-runtime (plusieurs instances coexistantes).
- Compatibilité avec l’écosystème Tokio. Les crates tierces qui dépendent de Tokio ne fonctionneront pas avec air-runtime ; c’est assumé.
Ces exclusions peuvent être levées en phase 2 ou 3 si l’expérience montre un besoin réel.
Architecture proposée
Le runtime est organisé en plusieurs crates au sein du workspace Air :
air-runtime/
├── air-runtime-core/ -- Scheduler, Task, primitives de base
├── air-runtime-io/ -- Intégration io_uring
├── air-runtime-time/ -- Timers basés sur timerfd
├── air-runtime-signal/ -- Gestion des signaux async via signalfd
├── air-runtime-sync/ -- Mutex async, channels, notify
└── air-runtime/ -- Façade qui réexporte les modules nécessaires
Chaque sous-crate suit les Principes d’ingénierie Air (test exhaustif, validation amont, etc.). La crate façade air-runtime est ce que les couches supérieures consomment.
Coût estimé
Construire un runtime async Rust de qualité production sur io_uring représente plusieurs mois-personne. C’est un investissement non négligeable mais qui s’amortit sur la durée de vie du projet (10+ ans selon la promesse de stabilité ABI). Comparé au coût d’une dépendance externe massive comme Tokio (audit continu, migrations forcées, friction d’intégration), l’investissement est défendable.
Statut futur
ADR immuable dans son intention. L’ajout de fonctionnalités au runtime au fil des phases (work-stealing scheduler en phase 3 si nécessaire, par exemple) est de la responsabilité du développement normal et ne requiert pas d’amendement.
Si l’expérience montre que le runtime maison est intenable (ce qui serait surprenant mais possible), un RFC explicite peut le remplacer par un runtime externe. Cette éventualité est mentionnée pour transparence ; elle n’est pas anticipée.
Licence du document : MPL 2.0 Statut : Document fondateur immuable.
ADR-024 — Workflow de gestion des dépendances
Statut : Accepté. Document fondateur de la phase 0.
Catégorie : Méthode (gouvernance technique).
Contexte
Le Principe d’ingénierie 6 d’Air énonce la “règle des 80 %” : si moins de 80 % du code d’une crate de dépendance est effectivement utilisé par Air, la dépendance est refusée et le code nécessaire est intégré (vendoré partiellement) au lieu d’être consommé comme dépendance externe.
Cette règle est claire dans son intention mais demande un workflow opérationnel pour être appliquée systématiquement. Sans processus explicite, les contributions vont naturellement vers la solution facile (ajouter une dépendance) plutôt que vers l’évaluation rigoureuse de chaque ajout.
Décision
Air met en place un workflow explicite de gestion des dépendances qui opérationnalise le Principe 6. Le workflow comprend trois éléments :
- Un format standardisé de documentation des dépendances (
DEPENDENCIES.md). - Un format de documentation des exceptions (
EXCEPTIONS.md). - Un processus d’ajout encadré par revue.
- Un audit régulier en fin de phase.
Format DEPENDENCIES.md par crate
Chaque crate Air contient à sa racine un fichier DEPENDENCIES.md qui liste exhaustivement ses dépendances directes. Chaque entrée documente :
- Nom et version (avec range de compatibilité).
- Objectif fonctionnel.
- Pourcentage estimé d’API utilisée.
- Justification de l’inclusion.
- Coût estimé de désengagement (faible, moyen, élevé, catastrophique).
- Statut d’audit (date de dernière revue, identité du reviewer).
- Statut éventuel (temporaire, en cours de désengagement, etc.).
Exemple :
# Dependencies of air-sys-syscall
## Direct dependencies
### bitflags
- **Version:** 2.4 (range: 2.4, <3.0)
- **Purpose:** Macro to define type-safe bitmask types (CloneFlags, OpenFlags, etc.).
- **API usage estimated:** 100% (the `bitflags!` macro is used as-is for ~15 types).
- **Justification:** Defining bitmask types manually involves ~150 lines of boilerplate per type. The macro generates equivalent code that follows Rust conventions and is well-tested.
- **Disengagement cost:** Low (2-3 days). Each `bitflags!` invocation expands to deterministic Rust code that can be written manually.
- **Audit status:** Reviewed 2026-01-15 by [reviewer]. No security or licensing concerns.
- **Last reviewed:** 2026-01-15
Le format est standardisé pour permettre l’agrégation et l’analyse à l’échelle du workspace.
Format EXCEPTIONS.md au workspace
Un fichier EXCEPTIONS.md à la racine du workspace Air liste les dépendances qui bénéficient d’une exception explicite à la règle des 80 %. Chaque exception documente :
- Le nom de la dépendance.
- L’usage estimé (qui ne respecte pas le seuil).
- La justification détaillée de l’exception.
- L’autorité qui a approuvé (avec date).
- La fréquence de révision prévue.
Exemple :
# Dependency exceptions to the 80% rule
## icu4x (cf. ADR-016)
- **API usage:** Variable (~30-50% across Air components).
- **Exception justification:** icu4x is the worldwide reference implementation
for Unicode and internationalization. Reproducing it would be a multi-year
project with high risk of subtle bugs in character handling. The cost of
inclusion (even partial) is outweighed by the benefits of correct
internationalization from day one.
- **Approved by:** [Founder], 2026-01-15.
- **Review frequency:** End of every major phase.
Les exceptions sont rares et délibérées. La première exception inscrite est icu4x (cf. ADR-016).
Processus d’ajout d’une dépendance
Toute nouvelle dépendance dans un PR doit faire l’objet d’une analyse explicite documentée dans la description du PR. L’analyse comprend :
- Le besoin fonctionnel précis.
- Les alternatives évaluées (autres crates, implémentation maison, fonctionnalité standard).
- L’estimation du pourcentage d’API utilisée.
- L’estimation du coût de désengagement.
- Une entrée correspondante dans
DEPENDENCIES.mddu PR. - Si exception nécessaire, une entrée dans
EXCEPTIONS.mdégalement.
Le mainteneur qui revoit le PR valide la nécessité, la justification, et les estimations. Une dépendance ne peut pas être ajoutée sans cette analyse.
Audit en fin de phase
À chaque fin de phase majeure (phase 0, phase 1, etc.), un audit complet des dépendances est conduit. L’audit consiste à :
- Revoir chaque entrée
DEPENDENCIES.mdde chaque crate. - Vérifier que l’usage estimé reste cohérent avec l’usage réel (mesurable via les outils Rust).
- Vérifier les mises à jour disponibles et les vulnérabilités annoncées (
cargo audit). - Réévaluer le coût de désengagement.
- Pour les dépendances temporaires (statut explicite), décider de la migration ou de la prolongation.
- Pour les exceptions, vérifier que la justification reste valide.
L’audit produit un rapport public qui documente les décisions prises.
Outillage CI
Le pipeline CI Air intègre dès le démarrage :
cargo audità chaque PR pour détecter les vulnérabilités.cargo denyconfiguré pour appliquer les règles Air (licences acceptables, versions interdites, etc.).cargo machetepour détecter les dépendances déclarées mais non utilisées.- Un script Air spécifique qui vérifie que chaque dépendance déclarée dans
Cargo.tomla une entrée correspondante dansDEPENDENCIES.md. Un PR qui ajoute une dépendance sans entrée correspondante est rejeté en CI.
Échelle de coût de désengagement
Le coût de désengagement est estimé sur une échelle à quatre niveaux, documentée pour assurer la cohérence des estimations entre contributeurs :
Faible (jours) : la dépendance fournit une fonctionnalité bien définie qui pourrait être réimplémentée manuellement en moins d’une semaine de travail. Exemples typiques : macros utilitaires, helpers de bas niveau.
Moyen (semaines) : la dépendance fournit une fonctionnalité substantielle dont la réimplémentation prendrait plusieurs semaines mais reste accessible. Exemples typiques : crates de parsing, wrappers d’API système modérément complexes.
Élevé (mois) : la dépendance est structurelle, son remplacement représente plusieurs mois de travail d’un développeur expérimenté. Exemples typiques : crates de cryptographie, bibliothèques d’internationalisation.
Catastrophique (année+) : la dépendance est intégrée à un niveau si profond que son remplacement représenterait une refonte majeure du projet. Aucune dépendance Air ne devrait avoir ce niveau ; si une analyse identifie un coût catastrophique, c’est un signal d’alarme qui demande une revue architecturale.
Conséquences
Le workflow ajoute du surcoût à chaque ajout de dépendance (rédaction de l’entrée DEPENDENCIES.md, analyse formelle). Ce surcoût est intentionnel : il décourage les ajouts de dépendances réflexes et force une réflexion proportionnée à l’engagement de long terme que représente une dépendance.
Le bénéfice est triple : la qualité du stack technique reste maîtrisée sur la durée, les contributeurs comprennent les engagements pris, et les audits de fin de phase ont une base documentaire structurée.
Statut futur
ADR immuable dans ses principes. Les détails de format (DEPENDENCIES.md, EXCEPTIONS.md) peuvent évoluer par convention sans amendement ADR, tant que les principes de transparence, justification, et révision périodique sont préservés.
Licence du document : MPL 2.0 Statut : Document fondateur immuable.
ADR-025 — Stratégie de builds reproductibles
Statut : Accepté. Document fondateur de la phase 0.
Catégorie : Méthode (gouvernance technique et infrastructure).
Contexte
Un build reproductible est un build qui, à partir des mêmes sources, produit des artefacts binaires bit-pour-bit identiques indépendamment de la machine, du moment, ou de l’utilisateur qui le déclenche. Cette propriété, longtemps considérée comme un détail d’ingénierie, est devenue un élément structurant de la chaîne de confiance d’un projet logiciel : elle permet à n’importe quel tiers de vérifier que les binaires distribués correspondent exactement aux sources publiées.
Pour Air, la reproductibilité n’est pas un objectif décoratif. Elle découle directement de plusieurs engagements antérieurs :
- Charte principe 3 (Confiance par défaut) : la confiance ne se déclare pas, elle se vérifie. Tant qu’un utilisateur ne peut pas vérifier qu’un binaire d’Air correspond aux sources auditables, la confiance reste un acte de foi.
- Charte principe 5 (Indépendance et durabilité institutionnelle) : la reproductibilité protège le projet contre la compromission d’une infrastructure de build unique.
- ADR-013 (Distribution Modèle C → B) : pendant la phase d’incubation, les distributions Linux tierces re-construisent Air depuis les sources. La reproductibilité leur donne un moyen technique de vérifier qu’aucun écart n’a été introduit.
- ADR-015 (Gouvernance et DCO) : la transparence de gouvernance se prolonge naturellement par la transparence de la chaîne de build.
- Phase C de setup (
../setup/phase-C-infrastructure.md, section 3 « Builds reproductibles ») : l’objectif a été inscrit dès la définition de l’infrastructure, en attente du présent ADR pour formalisation.
L’instruction technique de cet ADR a tranché trois arbitrages structurants : périmètre d’artefacts en phase 0, calendrier d’application de la contrainte, et famille d’outillage retenue. Ces arbitrages sont consignés dans la section Décision ci-après. Les pratiques détaillées qui en découlent (toolchain, env vars, flags, vérification CI) sont consignées dans la section Mécanismes techniques.
Décision
Air vise la reproductibilité bit-pour-bit des artefacts Rust natifs produits par cargo build, dès le premier commit de la phase 0, par une approche minimaliste fondée sur la discipline et les capacités natives de Rust.
Cette décision se décompose en quatre points fermes :
-
Périmètre : la reproductibilité s’applique aux binaires et bibliothèques Rust produits par
cargo buildpour toutes les crates Air (couches 0 et 1 dans un premier temps, étendu mécaniquement aux couches supérieures à mesure de leur ajout au workspace). Les artefacts de plus haut niveau — bundles.airapp/.airservice(ADR-010), images OS, paquets distros tiers — font l’objet d’ADRs ultérieurs et ne sont pas couverts ici. -
Cible : un même tag Git, build sur n’importe laquelle des machines de référence d’ADR-014 (Raspberry Pi 4 ARM64, Mac Intel x86_64, runners cloud x86_64), produit des artefacts dont les hashes SHA-256 sont identiques pour une cible donnée. La reproductibilité est intra-cible (deux builds x86_64 produisent le même binaire x86_64), pas inter-cible.
-
Calendrier : la reproductibilité est bloquante en CI dès le premier commit de la phase 0. Toute PR qui casse la reproductibilité est rejetée. Rationale : Rust est en grande partie reproductible par défaut ; le coût d’introduction est faible à condition de l’imposer dès le départ. Reporter la contrainte serait accepter une dette technique rétroactive coûteuse.
-
Outillage : approche minimaliste —
rust-toolchain.tomlstrict,Cargo.lockcommitté,RUSTFLAGSwhitelisté,SOURCE_DATE_EPOCHdérivée du commit Git, build dans un container minimal Debian stable pinné par digest. Vérification par double-build et comparaison viadiffoscope. Aucun système de build alternatif (pas de Nix, pas de Bazel, pas derepro-envdédié). Cette approche est cohérente avec le Principe d’ingénierie 7 (verbosité au service de la clarté, pas de magie) et avec le Principe 6 (règle des 80 % sur les dépendances).
Mécanismes techniques
Toolchain pinnée
Un fichier rust-toolchain.toml à la racine du workspace fige la toolchain Rust de manière exacte :
[toolchain]
channel = "1.96.0"
components = ["rustfmt", "clippy", "llvm-tools-preview"]
targets = ["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"]
profile = "minimal"
Aucun usage de canaux flottants (stable, beta, nightly). Le channel est une version exacte. Toute mise à jour de toolchain passe par une PR dédiée qui (a) justifie la mise à jour, (b) exécute la suite de tests reproductibilité complète, (c) est explicitement approuvée par un mainteneur senior. La mise à jour ne peut pas être combinée à d’autres changements fonctionnels.
Dépendances pinnées
Le fichier Cargo.lock est committé à la racine du workspace. Il fige l’arbre transitif complet des dépendances à des versions exactes (incluant les hashes). Cohérent avec ADR-024 (workflow dépendances) qui acte le DEPENDENCIES.md par crate.
Dans les Cargo.toml, les dépendances utilisent des ranges semver explicites ("2.4", "2.4.0") sans wildcards ("*", ">=2.0" sans borne haute). Les ranges sont resserrés par le Cargo.lock lors du build effectif.
Container de build minimal
Le CI exécute les builds dans un container Debian stable dont l’image est référencée par son digest SHA-256 (ex. debian@sha256:abc123...). L’image contient uniquement : la toolchain pointée par rust-toolchain.toml (installée via rustup aux versions exactes), mold comme linker (version pinnée), et les utilitaires strictement nécessaires (git, pkg-config, clang si besoin pour build.rs FFI). Pas d’inclusion d’utilitaires « pratiques » qui pourraient introduire des variations.
Le digest de l’image est mis à jour par PR explicite, traitée comme une mise à jour de toolchain.
Variables d’environnement contrôlées
Le job CI démarre avec un environnement nettoyé. Une whitelist explicite définit les variables autorisées :
PATH(réduit à la toolchain et aux utilitaires du container).HOME,CARGO_HOME,RUSTUP_HOME(pointés vers des chemins déterministes dans le container).RUSTFLAGS(figé, voir ci-dessous).SOURCE_DATE_EPOCH(dérivée du timestamp du commit Git viagit log -1 --format=%ct).CARGO_NET_OFFLINE=trueaprès l’étape de récupération des dépendances, pour interdire tout fetch implicite.
Toute autre variable est explicitement unset. Le script CI vérifie cette whitelist avant de lancer cargo build.
Flags de compilation
RUSTFLAGS est figé dans le CI et dans .cargo/config.toml :
[build]
rustflags = [
"-D", "warnings",
"--remap-path-prefix", "/build=.",
"-C", "codegen-units=1",
"-C", "link-arg=-Wl,--build-id=none",
]
Justifications :
--remap-path-prefixneutralise les chemins absolus du workspace de build dans les binaires (sinon le chemin du runner CI s’incruste dans les symboles).codegen-units=1garantit un ordre déterministe de génération de code en LLVM, au prix d’un build légèrement plus lent. Acceptable.--build-id=noneneutralise l’ID de build généré par le linker (sinon il varie à chaque build par construction).
Profils release du Cargo.toml figés : pas de incremental = true, pas de variation de opt-level par machine.
Linker reproductible
Linker imposé : mold (version pinnée via le container). Justification : mold est plus déterministe que ld.bfd ou gold sur les détails de layout, et il est suffisamment mature pour Air. Cohérent avec Principe 6 (utilisation large justifiée).
Symboles debug : produits dans des fichiers compagnons .debug séparés via objcopy --only-keep-debug. Le binaire principal référence le fichier debug par chemin relatif (pas absolu). Les .debug sont reproductibles également.
Inputs déterministes dans le code
Discipline imposée par convention et vérifiée par revue de code :
build.rsne lit pas l’environnement non-déterministe : pas d’inclusion de date courante, hostname, UID, état machine. Si unbuild.rsgénère du code, il le génère à partir de fichiers committés dans le workspace, jamais depuis l’environnement courant.- Code généré (
include_str!, macros procédurales, codegen) : ordres déterministes garantis. En particulier, les itérations surHashMap/HashSetsont remplacées parBTreeMap/BTreeSetquand l’output dépend de l’ordre. - Inputs filesystem :
globet listings de répertoire triés alphabétiquement avant traitement.
Ces règles sont consignées dans STYLE_GUIDE.md (à produire, cf. backlog) avec exemples et anti-patterns.
Vérification en CI
Pipeline pr-quick
Pas de test de reproductibilité (priorité au feedback rapide). Validation des prérequis seulement : Cargo.lock à jour, rust-toolchain.toml non modifié hors PR dédiée.
Pipeline pr-full
Double cargo build --release consécutif sur la même machine, comparaison via diffoscope. Diff non vide → CI rouge, PR bloquée. Rapport diffoscope archivé comme artefact CI pour diagnostic.
Outil dédié : script air-repro-check (à produire au démarrage du projet) qui orchestre le double-build, neutralise les caches entre les deux passes, et invoque diffoscope avec format de rapport humain-lisible.
Pipeline pr-hardware
Build sur Pi 4 ARM64 et sur Mac Intel x86_64, comparaison intra-cible avec le build cloud x86_64. Vérifie la reproductibilité cross-machine pour chaque triplet cible. Obligatoire avant merge sur les PRs touchant la couche 0 ou les configurations de build.
Pipeline main-nightly
Triple build sur chacune des machines de référence (cf. ADR-014). Comparaison croisée complète. Hash SHA-256 des artefacts publié dans un journal auditable.
Outillage de vérification
diffoscope est l’outil de référence pour analyser les différences entre deux artefacts binaires. Largement utilisé par Debian, NixOS et le projet Reproducible Builds. Couvre les binaires ELF, les archives .rlib, la documentation HTML produite par cargo doc. Couverture API estimée >80 % au sens du Principe 6, donc dépendance recevable et inscrite dans DEPENDENCIES.md au moment de son introduction.
Articulation avec les ADRs existants
- ADR-010 (
.airapp/.airservice, entitlements signés) : la reproductibilité Air assure que les binaires inclus dans les bundles sont auditables avant signature. La signature des bundles vient au-dessus de cette base. Un ADR ultérieur traitera spécifiquement de la reproductibilité du processus de bundle (manifeste, layout, signature détachée). - ADR-013 (Distribution Modèle C → B) : la reproductibilité donne aux distributions tierces (Modèle C) un moyen technique de vérifier que leur build local correspond à l’upstream Air. Précieux pour la supply chain security et pour la confiance mutuelle pendant la phase d’incubation.
- ADR-014 (catalogue de matériel) : les machines de référence servent à la fois aux tests fonctionnels et à la validation de reproductibilité cross-machine. La diversité du catalogue (Pi 4, Mac Intel) est un atout : elle révèle des sources de non-déterminisme qu’un parc homogène masquerait.
- ADR-015 (Licence, DCO, gouvernance) : reproductibilité comme propriété auditable du projet, prolongement naturel de la transparence de gouvernance.
- ADR-024 (workflow de dépendances) :
Cargo.lockcommitté etDEPENDENCIES.mddétaillé sont des prérequis pour la reproductibilité. ADR-024 et ADR-025 se renforcent mutuellement. - Principe d’ingénierie 1 (test exhaustif des fondations) : la reproductibilité est un test transverse appliqué à tout commit ; cohérent avec l’esprit du Principe 1 même s’il n’est pas exprimé en termes de coverage.
- Principe d’ingénierie 6 (règle des 80 %) : l’approche minimaliste retenue (pas de système de build alternatif) découle directement de ce principe.
diffoscopeest la seule dépendance d’outillage spécifique introduite, et elle dépasse largement le seuil d’utilisation. - Principe d’ingénierie 7 (verbosité au service de la clarté) : la stratégie est lisible et auditable. Toutes les variables, flags, et étapes sont explicites dans des fichiers committés (
rust-toolchain.toml,.cargo/config.toml, scripts CI), pas dans des configurations cachées.
Conséquences
Bénéfices
- Confiance technique vérifiable. Un utilisateur, chercheur en sécurité, ou distributeur peut re-construire Air depuis les sources et vérifier que son binaire correspond exactement à l’upstream. Cette propriété n’est pas une déclaration, elle est un test reproductible.
- Supply chain security. Un attaquant qui distribuerait un binaire « légèrement modifié » de
libair-base.soserait détecté par toute partie qui vérifie le hash. La barrière à l’attaque est élevée. - Détection précoce de sources de non-déterminisme. Beaucoup de bugs liés à l’ordre d’itération sur
HashMap, aux conditions de course de codegen, ou aux interactions avec l’environnement, deviennent visibles dans l’écart entre deux builds. La reproductibilité bit-pour-bit force la chasse à ces sources, qui sont par ailleurs des sources de bugs subtils en production. - Préparation des couches supérieures. La reproductibilité des binaires Rust est la base sur laquelle s’appuieront, en phase 4+, la reproductibilité des bundles
.airappet celle des images OS.
Coûts
- Discipline supplémentaire pour les contributeurs. Pas de variables d’environnement libres dans le CI, pas de date/hostname/UID dans
build.rs, pas d’HashMapdans le code généré. Cette discipline est formalisée dans leSTYLE_GUIDE.mdà produire. - Investissement initial en outillage. Script
air-repro-check, configuration CI multi-pipeline, container minimal pinné. Le coût est concentré au démarrage du projet et amorti par la durée. - Procédure formalisée pour les mises à jour de toolchain. Une mise à jour de Rust ne peut plus être un changement opportuniste — elle est traitée comme une migration mineure d’infrastructure.
Risques et mitigations
- Risque : une dépendance externe introduit du non-déterminisme (rare en Rust, mais possible). Mitigation : analyse au moment de l’ajout (workflow ADR-024) ; détection en CI ; entrée d’exception dans
EXCEPTIONS.mdsi la dépendance est indispensable et le non-déterminisme négocié à un niveau cosmétique. - Risque : une mise à jour de toolchain Rust casse la reproductibilité existante (changements LLVM, codegen, etc.). Mitigation : la PR de mise à jour exécute le test de reproductibilité ; si elle échoue, l’écart est analysé et la mise à jour est différée ou accompagnée d’adaptations.
- Risque : différences subtiles inter-machines (Pi 4 vs Mac Intel) que la cible « intra-cible » ne couvre pas. Mitigation : le pipeline
pr-hardwaredétecte tôt ;diffoscopepermet le diagnostic ; le projet Reproducible Builds publie un catalogue de problèmes connus et leurs résolutions.
Statut futur
ADR immuable dans ses principes (périmètre, calendrier, approche outillage minimaliste). Les détails opérationnels (version exacte de rust-toolchain.toml, digest de l’image Debian, contenu de RUSTFLAGS) sont documentés dans INFRASTRUCTURE.md à la racine du workspace (à produire) et peuvent évoluer par convention sans amendement de l’ADR, tant que les principes de l’ADR sont préservés.
ADRs ultérieurs identifiés :
- Reproductibilité des bundles
.airapp/.airservice(instruction conjointe avec la chaîne de signature complète). - Reproductibilité des images OS Air (à instruire au moment de la définition de l’image OS).
- Politique d’archivage long terme des artefacts reproductibles auditables.
Licence du document : MPL 2.0 Statut : Document fondateur immuable.
ADR-026 — Contrat 1.0 d’air-tui : UI texte riche, cellulaire, capability-gated
Statut : Accepté. Document fondateur de design pré-implémentation.
Catégorie : Architecture (couche 4 — framework TUI).
Contexte
L’ADR-008 acte qu’air-tui supporte trois backends d’exécution : air-console
en bypass complet, terminal Air en protocole privé, et terminal tiers/SSH en
mode dégradé. L’ADR-009 acte que l’API framework d’Air est exclusivement
déclarative et n’expose ni pixels ni cellules à l’application. L’ADR-018 acte
que le mode console d’Air reste nativement cellulaire, contrairement au mode
graphique qui suit un modèle vectoriel.
Ces trois décisions donnent la direction générale, mais elles laissent ouverte
une question décisive pour air-base : qu’est-ce qu’une UI texte riche Air
au juste, et quel contrat minimal est garanti à une application TUI ?
Sans ce contrat, air-tui risquerait de dériver soit vers un simple wrapper de
terminal ANSI, soit vers une tentative confuse de reproduire une GUI dans un
milieu cellulaire. Aucune de ces deux directions n’est acceptable.
Décision
Air définit air-tui comme un framework keyboard-first, cellulaire
natif, orienté commandes sémantiques, capable de se dégrader
prédictiblement selon les capacités du backend d’exécution.
La décision se décompose en huit points.
1. Le mode texte riche d’Air reste strictement cellulaire
air-tui ne poursuit pas un objectif de rendu pixel-perfect ni de layout libre
en coordonnées physiques. Son modèle de sortie est une scène cellulaire :
- cellules adressables logiquement ;
- glyphes stylés ;
- attributs de cellule ;
- régions sales et invalidation incrémentale ;
- curseurs, sélections et zones de focus modélisés explicitement.
L’application ne manipule pas directement ces cellules ; elle déclare des vues
et le renderer air-ui-render-tui résout la scène finale.
Conséquence ferme : aucune primitive publique air-tui ne dépend d’un
positionnement absolu en pixels ou en colonnes/lignes.
2. L’API publique est orientée layout logique, jamais physique
Le layout s’exprime exclusivement par des contraintes logiques compatibles avec ADR-009 :
- piles verticales et horizontales ;
- grilles logiques ;
- panneaux fractionnés ;
- contraintes
min/ideal/max; - alignement, spacing, padding ;
- règles d’élision, wrapping et scroll.
air-tui refuse les APIs du style move_to(x, y) ou draw_at(col, row) dans
sa surface publique stable. De telles opérations peuvent exister dans des
couches internes ou expérimentales, jamais comme contrat applicatif de premier
rang.
3. Le framework fournit un noyau réduit de primitives TUI stables
Le set minimal de primitives 1.0 est volontairement restreint. Il couvre les cas d’usage productifs du mode texte sans chercher l’exhaustivité :
TextetSpanstylé ;Block/Panel;List;Table;Tree;Input;Editor;ScrollView;Tabs;StatusBar;Dialog;CommandPalette;Progress;Charttextuel simple.
Les widgets 1.0 sont pensés pour être :
- navigables intégralement au clavier ;
- rendables proprement en cellule ;
- sérialisables en arbre logique pour inspection et test ;
- dégradables sans rupture fonctionnelle majeure.
4. Le modèle d’événements repose sur des événements structurés, pas sur des séquences terminal brutes
air-tui standardise un modèle d’événements sémantiques commun à tous les
backends. Le backend traduit son environnement local vers ce modèle :
KeyDownKeyUpModifiersChangedMouseDownMouseUpMouseMoveScrollFocusGainedFocusLostResizePasteImeCommitquand disponibleCommandpour les actions résolues
Dans les backends dégradés où certaines informations n’existent pas nativement, l’événement manquant est signalé comme indisponible par le système de capacités, jamais simulé de manière mensongère.
5. Les commandes sémantiques sont first-class
Le contrat d’interaction d’air-tui n’est pas fondé uniquement sur les touches
brutes. Le framework introduit un niveau de résolution explicite :
input brut -> binding -> commande sémantique -> dispatch
Exemples de commandes sémantiques :
app.quitscene.confirmcollection.move_nexteditor.savescene.open_palettepane.focus_left
Cette décision répond à trois objectifs :
- rebindabilité des raccourcis ;
- accessibilité et introspection cohérentes ;
- dégradation plus propre entre backends riches et terminaux tiers.
6. Les capacités sont déclarées explicitement en trois niveaux
L’API Capabilities d’ADR-008 est précisée en trois profils conceptuels.
Profil A — Full Console
Exécution sous air-console, avec bypass complet :
- couleurs riches ;
- souris native ;
KeyDownetKeyUpgarantis ;- modifiers complets ;
Resizefiable ;- Unicode sérieux ;
- focus structuré ;
- clipboard et IME selon disponibilité système.
Profil B — Air Terminal
Exécution dans un terminal Air parlant le protocole privé du framework :
- garanties proches du profil A ;
- transport indirect mais sémantique quasi équivalente ;
- même contrat applicatif par défaut.
Profil C — Compatible Terminal
Exécution sur terminal tiers ou SSH via Kitty keyboard protocol ou ANSI :
- clavier potentiellement réduit ;
- souris optionnelle ;
KeyUpnon garanti ;- rendu Unicode dépendant du terminal ;
- paste, resize et modifiers parfois partiels.
Le développeur consomme une seule API, mais le framework expose clairement les capacités garanties, optionnelles, ou absentes.
7. La dégradation doit être visuelle d’abord, fonctionnelle le moins possible
Air impose une règle de design ferme :
- la dégradation visuelle est acceptable ;
- la dégradation ergonomique est acceptable si elle est prévisible ;
- la dégradation fonctionnelle critique n’est pas acceptable silencieusement.
Exemples de dégradations acceptées :
- palette moins ornée ;
- couleurs réduites ;
- souris absente ;
- panneaux repliés ;
- scroll plus grossier ;
- widget avancé remplacé par variante plus simple.
Exemples de dégradations interdites en silence :
- action essentielle inaccessible ;
- focus incohérent ;
- raccourci critique perdu sans alternative ;
- affichage corrompu ;
- faux support déclaré pour une capacité absente.
8. air-tui ne vise ni l’émulation parfaite des terminaux historiques ni la copie d’une GUI
Les non-objectifs 1.0 sont explicites :
- pas de promesse de compatibilité exhaustive avec tous les comportements ANSI historiques ;
- pas de placement libre au pixel ;
- pas d’animations lourdes comme fondation du framework ;
- pas de dépendance primaire à la souris ;
- pas de modèle d’imagerie vectoriel en console ;
- pas de tentative de transformer le mode texte en sous-Wayland.
air-tui vise un médium texte moderne, pas un compromis flou entre terminal
legacy et GUI.
Justifications
air-base doit être un produit complet, pas une absence de GUI
L’ADR-006 pose air-base comme profil premier rang. Cela impose un framework
texte capable de produire des applications confortables, structurées et
productives, et non une simple collection de flux ANSI.
Le mode texte a des forces propres qu’il faut assumer
Le mode texte excelle en densité informationnelle, en latence, en robustesse, en navigation clavier, et en fonctionnement distant. Le framework doit renforcer ces qualités au lieu de les dissoudre dans un imaginaire graphique mal adapté.
Le capability-gating est la seule manière honnête de concilier local riche et remote dégradé
Le même code applicatif doit pouvoir viser air-console, terminal Air, et
terminal tiers. Cette ambition n’est tenable que si le framework nomme
explicitement les garanties de chaque backend au lieu de prétendre qu’ils sont
équivalents.
Le modèle par commandes sémantiques prépare la stabilité et l’accessibilité
Un framework fondé uniquement sur les touches brutes dérive vite vers des comportements ad hoc, difficiles à remapper, difficiles à exposer aux aides techniques, et difficiles à stabiliser sur dix ans.
Conséquences
Bénéfices
- contrat clair pour les développeurs TUI ;
- meilleure cohérence entre
air-consoleetair-tui; - dégradations prévisibles entre local riche et terminal tiers ;
- base saine pour accessibilité, tests d’interface et introspection ;
- différenciation nette d’Air face aux simples stacks terminales POSIX.
Coûts
- davantage de travail initial qu’une bibliothèque ANSI classique ;
- nécessité de concevoir tôt un vrai modèle de focus, de commandes et de capacités ;
- maintenance d’un backend dégradé compatible sans laisser ce backend dicter la qualité du design principal.
Risques assumés
- certains outils terminal historiques s’intègreront moins naturellement au
modèle
air-tuique des applications Air natives ; - une partie des attentes héritées du monde ANSI devra être explicitement rejetée ;
- le framework devra résister à la tentation d’ajouter trop vite des widgets ou escape hatches non cohérents avec le modèle cellulaire.
Articulation avec les autres ADRs
- ADR-006 —
air-baseest un profil premier rang, ce qui justifie un framework TUI ambitieux. - ADR-007 —
air-consolefournit le backend local riche qui rend ce contrat réaliste. - ADR-008 — cet ADR précise le contrat applicatif sous-jacent aux trois backends déjà actés.
- ADR-009 — le layout reste déclaratif, sans coordonnées physiques publiques.
- ADR-016 — Unicode, bidi et IME restent des exigences transverses ; leur niveau de garantie dépend des capacités du backend.
- ADR-017 — commandes sémantiques, focus explicite et arborescence logique facilitent l’accessibilité.
- ADR-018 — confirme le caractère strictement cellulaire du mode console.
Statut futur
ADR immuable dans son intention.
Les détails de surface API (noms exacts de types, modules, traits, enums,
widgets additionnels) relèveront des futures specs d’implémentation de
air-tui, mais devront respecter ce contrat.
Licence du document : MPL 2.0 Statut : Document fondateur. Édition directe autorisée en phase de design pré-ouverture publique ; immuable après ouverture publique (toute évolution via RFC, cf. ADR-015).
ADR-027 — Stratégie de documentation polyglotte (ABI C et bindings multi-langages)
Statut : Accepté — mise en œuvre différée à la couche 2.
Catégorie : Documentation / Architecture (couche 2 — ABI & polyglotte).
Contexte
La couche 0 (air-sys-types, air-sys-syscall) est du Rust interne
(publish = false), sans ABI C publique : sa documentation (rustdoc) s’adresse à
des développeurs Rust. Or la vision d’Air est polyglotte dès le départ : le
modèle d’objet et les services sont exposés en ABI C à partir de la couche 2
(cf. ADR-002), consommables sans glue depuis C, Swift, Python, Ruby
(libair-runtime.so, libair-aircom.so). Cette ABI est un contrat stable
(garantie 10 ans, outillée — Principe 8, ADR-012).
Il manque une décision sur comment documenter cette surface pour les développeurs non-Rust, de façon cohérente, testée et maintenable, sans maintenir à la main une documentation parallèle qui dériverait.
Décision
- Source unique de vérité. La documentation C / polyglotte est générée
depuis le code Rust (commentaires
///sur l’APIpub extern "C"), jamais rédigée en parallèle. Le code et sa doc ne font qu’un (cohérent avec le workflow « doc d’abord » du projet). - Outillage C (socle).
cbindgengénère le header.h(types#[repr(C)]+ fonctionsextern "C") en reportant les commentaires de doc ; le header est à la fois artefact de doc et artefact de contrat ABI (réutilisable par l’outillage de stabilité —air-abi-check,air-symver). - Multi-langages (à instruire). Pour l’ambition Swift/Python/Ruby, évaluer
Diplomat(bindings + doc idiomatiques multi-langages depuis une API Rust) ; le choix définitif fera l’objet d’un ADR dédié au moment de la couche 2, sous la règle des 80 % de dépendances (ADR-024). - Intégration docbook. Une section « API C / Polyglotte » du mdBook est
produite par
scripts/build-docs.sh(cbindgen → header annoté → Doxygen ou équivalent → markdown embarqué), aux côtés de la référence rustdoc. Un hook est réservé dansbuild-docs.shdès maintenant. - Périmètre / calendrier. Mise en œuvre différée à la couche 2 : tant qu’aucune crate n’expose d’ABI C, rien n’est généré. La couche 0 reste du Rust interne. Convention à tenir dès la première crate C-ABI : son API publique porte des commentaires de doc de qualité « export ».
Conséquences
- + Doc C/polyglotte toujours synchrone du code ; développeurs non-Rust traités en citoyens de première classe ; le header généré sert aussi de contrat ABI.
- + Pas de documentation parallèle à maintenir (zéro dérive).
- − Introduit des dépendances d’outillage de doc (
cbindgen, Doxygen, éventuellementDiplomat) à auditer sous ADR-024 au moment de la couche 2. - − Impose une discipline de commentaires « export-quality » sur l’API C-ABI.
Alternatives écartées
- Doc C rédigée à la main : rejetée (dérive inévitable vs le code).
- ABI C dès la couche 0 : rejetée (sans valeur — la libc le fait déjà — et contraire à l’architecture en couches).
- Pas de doc C dédiée : rejetée (contredit la vision polyglotte, ADR-002).
Addendum (2026-06-25) — format de rendu + doc des OBLIGATIONS du développeur C
Précise la mise en œuvre (clôt le « Doxygen ou équivalent » de la Décision §4) et grave les exigences de contenu orientées développeur C.
A. Format de rendu — Doxygen (choix arrêté)
- Référence par symbole = Doxygen sur le header généré par
cbindgen. Raisons : standard du monde C (le dev C s’attend à le lire) ; génère HTML + pagesman(man air_log_open) ; langage-agnostique, mature, déterministe (version pinnée,HTML_TIMESTAMP=NO— ADR-025). Écartés : Sphinx+Breathe (toolchain Python plus lourde), rustdoc (non lu par les devs C). - Header
cbindgenCOMMITTÉ (comme le.rscapnpc d’ADR-040) : c’est un contrat ABI — committé, régénéré par le mainteneur, et diffé en CI pour détecter toute dérive d’ABI (consommé parair-abi-check/air-symver). Versionscbindgen/Doxygen pinnées. - Intégration mdBook : section « API C / Polyglotte » (Décision §4), FR + EN (convention doc du projet).
B. Doc des OBLIGATIONS du développeur C (exigence de CONTENU, gravée)
La doc par-symbole ne suffit pas : des guides conceptuels mdBook (FR+EN) doivent expliquer comment utiliser la lib et quelles sont les obligations de l’appelant. Couverture obligatoire :
- Mise en route : header à inclure, lien (
-lair-base), versionnement/ABI (ADR-012). - Modèle d’erreurs :
AirError/AirStatus(ADR-019) — comment tester, lire, propager ; conventions de valeur de retour et de nullité ; aucunepanicne traverse l’ABI. - Propriété mémoire & cycle de vie (LE point critique). Pour chaque type complexe/
opaque, la doc DOIT énoncer explicitement, sans ambiguïté :
- qui alloue / qui libère, et avec quelle fonction (
air_*_free/air_*_release) ; - transfert de propriété sur RETOUR : un type complexe retourné est-il possédé par l’appelant (→ il doit le libérer) ou emprunté/partagé (→ il ne doit pas le libérer) — règle uniforme +1 retours / +0 arguments du modèle d’objet (air-object) ;
- types complexes passés en ARGUMENT : empruntés (+0), l’appelant garde la propriété ; l’appelé ne libère pas, ne conserve pas de référence au-delà de l’appel (sauf doc explicite) ;
- chaînes / buffers /
out-paramètres : qui possède la mémoire, durée de validité, taille, encodage (octets vs UTF-8) ; - refcompté :
retain/releaseappariés ; secrets : effacement (zeroize) à charge de qui. → Objectif : un dev C sait toujours s’il doit libérer un type, et comment.
- qui alloue / qui libère, et avec quelle fonction (
- Thread-safety par type : politiques déclarées (Immutable / MainThreadOnly / ThreadSafe, air-object) — ce qui est appelable depuis quel thread.
- Exemples C compilables (testés autant que possible — un échantillon dans la suite ABI).
Discipline gravée : chaque fonction
pub extern "C"porte un commentaire///« export-quality » qui inclut sa clause de propriété (retour possédé/emprunté ; arguments empruntés ; fonction de libération).cbindgenla reporte dans le header ; Doxygen la rend. La doc de propriété n’est donc pas un guide séparé qui dérive : elle naît du commentaire de chaque symbole + est synthétisée dans le guide « propriété mémoire ».
C. Portée
Mis en œuvre à la première passe ABI C (à commencer par air-base-lib → libair-base.so).
Les dépendances de doc (cbindgen, Doxygen) auditées sous ADR-024 à ce moment.
ADR-028 — Soundness et téardown du module io_uring (S1/S2/S3)
Statut : Accepté. Complète l’ADR-022 sans en modifier les 10 décisions.
Catégorie : Architecture (couche 0).
Contexte
L’ADR-022 fixe l’architecture du module air-sys-syscall::io_uring (niveau
d’abstraction 2, coexistence syscalls/io_uring, trois mécanismes de buffers,
etc.). La spécification détaillée du module (document maître
../specs/layer-0/io-uring-0-inventaire.md et Temps 1
../specs/layer-0/io-uring-1-core.md) a fait émerger trois décisions de
soundness qui ne figuraient pas dans ADR-022 et qui déterminent toutes les
signatures de la façade. Elles doivent être gravées comme décisions
d’architecture à part entière.
io_uring est asynchrone et concurrent avec le kernel : un buffer soumis peut être écrit par le kernel après le retour de l’appel de soumission, et jusqu’à la complétion. Deux classes de défauts en découlent si l’API est mal conçue :
- Coût d’allocation : retrouver, à la complétion, le buffer et les
métadonnées d’une opération via son
user_datatente d’imposer une table dynamique (allocation par opération) — en conflit avec la règle « pas d’allocation heap dans le happy path » (CLAUDE.md, conventions couche 0). - Usage après libération : libérer le ring (ou un buffer) alors qu’une opération est encore en vol laisse le kernel écrire dans de la mémoire libérée — le défaut de soundness majeur d’io_uring en Rust.
Par ailleurs, io_uring peut exécuter des opérations qui contournent les filtres seccomp (l’opération n’est pas un syscall), ce qui pose un problème de confinement pour un système à capabilities (ADR-001 AirCom, ADR-010 entitlements).
Décisions
S1 — État des opérations en vol : slab pré-alloué
L’IoUring possède un slab pré-alloué dimensionné (par défaut) à la
capacité de la file de complétion (cq_entries). Chaque opération en vol occupe
un slot ; le buffer transféré (modèle d’ownership, ADR-022 Décision 3) y est
déplacé (move), sans copie ni réallocation. Le SubmissionToken encapsule un
index de slot + génération (compteur de génération anti-réutilisation) ; le user_data kernel encode cet
index. Le slab plein provoque un refus (EBUSY) avant tout appel kernel,
fournissant une back-pressure structurelle. Conséquence : zéro allocation
heap par opération dans le happy path.
S2 — Téardown sûr : Drop quiescent + shutdown() explicite
shutdown(self)est la voie propre : annulation globale (REGISTER_SYNC_CANCEL), drainage des complétions restantes, puis fermeture du FD et libération de la mémoire ; bornée par un timeout.Dropest un filet de sécurité : s’il reste des opérations en vol, il quiesce (annule + draine, de façon bloquante et best-effort) avant de rendre la mémoire. Le coût d’unDroppotentiellement bloquant est assumé et documenté ; l’usage soucieux de performance appelleshutdown().- Invariant : tant qu’une opération est en vol, son buffer vit dans son slot (S1) et n’est pas libérable par l’appelant ; le ring ne peut être détruit sans quiescence. Ces invariants garantissent qu’aucune écriture kernel ne tombe sur de la mémoire libérée.
Choix conforme au Principe d’ingénierie 5 (« sur-sécuriser puis dégraisser après
mesure, jamais l’inverse ») : un Drop non sûr en couche 0 est inacceptable.
S3 — Confinement comme primitive de capability
Le triptyque SETUP_R_DISABLED + REGISTER_RESTRICTIONS + REGISTER_ENABLE_RINGS
est traité en première classe (Temps 3f). Un ring peut être créé désactivé,
restreint à une liste blanche d’opcodes / register-ops / drapeaux SQE
(default-deny), puis activé — les restrictions devenant immuables, imposées
par le kernel. La couche 0 fournit le mécanisme (RestrictionSet,
restrict, enable) ; la politique (traduction des entitlements signés
d’ADR-010 en restrictions) vit en couche supérieure. C’est la brique io_uring du
modèle de capabilities d’Air, en défense en profondeur avec seccomp/Landlock
(family-security), fermant la voie de contournement des filtres syscall.
Articulation avec les autres ADRs
- ADR-022 : ADR-028 le complète. Les 10 décisions d’ADR-022 restent inchangées ; S1/S2/S3 s’y ajoutent comme décisions de soundness du même module.
- ADR-021 (conventions couche 0) : S1 met en œuvre « pas d’allocation heap
dans le happy path » et l’usage de
Option/newtypes (token typé) ; le slab respecte la non-allocation. - ADR-001 / ADR-010 : S3 matérialise au niveau kernel le modèle de capabilities AirCom et les entitlements signés.
- ADR-023 (runtime async) : le runtime de couche 1 s’appuie sur ces invariants (slab, téardown sûr) pour offrir des primitives async sûres.
Conséquences
Bénéfices
- Sûreté mémoire par construction (pas d’usage après libération, pas de buffer accessible pendant qu’il est détenu par le kernel).
- Happy path sans allocation, back-pressure naturelle.
- Confinement kernel-enforced aligné sur le modèle de sécurité d’Air.
Coûts
- Un
Droppotentiellement bloquant (quiescence) : assumé, documenté, contournable parshutdown(). - Le slab impose une borne d’opérations en vol (
cq_entriespar défaut, ajustable) : c’est une limite explicite, pas un défaut.
Risques et mitigations
- Risque : la quiescence en
Dropmasque un oubli deshutdown()et coûte en latence à la destruction. Mitigation : documentation + lint/conseil d’appelershutdown()sur chemin chaud. - Risque : sous-dimensionnement du slab →
EBUSYfréquents. Mitigation :max_inflight()explicite au builder ; métriquein_flight().
Statut futur
ADR complémentaire d’ADR-022, immuable dans ses trois décisions (S1/S2/S3) sauf
RFC. Le filtrage fin par programme BPF (IORING_REGISTER_BPF_FILTER), postérieur
au kernel 6.12, pourra enrichir S3 ultérieurement, par détection runtime, sans
amendement.
Licence du document : MPL 2.0 Statut : Document d’architecture, complément d’ADR-022.
ADR-029 — Nommage de la surface publique : explicite, sans abréviation ; noms d’autorité conservés à l’interface
Statut : Accepté. Décline le Principe d’ingénierie 7 pour le nommage de l’API.
Catégorie : Méthode (transverse, tout le framework).
Contexte
Air produit des API destinées à des développeurs de tous niveaux, y compris des débutants qui ne connaissent pas (encore) les interfaces du kernel Linux. Un des buts du framework est de décomplexifier l’accès au système : apprendre les concepts est déjà exigeant, l’API ne doit pas y ajouter d’ambiguïté.
Or les abréviations sont une source d’erreur silencieuse pour qui ne connaît pas
déjà l’API. Un sénior lit sock et pense « socket » sans effort ; un débutant
qui a lu « socket » dans la documentation peut douter que sock désigne la
même chose — et le doute coûte du temps et induit des erreurs. L’abréviation est
invisible pour l’expert, piégeuse pour le débutant.
Cet ADR érige en règle ce que le Principe 7 (« verbosité au service de la clarté ») implique pour le nommage de l’API, et tranche les cas limites rencontrés en spécifiant le module io_uring (ADR-022, ADR-028).
Décision
Règle des deux zones
Zone 1 — la surface offerte au développeur (types, fonctions, méthodes, paramètres, champs publics que Air conçoit) : noms explicites, en toutes lettres, sans abréviation.
socket, passock;buffer, pasbuf;address, pasaddr;length, paslen;previous, pasprev;completion_queue_entries, pascq_entries;attach_work_queue, pasattach_wq;IoUringOpcode, pasIoUringOp; etc.
Zone 2 — l’interface avec une autorité que le développeur peut consulter : noms conservés verbatim, pour que le développeur retrouve le même nom dans la documentation de référence et fasse le lien.
Les autorités reconnues sont :
- L’ABI du kernel Linux — noms de syscalls, d’opcodes, de structures et de
champs (
io_uring_sqe,IORING_OP_*,sqe->res,struct sock_filter,utsname,pidfd…). Le développeur les retrouve dans les man pages / le header uapi. - La bibliothèque standard de Rust — types et conventions de
std(OwnedFd,BorrowedFd,RawFd,IoSlice,SocketAddr,Instant…). Le développeur les retrouve dans la doc destd; les renommer diminuerait la familiarité, pas l’inverse. - Les termes universels Unix/POSIX —
errno,pid,tid,fd: un vocabulaire que tout développeur système croise partout.
Le test de décision
« Ce nom, est-ce Air qui l’a inventé, ou est-il hérité d’une autorité que le développeur ira consulter ? »
- Inventé par Air ⇒ explicite, sans abréviation (Zone 1).
- Hérité d’une autorité (kernel /
std/ POSIX) ⇒ conservé verbatim (Zone 2).
Illustration cardinale :
Completion::sock_nonempty()est un nom coiné par Air qui abrège un concept (« socket ») → Zone 1 → à renommer (socket_has_pending_data).BorrowedFdn’est pas une abréviation d’Air : c’est le nom du type de la bibliothèque standard (std::os::fd::BorrowedFd) → Zone 2 → conservé. De mêmestruct sock_filter(nom exact du kernel) resteSockFilter— lesocky est l’ABI kernel, pas une abréviation d’Air.
Autrement dit : le même fragment sock est proscrit quand c’est nous qui
abrégeons « socket » dans un nom maison, et conservé quand il fait partie
d’un nom d’autorité que le développeur peut retrouver.
Nuance des types « miroir » (ex. niveau brut io_uring, Temps 4)
Un type public dont la raison d’être est de refléter 1:1 une structure kernel suit une règle mixte, déjà appliquée au Temps 4 :
- le nom du type est explicite (Zone 1) :
RawSubmissionQueueEntry(et nonRawSqe) ; - ses champs qui miroitent la struct kernel conservent les noms kernel
(Zone 2) :
user_data,res,flags,addr— précisément pour que le développeur fasse le lien avecstruct io_uring_cqe.
Conséquences
Bénéfices
- API lisible et sans ambiguïté pour les débutants ; décomplexification réelle.
- Cross-référence préservée vers le kernel,
stdet POSIX pour les développeurs qui creusent. - Règle de décision objective : tranche sans débat les cas limites (cf.
cq_entriesbuilder →with_completion_queue_entries;attach_wq→attach_work_queue).
Coûts
- Identifiants plus longs (verbosité assumée — c’est l’esprit du Principe 7).
- Une passe de renommage de la surface existante (io_uring et familles couche 0) est nécessaire pour mettre l’existant en conformité.
Portée et application
- S’applique à toute la surface publique d’Air, toutes couches.
- Les abréviations tolérées comme mots à part entière d’usage universel
(
id,min,max,info) sont admises au cas par cas, mais la préférence va toujours à la forme explicite quand elle ne nuit pas à la lisibilité. - Un futur
STYLE_GUIDE.mdréférencera cet ADR pour la partie nommage.
Articulation avec les autres ADRs
- Principe d’ingénierie 7 : cet ADR en est la déclinaison opérationnelle pour le nommage de l’API.
- ADR-021 (conventions transverses couche 0) : même esprit (lisibilité, pas de magie cachée) ; ADR-029 étend la portée à toute la surface publique.
- ADR-022 / ADR-028 (io_uring) : les renommages de la façade io_uring
(
IoUringOpcode,with_completion_queue_entries,attach_work_queue,socket_has_pending_data,RawSubmissionQueueEntry…) appliquent cet ADR.
Statut futur
ADR immuable dans sa règle (deux zones + test de décision) sauf RFC. La liste des
abréviations universelles tolérées peut être précisée dans le STYLE_GUIDE.md
sans amender cet ADR.
Licence du document : MPL 2.0 Statut : Document de méthode, transverse au framework.
ADR-030 — Périmètre de la règle des 80 % : dépendances de production vs test-only
Statut : Accepté. Companion d’ADR-024 (n’amende pas ADR-024, en précise le périmètre).
Catégorie : Méthode (gouvernance technique).
Contexte
Le Principe d’ingénierie 6 (« règle des 80 % ») et son workflow opérationnel (ADR-024) imposent : si moins de 80 % de l’API d’une crate candidate est utilisée, la dépendance est refusée et le code nécessaire est intégré (vendoré partiellement) en sous-module Air.
Cette règle a été pensée pour le code livré : ce qui entre dans l’artefact binaire d’Air, en détermine l’ABI, la reproductibilité (ADR-025) et la surface de supply-chain du produit. Elle ne répond pas explicitement au cas des outils de test qui ne sont jamais livrés.
L’implémentation du module io_uring (couche 0) en a besoin pour atteindre le
standard de test couche 0 (Principe 1) :
- proptest — property-based testing (invariants du slab S1, du jeton slot+génération) ;
- loom — modèle de concurrence du protocole d’anneau acquire/release
(§3.2 de
io-uring-1-core.md), activé uniquement souscfg(loom); - cargo-fuzz (
libfuzzer-sys+arbitrary) — fuzzing du décodage des CQE et desio_uring_params(données kernel = externes, Principe 3), dans une cratefuzz/séparée.
Appliquer la règle des 80 % et le vendoring à ces frameworks serait absurde : ce sont des dizaines de milliers de lignes, non raisonnablement vendorables, et ils ne participent pas au binaire livré.
Décision
La règle des 80 % et l’obligation de vendoring d’ADR-024 / Principe 6
s’appliquent aux dépendances de PRODUCTION — celles déclarées en
[dependencies], qui entrent dans l’artefact livré, son ABI et sa
reproductibilité.
En sont EXEMPTÉES les dépendances qui ne sont jamais livrées :
- les dépendances
[dev-dependencies](tests, benchmarks, exemples) ; - les dépendances activées uniquement par un
cfgde test/modèle (p. ex.[target.'cfg(loom)'.dependencies]) ; - la (les) crate(s)
fuzz/séparée(s), hors du workspace couche 0.
Cette exemption porte uniquement sur la règle des 80 % et le vendoring.
Garde-fous maintenus (l’exemption ne les lève PAS)
Les dépendances test-only restent soumises à :
- Licences :
deny.tomlreste autoritaire sur l’arbre transitif complet (une dev-dep qui tirerand,bit-set, … : c’est tout l’arbre qui doit passercargo deny check, pas seulement les crates de tête). Allowlist inchangée (MPL-2.0, Apache-2.0, MIT, BSD-3-Clause, ISC, Unicode-DFS-2016). - Advisories :
cargo auditvert sur l’arbre complet. - Documentation : entrée par crate dans le
DEPENDENCIES.mdde la crate, avec le champ « % API utilisée » = N/A (test-only) et la justification du carve-out référençant le présent ADR. - Isolation
fuzz/: la cratefuzz/a ses propresCargo.toml/Cargo.locket ne touche ni la reproductibilité ni la mesure de couverture du produit. La barrière de couverture 100 % (couches 0/1) se mesure sur la lib, pas surfuzz/; le fuzzing tourne sur une cadence séparée. cfg(loom):loomest déclarée sous[target.'cfg(loom)'.dependencies], hors du graphe de build normal (inerte sans--cfg loom). Vérifier quecargo machetene la signale pas à tort (l’ajouter à son ignore si besoin).
Conséquences
- Le module
io_uringpeut être testé au standard couche 0 (property-based, loom, fuzzing) sans violer le Principe 6. DEPENDENCIES.mdpeut référencer cette politique pour justifier proptest / loom / cargo-fuzz comme exemptés-mais-conformes.- Toute dépendance qui migrerait vers
[dependencies](production) reperd l’exemption et redevient pleinement soumise à ADR-024.
Alternatives écartées
- Vendorer proptest/loom : impraticable (taille), et sans bénéfice (non livré).
- Une ligne dans
EXCEPTIONS.md:EXCEPTIONS.mdest réservé aux exceptions nommées de la règle des 80 % pour des deps de production (p. ex. icu4x, bitflags). Le cas présent est une politique de périmètre, pas une exception ponctuelle — sa place est dans la couche ADR.
Licence du document : MPL 2.0
ADR-031 — Mesure de couverture en root sur les runners self-hosted
Statut : Accepté (2026-06-11). Companion d’ADR-014 (cibles tier-1) et de la politique de couverture couche 0 (Principe 1) ; n’amende pas ces documents, en précise la mise en œuvre CI.
Catégorie : Méthode (gouvernance technique / CI).
Contexte
La couche 0 vise 100 % de couverture (lignes + branches) hors exceptions
documentées (Principe 1). La CI applique un plancher anti-régression sur la
couverture brute (cargo llvm-cov --workspace --fail-under-lines 96,
cf. docs/CI.md), l’invariant « 100 % hors exceptions » restant revu humainement
via docs/COVERAGE-EXCEPTIONS.md.
Jusqu’ici, le job test-coverage s’exécutait en tant que l’utilisateur du
runner self-hosted (non-root). Les huit premières familles ont une surface
majoritairement non privilégiée : leurs quelques chemins exigeant une
capability Linux étaient inscrits en exceptions PRIVILEGE, et le plancher 96 %
absorbait ces lignes rouges.
La famille ebpf rompt cet équilibre. Le syscall bpf() exige
CAP_BPF/CAP_PERFMON pour quasiment toute opération (création de carte,
chargement de programme, attache, introspection…). En non-root, seuls les
chemins d’erreur sont atteignables → plafond intrinsèque de couverture ~74 %
pour air-sys-syscall::ebpf. Mesuré en non-root, le total workspace tombe à
95,5 %, sous le plancher de 96 % → la CI échoue, alors que la famille est
réellement couverte à 99,4 % quand le harnais d’intégration privilégié
s’exécute (mesure root sur speedy).
Documenter les ~177 lignes privilégiées en exceptions ne résout pas le problème :
le plancher est numérique, et la politique couche 0 (cf. prompt de
finalisation ebpf) refuse les fausses exceptions « privilège » pour des chemins
réellement atteignables en root sur les exécuteurs. Toute la documentation de
mesure (*.out, JOURNAL.md) mesure d’ailleurs déjà « en root ».
Décision
Le job test-coverage mesure la couverture EN ROOT sur les runners
self-hosted (air-x86_64 = speedy, air-aarch64 = raspi-srv-2), via sudo -n
(non-interactif), de sorte que les tests d’intégration privilégiés (bpf(),
uinput, perf, cgroup, bpffs) s’exécutent réellement sous instrumentation.
- Les étapes
cargo llvm-cov clean/--fail-under-lines/--branchsont préfixées desudo -n -E env "PATH=$PATH"(préserveHOME/CARGO_HOMEet lePATHcargo).sudo -néchoue franchement (pas de prompt) si le runner n’a pasNOPASSWD→ signal clair. - Une étape finale
if: always()rétablit la propriété detarget/à l’utilisateur du runner (sudo chown -R), afin que legit clean(non-root) du run suivant ne bute pas sur des fichiers root. - Prérequis infra : l’utilisateur des deux runners doit disposer du
sudosans mot de passe (NOPASSWD). C’est une configuration des machines (hors dépôt).
Le plancher reste 96 % lignes / 78 % branches : avec la mesure root, les
familles privilégiées atteignent leur couverture réelle, et le plancher conserve
sa marge anti-régression. Les exceptions légitimes restantes sont
structurelles (gardes de borne défensives, branches EFAULT/per-process
inatteignables), pas « privilège ».
Conséquences
Positives.
- La CI valide la couverture réelle des familles privilégiées (
ebpfà 99,4 %), au lieu d’un plafond non-root artificiel. - Plus de fausses exceptions « PRIVILEGE » pour masquer des chemins atteignables en root : la politique couche 0 (Principe 1) est tenue mécaniquement.
- Alignement CI ↔ méthode de mesure déjà employée partout (« en root »).
Coûts / risques.
- Modèle de confiance. Exécuter le build/tests en root sur les runners signifie qu’un code de PR s’exécute avec privilège. Acceptable tant que le dépôt est privé et mono-mainteneur (même posture que la dette « protections de branche inactives », cf. JOURNAL) : aucune PR externe n’atteint les runners. À revisiter impérativement avant l’ouverture publique ou l’arrivée de contributeurs externes (p. ex. : runners éphémères/conteneurisés, ou job privilégié réservé aux PR de mainteneurs, le fork CI restant non-root).
- Dépendance infra : les deux runners doivent garder
NOPASSWD. Sans lui, l’arche concernée échoue au premiersudo -n(diagnostic explicite). - Propriété de
target/: normalisée après coup ; lechownfinal est enif: always()pour survivre à un échec de mesure.
Alternatives écartées
- Exclure la surface privilégiée du gate (
#[coverage(off)]/--ignore-filename-regex) : cesse de mesurer ces lignes — divergence avec la méthode des huit familles, masque d’éventuelles régressions. - Abaisser le plancher (96 → 95) : affaiblit le gate global pour toutes les familles afin d’accommoder une seule famille privilégiée.
- Garder la mesure non-root + exceptions : impossible, le plancher est numérique ; et contredit le refus des fausses exceptions privilège.
Références
- Principe d’ingénierie 1 (couverture couche 0).
- ADR-014 (cibles tier-1 x86_64 + aarch64 ⇒ CI sur les deux runners).
docs/CI.md(seuils, baseline),docs/COVERAGE-EXCEPTIONS.md.- Dette « protections de branche inactives » (même modèle de confiance privé /
mono-mainteneur),
docs/JOURNAL.md.
Licence du document : MPL 2.0.
ADR-032 — Préservation des données confiées : zéro discard silencieux
Statut : Accepté (2026-06-11). Décline les Principes d’ingénierie 3 (zéro présomption sur buffers/encodages) et 7 (verbosité au service de la clarté) en une règle transverse de conception d’API.
Catégorie : Méthode (transverse, tout le framework).
Contexte
Une grande partie des API d’Air prend possession de données fournies par
l’appelant : buffers (Vec<u8>, Vec<Vec<u8>>), descripteurs de fichiers
(OwnedFd), structures (chemins, messages, requêtes). C’est notamment le modèle
d’ownership de la couche 0 io_uring (S1) : la donnée est déplacée dans un slot à
la soumission, puis restituée à la complétion.
Le risque, récurrent et silencieux, est la perte de donnée : une API qui prend
plusieurs buffers mais n’en rend qu’un, un FD reçu non matérialisé (fuite), une
troncature kernel (MSG_CTRUNC) avalée sans signal, un buffer non restitué sur un
chemin d’erreur. Ces pertes ne lèvent pas d’erreur — elles disparaissent —, ce
qui les rend coûteuses à diagnostiquer et dangereuses (fuites de ressources,
corruption logique en aval).
Le cas qui a fait émerger la règle : au Temps 2b d’io_uring, une première
implémentation de la restitution zero-copy d’un sendmsg_zc multi-buffers ne
rendait que le premier buffer, droppant les autres. Comportement « pratique »
en apparence, perte de donnée réelle.
Décision
Une API Air ne discarde jamais une donnée que l’appelant lui a confiée : elle restitue tout, proprement et sûrement.
Concrètement, lorsqu’une opération prend l’ownership de données de l’appelant :
-
Restitution intégrale. Elle rend l’intégralité de ce qu’elle a pris. Une opération vectorielle/multi-buffers restitue toute la collection (
Vec<Vec<u8>>), jamais un sous-ensemble. (Exemple :into_zero_copy_buffersrend tous les buffers d’unsendmsg_zc.) -
Ressources matérialisées, jamais fuitées. Les ressources reçues du kernel (FD via
SCM_RIGHTS…) sont matérialisées en types possédés (OwnedFd), jamais laissées en entiers bruts ni perdues. Une troncature (MSG_CTRUNC,MSG_TRUNC,ERANGE…) est signalée explicitement à l’appelant ; les éléments partiels sont fermés/libérés proprement (pas de fuite). -
Chemins d’erreur compris. La restitution vaut aussi en cas d’erreur : si une soumission échoue après avoir pris l’ownership, la donnée est rendue (ou l’erreur la transporte), jamais silencieusement abandonnée.
Distinction : consommation explicite ≠ discard
La règle interdit la perte silencieuse, pas la consommation documentée par
contrat. Une API peut légitimement consommer une donnée si c’est sa sémantique
explicite et typée. Exemple : submit_close(fd: OwnedFd) consomme le FD (cédé
au kernel) — ce n’est pas un discard, c’est un transfert d’ownership voulu, gravé
dans la signature. Le test de décision :
« Cette API a-t-elle pris une donnée de l’appelant ? Si oui : la lui rend-elle intégralement, ou la consomme-t-elle explicitement par contrat ? » Tout ce qui n’est ni l’un ni l’autre — une donnée prise puis perdue sans le dire — est proscrit.
Conséquences
Bénéfices
- Aucune fuite de ressource ni perte de donnée silencieuse : cycle de vie des ressources prévisible, auditable, sûr par construction.
- Diagnostic : une troncature/perte devient un signal typé, pas un bug fantôme.
- Cohérence d’API transverse : l’appelant sait qu’il récupère toujours ce qu’il a confié.
Coûts (assumés)
- Types de retour parfois plus verbeux (
Vec<Vec<u8>>plutôt queVec<u8>, métadonnées de troncature explicites). C’est l’esprit du Principe 7 (verbosité au service de la clarté) : on préfère un retour complet et honnête à un retour « simple » qui ment par omission.
Portée et application
- S’applique à toute la surface d’Air, toutes couches (couche 0 io_uring/IPC, primitives couche 1, runtime, services).
- Le reviewer vérifie, pour toute API prenant de l’ownership : restitution intégrale ? ressources reçues possédées et troncatures signalées ? chemin d’erreur couvert ? consommation, si elle existe, explicite dans la signature ?
Articulation avec les autres ADRs / principes
- Principe 3 (zéro présomption sur buffers/encodages) : la restitution rend les octets bruts tels quels, sans perte ni réinterprétation.
- Principe 7 (verbosité au service de la clarté) : justifie le surcoût de verbosité des retours complets.
- Principe 4 (validation en amont) : les conditions de troncature sont détectées et signalées, pas avalées.
- ADR-021 (conventions transverses couche 0) et ADR-028 (soundness io_uring) : même esprit de sûreté par construction ; cet ADR généralise « ne rien perdre » à toute la surface.
- ADR-019 (modèle d’erreurs) : sur les chemins d’erreur, la donnée confiée est restituée et/ou l’erreur porte le contexte ; rien ne disparaît.
Statut futur
ADR immuable dans sa règle (restitution intégrale + matérialisation sûre + chemins d’erreur + distinction consommation explicite) sauf RFC (ADR-015).
Licence du document : MPL 2.0 Statut : Document de méthode, transverse au framework.
ADR-033 — Modèle de configuration : source typée, compilation validée, artefact binaire
Statut : Accepté (2026-06-12). Décline les Principes d’ingénierie 4 (validation en amont, « parse, don’t validate »), 3 (zéro présomption sur les entrées) et 9 (budgets sur matériel modeste) en un modèle transverse de configuration système. Cohérent avec ADR-025 (builds reproductibles) et ADR-005 (systemd socle pour V1).
Catégorie : Architecture (transverse — couche 1 air-base-lib à couche 5
outils d’administration).
Contexte
Air s’adresse à deux publics qu’il refuse d’opposer : l’utilisateur qui ne veut pas savoir ce qu’il y a sous le capot et veut que « ça marche », et le développeur/technicien qui connaît sa machine et veut la régler finement. Les deux doivent être servis par le même système de configuration, sans que le premier puisse se tirer une balle dans le pied ni que le second soit bridé.
Le modèle Unix traditionnel — des fichiers texte éditables à la main sous /etc
— a une vertu (lisibilité, grep, diff, versionnable) et un vice symétrique :
une faute de frappe brique le système. Un grub.cfg erroné et la machine ne
boote plus ; un fichier de /etc corrompu et un pan entier de la sécurité tombe,
silencieusement. La validité n’est vérifiée qu’au runtime, trop tard, par le
consommateur, de façon non uniforme.
À l’inverse, un magasin binaire canonique sans source lisible (le Registre Windows) échange ces vices contre d’autres : opacité, fragilité à la corruption, impossibilité de diff/versionner, point de défaillance monolithique.
Air veut le meilleur des deux : la sûreté et la rapidité d’un format de données vérifié, et la lisibilité et la maîtrise du texte — sans le piège de l’un ni de l’autre.
Décision
La configuration Air a pour source de vérité une source typée validée à l’écriture par un compilateur de configuration, qui en dérive un artefact binaire reproductible que le runtime consomme. Le texte lisible (JSON/YAML/TOML) est une projection d’import/export, jamais la forme runtime ; le binaire est un artefact de build, jamais la source de vérité.
Sept règles gravées :
-
La source typée fait foi ; le binaire est dérivé. La source (forme texte typée, ou entrée structurée d’une GUI) est canonique, versionnable, diffable. Le binaire est un artefact reproductible qu’on peut détruire et reconstruire à l’identique (même esprit qu’ADR-025). Jamais l’inverse : inverser, c’est réinventer le Registre.
-
La compilation valide en amont (Principe 4). Le compilateur vérifie schéma, types et invariants avant de produire le binaire, refuse une entrée invalide, et émet des diagnostics de qualité compilateur (ligne/colonne, message actionnable, suggestion). Le runtime ne voit jamais de configuration invalide.
-
Le compilateur est tenu à la barre couches 0/1. Il ingère de la donnée humaine = donnée hostile (Principe 3) : fuzzing obligatoire, property-testing, couverture 100 %. La confiance se déplace du « sysadmin soigneux » vers cet outil, qui devient un composant sécurité-critique.
-
Artefact binaire schema-first à évolution intégrée. Le format doit gérer la compatibilité avant/arrière par construction (numérotation de champs, optionnalité — famille Cap’n Proto / FlatBuffers / protobuf, à auditer sous la règle des 80 %, ADR-024). Il porte version de schéma + checksum : une corruption est détectée, jamais lue en silence. Lecture zéro-copie / mmap privilégiée (budget runtime, Principe 9).
-
Projections texte round-trippables. Import/export JSON/YAML/TOML pour le développeur et le technicien ; outil de diff lisible entre deux générations. Ces formes alimentent ou exportent la source, mais ne sont pas lues par le runtime.
-
Sûreté système par générations + switch atomique + rollback. Une configuration valide peut exprimer une mauvaise intention (couper le réseau, mauvais paramètre kernel) : la validité ne suffit pas. Le filet anti-brick réel est le modèle générationnel — conserver la dernière configuration compilée known-good, basculer atomiquement (
air-filesystem::write_atomic), offrir un retour à la génération précédente (y compris une entrée de secours au boot pour le cas pré-userspace type bootloader). -
Backends de compilation = le joint d’indépendance. Le compilateur émet vers une cible enfichable. Pour V1 sur systemd (ADR-005), la cible est l’ensemble unit files + drop-ins
/etcgénérés et validés : l’humain édite la source Air, jamais l’unit file. Le jour où une brique systemd est remplacée par une brique Air native, on change le backend (émettre le binaire natif), sans toucher la source de l’utilisateur. Cf. la note de stratégie de remplacement progressif ci-dessous.
Conséquences
AirConfig(specair-base-libservices, aujourd’hui « parseur TOML derrière une feature ») est étendu vers ce modèle : un cœur validant unique,air-config, partagé par tous les frontends.- Deux publics, un cœur. Frontend texte typé piloté par le schéma (autocomplétion, doc inline, erreurs précises) pour le développeur ; GUI contrainte par le schéma — l’état invalide devient inexprimable — pour l’utilisateur lambda. Même validation, mêmes générations, même rollback.
- Couches touchées :
air-base-lib(couche 1, le cœurair-config),air-systemd(couche 2, backend unit files pour V1), les outils d’administration (couche 5), et les consommateurs commeair-device. - Articulation principes/ADRs : Principe 4 (valider en amont, parse don’t
validate), Principe 3 (entrée hostile → fuzzing), Principe 9 (lecture binaire
économe), ADR-025 (artefact reproductible), ADR-019 (les erreurs de compilation
sont des
AirErrorriches et contextualisées).
Alternatives rejetées
- Statu quo Unix (texte
/etccanonique, édité à la main). Lisible et versionnable, mais validité tardive et « typo qui brique » — le problème même qu’on résout. - Binaire canonique sans source texte (modèle Registre). Rapide mais opaque, fragile, non diffable, non versionnable.
- Format binaire maison sans politique d’évolution. Dette de schéma garantie sur l’horizon 20 ans ; d’où l’exigence schema-first (règle 4).
Hors périmètre (à traiter ailleurs)
L’authentification/intégrité des exécutables, bibliothèques et pilotes présents sur la machine (signature, contrôle de ce qui « doit » être là, exceptions développeur) est un second pilier distinct — adjacent au package management et au confinement OS — qui fera l’objet de son propre ADR. Ce document ne couvre que la configuration.
Licence du document : MPL 2.0 Statut : Accepté. Édition directe autorisée en phase de design pré-ouverture publique ; après ouverture publique, toute évolution passe par RFC (ADR-015).
ADR-034 — Discipline des dépendances cryptographiques
Statut : Accepté (2026-06-13). Companion d’ADR-024 (workflow de dépendances / règle des 80 %) ; précise une exemption et une discipline propres à la crypto, sans amender ADR-024. Décline le Principe 6 (dépendances) et la posture « très sérieux sur la crypto » du projet.
Catégorie : Méthode (gouvernance des dépendances).
Contexte
air-crypto (couche 1) n’écrit aucune crypto maison : il enrobe des crates
amont auditées et massivement adoptées. Ces crates proviennent de trois
upstreams distincts, tous purs Rust, sans dépendance C, sous licences permissives
(Apache-2.0 / MIT / BSD-3-Clause ; blake3 en dual CC0-1.0 OR Apache-2.0) :
- RustCrypto : hachages (
sha2,sha3,blake2), AEAD (aes-gcm,chacha20poly1305),hmac,hkdf,argon2, les trait-crates (digest,aead,signature,crypto-common…), etzeroize. MSRV courant des traits :rustc 1.85+(bumpé par version mineure) — bien en dessous du toolchain pinné d’Air (ADR-025), donc aucune contrainte de toolchain. - dalek-cryptography :
ed25519-dalek,x25519-dalek,curve25519-dalek, etsubtle(constant-time). (À ne pas attribuer à RustCrypto.) - équipe BLAKE3 :
blake3.
Deux tensions à arbitrer. (1) La règle des 80 % (ADR-024) impose de vendorer une dépendance dont moins de 80 % de l’API est utilisée : appliquée à la crypto, elle conduirait à copier du code cryptographique dans l’arbre Air — ce qui transfère à Air la charge de back-porter les correctifs amont, exactement ce qu’il ne faut jamais faire en crypto. (2) Les builds pinnés/reproductibles (ADR-025) sont indispensables, mais pinner ne doit jamais faire traîner Air derrière un correctif de sécurité crypto.
Décision
Les dépendances cryptographiques d’Air sont suivies au plus près de l’amont, jamais vendorées, et tout correctif de sécurité crypto est appliqué en priorité — sous filet des vecteurs de test connus.
-
Exemption de vendoring. Les crates crypto des trois upstreams (RustCrypto, dalek-cryptography, BLAKE3) et leurs crates de support (
zeroize,subtle,curve25519-dalek, traits…) sont exemptées de l’obligation de vendoring du Principe 6 / ADR-024. On dépend de l’upstream directement, jamais d’une copie vendorée. Exceptions nommées dansdocs/EXCEPTIONS.md(commeicu4x, ADR-016). -
Trois upstreams suivis nommément.
docs/DEPENDENCIES.mddocumente qui fournit quoi (RustCrypto vs dalek vs BLAKE3) et la veille associée. Ne pas confondre les origines (p. ex.subtle/25519 = dalek, pas RustCrypto). -
Tripwire CI (déjà en place, bloquant).
cargo audit(base RustSec) etcargo deny check advisoriesfont partie de la barrière : une version vulnérable connue casse le build. -
Veille proactive. Au-delà des avis publiés, surveiller les GitHub Security Advisories + releases des trois upstreams (candidat : tâche planifiée périodique). Objectif : ne pas découvrir un correctif crypto avec retard.
-
Fast-lane sécurité. Les builds restent pinnés (Cargo.lock, ADR-025) pour la reproductibilité, mais un correctif de sécurité crypto est fast-tracké : relèvement prompt du pin hors cadence normale, dès publication amont.
-
KAT-gated. Tout bump d’une dépendance crypto re-passe les vecteurs de test connus (RFC/NIST — obligatoires en spec
air-crypto) avant merge : filet anti-régression qui autorise la mise à jour rapide sans crainte. -
Rapide sur la sécurité, pas bleeding-edge sur tout. Release de sécurité → fast-track ; release de feature → cadence normale (éviter les régressions du suivi systématique de chaque mineure).
-
Licences. Apache-2.0 / MIT / BSD-3-Clause (allowlist
deny.toml) ; pourblake3(dualCC0-1.0 OR Apache-2.0), sélectionner l’option Apache-2.0 dansdeny.toml(CC0 hors allowlist).
Conséquences
- La spec
air-cryptorenvoie à cet ADR pour la discipline de dépendances et ne la redétaille pas. docs/EXCEPTIONS.md(exemption vendoring + 80 %) etdocs/DEPENDENCIES.md(3 upstreams + veille) sont tenus à jour à l’implémentation d’air-crypto.- La CI est inchangée (
cargo audit/cargo denydéjà présents) ; s’ajoute la veille (manuelle ou planifiée) et le KAT-gating des bumps crypto. - Articulation : companion d’ADR-024 (ne l’amende pas — précise une exemption
pour la crypto) ; cohérent ADR-025 (repro) et ADR-016 (précédent d’exception
nommée,
icu4x).
Alternatives rejetées
- Vendorer la crypto (application stricte d’ADR-024) : transfère la charge des correctifs de sécurité à Air → dangereux, rejeté.
- Tout pinner sans fast-lane : reproductible mais expose à un retard sur les correctifs → rejeté (la sécurité prime).
- Suivre systématiquement chaque release amont : risque de régressions → rejeté au profit du fast-track sécurité + cadence normale features, KAT-gated.
Licence du document : MPL 2.0 Statut : Accepté. Édition directe autorisée en phase de design pré-ouverture publique ; après ouverture publique, toute évolution passe par RFC (ADR-015).
ADR-035 — Taxonomie des exceptions de couverture
Statut : Accepté (2026-06-14). Companion d’ADR-031 (mesure de couverture en root) et de la politique de couverture couche 0 (Principe 1) ; n’amende ni l’un ni l’autre, il en grave la taxonomie d’exceptions.
Catégorie : Méthode (gouvernance technique / CI).
Contexte
La couche 0 vise 100 % de couverture (lignes + branches) hors exceptions
documentées (Principe 1). Le registre docs/COVERAGE-EXCEPTIONS.md énumère
chaque ligne/branche de production non couverte et la justifie. Au fil de
l’implémentation, six catégories d’exception sont apparues : trois
« canoniques » (STRUCTURAL, PRIVILEGE, FEATURE-KERNEL) et trois introduites en
cours de route (EFAULT-SAFE, CHILD-EXIT, TEST-HARNESS), plus une étiquette
VALUE-UNREACHABLE. L’audit 053 a montré que cette prolifération brouillait
l’invariant « couvrable VIDE » : certaines entrées « inatteignables » étaient
en réalité atteignables-mais-non-outillées (donc à couvrir, pas à
exempter — cf. le cas Sqpoll réglé au 046).
Décision
1. Quatre catégories d’exception, plus une étiquette de dette
Canoniques — branche/ligne qu’aucun test légitime ne peut atteindre :
- STRUCTURAL — inatteignable par construction : un invariant garantit que
le bras n’est jamais pris (longueur ≤ borne,
bid < count, free-list, sémantique kernel per-process, joker#[non_exhaustive]dont toutes les variantes réelles sont mappées, garde défensive permanente du Principe 5…). Absorbe les anciennes étiquettes :EFAULT-SAFE(brasEFAULT/EBADFexclu car buffers/sorties valides par construction) — cas particulier de STRUCTURAL ; le mot-clé reste autorisé dans la justification, pas comme catégorie ;VALUE-UNREACHABLEpermanente (joker exhaustif, garde défensive) — idem.
- PRIVILEGE — exige une capability / condition de privilège absente sur le
runner (
CAP_*,RLIMIT_*, Yamaptrace_scope, cpuset cgroup…). Le bras opposé est mort dans cet environnement. (Mesure root : cf. ADR-031.) - FEATURE-KERNEL — une feature kernel est présente sur les exécuteurs (≥ 6.12) ⇒ le bras de repli (kernel ancien / feature absente) est mort.
Limite d’instrumentation (le code s’exécute mais n’est pas mesurable) :
- CHILD-EXIT — code réellement exécuté dans un processus enfant forké
dont le profil LLVM ne peut pas être écrit : l’enfant a largué ses privilèges
(écriture
.profrawrefusée à un non-root), s’est confiné (seccomp-strict tue / Landlock refuse l’O_RDWR), ou s’estabort()é (bypass de l’atexit). Modèle fork+flush d’ADR-031. Preuve d’exécution : le code de sortie observé par le parent viawaitid. Distincte de STRUCTURAL (ce n’est pas inatteignable) et de PRIVILEGE (le privilège n’est pas le frein — l’instrumentation l’est).
Dette outillée (à résorber, jamais permanente) :
- DEFERRED-TOOLING — branche réellement atteignable mais non encore couverte faute d’outillage (un primitif manquant, un harnais à écrire). À couvrir, jamais exemption permanente. Chaque entrée nomme l’outillage requis et la condition de levée. Une exception « atteignable-mais-dure » doit être ici (jamais déguisée en STRUCTURAL).
2. Le code de test n’est pas une exception production
Les bras de code de test (court-circuit d’assert!, matches! de validation,
gardes de harnais) ne figurent pas au registre production. S’ils nuisent à
la lisibilité d’une mesure, ils sont consignés en annexe « résidus internes aux
tests », exclus du décompte. (L’ancienne étiquette TEST-HARNESS est
retirée du registre.)
3. Invariant « couvrable VIDE »
L’ensemble « couvrable » = les branches atteignables non couvertes hors
exception légitime. Il doit rester VIDE : toute branche atteignable est soit
couverte par un test, soit en DEFERRED-TOOLING avec un plan de levée. Une entrée
STRUCTURAL/PRIVILEGE/FEATURE-KERNEL/CHILD-EXIT atteste, elle, d’une
impossibilité (de construction, de privilège, de feature, d’instrumentation),
pas d’une dette.
Conséquences
- Registre simplifié : 4 catégories + 1 étiquette de dette, légende et récap
alignés.
EFAULT-SAFE/VALUE-UNREACHABLErepliées dans STRUCTURAL ;TEST-HARNESSen annexe. - Discipline anti-complaisance : une branche atteignable ne peut plus être
« rangée » en STRUCTURAL ; elle va en
DEFERRED-TOOLING(visible, à couvrir) ou est couverte. Application immédiate au055: les 3 candidates (fs::try_lockcontention,net::get_so_errorasync,process::pidfd_send_signalSome) sont couvertes par de vrais tests ⇒DEFERRED-TOOLINGvide. - Pas d’amendement à ADR-031 (mesure root) ni au Principe 1 (objectif 100 %) : ADR-035 ne fait que nommer les exceptions légitimes et bannir les fausses.
- Revue humaine du registre inchangée ; le plancher CI (
--fail-under-lines 96) reste l’anti-régression brut.
Alternatives écartées
- Garder les 6 catégories : prolifération sans valeur ;
EFAULT-SAFEetVALUE-UNREACHABLEpermanente sont indiscernables de STRUCTURAL à l’usage. - Supprimer toute catégorie non canonique : perdrait
CHILD-EXIT(limite d’instrumentation réelle, bien distincte) etDEFERRED-TOOLING(le filet anti-complaisance qui force la couverture des atteignables). - Exempter les branches atteignables-mais-dures en STRUCTURAL : exactement le
travers que cet ADR interdit (cf.
Sqpoll/046).
Références
- ADR-031 (mesure de couverture en root —
modèle fork+flush dont découle
CHILD-EXIT). - Principe 1 (couverture 100 % couches 0/1),
docs/COVERAGE-EXCEPTIONS.md. - Audit
053(~/Code/air-relay/053-consolidation-flaky-exceptions.out) et son application055.
Amendement — catégorie TARGET-ONLY (2026-06-29, RFC ADR-015)
Statut : Proposé (à ratifier BDFL). Companion d’ADR-052
(runtime air-runtime couche 1) et d’ADR-049. Étend la
taxonomie d’une cinquième catégorie ; n’amende ni ADR-031 ni le Principe 1.
Contexte
Le runtime userland (air-runtime, ADR-052) contient du code dont la sémantique n’est
correcte que sur la cible *-linux-air : programmer le registre TLS (set_fs/tpidr_el0),
créer un thread avec CLONE_SETTLS pointant un bloc TLS d’Air, appliquer les relocations
static-PIE, lire le self-pointer du TCB. Exécuter ces lignes sous le gate de couverture — qui
tourne sur *-linux-gnu (glibc) — est unsound : mêler le TLS d’Air et celui de glibc
corrompt l’état par-thread (c’est l’unsoundness #151, la raison d’être même du runtime). Ce code
ne peut donc pas tourner du tout sous le gate, et encore moins être mesuré.
Ce n’est ni STRUCTURAL (le code est atteignable et s’exécute — sur la bonne cible), ni CHILD-EXIT (là le code tourne dans le run du gate mais le profil n’est pas écrit ; ici il ne tourne pas dans le run du gate tout court), ni DEFERRED-TOOLING (ce n’est pas une dette d’outillage à résorber par un test hôte : aucun test hôte ne pourra jamais l’exécuter sainement).
Décision — TARGET-ONLY (limite d’environnement de gate)
-
TARGET-ONLY — code sound uniquement sur
*-linux-air, donc ni exécuté ni mesuré par le gate de couverture (*-linux-gnu). Preuve de correction : un selftest dédié sur*-linux-air(binairert/crates/airrt-selftest), validé par code de sortie sur les 2 arches (x86_64 sur carbon, aarch64 sur raspi/CI ARM) — même esprit de preuve-par-exit-code que CHILD-EXIT, mais le frein est l’environnement de cible du gate, pas l’instrumentation. -
Granularité = fichier. Le code TARGET-ONLY est isolé dans des fichiers dédiés, retirés de la mesure par
--ignore-filename-regex(précédent : le code généré_capnp.rs), jamais par une exception ligne-à-ligne. Où une ligne TARGET-ONLY isolée vit dans un fichier par ailleurs mesuré (p. ex. l’applier de relocation), elle est extraite dans un fichier dédié pour rester ignorable par fichier. Fichiers concernés (à date) :air-runtime/src/{thread_control_block, thread_local_storage,thread,start}.rs, le module de primitives target-only d’air-thread, l’applier dereloc. -
Le reste de la crate reste mesuré à 100 %. Retirer
--exclude air-runtime(exclusion en bloc du squelette) au profit de l’--ignore-filename-regexciblé : les modules host-testables (env/args/parser dereloc/machineriefork) retombent sous le 100 % lignes+branches. -
Discipline anti-complaisance. TARGET-ONLY n’est pas un refuge pour du code « dur à tester » (cela reste DEFERRED-TOOLING) : il est réservé au code structurellement insondable sur la cible du gate (unsoundness TLS Air/glibc). Chaque fichier TARGET-ONLY : commentaire in-code justifiant, nom du selftest qui le prouve, et condition de levée — réversible : la couverture sera réactivée le jour où une toolchain Air-native mesurera
cargo llvm-covdirectement sur*-linux-air(cf. note « cohabitation toolchain », PR #161).
Invariant « couvrable VIDE » (inchangé)
Une entrée TARGET-ONLY atteste une impossibilité (cible du gate unsound), au même titre que STRUCTURAL/PRIVILEGE/FEATURE-KERNEL/CHILD-EXIT — pas une dette. L’ensemble « couvrable » reste VIDE. Récap : 5 catégories (STRUCTURAL, PRIVILEGE, FEATURE-KERNEL, CHILD-EXIT, TARGET-ONLY)
- 1 étiquette de dette (DEFERRED-TOOLING).
Alternatives écartées
- Garder
--exclude air-runtimeen bloc : perd la mesure des modules host-testables de la crate (env/args/reloc-parser/fork) → moins rigoureux ; masque la frontière host/target. - Laisser le mécanisme dans
rt/(hors-arbre, déjà exclu) : contredit ADR-052 D4 (la logique de runtime est couche 1) ; le code couche 1 ne serait jamais ni porté ni mesuré. - Exception ligne-à-ligne plutôt que fichier : fragile et bruité ; la granularité fichier
(code target-only isolé) est nette et auditable (modèle
_capnp.rs).
Licence du document : MPL 2.0.
ADR-036 — Filtrage par chemin de la re-vérification d’une couche scellée
Statut : Accepté (2026-06-14, validé par le BDFL). Companion d’ADR-031 (mesure en root) et d’ADR-035 (taxonomie d’exceptions) ; s’appuie sur le Principe 1 (rigueur des couches fondatrices) et ADR-025 (reproductibilité). N’amende pas le standard 100 % de la couche 0.
Catégorie : Méthode (gouvernance technique / CI).
Contexte
La couche 0 est scellée au tag couche-0-v1 (commit 08f6d77) : ses sources
(crates/air-sys-types, crates/air-sys-syscall) sont gelées. Le travail de la
couche 1 ne les modifie pas.
Or la CI (test-coverage, matrice x86_64 + aarch64) re-mesure intégralement la
couverture 100 % (lignes + branches) de la couche 0 à chaque PR, y compris les PR
qui ne touchent que la couche 1. Sur le runner raspi-srv-2 (Raspberry Pi 4,
aarch64), ce travail redondant est le poste de coût dominant et ralentit chaque
itération.
Re-vérifier une couche scellée à chaque PR couche 1 n’apporte presque rien :
les sources sont identiques au sceau. Sauf un cas réel : la dérive de
toolchain. Les résultats de clippy et de llvm-cov peuvent changer quand le
nightly (ou le stable pinné) est bumpé, à sources identiques — c’est la seule
voie par laquelle la couche 0 scellée pourrait « régresser » sans changement de code.
Décision
1. La vérification lourde de la couche 0 est conditionnée par chemin
Les étapes coûteuses propres à la couche 0 — mesure de couverture (lignes + branches) et fuzzing — ne s’exécutent que si une PR touche l’un de :
crates/air-sys-types/**oucrates/air-sys-syscall/**(sources couche 0) ;rust-toolchain.toml, ou leCargo.lock/manifestes affectant la couche 0 (changement de toolchain ou de dépendance) ;.github/workflows/**,deny.toml, ou les fichiers de politique de couverture (docs/COVERAGE-EXCEPTIONS.md).
2. Pour les autres PR : bâtie + testée, pas re-mesurée
Une PR qui ne touche aucun de ces chemins bâtit toujours la couche 0 comme dépendance (via cache, cf. sccache) et exécute ses tests unitaires + doctests (cheap), mais ne re-mesure pas sa couverture et ne relance pas le fuzzing.
3. Garde-fous anti-dérive (la rigueur est préservée, pas réduite)
La barrière complète de la couche 0 (couverture + fuzz) s’exécute toujours :
- (a) sur la PR qui bumpe la toolchain ou une dépendance couche 0 (règle 1) ;
- (b) sur
push/merge versmain— doncmainn’est jamais dans un état non vérifié : une éventuelle régression de dérive de toolchain est bloquée au merge, pas seulement signalée ; - (c) en planifié hebdomadaire — détecte une dérive d’environnement runner même sans activité de PR.
Un rouge sur l’une de ces exécutions rouvre le sceau (traitement prioritaire).
4. Le tag couche-0-v1 reste la référence d’autorité
Aucune de ces optimisations ne déplace la référence : l’état attesté reste le sceau.
Conséquences
- Itérations couche 1 nettement plus rapides sur le Pi : on ne repaye plus la
re-mesure de la couche 0 à chaque
pushd’une branche de feature. - Garantie du sceau préservée : la vérification complète a toujours lieu là où
elle compte (merge vers
main) et périodiquement (hebdo). Le compromis n’est pas « moins de rigueur » mais « pas de rigueur redondante par PR ». - Cohérence outillage : les gates
barrier/couvrable-videduxtask(060) peuvent honorer le même périmètre par chemin (option, pas obligation). - Orthogonal au cache : se combine avec
sccache(recompilation évitée) et, plus tard, avec un éventuel offload de compilation (cross-compile sur speedy, exécution des tests sur le Pi).
Alternatives écartées
- Tout re-vérifier à chaque PR (statu quo) : sûr mais lent — le problème qu’on corrige. La redondance n’achète aucune sécurité supplémentaire entre deux bumps de toolchain.
- Ne plus jamais re-vérifier après le sceau : rejeté — ignore la dérive de toolchain, seule voie réaliste de régression à sources gelées.
- Offload de compilation seul : utile mais orthogonal ; ne supprime pas la redondance de re-mesure, seulement son coût de compilation.
Risques et mitigations
- Une régression de dérive de toolchain introduite entre deux runs hebdo pourrait
passer inaperçue sur une PR couche 1. → Mitigé par le garde-fou (b) : le merge
vers
mainre-vérifie tout, donc une telle régression ne peut pas atteindremain. Au pire, elle est détectée au merge plutôt qu’à l’ouverture de la PR. - Mauvais périmètre de filtre (un changement couche 0 non couvert par les globs). → Les globs sont conservateurs (sur-déclencher plutôt que sous-déclencher) et testés ; tout doute déclenche la barrière complète.
Adoption
Câblage CI dans un prompt relais dédié, après l’ADR accepté. La doc CI
(docs/CI.md) décrira le périmètre, les déclencheurs et les trois garde-fous.
ADR-037 — CI ARM seule (raspi-srv-2) + validation x86 par barrière pré-merge
Statut : Accepté (2026-06-24, validé par le BDFL).
Companion d’ADR-031 (mesure en root),
d’ADR-036 (filtrage par
chemin) et de l’outillage xtask (gate barrier, cf.
outillage-xtask.md) ; s’appuie sur le Principe 1
(rigueur des couches fondatrices) et ADR-014 (validation x86_64 ET aarch64).
N’amende pas l’exigence de validation sur les deux arches.
Catégorie : Méthode (gouvernance technique / CI).
Contexte
La CI tournait sur deux runners self-hosted : speedy (Mac mini Intel,
x86_64) et raspi-srv-2 (Raspberry Pi 4, aarch64), chacun rejouant test-coverage
(fmt, clippy, test, couverture lignes/branches en root). Deux constats motivent une
réorganisation :
- raspi-srv-2 est lent. Le Pi est le poste de coût dominant de chaque run ; le réserver à un rôle unique (valider que le code compile et passe la barrière sur ARM) maximise son utilité sans le surcharger.
- speedy et carbon sont désormais pilotés comme exécuteurs de tâches (agents Claude Code headless, cf. relais multi-machines). carbon (x86_64, 8 cœurs, 16 Go) est plus capable que speedy (4 cœurs, 8 Go) ; les deux peuvent porter la validation x86 avant merge, sur deux micro-architectures x86 différentes — couverture x86 plus robuste que l’unique runner d’origine.
Faire porter à la CI GitHub la validation x86 et ARM n’est donc plus le meilleur découpage : l’x86 peut être validé hors CI, par orchestration pré-merge.
Décision
1. La CI GitHub ne conserve que le runner ARM (air-aarch64, raspi-srv-2)
ci.yml exécute test-coverage sur aarch64 uniquement. Les jobs
arch-indépendants (changes, supply-chain) basculent sur le runner ARM (seul
runner CI restant). La CI ARM gate les PR et main côté ARM (compilation +
barrière + planchers de couverture 96 lignes / 78 branches, inchangés).
2. La validation x86 est une « barrière pré-merge » hors CI
Avant tout merge sur main, la barrière complète est exécutée sur speedy
ET carbon via cargo xtask barrier — strictement les mêmes contrôles que
l’ancien runner x86 : fmt (stable + nightly), clippy -D warnings,
cargo test --workspace, couverture lignes ≥ 96 / branches ≥ 78 en root
(ADR-031), cargo audit / deny / machete, // SAFETY:, cohérence
Cargo.toml ↔ DEPENDENCIES.md, check-c-surface. Un merge n’a lieu que si les
deux machines x86 sont vertes et la CI ARM est verte.
3. speedy reste enregistré comme runner, mais hors workflows
Le service runner speedy (label air-x86_64) reste enregistré (pas de
désenregistrement) ; il n’est simplement plus référencé par ci.yml. À
l’ouverture publique, une barrière CI x86 sera rebranchée (on tranchera alors
entre speedy et carbon comme runner x86 « officiel »).
4. Équivalence de validation préservée
L’invariant ADR-014 (« x86_64 ET aarch64 ») est tenu : ARM par la CI, x86 par la barrière pré-merge sur deux machines. La rigueur n’est pas réduite — elle est seulement déplacée pour l’x86, de la CI vers l’orchestration pré-merge.
Conséquences
- raspi-srv-2 dédié à un rôle clair (CI ARM), sans validation x86 redondante à porter ; itérations plus lisibles.
- Couverture x86 élargie : deux micro-architectures x86 (Mac mini Intel + carbon plus ancien) au lieu d’une.
- Le gate x86 n’est plus imposé par GitHub. Sa garantie repose sur la discipline d’orchestration pré-merge (mainteneur / agent), pas sur la branch-protection. Acceptable en dépôt privé mono-mainteneur (modèle de confiance déjà posé par ADR-031) ; à rebrancher en CI à l’ouverture publique (Décision 3).
- raspi-srv-2 devient un SPOF de la CI : s’il est indisponible, le gate ARM ne passe plus. Mitigation : la barrière x86 (speedy + carbon) reste disponible, et le runner Pi est supervisé en service persistant.
- Cohérence outillage :
cargo xtask barrierest la source unique de la barrière (déjà utilisée localement) ; aucun nouveau script à maintenir.
Alternatives écartées
- Garder les deux runners CI (statu quo) : fait porter au Pi lent une validation x86 redondante avec ce que speedy/carbon peuvent faire hors CI, et immobilise speedy en runner alors qu’on veut l’exploiter comme exécuteur.
- Sortir aussi l’ARM de la CI (tout en pré-merge) : rejeté — on perd le
gate ARM automatique sur
mainet l’invariant ADR-014 ne serait plus garanti par la CI du tout. L’ARM natif fiable n’est par ailleurs pas fourni par les runners GitHub-hosted. - Désenregistrer speedy : inutilement destructif ; le garder enregistré permet une réactivation immédiate à l’ouverture publique (Décision 3).
Risques et mitigations
- Un merge effectué sans avoir lancé la barrière x86 ferait passer du code non
validé sur x86 vers
main. → Mitigé par la discipline d’orchestration (la barrière x86 sur speedy + carbon est un prérequis explicite de merge, gravé dans cet ADR et dansdocs/CI.md) ; résolu à l’ouverture publique par le rebranchement d’un gate CI x86. - Indisponibilité du Pi. → Le gate ARM est bloquant par conception ; on attend son rétablissement plutôt que de contourner (cohérent avec « rigueur > vitesse »).
Adoption
Câblage immédiat : ci.yml (matrice réduite à air-aarch64 ; changes et
supply-chain sur air-aarch64), docs/CI.md mis à jour (runners, barrière
x86 pré-merge, planchers). speedy conservé enregistré, retiré des workflows.
ADR-038 — Modèle d’exécution : runtime async natif io_uring (sans tokio), couches basses synchrones, io_uring seul moteur de readiness
Statut : Accepté (2026-06-24, validé par le BDFL). AMENDÉ par ADR-092
(2026-07-11) : le runtime async, placé ici « couche 2 » sous le nom air-event, se scinde
en un réacteur couche 1 air-uring (rempart safe sur io_uring, contrainte check-layers :
couche 2 ↛ couche 0) + un exécuteur couche 2 renommé air-async (le nom air-async remplace
air-event). Le modèle (natif io_uring, sync-first / async opt-in, io_uring seul moteur de readiness)
est inchangé — seuls le découpage en couches et le nom changent.
Amende et précise ADR-023 (runtime asynchrone Air sur
io_uring) ; s’appuie sur ADR-022 (architecture
io_uring). Met à jour la macro-architecture : les mentions « air-event
au-dessus de tokio » deviennent caduques.
Catégorie : Architecture (couches 1/2) / Méthode.
Contexte
tokio avait été envisagé au tout début, avant que le support intégral
d’io_uring ne soit acquis. Ce n’est plus le cas : le module io_uring est complet
(12 Temps, couche 0 scellée couche-0-v1) et fournit nativement readiness (POLL_ADD,
multishot), complétion, timeouts, et un eventfd de complétion enregistrable
(IORING_REGISTER_EVENTFD).
Deux questions en découlent, posées par le BDFL :
- Le système étant parti sur une exécution asynchrone, quels composants/services ont réellement besoin d’un traitement synchrone ?
- Faut-il redescendre en couche 0 implémenter le syscall
epoll?
Décision
1. Pas de tokio — air-event est un runtime async natif sur io_uring
Air n’a plus aucune dépendance à tokio. Le runloop / runtime asynchrone
(air-event, couche 2) est notre cœur maison bâti sur io_uring (cohérent
ADR-023). Cohérent avec la stratégie « maison, sans dépendance lourde non maîtrisée »
et avec le déterminisme de build (ADR-025).
2. Posture synchrone-first (couches 0/1) + asynchrone opt-in (couche 2)
Le système n’est pas « tout-asynchrone ». Les couches basses sont synchrones :
les wrappers couche 0 bloquent, et les API couche 1 (air-socket,
air-filesystem, air-process…) sont synchrones, exposant as_fd() comme
couture (seam) d’intégration. air-event (couche 2) est le moteur de
concurrence async, opt-in — jamais imposé.
Composants/services qui doivent rester synchrones (le synchrone est de première classe) :
- Initialisation avant runtime + sécurité : parse config/arguments,
drop_privileges, seccomp, Landlock — exécutés avant que l’event loop existe ; ordonnés, sécurité-critique. - Outils one-shot / CLI (
air-call,air-trust-tool, petites.airappconsole) : monter un runtime async pour « faire une chose et sortir » n’a pas de sens. - Travail compute-bound (
air-crypto,air-memory, parsing) : aucun I/O → l’async n’apporte rien. - Embarqueurs avec leur propre boucle (GLib, boucle de jeu, runtime d’un langage
hôte) : ils intègrent nos fd (seam
as_fd()) dans leur boucle ; Air ne doit pas imposer son runtime. - RAII / teardown /
Drop; poignées de main systemd (sd-notify).
3. io_uring est le seul moteur de readiness + complétion — pas d’epoll
io_uring remplace epoll : readiness via POLL_ADD/IORING_POLL_ADD_MULTI,
timeouts, complétions, et register_eventfd pour l’interopérabilité. Ajouter epoll
serait un second mécanisme d’événements redondant à maintenir, à rebours de
l’ethos « un mécanisme propre ». On n’implémente donc pas epoll.
Porte laissée ouverte. Si un besoin réel et mesuré d’epoll émerge un jour, on réexaminera par RFC. Mais tant qu’io_uring fait le travail, on s’en contente — io_uring seul.
4. Multiplexage synchrone rare de quelques fd
Pour le cas (rare) d’un code synchrone devant attendre plusieurs fd : soit
io_uring en mode submit-and-wait (il fonctionne très bien en synchrone), soit —
si l’on veut un multiplex sync sans tirer io_uring — un futur petit wrapper
ppoll(2) (bien plus simple qu’epoll). La couche 0 étant scellée, un tel ajout
passerait par RFC et ne se ferait que sur besoin concret. Pas d’epoll dans ce
cas non plus. Constat : le client DNS d’air-socket (synchrone, livré) gère déjà ses
timeouts en non-bloquant + clock_nanosleep, sans epoll ni poll.
5. Embarquement dans une boucle hôte (y compris epoll-based)
io_uring expose un eventfd de complétion (IORING_REGISTER_EVENTFD) : une boucle
hôte (sélecteur, epoll, GLib…) attend ce seul fd pour savoir qu’Air a du travail
prêt. Air interopère ainsi avec des boucles epoll externes sans implémenter
epoll. (io_uring sait même piloter un epoll existant via IORING_OP_EPOLL_CTL si
le besoin se présentait.)
Conséquences
- Un seul mécanisme d’événements (io_uring) : pas de seconde pile de readiness à écrire, tester, maintenir.
- Chemin synchrone de première classe : pas de « taxe async » sur le code d’init, de sécurité, CLI, compute-bound, ou pour les embarqueurs.
- Zéro dépendance tokio : contrôle total, déterminisme (ADR-025), cohérence avec la stratégie maison.
- Mises à jour documentaires : la macro-architecture (mentions « au-dessus de
tokio ») et le framing d’ADR-023 sont corrigés ; la spec
air-events’écrira sur cette base (runtime io_uring natif). - Les primitives de synchronisation bloquantes de couche 1 (
air-thread) restent adossées àstd::sync(cf. specair-thread), pas àtokio::sync.
Alternatives écartées
- Garder tokio : dépendance lourde et runtime non maîtrisé, redondant avec le runtime io_uring natif désormais disponible ; contraire au contrôle/déterminisme.
- Implémenter
epollen couche 0 : redondant avec io_uring (poll/multishot/ timeouts) ; seconde pile d’événements sans gain tant qu’io_uring suffit. (Rejeté maintenant, ré-examinable par RFC si besoin réel.) - Tout-asynchrone obligatoire : pénalise l’init, les outils CLI, le compute et les embarqueurs ; complexité imposée sans contrepartie.
Adoption
Édition documentaire immédiate : ADR-038 + correction des mentions tokio de la
macro-architecture. La spec de composant air-event (couche 2) sera rédigée sur la
base de cet ADR (runtime async natif io_uring, API C-ABI, intégration as_fd() /
eventfd). Un éventuel ppoll ou epoll reste hors périmètre sauf RFC ultérieur
motivé par un besoin mesuré.
ADR-039 — Nommage et placement : runtime async air-runtime (couche 1), modèle d’objet air-object (couche 2), et modèle d’ordonnancement
Statut : Accepté (2026-06-24, validé par le BDFL).
Amende ADR-002 (renomme le crate du modèle d’objet) ;
précise ADR-023 (nom, couche et ordonnancement du
runtime async) et ADR-038 (place la
façade air-event). Relève de la discipline de nommage ADR-029.
Catégorie : Architecture (couches 1/2) / Nommage.
Contexte
Trois incohérences bloquaient la spécification de la couche 2 :
- Collision de nom
air-runtime: la macro-architecture l’emploie pour le modèle d’objet C-ABI (ADR-002, couche 2) ; ADR-023 l’emploie pour le runtime asynchrone. - Ambiguïté de couche du runtime async : ADR-023 le place en couche 1 (il
consomme directement
air-sys-syscall::io_uring) ; la macro-architecture décrit l’event loop en couche 2 (air-event). - ADR-038 a tranché « runtime async natif io_uring, sans tokio » mais sans nettoyer le nom ni la couche.
Décision
1. Le modèle d’objet C-ABI est renommé air-object (couche 2)
Le crate du modèle d’objet (ADR-002) — AirObject/AirClass/AirValue, propriétés
observables, introspection, bindabilité polyglotte — s’appelle désormais air-object
(et air-object-macros). La bibliothèque C devient libair-object.so. Motif :
« runtime » désigne en Rust un runtime d’exécution ; le réserver à l’objet prêtait à
confusion. air-object centre le nom sur son type-clé AirObject.
2. Le runtime asynchrone garde air-runtime (couche 1)
Conforme à ADR-023. Il consomme directement io_uring (couche 0) → son placement en
couche 1 respecte la discipline « une couche ne consomme que la couche d’en
dessous » (un composant qui tape la couche 0 est en couche 1, pas en couche 2). Sémantique
naturelle : air-runtime = le runtime d’exécution async.
Découpage multi-crate (ADR-023, conservé) :
air-runtime-core (scheduler, Task) · air-runtime-io (intégration io_uring) ·
air-runtime-time (timerfd) · air-runtime-signal (signalfd) · air-runtime-sync
(mutex/channel/notify async) · façade air-runtime.
3. air-event (couche 2) = façade C-ABI par-dessus air-runtime
air-event n’est pas le moteur : c’est la façade C-ABI exposable depuis
C/Swift/Python (AirEventLoop/AirFuture/AirTimer/AirSignal/AirChannel) plus
l’intégration AirCom, qui consomme le runtime air-runtime de la couche 1. Couches
propres : air-runtime → couche 0 (L1→L0) ; air-event → air-runtime (L2→L1). Ceci
précise ADR-038 (qui parlait d’« air-event, moteur async couche 2 » : le moteur est
en L1, la façade en L2).
4. Modèle d’ordonnancement : single-thread → thread-per-core, sans work-stealing
Pensé pour le matériel modeste (Raspberry Pi 4B 2/4/8 Go — Principe 9) et « mesurer avant d’optimiser » (Principe 5) :
- Unité de base = un exécuteur single-thread (un ring io_uring). Empreinte mémoire minimale (vital sur 2 Go), zéro synchro inter-cœurs, le plus lisible (Principe 7). Suffit pour la majorité des charges desktop (I/O-bound) et pour tout outil CLI.
- Scaling = thread-per-core shared-nothing (un exécuteur indépendant par cœur,
chacun son ring, tâches non migrantes) quand un daemon en a besoin. Réutilise les
primitives déjà livrées en couche 0 (io_uring Temps 3e, PR #49) :
RingPool(ATTACH_WQ),msg_ring(messagerie inter-ring),LockedIoUring,SqpollIoUring. - Pas de work-stealing en v1 (ADR-023 l’exclut) : on évite le coût mémoire/synchro ; réexamen uniquement si la mesure montre un déséquilibre pénalisant sur charge réelle.
- Travail CPU-bound déporté vers un pool de threads bloquants (
air-thread) pour ne pas bloquer le réacteur.
5. Deux familles de synchronisation, distinctes
- Bloquante :
std::sync/parking_lot, dansair-thread(couche 1). - Asynchrone : mutex/channel/notify de
air-runtime-sync(couche 1).
Cohérent ADR-038 (synchrone-first + async opt-in).
Conséquences
- Noms cohérents :
air-object= système d’objets ;air-runtime= runtime d’exécution async ;air-event= façade C-ABI de l’event loop. - Couches propres : runtime→L0, façade→runtime ; aucun saut de couche.
- Empreinte Pi-friendly : on démarre minuscule (un exécuteur), on scale en ajoutant des réacteurs indépendants, jamais de coût work-stealing.
- Sweep documentaire : la macro-architecture (
air-runtime/libair-runtime.sodu modèle d’objet →air-object/libair-object.so) est mise à jour ; ADR-002 reste valable (il ne nomme pas le crate). La spec de composant du runtime + d’air-events’écrira sur cette base. (L’ajout d’air-runtimeasync à l’inventaire couche 1 de la macro-architecture relève de la passe-2 d’enrichissement.)
Alternatives écartées
- Renommer le runtime async (au lieu du modèle d’objet) : « runtime » est plus
naturel pour l’async ; renommer le modèle d’objet en
air-objectest plus clair et conserve le nommage déjà posé par ADR-023 côté runtime. - Runtime async en couche 2 : violerait la discipline de couches (il consomme io_uring de la couche 0).
- Work-stealing dès la v1 : coût mémoire/synchro sans gain mesuré ; contraire au Principe 5 et à la cible matériel modeste.
Adoption
ADR-039 + sweep macro-architecture (air-runtime → air-object du modèle d’objet) +
entrées registre/INDEX/SUMMARY. Les specs de composant air-runtime (couche 1) et
air-event (couche 2) suivront, sur la base de cet ADR + ADR-023 + ADR-038.
ADR-040 — Format de l’artefact binaire de configuration : Cap’n Proto
Statut : Accepté (2026-06-25, validé par le BDFL). Companion d’ADR-033 (modèle de configuration : source typée → compilateur → artefact binaire). Tranche la question laissée ouverte par ADR-033 §4 (« famille Cap’n Proto / FlatBuffers / protobuf, à auditer sous la règle des 80 % »). Cohérent avec ADR-001 (AirCom encode déjà en Cap’n Proto), ADR-024 (règle des 80 %), ADR-025 (builds reproductibles) et le Principe 9 (budget runtime sur matériel modeste).
Catégorie : Architecture (transverse — air-config, couche 1).
Contexte
ADR-033 a posé le modèle : la configuration Air a pour source de vérité une source typée validée par un compilateur de config, qui en dérive un artefact binaire reproductible consommé par le runtime ; le texte (JSON/YAML/TOML) n’est qu’une projection. Le format de cet artefact binaire restait à choisir, sous trois exigences d’ADR-033 §4 :
- schema-first à évolution intégrée (numérotation de champs, optionnalité — compat avant/arrière par construction) ;
- version de schéma + checksum (corruption détectée, jamais lue en silence) ;
- lecture zéro-copie / mmap privilégiée (budget runtime, Principe 9 — important au boot, en particulier sur Raspberry Pi).
ADR-033 rejette déjà le format binaire maison sans politique d’évolution (dette de schéma sur 20 ans). Restaient trois candidats éprouvés : Cap’n Proto, FlatBuffers, protobuf.
Décision
L’artefact binaire de
air-configest encodé en Cap’n Proto.
Comparaison :
| Critère (ADR-033 §4) | Cap’n Proto | FlatBuffers | protobuf |
|---|---|---|---|
| Zéro-copie / mmap | ✅ natif | ✅ natif | ❌ decode + alloc à chaque lecture |
| Évolution de schéma | ✅ | ✅ | ✅ |
| Coût lecture (boot, Pi) | très faible | très faible | parse + alloc |
| Déjà adopté dans Air | ✅ AirCom (ADR-001) | ❌ | ❌ |
Deux raisons, dont une décisive :
- Cohérence projet (décisive). Air s’engage déjà sur Cap’n Proto pour AirCom
(ADR-001). Réutiliser le même langage de schéma, la même chaîne de codegen et la
même dépendance pour la configuration donne un seul modèle mental et une seule
dépendance à auditer sur tout le stack — un atout majeur sur l’horizon 20 ans. La
dépendance Cap’n Proto étant de toute façon justifiée pour AirCom,
air-configla réutilise sans coût d’exception supplémentaire (l’audit règle-des-80 % est partagé). - Zéro-copie / mmap (ADR-033 §4, Principe 9) : on
mmapl’artefact compilé et on lit les champs sans parser — gain réel au boot sur matériel modeste.
Écartés : protobuf — pas zéro-copie (decode + allocation à chaque lecture),
contraire au §4 et au Principe 9. FlatBuffers — techniquement équivalent (zéro-copie),
mais introduit un second écosystème alors que Cap’n Proto est déjà adopté ; ne serait
préféré que si l’audit révélait capnp (Rust) nettement pire sous la règle des 80 % et
qu’AirCom abandonnait Cap’n Proto.
Conséquences
- Audit règle des 80 % (ADR-024) : exception nommée à acter pour la crate Cap’n Proto
Rust (
capnp), partagée avec AirCom (à consigner dansdocs/EXCEPTIONS.mdau moment de l’implémentation, conjointement avec la spec AirCom — cf. ADR-001). On n’utilisera qu’une fraction de l’API, mais la dépendance est mutualisée et son adoption est large. - Placement de crate (à trancher au cadrage
air-config). ADR-033 situe le cœur dansair-base-lib. Faire tirer Cap’n Proto parair-base-lib— la crate la plus consommée — l’alourdirait. Recommandation :air-configen crate dédiée (couche 1) pour ne pas imposer Cap’n Proto à tout consommateur d’air-base-lib. Décision formelle reportée à la spec de composant. - Version + checksum dans l’artefact (ADR-033 §4) : portés par l’en-tête de l’artefact, vérifiés à l’ouverture (corruption → erreur, jamais lecture silencieuse).
- Reproductibilité (ADR-025) : l’encodage Cap’n Proto doit être déterministe
(ordre des champs stable, pas de padding non initialisé exposé) — à vérifier par le gate
cargo xtask repro.
Hors périmètre (traités ailleurs)
- La projection / les backends d’émission vers le monde C-Unix (régénération des
fichiers texte
/etclus par la libc et les services Unix classiques —resolv.conf,nsswitch.conf,hosts, unit files systemd…), de sorte que ces fichiers deviennent des artefacts générés (lecture seule), jamais édités à la main : c’est le modèle de backends d’ADR-033 §7, à détailler dans la specair-config(et au-delà de systemd). Cet ADR ne fixe que le format de l’artefact binaire Air-natif, pas la stratégie de coexistence/etc. - Le langage de la source typée et l’ergonomie du compilateur (diagnostics, GUI
contrainte par schéma) : spec
air-config.
Adoption
Édition documentaire immédiate (ADR-040 + registre/INDEX/SUMMARY). La spec de composant
air-config (couche 1) détaillera : schéma Cap’n Proto de la config, en-tête version+
checksum, backends d’émission (dont coexistence /etc pour la libc/legacy), placement de
crate, et l’exception EXCEPTIONS.md partagée avec AirCom.
Addendum (2026-06-25) — outil de compilation de schéma : exigence build-time + code généré committé
Constat (vérifié empiriquement sur l’arbre réel, capnp/capnpc v0.26).
- Runtime — propre. Le crate
capnp(runtime, linké dans les binaires Air) a ZÉRO dépendance, pur Rust,no_std-capable. Aucun C linké ;check-c-surface(zéro surface C/C++) n’est pas affecté. - Build-time — exige le binaire C++
capnp. Le codegencapnpcest un backend qui shelle vers le front-end C++capnppour parser les schémas.capnp(écheccargo buildsans le tool : « Please verify that version 0.5.2 or higher of the capnp executable is installed »). Ce n’est pas propre à Cap’n Proto — FlatBuffers exigeflatc, protobufprotoc(saufprost+protox) ; les formats schema-first binaires ont tous un compilateur de schéma. Cela ne renverse donc pas la décision (Cap’n Proto conserve l’avantage zéro-copie + cohérence AirCom).
Décision (gravée) — politique « code généré committé ».
- Les schémas
.capnpsont versionnés dans le repo. - Un mainteneur (disposant du tool C++
capnp, version pinnée, ADR-025) régénère le code Rust uniquement quand un schéma change, et commite le.rsgénéré. - Les builds normaux et la CI consomment le
.rscommitté → aucun tool C++ requis ; le build reste pur-Rust, cargo-only, reproductible. Le.rsgénéré ne dépend que du cratecapnpruntime (propre). - Le binaire C++
capnpest donc confiné à une opération mainteneur rare (changement de schéma), documentée et version-pinnée — hors du chemin de build critique et hors CI.
Portée. Ce constat et cette politique valent identiquement pour AirCom (ADR-001,
qui encode aussi en Cap’n Proto) : régler une fois pour les deux (à consigner aussi
dans la spec AirCom). L’exigence du tool C++ au build — et la politique de code généré
committé — seront détaillées dans la spec air-config (et la spec AirCom), avec le mode
de pinning du tool (ADR-025).
ADR-041 — Coexistence /etc : projection générée, lecture seule sélective, air-config seul écrivain
Statut : Accepté (2026-06-25, validé par le BDFL). Companion d’ADR-033 (modèle de configuration : source typée → compilateur → artefact binaire) — détaille la règle 7 (backends d’émission) au-delà de systemd. Cohérent avec ADR-005 (systemd socle V1), ADR-040 (artefact Cap’n Proto), ADR-032 (zéro discard) et le Principe 4 (valider en amont).
Catégorie : Architecture (transverse — air-config couche 1 + intégration OS couche 5).
Contexte
ADR-033 a posé que la configuration Air a pour source de vérité une source typée
compilée en artefact binaire (Cap’n Proto, ADR-040), le texte n’étant qu’une
projection. Reste un problème de coexistence avec le monde C-Unix : tant qu’Air
n’apporte pas sa propre libc et ne recompile pas les outils C contre elle, on ne peut
pas empêcher un programme C de lire — ni de tenter d’écrire — sous /etc. La libc
(glibc/musl) et les services Unix classiques lisent directement /etc/resolv.conf,
/etc/nsswitch.conf, /etc/hosts, /etc/passwd, /etc/systemd/system/…, etc. au
runtime, indépendamment d’Air.
L’objectif : un utilisateur faillible ne doit modifier la configuration qu’à travers
l’outil Air validant (Principe 4) ; mais les consommateurs C doivent continuer à
lire leur config. Il faut donc que /etc reflète la source Air sans redevenir une
surface d’édition libre.
Décision
Les fichiers
/etcque possède Air sont des projections générées parair-config(backend d’émission, ADR-033 §7), protégés en lecture seule sélective ;air-configen est le seul écrivain (réécriture par swap atomique). Les consommateurs C les lisent, ne les écrivent jamais. C’est un garde-fou, pas une frontière de sécurité.
Six règles :
-
Projection générée, pas source. Les fichiers
/etcpossédés par Air sont dérivés de la source typée par le backend d’air-config(cohérent ADR-033 §7) ; jamais édités à la main. La source typée (compilée en artefact Cap’n Proto, ADR-040) reste canonique. -
air-configseul écrivain, par swap atomique. Les régénérations passent parair-filesystem::write_atomic(tmpfile + fsync + rename). Le runtime Air-natif ne lit pas/etc(il lit l’artefact binaire) ;/etcn’existe que pour le monde C. -
Lecture seule SÉLECTIVE, pas globale. Seuls les fichiers possédés par Air sont rendus immuables-aux-autres (bind
ropar fichier,chattr +i, ou overlay à lower RO). Un/etcentièrement RO est rejeté : il casserait des écrivains runtime légitimes (passwd/useradd→/etc/shadow/passwd;ldconfig→ld.so.cache; installations de paquets). Donnée du réel : même les OS « immuables » (Fedora Silverblue, openSUSE MicroOS) gardent/etcinscriptible (overlay) et rendent/usrimmuable — précisément pour cette raison. -
Mutables-runtime → redirigés vers
/run. Les fichiers qu’un service réécrit en continu (resolv.conf…) sont des symlinks vers/run(tmpfs inscriptible), pattern systemd-resolved./etc/machine-id: symlink/runou pré-provisionné (premier boot). -
systemd :
air-configpossède/etc/systemd/system. L’activation de services (enable/disable/mask), les drop-ins (edit) ethostnamectl/localectl/timedatectlécrivent normalement sous/etc; dans le modèle Air, l’humain les déclare dans la source Air et le backend systemd d’air-configémet les symlinks/etc/systemd/system/*.wants+ drop-ins. systemd ne fait que LIRE cette zone ⇒ elle peut être RO-aux-autres,air-configseul écrivain. Les opérations runtime (start/stop/restart/status, via sd-bus → état dans/run) ne touchent pas/etcet restent intactes sous/etcRO. -
Garde-fou, pas frontière de sécurité. root peut
mount -o remount,rw. Le RO sélectif empêche les accidents et le «vim /etc/resolv.conf» distrait, et signale « fichier généré » (le tool C reçoitEROFS, échec bruyant plutôt qu’écrasement silencieux). La contrainte dure viendra avec air-launchd (sandbox/Landlock) ou le bit immuable — hors périmètre ici.
Conséquences
- Deux responsabilités distinctes.
air-config(couche 1) génère les projections/etc; la protection (montage RO sélectif /chattr/ overlay / symlinks/run) est une décision d’intégration OS (couche 5) — réalisable par une unit systemd de montage dès maintenant, avantair-launchd. - Périmètre croissant (progressif). Au début, peu de fichiers gérés/verrouillés ; une
zone
/etcne passe en RO-sélectif qu’une fois que le backend d’air-configcouvre les mutations correspondantes (mêmes incréments que ADR-033 §7). Tant qu’une mutation n’est pas couverte, sa zone reste inscriptible. - Bootstrap bidirectionnel. Sur installation/migration existante, l’outil doit
importer
/etcexistant → source Air (sens inverse) avant d’émettre. Import et émission. - Périmètre de possession explicite. Tout
/etcn’appartient pas à Air (des paquets en possèdent) ;air-configdéclare les fichiers qu’il possède — les autres restent hors de sa gestion.
Alternatives rejetées
/etcglobalement read-only. Cassepasswd/useradd/ldconfig/installations ; plus agressif que l’état de l’art (Silverblue/MicroOS gardent/etcinscriptible).- Régénération qui écrase en silence (sans RO). L’écriture d’un tool C « réussit » puis
est écrasée plus tard → surprise, dérive non signalée. Le RO sélectif échoue tout de
suite (
EROFS), plus honnête (Principe 4 / ADR-032). - Interdiction dure par défaut (Landlock/sandbox) dès maintenant. Prématuré sans air-launchd ; le garde-fou par montage suffit pour la phase de coexistence.
Hors périmètre
- L’enforcement dur (air-launchd, Landlock, bit immuable) — couche 5, ADR ultérieur.
- Le langage de la source typée, l’ergonomie du compilateur, le schéma Cap’n Proto et
la liste précise des backends/fichiers possédés — spec
air-config(couche 1).
Adoption
Documentaire immédiat (ADR-041 + registre/INDEX/SUMMARY). Mise en œuvre échelonnée : la
spec air-config détaillera les backends d’émission (dont /etc/systemd/system), l’import
inverse et le périmètre de possession ; l’intégration OS (unit de montage RO sélectif +
symlinks /run) viendra côté couche 5, de façon progressive.
ADR-042 — Pile TLS : rustls + aws-lc-rs (exception C nommée, étroite), TLS 1.3, air-tls maison en contingence
Statut : Accepté (2026-06-25, validé par le BDFL).
Companion de la note docs/notes/reseau-architecture-crates-fr.md (architecture des
crates réseau) ; déroge ponctuellement à la politique « zéro surface C » (audit 082,
check-c-surface, deny.toml) via une exception nommée au sens
ADR-024/ADR-034 ;
impacte ADR-025 (toolchain C au build). Cohérent
avec le choix RustCrypto d’air-crypto.
Catégorie : Architecture (couche 2 — air-tls/réseau) / Sécurité.
Contexte
La pile TLS conditionne toute la connectivité sécurisée d’Air (HTTPS, QUIC/HTTP-3, demain les services AirCom distants). C’est aussi la surface de sécurité la plus critique d’un système. Deux dogmes Air s’y opposaient en apparence : « on ne réécrit pas de crypto » et « zéro surface C ». Une analyse a été menée explicitement (cette session) ; cet ADR en consigne le résultat et le raisonnement.
Démarche d’analyse (consignée pour transparence)
- Frontière primitives / protocole. « Pile TLS maison » ne signifierait jamais réécrire des primitives crypto (AES, ChaCha20, SHA, X25519, Ed25519…) — celles-ci restent RustCrypto/équivalent. Seul le protocole (record layer, handshake, key schedule, machine à états, X.509) serait « à nous ». C’est exactement ce que fait rustls : le protocole, au-dessus d’un provider crypto enfichable.
- Classes de vulnérabilités TLS : (a) sûreté mémoire (Heartbleed-class —
empiriquement dominante, vit dans le C) ; (b) machine à états / protocole
(
goto fail, downgrade) ; (c) side-channel / primitives (Lucky13, Bleichenbacher). « Sécurité maximale » veut neutraliser les trois. - Comparaison des piles (focus sécurité) : la plus sûre n’est pas une pile tout-C (même la meilleure, s2n-tls d’AWS — petite, machine à états formellement vérifiée — ne peut éliminer la classe (a) par construction). La combinaison la plus sûre disponible = protocole memory-safe + primitives formellement vérifiées.
- Providers rustls :
ring(C+asm, maintenance irrégulière),aws-lc-rs(AWS-LC : C+asm, équipe AWS financée, asm formellement vérifié, FIPS 140-3, devenu défaut de rustls),rustls-rustcrypto(pur Rust, non-défaut, moins éprouvé).
Décision
La pile TLS d’Air est
rustls(protocole, pur Rust, memory-safe) +aws-lc-rs(provider crypto, AWS-LC) +rustls-webpki(validation X.509, pur Rust). TLS 1.3 ciblé. Le C est admis par EXCEPTION NOMMÉE, ÉTROITE : il ne concerne QUE les primitives crypto (aws-lc-sys), jamais le protocole ni le parseur X.509, qui restent memory-safe Rust.
Justification (sécurité + fiabilité) :
- La partie bug-prone reste memory-safe. Record layer, handshake, machine à états et parsing X.509/ASN.1 (la surface hostile, là où vivent les CVE catastrophiques) sont en Rust (rustls + rustls-webpki) → la classe (a) est éliminée par construction là où elle compte.
- Le C est confiné là où il a une valeur prouvée : les primitives d’
aws-lc-rs, asm formellement vérifié, FIPS 140-3, constant-time, équipe AWS financée (réponse sécurité rapide — fiabilité sur 10-20 ans). C’est une petite surface auditée, pas le protocole tentaculaire. - rustls : protocole moderne TLS 1.2/1.3 only (zéro legacy), financé ISRG (Let’s Encrypt), testé contre BoGo, largement déployé. C’est le défaut qu’AWS/ISRG recommandent.
→ C’est la combinaison la plus secure objectivement disponible aujourd’hui : memory-safety + vérification formelle + TLS 1.3 + maintenance financée — qu’aucune pile seule (ni full-C, ni full-pur-Rust) n’égale.
Périmètre
- TLS 1.3 ciblé ; rustls est configuré 1.3-first (1.2 admis seulement si un besoin d’interop réel l’impose, configurable — par défaut on minimise la surface legacy).
- X.509 :
rustls-webpki(pur Rust). Pas de parseur ASN.1 maison. - Exception C : limitée à
aws-lc-sys/aws-lc-rs(+ ses outils de buildcc/cmake). Aucune autre surface C n’est ouverte par cet ADR.
Contingence — air-tls maison reste une porte de sortie
Un air-tls maison (protocole TLS implémenté par Air, sur le patron sans-IO 9-composants
de la note réseau, primitives RustCrypto, régime d’homologation IETF — RFC 8448, tlsfuzzer,
interop, fuzzing) pourra voir le jour SI le choix rustls devient un passif :
faille de sécurité non corrigée en temps voulu, abandon/maintenance dégradée, ou
complexité d’intégration rédhibitoire. Cette option est explicitement gardée
ouverte (souveraineté + valeur de démonstration), mais n’est pas le choix présent :
on ne porte pas le pari le plus risqué tant que rustls fait le travail.
Conséquences
- Exception nommée à appliquer à l’implémentation (pas maintenant — aucun code réseau
encore). Quand la dépendance rustls entrera (crate
air-tls/air-network) :deny.toml: lever le ban deaws-lc-sys(etcc/cmakesur ce sous-arbre) par une entrée nominative justifiée ;ringreste banni (on prend aws-lc-rs, pas ring).docs/EXCEPTIONS.md: entrée nommée (motif = sécurité par crypto formellement vérifiée/FIPS + memory-safety du protocole ; plan de sortie =air-tlsmaison en contingence).check-c-surface: ajuster pour autoriser uniquementaws-lc-sys(le ban générique*-sysreste pour tout le reste).
- Builds reproductibles (ADR-025) :
aws-lc-rstire un build C (cmake) → pinner la toolchain C ou utiliser les bindings pré-générés ; à traiter dans la specair-tls(sinon la repro cargo-only ne tient plus pour ce crate). - Homologation : même sans pile maison, on impose et publie un régime de test/ interop sur notre intégration (matrice d’interop OpenSSL/rustls, RFC 8448 rejouées via rustls, fuzzing de notre couche d’intégration) — la rigueur reste, l’effort est moindre.
- air-tls maison : tracé comme option de contingence dans la note réseau.
Alternatives écartées
- air-tls maison d’emblée : le pari maison le plus risqué (historique TLS brutal) ; tout à prouver ; reporté en contingence plutôt qu’écarté.
- rustls + provider RustCrypto (zéro-C total) : séduisant (pas d’exception C), mais provider non-défaut, moins éprouvé, sans vérification formelle ni FIPS → moins sûr que aws-lc-rs sur la classe (c). Recevable comme repli si l’exception C devait être refermée.
- Pile tout-C (OpenSSL/BoringSSL/s2n) : protocole+parsing en C → expose la classe (a) (mémoire), la plus dommageable. Rejeté.
Adoption
Documentaire immédiat (ADR-042 + registre/INDEX/SUMMARY + correction de la ligne air-tls
de la note réseau). Les changements deny.toml/EXCEPTIONS.md/check-c-surface et le
pinning toolchain C (ADR-025) sont appliqués à l’implémentation d’air-tls/air-network
(spec à venir), pas avant.
ADR-043 — Pile SSH : OpenSSH système en incubation, air-ssh maison à terme
Statut : Accepté (2026-06-25, validé par le BDFL).
Companion de la note docs/notes/reseau-architecture-crates-fr.md (architecture des crates
réseau) et pendant d’ADR-042 (pile TLS). Cohérent avec
ADR-011 (phasage bottom-up), ADR-013
(incubation / coexistence), et le périmètre connectivité tier-1 (ssh/sshd).
Catégorie : Architecture (couche 2 — air-ssh / réseau) / Sécurité.
Contexte
SSH (client ssh/scp/sftp + serveur sshd) est dans le périmètre tier-1 d’Air.
La question — comme pour TLS (ADR-042) — : implémentation maison, réutilisation d’une
brique existante, ou usage de l’existant système ? L’analyse a été menée explicitement.
Démarche d’analyse (consignée)
- OpenSSH = référence solide (l’équipe OpenBSD a inventé la separation de privilèges, le
sandboxing), mais expose des défauts réels : regreSSHion (CVE-2024-6387) — RCE non
authentifiée via race de handler de signal (async-signal-unsafe) = classe sûreté-
mémoire (C) ; Terrapin (CVE-2023-48795) — faille protocolaire (troncature de
préfixe, corrigée par « strict kex ») ; surface legacy (agent/X11/port forwarding) ;
vecteur xz via
libsystemdlié àsshddans les distros. - SSH ≠ TLS sur trois points décisifs :
- Pas d’option Rust mature. L’équivalent Rust est russh (communautaire) — bien moins audité/éprouvé que rustls. Il n’existe pas de SSH Rust de calibre rustls. L’argument « réutiliser le mûr » (décisif pour TLS) est donc faible ici.
- Surface bien plus grande : transport (BPP/kex/rekey) + auth (publickey/password/
keyboard-interactive/certificats) + connexion (canaux/multiplexage/forwardings/pty/
exec/SFTP). Plus gros que TLS 1.3 ; un
sshdmaison = surface d’attaque distante critique (classe regreSSHion). - Même base crypto (RustCrypto) et memory-safe des deux côtés (russh ou maison) → tous deux éliminent la classe regreSSHion ; la différence est la maturité de correction protocolaire.
- SSH n’est pas sur le chemin critique : c’est un protocole applicatif couche 2+ ; Air construit par le bas (ADR-011) et, en incubation (ADR-013), les outils Unix classiques — dont l’OpenSSH système — tournent normalement.
Décision
Court terme :
air-sshest DIFFÉRÉ ; Air s’appuie sur l’OpenSSH du système pendant l’incubation. À terme (cadrage réseau couche 2) :air-sshMAISON (memory-safe Rust), PAS russh — car russh n’est pas de calibre « confiance les yeux fermés » pour unsshd(surface distante critique), contrairement à rustls pour TLS. russh reste une référence à étudier / base possible si fortement auditée. Client (ssh/scp/sftp) livrable AVANT le serveur (sshd), traité avec un soin extrême (audit externe avant exposition).
Exigences gravées du futur air-ssh (quand il sera planifié)
- Memory-safe Rust (élimine par construction la classe regreSSHion).
- Anatomie 9-composants de la note réseau (Framer/Codec/Handshaker… ) — sans-IO, composants fuzzés isolément (Handshaker/Framer = surface hostile).
- « Strict kex » anti-Terrapin dès le départ ; « moderne uniquement » (pas de vieux kex/ciphers — comme rustls pour TLS) ; surface forwarding minimale et explicite.
- Crypto = RustCrypto (jamais réécrite — frontière primitives/protocole, cf. ADR-042).
- Homologation : interop OpenSSH + conformité RFC 4250-4254 (+ 4256, certificats),
fuzzing, et audit externe du
sshdavant toute exposition. - Client avant serveur :
ssh/scp/sftp(risque moindre) d’abord ;sshdensuite.
Conséquences
- Aucun code maintenant : pas de crate
air-sshtant que la couche 2 réseau n’est pas planifiée. La note réseau marqueair-ssh« maison, différé ». - Choix maison vs russh ré-arbitrable au moment du cadrage si russh atteint une maturité/ un audit comparables (peu probable à court terme) — la direction par défaut reste maison.
- Pas d’exception C (contrairement à TLS) :
air-sshmaison + RustCrypto = zéro surface C. (L’OpenSSH système utilisé en incubation est hors périmètre Air.) - Sécurité d’incubation : tant qu’on utilise l’OpenSSH système, on hérite de ses CVE (regreSSHion…) — assumé et temporaire, comme toute la coexistence ADR-013.
Alternatives écartées
- air-ssh maison d’emblée : chantier énorme + risque maximal (
sshddistant), sans nécessité (OpenSSH système suffit en incubation) → différé, pas écarté (c’est la cible). - Bâtir sur russh maintenant : memory-safe mais maturité insuffisante pour un daemon distant critique ; resterait à durcir/auditer autant qu’un maison → bénéfice incertain. Gardé comme référence/base auditée possible.
- Renoncer à un SSH natif (OpenSSH à vie) : contraire à l’ambition « tout maison / zéro-C / souveraineté » d’Air sur la connectivité tier-1.
Adoption
Documentaire immédiat (ADR-043 + registre/INDEX/SUMMARY + note réseau : air-ssh « maison,
différé ; OpenSSH système en incubation »). Implémentation au cadrage réseau couche 2
(spec air-ssh : périmètre client/serveur, algorithmes, SFTP, régime d’homologation).
ADR-044 — Extension de la couche 0 : famille poll (ppoll) pour l’attente synchrone bornée et interruptible (re-sceau couche-0-v1.5)
Statut : Accepté (2026-06-26, validé par le BDFL). RFC d’extension d’une couche
scellée (ADR-015) : ajoute une nouvelle famille au socle syscall — ce n’est
pas un complément additif type Errno (qui, lui, ne requiert pas de RFC). S’appuie
sur le Principe 1 (rigueur des couches fondatrices), l’ADR-021 (conventions couche 0),
l’ADR-032 (zéro discard) et l’ADR-037 (validation x86 par barrière + CI ARM). Cadrage
détaillé : docs/draft/wait-timeout-design-fr.md.
Catégorie : Couche 0 (extension du socle syscall — RFC).
Contexte
La couche 0 a été scellée comme complète (11 familles + io_uring ; tags
couche-0-v1 → v1.4). Elle n’expose aucun wrapper poll/ppoll/epoll
synchrone.
L’incident fork/io_uring du 2026-06-25 (PR #128) a révélé un footgun de conception :
AirProcess::wait() fait un waitid bloquant non borné sur un pidfd ; si l’enfant
ne se termine jamais (deadlock), le parent hang indéfiniment. La correction de fond
exige, côté couche 1, une attente bornée et/ou interruptible (cf.
wait-timeout-design-fr.md : wait_timeout/wait_until/wait_forever).
Un pidfd devient lisible (POLLIN) à la terminaison de l’enfant ; mais waitid
n’a pas de timeout. Pour une attente synchrone bornée/interruptible, il faut
attendre la lisibilité d’un ou plusieurs fd avec une échéance — c’est exactement
ppoll(2). Sans lui, la seule alternative serait de tirer io_uring dans
air-process (allocation, lourd, conceptuellement couche 2) — rejeté.
Ajouter un syscall/une famille au socle scellé évolue le socle → relève de l’RFC (ADR-015), d’où le présent ADR (et non un simple jalon de re-sceau additif).
Décision
-
Nouvelle famille couche 0
poll(module dédié), extensible plus tard verspoll/select/epollsi un besoin réel apparaît. Chaque opération = une fonction dédiée typée (ADR-021 §3 — pas de wrapper multiplexé). -
Wrapper
ppoll(et nonpoll) :#![allow(unused)] fn main() { pub fn ppoll( fds: &mut [PollFd<'_>], timeout: Option<Duration>, // None → NULL (infini) ; ZERO → sondage sigmask: Option<&SignalMask>, // None → NULL (réservé usage futur) ) -> Result<usize, Errno>; // nb de fd à revents non vide ; 0 = expiration }EINTRremonté à l’appelant, jamais de retry (ADR-021 §2 ; la reprise vit en couche 1).- Conversion
Duration→timespecdéfensive :tv_secviai64::try_fromclampé ài64::MAXsi débordement (jamaisas; Principe 2) ;tv_nsec=subsec_nanos(u32 → i64sûr). - Choix
ppoll(paspoll) : précision ns (aligneDuration),timeoutnon muté au retour, sigmask atomique — la variante moderne (ADR-021 « variantes modernes préférées » ; doctrine « kernel = bible »).
-
Nouveau type
PollFd<'fd>:BorrowedFd<'fd>+eventsdemandés +reventsremplis par le kernel. Emprunté (+0), aucune prise d’ownership. Lesreventssont restitués intégralement à l’appelant (ADR-032 — l’information du kernel n’est pas masquée). -
Promotion de
PollEventshors du moduleio_uringvers le modulepoll, ré-exporté depuisio_uring: zéro rupture, type inchangé sur le fil (réorganisation interne d’une couche scellée — d’où le re-sceau). -
Re-sceau
couche-0-v1.5: jalons antérieurs (v1→v1.4) conservés.check-syscallsajoute le numéroppoll(x86_64 271 / aarch64 73), certifié sur les 2 arches. Couverture 100 % lignes+branches hors exceptions ADR-035 (EINTRprobable STRUCTURAL si non injectable de façon déterministe, cf. précédentwaitid). Fuzz non requis (pas de parsing d’entrée externe : entrées = fd + durée). -
Gate :
cargo xtask barriersur speedy ET carbon + CI ARM (raspi-srv-2), les trois verts (ADR-037), avant merge.
Conséquences
- + Débloque l’attente synchrone bornée/interruptible de
air-process(wait_timeout/wait_until/wait_forever) sans io_uring ni allocation — cohérent avec une couche 1 synchrone. - + Brique fidèle au kernel :
ppollest le mécanisme kernel d’attente multi-fd bornée ; on l’expose typé sans en distordre la sémantique (doctrine « kernel = bible », re-présentationResult/Option/PollFd). - + La supervision asynchrone de N pidfds (reactor io_uring) reste, comme prévu, en couche 2 ; cet ADR ne traite que le synchrone.
- − Évolution d’une couche scellée → re-sceau
v1.5, barrière 2 arches + CI ARM à repasser au vert (coût assumé, Principe 1). - −
PollEventsdéménage (réorg interne) ; atténué par le ré-export (zéro rupture d’appelant).
Alternatives écartées
- io_uring (
poll_add+link_timeout) pour l’attente : rejeté — tire un ring entier dansair-process(allocation, complexité), conceptuellement couche 2. poll(2)au lieu deppoll: rejeté — précision ms,timeoutmuté au retour, pas de sigmask ;ppollest la variante moderne.timerfd+ attente combinée : rejeté — davantage de syscalls, sans avantage surppoll, et nécessiterait aussi un wrapper couche 0.- Ne rien ajouter (rester sur
waitidbloquant) : rejeté — c’est précisément le footgun de hang qu’a exposé l’incident fork. - Re-sceau additif sans RFC (gabarit
Errno) : rejeté — ajouter une famille/syscall évolue le socle (≠ compléter des constantes) → RFC requis (ADR-015).
ADR-045 — Modèle d’erreurs de l’ABI C : AirStatus in-band, sans errno, panic = abort
Statut : Accepté (2026-06-26, validé par le BDFL). Companion d’ADR-019
(modèle d’erreurs hybride), d’ADR-027 (doc ABI C) et
d’ADR-012 (stabilité ABI 10 ans). Réalise la doctrine
libc (vision « libc Rust Air » + « kernel = bible, pas de globale libc ») au niveau de
la frontière C. Cadrage détaillé : docs/draft/abi-c-t1-design-fr.md.
Catégorie : Architecture (ABI C — contrat, transverse à toute la surface C-ABI).
Contexte
La première passe ABI C (crate air-base-capi → libair-base.so, ADR-027
addendum C) doit décider comment les erreurs traversent la frontière C — décision
contractuelle (ABI 10 ans) qui fera précédent pour toute la future libc Air.
POSIX/glibc utilisent errno : un canal hors-bande, thread-local aujourd’hui
mais implicite (posé en effet de bord, absent de la signature, oublié, écrasé par un
appel intermédiaire, non composable). La doctrine Air le refuse : pas de globale
inventée par la libc, erreurs in-band, fidèles au kernel (qui rend -errno
dans le registre de retour — c’est la libc qui ajoute la couche errno par-dessus).
Par ailleurs, aucune panic Rust ne doit traverser l’ABI (ADR-027 §B.2) ; et Air
ne provoque aucune panic (Principes 2/3) — une panic = un bug.
Décision
-
AirStatusrendu in-band : enum#[repr(C)]retourné comme valeur de retour de chaquepub extern "C",AIR_STATUS_OK = 0. Sur-ensemble d’AirErrorKind(miroir 1..=13) + codes propres à la frontière (AIR_STATUS_NULL_ARGUMENT = 100,AIR_STATUS_BUFFER_TOO_SMALL = 101). Discriminants explicites et committés (stabilité ABI, ADR-012). -
Aucun
errno, aucune globale, aucun canal hors-bande. Les*out-params ne sont écrits que surAIR_STATUS_OK. Validation amont des pointeurs : tout pointeur requisNULL→AIR_STATUS_NULL_ARGUMENT, jamais de déréférencement (Principe 4). -
Message humain par chaîne statique :
air_status_message(AirStatus) -> *const c_charrend une chaîneconststatique (+0, jamais libérée), façonstrerrormais sans état global ni locale. Le message dynamique d’AirError(contexte riche) n’est pas transporté en T1 (différé ; à réinstruire si un besoin réel apparaît). -
panic = "abort"(fail-fast) : aucune panic n’est voulue (panic = bug Air) → pas decatch_unwind, pas de statutAIR_PANIC. Un bug tue le process au point de faute, bruyamment, sans état douteux qui continue. Mémoire-sûr : même sousunwind, une panic atteignant une frontièreextern "C"abort (Rust ≥ 1.81), jamais d’UB. La cdylib release est buildéepanic = "abort"; Cargo build les cibles de test enunwindautomatiquement (libtest /should_panic/ proptest intacts).
Conséquences
- + Fidèle au kernel (in-band), zéro globale libc, erreurs visibles dans la signature → transparence (« dit ce qu’elle fait »). Convention d’erreur unique pour toute la future libc.
- + ABI simple en T1 : aucun objet erreur à allouer/libérer (ownership trivial).
- +
panic = abortsimplifie l’ABI (pas d’AIR_PANIC) et reste honnête (un bug est un bug). - − Du code C attendant
errnone porte pas tel quel → adaptation requise (ou shimerrnoopt-in ultérieur — différé, réintroduirait le smell). - − Le message dynamique (contexte riche d’
AirError) est reporté.
Alternatives écartées
errno-style (globale / TLS) : rejeté — canal hors-bande implicite qui masque l’erreur ; viole « pas de globale libc » et « dit ce qu’elle fait ». (errnoest déjà thread-local dans toute libc à threads : le vrai défaut n’est pas la course mais le caractère hors-bande.)- Objet
AirErroropaque (air_error_message/air_error_free) dès T1 : rejeté pour T1 — introduit propriété mémoire + allocation + fonction de libération prématurément. Réinstruire si le message dynamique devient nécessaire. catch_unwind→AIR_PANIC: rejeté — convertit un bug Air en statut « récupérable » que l’appelant peut ignorer, sur un état possiblement douteux ;abortest plus honnête et simplifie l’ABI.
ADR-046 — La libc Air (Rust pur) : périmètre, layering couche-1-only, et doctrine d’exécution
Statut : Accepté (2026-06-27, validé par le BDFL). Document fondateur de la
future libc. Companion d’ADR-045 (erreurs ABI C),
d’ADR-019 (modèle d’erreurs hybride), d’ADR-021
(conventions couche 0 — EINTR, Option<T>, variantes modernes), d’ADR-004
(Linux tier-1), d’ADR-012 (stabilité ABI) et d’ADR-016
(i18n/icu4x). Réalise la vision « libc Rust Air ».
⚠️ PRÉCISIONS STRUCTURANTES (2026-06-30, ADR-053). Le ctype/locale/i18n de la libc s’appuie sur icu4x (caractères, casse, collation, normalisation, segmentation, calendriers). Deux points du § D7 ci-dessous deviennent OBSOLÈTES : « libm exclu de la v1 » (icu4x exige un
libmno_std — calendriers + LSTM) et « hors wide-char lourd » (lewcs*/towctransi18n-aware EST la surface adossée à icu4x). Voir ADR-053 (fait autorité sur le i18n de la libc) + l’auditdocs/notes/audit-icu4x-rust-pur-fr.md.
Catégorie : Architecture (fondateur, transverse). Toute décision structurante ultérieure sur la libc passe par un RFC amendant cet ADR (ADR-015).
Contexte
Air veut fournir une libc en Rust pur pour combler les trous userland que le C ne peut pas couvrir automatiquement : une libc prévisible, sans surprise, qui « fait ce qu’elle dit ». L’objectif fonctionnel est de pouvoir compiler et exécuter des userlands C/C++ existants sur Air (cible pilote : OpenSSH), tout en apportant une sécurité accrue par construction (Rust) là où la libc C historique laisse des angles morts.
Il faut distinguer deux artefacts que le langage courant tend à confondre :
libair-base.so(crateair-base-capi, ADR-027/045, tranches T1→T5 mergées) : l’export en ABI C des types de la couche 1 (AirString,AirPath,AirLog,AirInstant,AirDateTime, identifiants…). C’est « rendre notre bibliothèque utilisable depuis C ». Ce n’est pas la libc.- La libc Air (le présent ADR) : l’ensemble de crates fournissant la surface
POSIX-ish (
open/read/printf/malloc/getaddrinfo…) en ABI C, contre laquelle on linke un programme C. Artefact distinct et bien plus vaste.
POSIX/glibc reposent sur des choix qu’Air refuse : errno globale implicite,
conformité comme fin en soi, extensions tentaculaires. La couche 0 (attaque directe
du kernel, ADR-021) et la couche 1 ont justement été bâties pour fonder cette libc
sur nos propres mécanismes, pas sur glibc/musl.
Décision
D1 — Artefact distinct, en Rust pur
La libc Air est un nouvel ensemble de crates (hors air-base-capi), exposant la
surface POSIX-ish en extern "C". Pas de C non maîtrisé dans son cœur : Rust comble
les trous que le C ne couvre pas automatiquement (bornes, encodages, ownership).
D2 — Layering strict : la libc ne repose QUE sur la couche 1 (impératif)
La libc (et libair-base.so) ne dépendent QUE de la couche 1. JAMAIS d’accès direct
à la couche 0 (air-sys-*). Aucun saut de couche.
Conséquence directe et contraignante pour la couche 1 : c’est à elle d’exposer tout
ce dont la libc a besoin, y compris des passthroughs fidèles au kernel. Exemple
canonique : un read qui remonte EINTR sans retry automatique (sémantique POSIX
attendue) doit être atteignable via la couche 1 — et non en court-circuitant vers la
couche 0. La couche 1 offre donc deux tiers : un tier kernel-faithful (fidèle,
sans politique) et des helpers ergonomiques (retry, types possédés). La libc se lie
au tier fidèle. Gardé en CI (la libc ne doit jamais lister air-sys-* en dépendance).
D3 — POSIX = objectif fonctionnel, pas conformité
On vise « les userlands cibles tournent », pas la certification POSIX. Les SHOULD
de POSIX deviennent des MUST chez Air (comportement défini, jamais « implementation-
defined » laissé au hasard). Là où la conformité exigerait une concession à la prévisibilité,
la prévisibilité gagne.
D4 — errno thread-local, jamais global ; plomberie interne in-band
Notre plomberie interne reste in-band (erreurs dans la valeur de retour, cf. ADR-045) :
zéro globale inventée par la libc. Mais POSIX exige errno (les programmes C le
lisent) : la libc le fournit donc comme un errno thread-local — un shim de
compatibilité POSIX posé en bordure, jamais un canal interne. errno global de la libc C
historique = erreur de conception (effet de bord implicite, écrasable, non composable),
refusée.
D5 — Kernel = bible, pas POSIX
Fidélité à la sémantique et à l’information du kernel Linux, re-présentées en types sûrs côté Rust. On ne cache rien, on ne remappe pas arbitrairement. Là où POSIX diverge de ce que fait le kernel, le kernel fait foi ; POSIX est l’objectif fonctionnel, pas l’autorité sémantique.
D6 — Exécution par provenance (bases en place ; activation post-v1)
- Un binaire compilé par rustc, signé, ne tirant NI glibc NI musl (donc reposant sur la libc Air) et s’appuyant sur les API Air → exécution NON confinée (natif).
- Tout autre binaire (compilé autrement, ou tirant une autre libc) → exécution CONFINÉE : d’abord via LXC, puis via nos conteneurs maison (mappings couche 0 cgroups/seccomp/landlock).
- Chaque appel
exec*déclenche, côté Rust et AVANT le spawn, une analyse de l’exécutable (ELF :DT_NEEDED/dépendances + signature comme ancre de confiance) qui décide natif vs confiné. - Périmètre v1 : seules les bases sont requises (couche 0 : cgroups/seccomp/landlock ;
vérification de signature ; analyse ELF). L’enforcement complet à l’
execest différé post-v1 et fera l’objet d’un ADR sécurité dédié.
D7 — Périmètre et ordre de réalisation
Référence : libc classe musl (propre, sans le bloat glibc), libm exclu de la v1
⚠️ OBSOLÈTE (ADR-053) : libm no_std est un PRÉREQUIS (icu4x :
calendriers + LSTM).
- Ordre de grandeur : ~25-30 familles, ~600-800 fonctions pour un cœur utile
(POSIX base + C11,
hors libm[libm requis, ADR-053], hors SysV IPC,hors wide-char lourd[lewcs*i18n-aware EST la surface icu4x, ADR-053]) ; une libc musl-complète va à ~1 400. - Cible pilote = compiler/linker OpenSSH : ~250-350 fonctions sur ~15 familles
(I/O fichier, string/mem, réseau, process/signal, stdio, stdlib/malloc, credentials
pwd/grp, termios/PTY, time, ctype, poll/select, dirent, mman, divers). « OpenSSH + ses deps » (OpenSSL rendpthreadrequis, zlib) ≈ 300-400. - Le vrai travail userland se concentre sur ~6-7 familles : stdio (
FILE*bufferisé), malloc, printf/scanf (moteur de format), getaddrinfo/résolveur, termios/PTY, locale/ctype (→ adossé à icu4x, ADR-053). Le reste = wrappers fins sur la couche 1. - Déjà partiellement couvert : la majorité des syscall-wrappers (couche 0 exposée par
couche 1) ;
pwd/grpvia les backendsair.accounts.
D8 — Toolchain *-linux-air (quand la libc ≈ couvre la surface std)
- Cible Rust
*-linux-air:stdreposant sur la libc Air (et non glibc/musl). Tant que la libc ne couvre pas assez de surfacestd, la cible n’a pas de sens à produire. - Toolchain clang C
*-linux-air: on ne peut pas exclure des userlands C → un clang ciblant notre sysroot est nécessaire (uncargo/rustcseul ne compile pas du C). Un seul sysroot, deux front-ends (rustc + clang). - C++ séparable : supporter des userlands C++ n’impose pas un nouveau compilateur, mais
une runtime C++ (
libc++/libc++abi/libunwind) au-dessus de la libc Air. Décision différée, non bloquante pour le C. - Détails et prérequis :
docs/notes/rustc-cible-linux-air-fr.md.
D9 — libm exclu de la v1 ⚠️ OBSOLÈTE (ADR-053)
libm exclu de la v1Les fonctions mathématiques (libm) sont différées : hors périmètre de la première libc.
OBSOLÈTE depuis ADR-053 : la libc Air s’appuyant sur icu4x pour ctype/locale/i18n, un
libm no_std devient un PRÉREQUIS — icu_calendar/calendrical_calculations (math
calendaire, incontournable) et le LSTM d’icu_segmenter en dépendent. libm n’est donc
plus exclu ; il est un maillon de base de la i18n Air (réécrit ou vendoré — question
ouverte d’ADR-053). Cf. l’audit docs/notes/audit-icu4x-rust-pur-fr.md.
Conséquences
- La couche 1 doit grandir pour exposer des passthroughs fidèles au kernel (D2) là où elle n’offre aujourd’hui que des helpers ergonomiques. Chaque famille libc à venir révélera les primitives manquantes côté couche 1.
- Sécurité par construction : Rust rend structurelle la garantie « la libc fait ce qu’elle dit » (vs. garantie manuelle en C). La documentation (ADR-027 : pourquoi, alternatives, pièges) en est le pendant obligatoire — ne rien laisser au hasard, ne rien survendre.
- Dépendances : règle des 80 % (ADR-024) et licences (deny.toml) s’appliquent ; tout code C tiers nécessaire est intégré/maîtrisé, pas tiré aveuglément.
- Reproductibilité (ADR-025) et ABI (ADR-012) s’appliquent à la libc comme au reste.
Alternatives écartées
- Porter glibc ou musl : rejeté. C non maîtrisé au cœur,
errnoglobal, conformité-as-fin ; contraire à la raison d’être (sécurité userland par Rust). errnoglobal (modèle libc C historique) : rejeté (D4) — effet de bord implicite, écrasable, non composable.- Faire reposer les wrappers fins directement sur la couche 0 (proposé puis rejeté par le BDFL, D2) : casse le layering ; la fidélité kernel passe désormais par un tier dédié de la couche 1.
- Viser la conformité POSIX certifiée : rejeté (D3) — coût/concessions sans bénéfice pour les cibles d’Air.
- relibc / eyra / c-scape (libc Rust existantes) : veille maintenue, mais le créneau « libc Rust pur, mécanismes Air, exécution par provenance » reste vacant ; on construit le nôtre.
Questions ouvertes (différées)
- Enforcement exact à l’
exec(format de signature, politique natif/confiné fine, conteneurs maison) → ADR sécurité post-v1. libm: quand et comment (port vs. Rust pur).- Périmètre fonction-par-fonction : à figer famille par famille au fil de la réalisation (un sous-ADR ou une annexe vivante par famille).
- Runtime C++ (
libc++& co) : si/quand on supporte les userlands C++.
ADR-047 — Principes de construction de la libc Air
Statut : Accepté (2026-06-27, validé par le BDFL). Companion d’ADR-046
(libc Air — fondateur) : ADR-046 pose le cadre (artefact, layering, doctrine), le présent
ADR pose la méthode de construction et les invariants non négociables. Décline les
Principes d’ingénierie 2 (arithmétique défensive), 3 (chaînes/buffers/encodages) et 5
(optimiser après mesure), la discipline // SAFETY: d’ADR-021,
et le standard de doc d’ADR-027.
Catégorie : Architecture / Méthode (transverse à toute la libc). Toute évolution passe par un RFC (ADR-015).
Contexte
La libc Air est du code userland critique : tout programme C/C++ exécuté sur Air en dépend. ADR-046 a décidé qu’on la bâtit en Rust pur, sur la couche 1 uniquement, en s’inspirant de musl. Cet ADR fixe comment on s’en inspire, sous quel regard, et les garanties qu’on ne transige jamais. Le regard est SÉCURITÉ / PROTECTION, jamais la performance d’abord.
Décision
P1 — musl = périmètre (surface), pas implémentation
On prend l’inventaire des symboles exportés de musl — épinglé à une version précise — comme liste cible de ce que la libc doit fournir. Implémentation en Rust pur, selon nos principes. Les signatures et la sémantique restent dictées par les standards (The Open Group / POSIX + ISO C + Linux man-pages section 2 + kernel/UAPI, « kernel = bible » d’ADR-046 D5). musl = le quoi, les standards = le contrat, nos principes = la manière.
P2 — Audit de musl par famille, à double tranchant, artefact vivant
Avant d’implémenter une famille, on audite la famille correspondante de musl. Double but : (i) repérer ce qui est bien fait → s’en inspirer ; (ii) être critique → noter les faiblesses. L’audit inventorie, par famille :
- les structures internes ;
- les variables globales publiques et privées/opaques ;
- comment l’accès aux ressources partagées est protégé en concurrence/multi-thread ;
- la proportion d’assembleur, où, et pourquoi.
L’audit est un artefact versionné (un document par famille, gabarit en annexe A), épinglé à la version de musl auditée (reproductibilité de la référence). On audite par famille, juste avant de l’implémenter (pas tout d’un bloc — l’audit vieillirait).
P3 — Regard sécurité, jamais performance d’abord
On audite et on code sous l’angle protection / sécurité / accès partagé, en permanence. On préfère du code supplémentaire — impact performance assumé — qui garantit qu’aucun cas critique ne survient, plutôt que l’optimisation. Aucune optimisation n’est entreprise avant une implémentation robuste de TOUTE la librairie, tout en tête (Principe 5). L’optimisation, si elle a lieu, vient après, mesurée et justifiée.
P4 — Les quatre modes de défaillance (invariants vérifiables)
« Ne jamais être pris en échec » se vérifie fonction par fonction. Un context switch pur (préemption) n’est pas un échec si le verrouillage est correct ; les vrais modes sont :
- Async-signal-safety — un signal interrompt le thread en plein milieu : le handler ne doit ni observer ni corrompre un état incohérent.
- Fork-safety —
fork()en multi-thread : l’enfant hérite de tous les locks mais d’un seul thread (cf. deadlock malloc/atfork) ; états cohérents viapthread_atfork/conception. - Cancellation-safety —
pthread_cancelaux points d’annulation : ni fuite ni corruption. - Réentrance / état mutable partagé non gardé — le péché capital de la libc C : buffers
statiques (
strtok,gmtime,getpwnam…),environ, la locale, le registre desFILE*, l’arène malloc, la liste atexit,TZ…
Chaque famille produit l’inventaire de son état mutable partagé, sa protection (lock ? TLS ? rien ?), et sa sûreté face aux 4 modes.
P5 — Grille sécurité élargie
Au-delà de la concurrence, l’audit et l’implémentation traquent aussi : overflow entier
(Principe 2 — ex. calloc(n,size)), bornes & format (%n, format-string, Principe 3),
TOCTOU sur les chemins, injection via environ/locale, effacement des secrets (memzero à
la libération des données sensibles).
P6 — Doctrine de l’assembleur : minimiser ; chaque bloc = trou de confiance
Notre architecture absorbe la plupart des raisons d’asm de musl : les syscalls sont en
couche 0, les atomics = core::sync::atomic, la TLS = la cible/std, les str/mem =
Rust sûr. On minimise donc l’asm. Résidu réellement incompressible attendu : setjmp/longjmp
(sauvegarde des registres callee-saved + adresse de retour), le crt0/_start (entrée avant que
le Rust ne tourne), éventuellement ucontext. Chaque bloc asm est : justifié (// SAFETY:,
ADR-021), minimal, audité face aux 4 modes, et ne touche aucun état partagé non gardé.
P7 — La règle de la fonction-sœur SAFE
Quand POSIX impose un prototype qui manipule une ressource mais retourne void / ne fournit
aucun canal d’erreur (ex. void rewind(FILE*) jette l’erreur de fseek ; void setbuf(...)
jette celle de setvbuf ; tzset, perror…) :
- (a) on implémente la version POSIX pure, fidèle au contrat — donc avec son DÉFAUT de reporting — mais robuste en dessous : elle est muette, pas fragile (elle ne se corrompt jamais elle-même) ;
- (b) on implémente une fonction-sœur SAFE qui remonte l’erreur (in-band, ADR-046 D4),
même hors-standard, nommée
air_<nom>(préfixeair_) ; - (c) on documente le défaut qu’on a identifié et le risque pour le développeur, et on suggère la version SAFE (doc ADR-027 : pourquoi + piège) ;
- (d) règle systématique et homogène (même modèle d’erreur in-band partout), pas ad hoc.
Le développeur C garde le choix : POSIX pur (compatible, avec le risque documenté) ou la version Air SAFE.
Conséquences
- Cycle par famille : audit (artefact) → implémentation Rust → doc (standard faisant autorité cité + défauts POSIX relevés + sœurs SAFE).
- Doublement partiel de la surface (les sœurs SAFE) assumé : la valeur est le choix informé et « la libc fait ce qu’elle dit ».
- Aucune optimisation avant complétude robuste (P3).
- La couche 1 grandit au besoin d’un tier kernel-faithful (ADR-046 D2) au fil des familles.
Alternatives écartées
- Copier l’implémentation de musl (C, asm, état global) : rejeté (P1 ; ADR-046 D1).
- Optimiser au fil de l’eau : rejeté (P3 / Principe 5).
- Se limiter à POSIX strict (pas de sœurs SAFE) : rejeté (P7 — laisserait le développeur sans recours sûr face aux défauts connus de POSIX).
- Auditer toute la surface puis tout implémenter : rejeté (P2 — l’audit vieillirait avant usage).
Annexe A — Gabarit d’audit par famille
Famille : <ex. stdio> Version musl auditée : <tag/commit>
Fonctions de la famille (surface cible) : <liste>
1. Structures internes : <…>
2. Globales publiques : <…> | Globales privées/opaques : <…>
3. État mutable partagé + protection :
| ressource | protection (lock/TLS/rien) | async-sig | fork | cancel | réentrance |
4. Assembleur : où / pourquoi / peut-on l'éviter chez nous (couche 0 / core / std) ?
5. Grille sécurité élargie : overflow | bornes/format | TOCTOU | injection | secrets
6. Fonctions `void`→ sœur SAFE `air_*` à créer : <liste + défaut POSIX constaté>
7. Bien fait (à reprendre) : <…>
8. Faiblesses (à ne pas reproduire) : <…>
9. Décisions Air pour cette famille : <…>
ADR-048 — Descellement unique de la couche 0 : std-free certifié + famille futex(2) (re-sceau couche-0-v1.6)
Statut : Accepté (2026-06-28, validé par le BDFL). Companion d’ADR-044
(méthode de re-sceau de la couche 0), d’ADR-046 et d’ADR-047
(la libc — pourquoi on exige std-free + un futex(2)), et de l’ADR-021
(conventions couche 0). Décline le Principe 4 (no_std-future, errno sans alloc). S’appuie sur
l’audit docs/notes/audits/audit-couche0-std-free-fr.md.
Catégorie : Architecture — couche 0 (re-sceau). Évolution structurante de la couche scellée → RFC (ADR-015).
Contexte
Deux constats convergents :
- La couche 0 devait être
std-free (Principe 4) — elle ne l’est pas. L’audit montre queair-sys-typesetair-sys-syscallsontstdpar défaut (pas de#![no_std]) et dépendent destden code lib (std::os::fd,std::ffi::CString/OsString,std::sync::{Arc,Mutex}). - La libc Air (ADR-046/047) exige un substrat threading
std-free (air-threadréimplémenté sur la couche 0), ce qui requiert unfutex(2)classique (FUTEX_WAIT/WAKE sur&AtomicU32) absent de la couche 0 (le seul futex présent est via io_uring, trop lourd pour unMutexbasique). Or la couche 0 a elle-même besoin de cefutex(2)pour remplacer lestd::sync::Mutexde son état io_uring partagé.
Desceller une couche scellée a un coût (re-sceau, validation 2-arches, ABI). On regroupe donc
TOUT en un seul descellement pour ne re-sceller qu’une fois, certifié std-free.
Décision
D1 — Descellement unique, re-sceau couche-0-v1.6
Un seul descellement (depuis couche-0-v1.5) couvrant tout le périmètre ci-dessous. Le découpage
en tâches/PR est autorisé, mais le re-sceau couche-0-v1.6 ne se referme que lorsque TOUT est
fait et certifié std-free.
D2 — Conversion std-free (no_std + alloc)
#![no_std]+extern crate allocdans les deuxlib.rs.std::os::fd::*→core::os::fd::*(mécanique, ~22 modules).std::ffi::CString→alloc::ffi::CString;std::ffi::CStr→core::ffi::CStr.std::sync::Arc→alloc::sync::Arc.std::ffi::OsString/OsStr→ type octets (Unix : nom de fichier = octets, peut être non-UTF-8) — possédéalloc::vec::Vec<u8>/ emprunté&[u8], cohérent avec « chemins = octets ».allocest toléré pour les types possédés exigés (CString, nom deDirEntry…) ; le happy path reste sans alloc et errno resteNonZeroI32sans alloc (Principe 4 / ADR-021 inchangés).
D3 — Nouvelle famille futex(2)
Famille couche 0 dédiée : futex_wait / futex_wake (et requeue/cmp_requeue si justifiés), sur
&core::sync::atomic::AtomicU32. Une fonction typée par opération (pas de multiplexeur futex(op,…),
ADR-021 §3). EINTR remonté à l’appelant, sans retry (ADR-021 §2). Sert air-thread (couche 1,
libc) et le mutex de la couche 0 (D5).
D4 — clone3 : support du spawn de thread
Vérifier et, si besoin, étendre le wrapper clone3 (couche 0) pour exprimer les arguments de
création de thread : flags CLONE_VM|CLONE_THREAD|CLONE_SETTLS|CLONE_CHILD_CLEARTID|CLONE_PARENT_SETTID,
pile enfant, pointeur TLS, child_tid (pour le join via CHILD_CLEARTID/futex). (Le
bootstrap TLS + trampoline d’entrée du thread enfant relèvent de la couche 1 air-thread — hors de ce
re-sceau ; D4 garantit seulement que la couche 0 expose ce qu’il faut.)
D5 — Mutex io_uring sur futex(2)
Remplacer le std::sync::Mutex/PoisonError de io_uring/shared.rs par un mutex futex-maison
(au-dessus de D3). Supprime la dernière dépendance std::sync du code lib.
D6 — Critère d’acceptation du re-sceau (porte de sortie)
Le re-sceau couche-0-v1.6 n’est prononcé que si TOUT est vert :
cargo buildavec#![no_std]réussit (les deux crates) ⇒ preuve par le compilateur qu’il ne reste aucunstden code lib ;- couverture 100 % (lignes + branches) maintenue (couche 0) ;
- barrière 2-arches (x86_64 + aarch64) + CI ARM vertes ;
- les nouveaux symboles (
futex(2), éventuels ajoutsclone3) ont leurs tests + property/fuzz au standard couche 0, et leurs commentaires// SAFETY:.
Conséquences
air-thread(couche 1) pourra être réimplémentéstd-free sur la couche 0 (clone3 + futex), ce qui débloque le harnaisno_stdde la méthode libc (ADR-047 : thread Rust-pur-sur-couche-1 ∥ thread chemin-C, même behaviour).- La couche 0 redevient conforme au Principe 4, durablement (gardé par le critère
#![no_std]). - ABI : ajout de symboles (
futex(2)) ; la migrationstd::os::fd→core::os::fdest sans impact ABI (mêmes types, même layout —core::os::fdest la définition questdré-exporte).
Alternatives écartées
- Desceller plusieurs fois (std-free puis futex séparément) : rejeté — coût/risque de re-sceaux multiples ; D1 impose un descellement unique.
- Mutex via le futex io_uring existant : rejeté — un
Mutexbasique ne doit pas dépendre d’un anneau io_uring (poids, cycle de vie). - Spinlock au lieu de futex : rejeté — pas de blocage, gaspillage CPU, inversion de priorité.
- Garder
stden couche 0 : rejeté — viole le Principe 4 et contaminerait la libc (std tire la glibc), rendant impossible le harnaisno_std(ADR-047).
Questions ouvertes
- Opérations
futexexactes à exposer (WAIT/WAKE suffisent-ils, ou REQUEUE/CMP_REQUEUE/PI dès v1.6 ?). - Forme précise du type octets remplaçant
OsString(newtype dédié vsVec<u8>nu). - Le wrapper
clone3actuel exprime-t-il déjà tout D4, ou faut-il l’étendre (impact ABI à vérifier) ?
Amendement 1 — 2026-06-28 (ratifié BDFL) : core::os::fd n’existe pas → internalisation des types FD
Constat empirique (rustc 1.96 stable) : use core::os::fd::OwnedFd; produit error[E0432]: could not find fd in os. core::os::fd n’existe PAS en stable ; les types FD (OwnedFd/BorrowedFd/RawFd + traits AsFd/AsRawFd/FromRawFd/IntoRawFd) ne vivent que dans std::os::fd.
Correction de D2 et de la Conséquence « sans impact ABI » — les deux étaient FAUX pour les FD :
- La migration
std::os::fd→core::os::fdn’est ni possible ni mécanique. - Pour atteindre
#![no_std], Air doit internaliser ses propres types FD :RawFd,BorrowedFd<'fd>,OwnedFd(Drop → close(2)) + traits, comme la specair-sys-types.md(l.74-90) le prévoit déjà — lepub use std::os::fdactuel est un raccourci/dette qui dévie de la spec. - C’est une introduction de type air-stable (Principe 8, ADR-012, ABI 10 ans) → AVEC impact ABI (baseline
air-abi-checkà poser), PAS mécanique. ~88 sites lib + interop de tests à reprendre. - Point d’archi :
OwnedFd::dropexige unclose(2)minimal dans le crateair-sys-types(un seul syscall, conforme à la spec).
Tâche AJOUTÉE au cycle v1.6, AVANT le reste de D2 :
- Tâche 2a — Internalisation des types FD (air-stable) : définir
RawFd/BorrowedFd/OwnedFd+ traits +close(2), migrer les ~88 sites, interop tests, baselineair-abi-check. Miroir exact de la sémantiquestd(interop triviale viaRawFd+ layout ABI-stable). - Une fois 2a fait, le reste de D2 (CString→alloc, CStr→core, Arc→alloc, OsString→octets,
#![no_std]+alloc) redevient mécanique (piloté par le compilateur).
Déjà acquis : Tâche 1 futex(2) (#147, mergé) ; D5 mutex FutexMutex (5ca6fe8, livré).
Ordre v1.6 : futex(2) ✅ → FD (2a) → reste de D2 → #![no_std] → re-sceau v1.6 (D6 inchangé) → puis clone3 spawn-thread (D4 / Tâche 3).
ADR-049 — Runtime Air : TCB, TLS, démarrage (crt0), errno, environ — fondation *-linux-air
Statut : Accepté (2026-06-28, ratifié par le BDFL ; option (a) *-linux-air complète, cf. amendement en fin). Document fondateur du runtime userland d’Air.
⚠️ CORRECTION D’ARCHITECTURE (2026-06-29, ADR-052). Le placement d’
air-rtposé par cet ADR est OBSOLÈTE : D8 (« nouveau crate runtime reposant sur la couche 0 ») est faux au regard de la doctrine de layering d’Air — seule la couche 1 consomme la couche 0.air-rtest en réalité un objet couche 1 (AirRuntime) composé de briques couche 1 (ThreadControlBlock,ThreadLocalStorage, outils de relocation). Voir ADR-052, qui fait autorité. Le fond technique des décisions D1–D9 (TCB, TLS Variant I/II,crt0,errno-in-TCB, self-relocation static-PIE, spawnstd-free,panic=abort,no_std) reste valide ; seul l’étage (couche 0 → couche 1) et le consommateur des syscalls changent. Companion d’ADR-046/ADR-047 (libc) et d’ADR-048 (couche 0 std-free). Réalise l’étape « produire TCB/TLS » de la feuille de route BDFL (couche 1 → TCB/TLS → libc). Aboutissement des bloqueurs convergents : spawn std-free (volet 2 air-thread),environ/OsString(air-process/filesystem/config), état par-thread de la libc.
Catégorie : Architecture (fondateur, runtime). Toute évolution structurante = RFC (ADR-015).
Contexte
Plusieurs chantiers ont buté sur le même socle manquant, et tous le désignent :
air-threadspawn std-free (volet 2, #151) : faire tourner une closure Rust dans un thread spawné exige un TCB correct (errno, canari%fs:0x28, DTV, état par-thread) — impossible via l’ABI interne glibc.air-process/air-filesystem/air-configno_std (#153) : besoin deenviron(capturé au démarrage) et d’un équivalentOsString.- La libc (ADR-046/047) :
errnothread-local, état par-thread, démarrage des programmes C.
Le déblocage commun = le runtime userland d’Air : notre TCB, notre TLS, notre crt0/démarrage. C’est la
fondation de la cible *-linux-air, construite par nous (pas d’ABI glibc).
Décision (proposée — à arbitrer/ratifier)
D1 — TCB propre à Air (au registre TLS)
Air définit son Thread Control Block placé au registre TLS (%fs x86_64 / tpidr_el0 aarch64). Champs :
self-pointer, errno (le canal POSIX, thread-local, ADR-046 D4), canari de pile, DTV (Dynamic Thread
Vector), métadonnées du thread (tid, base/taille de pile, mot futex de join), crochet d’état par-thread (futur
cache malloc). Aucune compat ABI glibc : on possède le layout.
D2 — TLS ELF standard
Variant II (x86_64) / Variant I (aarch64). Image .tdata/.tbss copiée/zéroée à la création de thread ;
#[thread_local] résout via le registre TLS. Mise en place : crt0 (thread principal) / clone3 + CLONE_SETTLS
(threads spawnés, via air-thread).
D3 — errno dans le TCB
errno = champ du TCB, thread-local, lu/écrit par la libc via le registre TLS. Zéro globale (ADR-046 D4).
D4 — Démarrage (crt0/_start)
L’entrée capture argc/argv/envp/auxv depuis la pile initiale (ABI System V), met en place le TCB/TLS du
thread principal, exécute l’init, appelle l’entrée du programme. auxv (AT_RANDOM pour le canari, AT_PAGESZ, AT_HWCAP…).
D5 — Accès environ
Le envp capturé en D4 est exposé par une API runtime (équivalent getenv/environ) → débloque le no_std
d’air-process/air-filesystem/air-config.
D6 — Type chaîne-OS (octets)
Type octets possédé (réutiliser air-base-lib::AirPath/un newtype) en remplacement d’OsString dans les API couche 1
concernées (air-config) — cohérent avec la doctrine « chemins/env = octets ».
D7 — Spawn std-free (déblocage volet 2 air-thread)
air-thread::spawn : alloue pile + image TLS + TCB, câble CLONE_SETTLS, l’entrée enfant initialise son contexte
(registre TLS → son TCB) puis exécute la closure, nettoie (munmap), exit ; join via CHILD_CLEARTID/futex (couche 0).
D8 — Emplacement & dépendances ⚠️ (corrigé par ADR-052)
Nouveau crate runtime ( → OBSOLÈTE. air-rt ?) reposant sur la couche 0air-rt
est un objet couche 1 (AirRuntime) qui ne consomme que la couche 1 (air-thread pour
clone3/futex/join, air-memory pour mmap, air-process pour exit_group, et les nouveaux
objets couche 1 ThreadControlBlock/ThreadLocalStorage/relocation). Il ne dépend jamais de
air-sys-syscall (la surface kernel) ; air-sys-types (types transverses : AirError/Errno,
Pid/Tid… — ADR-052 D6) reste autorisé. air-thread (spawn), la libc et le futur std de
*-linux-air reposent sur AirRuntime. no_std + alloc conservé. Cf. ADR-052 D1–D6.
D9 — Modèle panic / destructeurs TLS
panic = abort partout (cohérent ADR-045) ; pas d’unwind cross-thread. Destructeurs thread_local! (Drop en fin de
thread) : supportés via une liste enregistrée dans le TCB, exécutée à la sortie du thread.
Conséquences
- Débloque tout le reste de la couche 1 no_std (les 3 crates env-gated) et le spawn std-free d’air-thread.
- Pose la fondation
*-linux-air: lestdde cette cible reposera sur ce runtime + la libc. - Introduit de l’asm (entrée
_start, mise en place TLS par arch) — chaque bloc// SAFETY:, audité, minimal (ADR-021).
Alternatives écartées
- Réutiliser le TCB glibc/musl : rejeté (ABI interne non maîtrisée, contraire à « pas de C non maîtrisé »).
- Bricoler
#[thread_local]nightly : insuffisant pour l’interop runtime complet ; on veut stable. - Rester lié à glibc pour le spawn : rejeté (unsound, cf. #151).
Questions ouvertes (décisions BDFL)
- Relation avec
stdRust : (a) cible*-linux-airavec unstdportant sur ce runtime (gros, propre), ou (b) resterno_std+libc pour les userlands C/C++ d’abord,std-Rust-sur-Air plus tard ? Recommandation : (b) d’abord. - Nom/forme du crate runtime (
air-rt) et frontière avecair-thread(le spawn migre-t-il dansair-rt?). set_tid/threads multiples au démarrage, gestionat_exit/init-array C (pour les userlands C).- Canari : source (
AT_RANDOMde l’auxv) et politique.
Amendement / ratification — 2026-06-28 (BDFL)
Statut : Accepté. Question ouverte n°1 tranchée : option (a) — cible *-linux-air complète avec std complet sur air-rt, construite comme un tout cohérent.
Justification technique décisive : le TCB/TLS d’air-rt ne peut pas être validé en isolation dans un binaire lié à glibc — mêler le TLS d’Air et celui de glibc-std est unsound (cf. #151, volet 2 air-thread). Le runtime n’a de sens que sur la cible *-linux-air entière (zéro glibc). Conséquence : air-rt, la spec rustc-target et le portage du sys/pal de std forment un seul programme cohérent (pas des pièces testées séparément sur glibc).
Programme *-linux-air (phases) : (1) spec rustc-target *-linux-air (sans glibc, +crt-static ou crt Air) ; (2) air-rt (TCB/TLS/crt0/environ/spawn, ADR-049 D1–D9) ; (3) portage std::sys (pal) sur air-rt + couche 0/1 (thread/TLS/fs/net/env/time/process) ; (4) bootstrap d’un binaire Rust *-linux-air (hello-world → tests) ; (5) libc C-ABI (ADR-046/047) sur la même fondation, pour les userlands C (OpenSSH). C’est un programme multi-sessions.
ADR-050 — Spécification de la cible *-linux-air (phase 1 du programme runtime)
Statut : Accepté (2026-06-28). Companion d’ADR-049
(runtime TCB/TLS — document parent, qui définit le programme en 5 phases) et
d’ADR-046/ADR-047
(libc). Réalise la phase 1 : spec de la cible rustc + squelette air-rt +
preuve de faisabilité build-std. Décision de mise en œuvre sous le chapeau
d’ADR-049 (déjà ratifié) ; gravée en ADR car elle fixe des contrats durables
(triple, mécanique de build, frontière de gate) que les phases 2–5 et tout
contributeur devront connaître. Toute évolution structurante = RFC (ADR-015).
Catégorie : Architecture (chaîne d’outils / cible de compilation).
Contexte
ADR-049 a ratifié l’option (a) : produire la cible *-linux-air complète
(runtime + std) comme un tout cohérent, parce que le TCB/TLS d’Air ne peut
pas être validé en isolation dans un binaire lié à glibc (mêler le TLS d’Air et
celui de glibc-std est unsound, cf. #151). La phase 1 doit poser le socle
matériel de ce programme sans engager encore l’écriture d’asm (TLS/_start/
spawn = phase 2) : il faut une spécification de cible que rustc accepte et la
preuve qu’on peut reconstruire core+alloc pour elle.
Contrainte structurante : le workspace stable racine est pinné en Rust
stable 1.96.0, cibles *-linux-gnu, gardé par la barrière pré-merge (ADR-037)
- CI ARM. Une cible JSON custom reconstruite avec
-Z build-stdexige nightly. Les deux mondes ne peuvent pas cohabiter sous un mêmerust-toolchain.tomlni un même gate.
Décision
D1 — Triples {x86_64,aarch64}-unknown-linux-air, env = "air"
⚠️ AMENDÉ par ADR-076 (2026-07-08). Le champ
envdu spec JSON passe de"air"à"musl"(Air est ABI-musl, cf. D-1.3) et l’identité Air passe parvendor = "air"— pour quelibc/std/ l’écosystème compilent sans patch (chantier B, option A :stdsur la libc d’Air). Le code Air utilise désormaiscfg(target_vendor = "air")au lieu decfg(target_env = "air"). La cible porteos = linux(Air est un userland/libc sur le kernel Linux, pas un kernel — l’ABI syscall reste celle de Linux) etenv = "air": la 3ᵉ saveur de libc Linux, à côté degnuetmusl. Vérifié : rustc dérive bientarget_env="air",target_os="linux",target_family="unix",unix,target_thread_local,target_object_format="elf". Les deux arches (x86_64 ET aarch64) sont définies dès le jour 1 (ADR-014).
D2 — llvm-target découplé du nom Rust (musl)
LLVM ne connaît pas l’env air. Le champ llvm-target vaut donc
{arch}-unknown-linux-musl : un triple LLVM connu, statique, sans hypothèse
glibc ni interpréteur dynamique, pour un codegen éprouvé. Le nom de la cible
Rust (-air) et le llvm-target (-musl) sont des champs indépendants
(pattern standard des cibles custom). Sans impact sur l’ABI syscall (os=linux
gouverne). Provisoire : bascule possible si un jour LLVM connaît air.
D3 — Champs de codegen repris verbatim de la base musl
data-layout, cpu (x86-64), features aarch64 (+v8a,+outline-atomics),
max-atomic-width (64 x86 / 128 aarch64), frame-pointer (aarch64 non-leaf),
stack-probes: inline sont copiés de rustc --print target-spec-json (musl).
Les inventer casserait l’accord avec llvm-target et le codegen. (Principe :
kernel/outil = bible re-présentée, on n’invente pas les invariants bas niveau.)
D4 — Cible statique, panic = abort, TLS activé
dynamic-linking = false, crt-static-default, crt-static-respected (binaire
natif sans glibc/musl, par construction ; modèle de relocation/PIE = D5) ;
panic-strategy = "abort" (cohérent ADR-045 et ADR-049 D9 — pas de dérouleur) ;
has-thread-local = true (requis pour #[thread_local] → errno-in-TCB, D3
d’ADR-049). Linker provisoire rust-lld/gnu-lld (aucune dépendance à un
compilateur C externe pour la cible native) non exercé en phase 1 : on ne
construit que des rlibs (pas d’édition de liens).
D5 — Cible PIE (static-PIE) affirmée ; codegen PIC dès la phase 1
La cible est position-independent : relocation-model = "pic",
position-independent-executables, static-position-independent-executables,
relro-level = "full" (posture de durcissement de la base musl-static, dont on
était parti). Arbitrage BDFL (2026-06-28) : « la cible est PIE » → on aligne la
spec sur la cible réelle dès la phase 1 plutôt que de partir non-PIE.
Conséquence exercée dès la phase 1 : build-std compile core/alloc/air-rt
en PIC (vérifié sur les 2 arches : relocation_model="pic"), donc aucun
re-build à prévoir pour entrer ensuite dans un PIE, et l’ASLR du binaire
principal est préservé (sur-sécuriser d’abord, Principe 5).
Seul morceau différé en phase 2 : le _start/crt0 auto-relocalisant.
Le binaire étant 100 % statique (pas de ld.so), c’est notre démarrage qui
applique les relocations R_*_RELATIVE (ajout de la base de chargement réelle)
avant main. Contrainte de bootstrap à respecter : ce code ne doit référencer
aucune globale non encore relocalisée (modèle rcrt1/startup du std Rust).
PIE n’est pas « reportée » : un PIE est un exécutable, et aucun exécutable
n’existe avant la phase 2 — le premier binaire PIE possible naît avec _start.
D6 — Développement hors-arbre (rt/), workspace + toolchain séparés
Le programme *-linux-air vit dans rt/ : un workspace cargo indépendant,
pinné nightly (rt/rust-toolchain.toml, pin daté pour rejouabilité — esprit
ADR-025), construit avec -Z build-std=core,alloc + -Z json-target-spec
(rt/.cargo/config.toml). Le Cargo.toml racine liste rt dans exclude.
Aucune contamination du workspace stable (vérifié : cargo metadata racine
n’expose pas air-rt, cargo check --workspace stable reste vert).
D7 — rt/ exclu de la barrière stable — carve-out, lane CI nightly différée
rt/ ne peut pas passer la barrière ADR-037 (stable + gnu) : c’est nightly +
cible custom. Il en est donc exclu, ainsi que de la mesure de couverture —
carve-out de même nature que fuzz/ (esprit ADR-030 ;
fuzz/ est déjà hors-workspace, nightly, hors couverture). Une lane CI nightly
dédiée à rt/ (build 2 arches + fmt/clippy) sera branchée quand le tree se
stabilisera (≥ phase 4). L’upstream Tier-3 de la cible n’aura lieu qu’après
une démo qui tourne (phase 4), conformément à la stratégie ratifiée (ADR-049).
D8 — Squelette air-rt aligné sur D1–D9 d’ADR-049
Le crate air-rt (no_std + alloc) expose cinq modules — tcb (D1/D3/D9),
tls (D2), start (D4), env (D5/D6), spawn (D7) — documentés mais sans
asm/unsafe en phase 1 : il compile pour la cible (point d’ancrage), fige la
structure et renvoie chaque zone à sa décision parente. L’asm (registre TLS,
_start, clone3+CLONE_SETTLS) relève de la phase 2, validé sur les 2
arches avec // SAFETY: (ADR-021 ; rappels : x19 réservé LLVM aarch64,
c_char i8/u8 selon arche).
Conséquences
- Programme
*-linux-airdémarré sur une base prouvée, sans toucher au socle stable scellé (couches 0/1) ni à son gate. - Preuve de faisabilité acquise :
compiler_builtins+core+alloc+air-rtse reconstruisent pourx86_64-etaarch64-unknown-linux-air(cross depuis l’hôte x86) ;fmt --checketclippy -D warningsverts surair-rt. - Dette de gate explicite :
rt/n’est pas (encore) gardé automatiquement → discipline de vérification manuelle 2 arches en attendant la lane CI nightly. - Entrée de phase 2 cadrée (TCB/TLS/
_start/spawn) et question ASLR consignée pour ne pas être oubliée.
Alternatives écartées
- Mettre
air-rtdans le workspace stable : casserait la barrière (nightly + cible custom incompatibles avec stable 1.96.0/gnu) ou imposerait de déclasser le gate. Rejeté. - Inventer un
llvm-target = *-linux-air: LLVM ne connaît pasair→ codegen non supporté. Rejeté (D2). - Cible dynamique / liée à une libc système : contraire à la doctrine « zéro glibc/musl, natif par construction » (ADR-046, vision libc). Rejeté.
- Upstreamer la cible (Tier-3) tout de suite : prématuré sans démo qui tourne
ni
stdporté ; on développe hors-arbre d’abord (ADR-049, stratégie de timing).
Questions ouvertes (phases ultérieures)
_startauto-relocalisant (static-PIE) : implémentation des relocationsR_*_RELATIVEau démarrage, avec la contrainte de bootstrap (aucune globale non relocalisée) — phase 2. (La cible PIE est, elle, déjà fixée, D5 ; ce n’est plus un arbitrage mais une tâche d’ingénierie.)- Frontière
air-rt↔air-thread: le primitif de spawn migre-t-il dansair-rt? (question ADR-049 §2) — phase 2/3. - Édition de liens d’un exécutable (
-nostartfiles/-nostdlib, CRT Air,build-std-features = compiler-builtins-mempour fournirmemcpy/memset) — phase 4. - Pin nightly : politique de mise à jour du nightly de
rt/(sécurité vs stabilité) — à instruire quand la lane CI nightly sera branchée.
ADR-051 — Descellement couche-0-v1.7 : 7 syscalls pour le runtime et la libc
Statut : Accepté (2026-06-28, ratifié par le BDFL — périmètre v1.7 validé ; livraison en une seule PR pour minimiser la CI ARM). RFC d’extension d’une couche scellée (ADR-015).
Extension d’une couche scellée (couche-0-v1.6), donc RFC, comme ADR-044
(ppoll, re-sceau v1.5) et ADR-048 (std-free, re-sceau v1.6).
Companion d’ADR-049 (runtime, fondation *-linux-air) et
d’ADR-046/ADR-047 (libc).
Conventions : ADR-021. Couverture/CI : ADR-031/ADR-037.
Catégorie : Couche 0 (extension du socle / re-sceau).
Contexte
Le programme *-linux-air (ADR-049) bute, en phase 2, sur un syscall absent de la couche 0 :
programmer le registre TLS du thread principal sur x86_64 exige arch_prctl(ARCH_SET_FS, …). Le
descellement de la couche scellée étant une opération rare et coûteuse (re-sceau, 100 % de couverture,
barrière 2 arches + CI ARM), le BDFL a décidé (2026-06-28) de regrouper en un seul descellement ce
besoin immédiat et les primitives de bas niveau déjà connues comme manquantes pour l’objectif libc
(ADR-046), afin d’éviter les cycles répétés scellage/descellage.
Un inventaire vérifié de la couche 0 (lecture des implémentations, pas un simple grep : zéro
todo!()/stub, dispatch asm réel, garde-fous 100 % couverture + xtask check_syscalls) a établi que la
couche 0 est déjà un layer syscall quasi-complet : I/O fichier synchrones (read/write/pread/
pwrite/*v/lseek/openat/close/statx/getdents64…), mmap/munmap/mprotect, clone3/
clone_thread (avec CLONE_SETTLS), futex, waitid, execve, signaux (signalfd), sockets, ppoll,
getrandom, etc. sont réellement implémentés. Les seuls manques pertinents pour la libc se réduisent à
deux lots ; ce v1.7 ne prend que le premier (justifié maintenant), le second (v1.8) étant reporté
à la phase 5 (cf. § Périmètre reporté).
Décision
Ré-ouvrir la couche 0 une fois → re-sceau couche-0-v1.7, en ajoutant exactement 7 syscalls,
chacun en fonction(s) dédiée(s) typée(s) (ADR-021 §3 : pas de wrapper multiplexé), Result<_, Errno>
ou type infaillible, Option<T> au lieu des sentinelles, sans allocation sur le happy path (ADR-021 §4),
newtypes d’identifiants (Pid/Tid).
| # | Syscall | nr x86_64 / aarch64 | API couche 0 (esquisse) | Arches |
|---|---|---|---|---|
| 1 | arch_prctl | 158 / — | set_fs(base) -> Result<(), Errno> + get_fs() -> Result<usize, Errno> (ARCH_SET_FS=0x1002/ARCH_GET_FS=0x1003) | x86_64 uniquement |
| 2 | set_tid_address | 218 / 96 | set_tid_address(clear_tid: Option<&AtomicU32>) -> Tid (rend le TID appelant ; infaillible) | 2 arches |
| 3 | getcwd | 79 / 17 | getcwd(buf: &mut [u8]) -> Result<&[u8], Errno> (octets, pas UTF-8 présumé — Principe 3 ; buffer fourni, zéro alloc) | 2 arches |
| 4 | umask | 95 / 166 | umask(new: FileMode) -> FileMode (rend l’ancien masque ; infaillible) | 2 arches |
| 5 | getppid | 110 / 173 | getppid() -> Pid (infaillible) | 2 arches |
| 6 | sched_yield | 24 / 124 | sched_yield() (infaillible sous Linux) | 2 arches |
| 7 | getrusage | 98 / 165 | getrusage(who: RusageWho) -> Result<Rusage, Errno> (Rusage/RusageWho = nouveaux types #[repr(C)] air-sys-types : SELF/CHILDREN/THREAD) | 2 arches |
Asymétrie d’arche assumée (1). arch_prctl n’existe que sur x86_64. Sur aarch64, le registre TLS
(tpidr_el0) est inscriptible directement en EL0 par une instruction msr — c’est l’affaire de l’objet
ThreadLocalStorage (couche 1) (asm encapsulé dans cet objet ; cf. ADR-052),
pas un syscall couche 0. Donc le thread principal x86 passe par set_fs (couche 0, appelé par l’objet TLS
couche 1) ; le thread principal aarch64 par msr tpidr_el0 (objet TLS couche 1). Les threads spawnés
(2 arches) reçoivent leur registre TLS du kernel via CLONE_SETTLS (déjà supporté par clone_thread).
⚠️ Note layering (ADR-052, 2026-06-29). Les 7 wrappers ajoutés ici restent en couche 0 (un wrapper de syscall y est à sa place). Ce qui est corrigé, c’est leur consommateur : ce ne sont pas
air-rt/ le runtime qui les appellent en direct, mais les objets couche 1 (ThreadLocalStoragepourset_fs,air-processpourset_tid_address/getppid/…). Partout où cet ADR écrit « air-rt » comme appelant d’un syscall, lire « l’objet couche 1 concerné ».set_fs/get_fssont donc#[cfg(target_arch = "x86_64")]et déclarés tels quels (cohérent avec l’espritUNSUPPORTED.mdpour le « pas d’équivalent aarch64 »).
Emplacement. getcwd près de chdir/fchdir (process) ; set_tid_address/getppid/sched_yield/
getrusage/umask en process.rs ; set_fs/get_fs en module arch dédié ou process.rs (détail d’impl).
Specs docs/specs/layer-0/ mises à jour ; xtask check_syscalls complété (numéros vs uapi, 2 arches).
Stratégie de test (100 %, sans gaming ADR-035)
Six des sept sont triviaux à couvrir (appel direct + assertions). Le cas délicat = arch_prctl(SET_FS) :
le modifier dans le binaire de test (lié à glibc) corromprait le TLS de glibc-std (c’est l’unsoundness
même qui motive *-linux-air, #151). Parade, sans exception de couverture :
get_fs: lecture pure, sûre.set_fssuccès :set_fs(get_fs())— réécrit la base courante (no-op effectif) → exerce le chemin succès sans rien casser.set_fserreur : adresse non canonique → le kernel rejette (EINVAL/EPERM) sans changer%fs→ exerce le bras d’erreur sûrement.
C’est une « stratégie de test complexe » bienvenue (CLAUDE.md) ; aucune entrée COVERAGE-EXCEPTIONS.md requise.
Critères de sortie (identiques aux re-sceaux précédents)
#![no_std]préservé (compile std-free), 100 % lignes + branches,couvrable-videVIDE.- Tests unitaires + property-based + (le cas échéant) injection de fautes ;
// SAFETY:sur chaque bloc asm. - Barrière speedy + carbon verte et CI ARM verte ;
check_syscallsà jour (2 arches). - Vérification indépendante 2 arches de tout asm/numéro avant push (leçon
c_chari8/u8,x19LLVM). - Jalon re-scellé
couche-0-v1.7; SUMMARY/registre ADRs/INDEX cohérents.
Périmètre reporté à couche-0-v1.8 (phase 5, avec OpenSSH comme consommateur)
Volontairement hors v1.7, car leur API typée (ADR-021 : une fonction dédiée par opération ioctl)
ne peut être bien conçue qu’avec son consommateur réel, et écrire 100 % de couverture sur du code que rien
n’appelle = travail spéculatif sujet au churn :
- termios (ioctl typé :
tcgetattr/tcsetattr/…,TIOCGWINSZ/TIOCSWINSZ) ; - PTY (
/dev/ptmx,ptsname/unlockpt/grantptvia ioctl) ; rt_sigaction/sigaltstack(handlers POSIX du C porté) — nécessite une décision vis-à-vis d’ADR-020 (signalfd par défaut) ;- au besoin :
recvfrom/sendto,set_robust_list,membarrier.
Le voyage libc ne comporte ainsi que deux descellements (v1.7 maintenant, v1.8 à la phase 5), pas une multitude — l’objectif « éviter les cycles » est tenu sans pré-construire à l’aveugle.
Conséquences
- Débloque la phase 2 (TLS du thread principal x86 via
set_fs). - Pré-équipe la libc des primitives universelles manquantes (
getcwd/umask/getppid/sched_yield/getrusage/set_tid_address) — testables à 100 % immédiatement, sans churn. - Réintroduit de l’asm (dispatch des 7 syscalls, 2 arches sauf
arch_prctl) — minimal, audité// SAFETY:. - Coût d’un re-sceau (gate complet) amorti sur 7 ajouts au lieu d’un.
Alternatives écartées
- Micro-descellements successifs (un par besoin) : multiplie le coût du gate et les re-sceaux. Rejeté (motivation même de cet ADR).
- Tout mettre en v1.7 (termios/PTY/sigaction inclus) : API spéculative sans consommateur → churn + 100 % de couverture sur du code mort. Reporté en v1.8 (conçu avec OpenSSH).
arch_prctlen syscall brut dans air-rt (hors couche 0) : viole le layering (tout syscall = wrapper typé couche 0). Rejeté.wrfsbase(FSGSBASE) pour%fsau lieu d’arch_prctl: dépend d’une feature CPU/kernel non universelle → fragile. Rejeté ;arch_prctlest la voie portable et correcte.
Questions ouvertes
- Type
FileMode/Rusage/RusageWho: réutiliser les types existants d’air-sys-types ou en introduire — tranché à l’implémentation (sans impact ABI couche 0, types internes). - Découpage en PRs : tranché — une seule PR pour les 7 syscalls + re-sceau (décision BDFL : la CI ARM étant longue, chaque PR la redéclenche → on minimise à un unique cycle de gate).
ADR-052 — air-rt est un objet couche 1 (AirRuntime), jamais un consommateur direct de la couche 0
Statut : Accepté (2026-06-29, ratifié par le BDFL). RFC de correction d’architecture
(ADR-015). Corrige et rend partiellement obsolète
ADR-049 (notamment D8 : « reposant sur la couche 0 ») et
reformule le cadrage « affaire d’air-rt » d’ADR-051.
Companion d’ADR-046/ADR-047 (libc)
et d’ADR-050 (cible *-linux-air).
Catégorie : Architecture (layering, fondateur). Toute évolution structurante = RFC.
Contexte
ADR-049 a posé le runtime userland d’Air (air-rt : TCB/TLS/crt0/environ/spawn) et a écrit,
en D8, qu’il « repose sur la couche 0 » (clone3, mmap, futex, arch_prctl, auxv…).
La phase 2 a été implémentée ainsi : le crate rt/crates/air-rt dépend de air-sys-syscall +
air-sys-types et appelle la couche 0 en direct (tls.rs → arch::set_fs, spawn.rs →
mem::mmap_anonymous/process::clone_thread/futex::futex_wait, process.rs → exit_group).
C’est une violation du layering d’Air, révélée à la relecture (2026-06-29) :
- La couche 0 (
air-sys-types+air-sys-syscall) est la surface bas-niveau au-dessus des syscalls : elle fait passer l’unsafeausafeRust, n’interprète rien, est fidèle à toutes les erreurs du kernel, est Linux (pas « POSIX »),no_std, et ne mappe volontairement que les syscalls récents (kernel ≥ 6.12). Elle ne consomme aucune autre couche. - La couche 1 héberge les objets Rust (purs ; asm quand justifié ; jamais de C) qui parlent au kernel de manière sûre via la couche 0. Doctrine non négociable : seule la couche 1 consomme la couche 0. Personne d’autre. (Cf. macro-architecture §1/§9, « consommation strictement descendante ».)
Or air-rt n’est rien d’autre qu’un objet userland de plus : un runtime. Un runtime de
programme (crt0) n’est pas que de la gestion de thread — c’est aussi la mise en place TLS, le
bloc de contrôle de thread, et, parce que la cible *-linux-air est PIE/static-PIE
(ADR-050), de la relocation (auto-application des
R_*_RELATIVE). Rien là-dedans n’exige de court-circuiter la couche 1 : ce sont des objets Rust
sûrs, exactement la nature de la couche 1.
Constat factuel (2026-06-29) : la couche 1 ne contient ni ThreadControlBlock, ni
ThreadLocalStorage, ni outil de relocation, ni objet AirRuntime. Ils manquent — il
faut les créer en couche 1, pas attaquer la couche 0 depuis le runtime.
Décision
D1 — air-rt (AirRuntime) est un objet couche 1
Le runtime userland d’Air est un objet de couche 1, nommé conceptuellement AirRuntime. Il
ne consomme que des contrats publics de couche 1 et ne dépend jamais de air-sys-syscall
(la surface kernel). Seule exception : air-sys-types — crate de types transverses (cf. D6)
— reste autorisé à toutes les couches. Sa place dans le stack :
libc C-ABI Air / std de *-linux-air ← reposent sur AirRuntime
─────────────────────────────────────────
AirRuntime (TCB + TLS + relocation + crt0 + environ + spawn) ← objet COUCHE 1
s'appuie sur ThreadControlBlock, ThreadLocalStorage, outils de relocation,
et sur air-thread / air-memory / air-process (tous couche 1)
─────────────────────────────────────────
couche 1 : air-thread, air-memory, air-process, … ← SEULES à parler couche 0
─────────────────────────────────────────
couche 0 (surface kernel) : air-sys-syscall ← SEULE la couche 1 la consomme
─────────────────────────────────────────
kernel Linux ≥ 6.12
air-sys-types (types transverses : AirError/Errno, Pid/Tid, repr(C)…)
↳ traverse TOUTES les couches — consommable partout, y compris par AirRuntime (cf. D6)
D2 — Nouveaux objets couche 1 à créer (briques d’AirRuntime)
ThreadControlBlock(TCB) — objet couche 1 : self-pointer,errnothread-local, canari de pile (depuisAT_RANDOM), DTV,tid, mot futex de join ; offset C-ABI figé pour la libc.ThreadLocalStorage(TLS) — objet couche 1 : modèle ELF Variant I (aarch64) / Variant II (x86_64), lecture du gabaritPT_TLS, copie/zéro de l’image, programmation du registre TLS (x86_64 viaair-sys-syscall::arch::set_fsappelé par cet objet couche 1, pas par le runtime ; aarch64 viamsr tpidr_el0, asm encapsulé dans cet objet couche 1).- Outils de relocation (PIE/static-PIE) — objet/module couche 1 : parcours
auxv→AT_PHDR, calcul du bias de chargement, parcours.dynamic(DT_RELA/DT_RELASZ), application desR_*_RELATIVE. Contrainte de bootstrap (aucune donnée relocalisée référencée, pas de panique) = propriété de l’objet couche 1, pas du runtime. AirRuntime— objet couche 1 qui compose (1)+(2)+(3) avec le décodage de la pile initiale (argc/argv/envp/auxv), l’exposition d’environ/args, lespawnstd-free et la terminaison de processus — en s’appuyant sur les objets couche 1 existants (air-threadpourclone3/futex/join,air-memorypourmmap/munmap,air-processpourexit_group…).
D3 — La couche 1 gagne ce qui manque, la couche 0 n’est jamais attaquée depuis le runtime
S’il manque une primitive pour qu’AirRuntime fonctionne, elle est ajoutée en couche 1 (nouvel
objet ou méthode). Interdiction absolue : appeler la couche 0 depuis le runtime / la libc / le
std cible. Les wrappers de syscalls (p. ex. set_fs ajouté par ADR-051) restent en couche 0,
mais leur consommateur est un objet couche 1 (ThreadLocalStorage), jamais le runtime.
D4 — Le crate rt/crates/air-rt : entrée ELF fine, le reste migre en couche 1
Le tree hors-arbre rt/ (ADR-050, nightly + build-std)
conserve uniquement ce qui est intrinsèquement lié à la cible : la spec JSON *-linux-air,
le point d’entrée ELF _start (trampoline global_asm! minimal : capture sp, saute) et la
plomberie build-std. _start appelle AirRuntime (couche 1). Toute la logique de runtime
(TCB/TLS/relocation/crt0/environ/spawn) migre dans le(s) crate(s) couche 1 du workspace stable.
D5 — Frontière avec air-thread
air-thread (couche 1) reste l’objet qui parle à la couche 0 pour clone3/futex/join.
AirRuntime consomme air-thread (et ThreadLocalStorage/ThreadControlBlock) pour le spawn :
le runtime orchestre, il ne syscall pas. La primitive de spawn bas-niveau demeure en
couche 1 (air-thread), conformément à « seule la couche 1 touche la couche 0 ».
D6 — air-sys-types est transverse (consommable par toutes les couches)
La restriction « seule la couche 1 consomme la couche 0 » vise air-sys-syscall (la surface
kernel). air-sys-types est d’une autre nature : il porte les types qui traversent toutes les
couches — AirError/Errno, identifiants (Pid/Tid/Uid/Gid…), structures #[repr(C)]
partagées. Une AirError est une AirError, en couche 0 comme en couche N. À ce titre,
air-sys-types est consommable par n’importe quelle couche (y compris AirRuntime, la libc et le
std cible), sans constituer un « accès couche 0 ». Plus on monte, plus on ajoute d’abstraction ;
mais le vocabulaire de types de base reste commun et partagé. Conséquence concrète : le crate
d’entrée et les objets de runtime peuvent dépendre de air-sys-types, jamais de
air-sys-syscall.
Conséquences
- Doc corrigée : ADR-049 D8 et le cadrage « affaire d’air-rt » d’ADR-051 sont tagués obsolètes
/ reformulés (bannières renvoyant ici) ; la macro-architecture nomme l’objet
AirRuntimeet les briques TCB/TLS/relocation en couche 1 ;rt/README.mdetrt/DECISIONS.mdcorrigés (« repose sur la couche 1 », plus « couche 0 »). - Code à refactorer (suivi séparé, non couvert par cet ADR de doctrine) : créer les objets
couche 1, déplacer la logique de
rt/crates/air-rt, retirer la dépendanceair-sys-syscalldu crate d’entrée, re-router via couche 1. LeCargo.tomld’air-rtne doit plus listerair-sys-syscall; il peut garderair-sys-types(types transverses, D6). - Couverture / barrière : les objets de runtime, devenus couche 1, retombent sous l’exigence
100 % (lignes + branches) et la barrière 2-arches — ils ne sont plus dans le carve-out
rt/. Seuls le_startasm et la plomberie cible restent hors-arbre (nightly). C’est un gain de rigueur : la gap-analysis (rt/DECISIONS.md, 2026-06-29) notait l’absence de gate comme un risque.
Alternatives écartées
- Garder
air-rtsur la couche 0 (statu quo ADR-049 D8) : viole la doctrine de layering ; fait d’air-rtun second consommateur direct du kernel à côté de la couche 1, exactement ce que l’on proscrit. Rejeté — motivation de cet ADR. - Faire d’
air-rtune « couche 0.5 » (entre couche 0 et couche 1) : invente un étage pour légitimer l’accès direct couche 0. Le runtime n’a aucune raison de court-circuiter les objets couche 1 ; il est un objet couche 1. Rejeté. - Déplacer les wrappers de syscalls du runtime dans le crate
air-rt(asm brut hors couche 0) : déjà rejeté par ADR-051 (« tout syscall = wrapper typé couche 0 ») — et ne résout pas le problème, qui est le consommateur, pas l’emplacement des wrappers. Rejeté.
Questions ouvertes (impl, sans impact sur la doctrine)
- Découpage en crates couche 1 : un crate dédié
air-runtimehébergeantAirRuntime+ThreadControlBlock+ThreadLocalStorage+ relocation, ou répartition (TCB/TLS dansair-thread, relocation à part) ? Tranché à l’implémentation. - Réentrée du
_startdansAirRuntime: l’appel d’entrée doit rester reloc-safe (la relocation s’exécute en tout premier) ; détail d’ABI d’appel à figer côté crate couche 1. no_std: ces objets couche 1 restent#.
ADR-053 — La libc Air s’appuie sur icu4x pour ctype/locale/i18n (caractères, casse, collation, normalisation, segmentation)
Statut : Accepté (2026-06-30, ratifié par le BDFL). Companion d’ADR-046
(libc Air), ADR-047 (construction libc) et
ADR-016 (i18n / icu4x). Corrige/precise ADR-046 D7 (« libm exclu v1 »,
« hors wide-char lourd » — devenus obsolètes, cf. D6). S’appuie sur l’audit
docs/notes/audit-icu4x-rust-pur-fr.md.
Catégorie : Architecture (libc / i18n / fondateur). Évolution structurante = RFC (ADR-015).
Contexte
Pendant des décennies, les développeurs C ont subi les interprétations par implémenteur de
libc du traitement des caractères et des locales : isprint/isalpha dépendant d’une locale
globale floue, casse cassée hors ASCII, strcoll de qualité variable, locale C naïve,
« implementation-defined » partout. Pour avoir des programmes portables et corrects en i18n,
le développeur sérieux embarque icu4x par-dessus sa libc. Même constat côté Rust : std
(str/String) ne repose pas sur icu4x — il embarque ses propres mini-tables Unicode
(propriétés, casse locale-indépendante) et ne fait ni collation ni normalisation ni
segmentation locale-aware ; pour ça, on importe des crates externes (icu4x). Les deux
écosystèmes laissent donc le i18n correct en bolt-on.
Air refuse cet héritage. La doctrine libc (ADR-046 D3/D5 : « POSIX = objectif fonctionnel, pas
conformité ; SHOULD→MUST ; kernel = bible, pas POSIX ») dit déjà : là où POSIX est faible, on
ne reproduit pas la faiblesse, on donne le comportement défini et correct. L’i18n est le terrain
emblématique de cette doctrine.
Décision
D1 — Le ctype/locale/i18n de la libc Air repose sur icu4x (couche 1)
La surface C de traitement de caractères, locales et i18n s’appuie sur icu4x (déjà adopté en couche 1, ADR-016) :
- classification (
isalpha/isdigit/isprint/iswalpha…) →icu_properties; - casse (
towupper/towlower/wcs*/towctrans) →icu_casemap; - collation (
strcoll/strxfrm/wcscoll) →icu_collator(UCA) ; - normalisation (NFC/NFD/NFKC/NFKD, extensions) →
icu_normalizer; - segmentation (graphème/mot/ligne/phrase, extensions) →
icu_segmenter; - locales (BCP-47, ADR-016 ; pas les noms POSIX) et calendriers →
icu_locale/icu_calendar.
Le développeur C n’a plus à importer icu4x : Air le lui offre au socle. Et là où POSIX a des trous (segmentation, normalisation, casse pleine, collation correcte), Air expose le standard Unicode plutôt que d’imposer la faiblesse POSIX.
D2 — Mapping POSIX ↔ icu4x : granularité (le vrai travail)
L’API POSIX ctype est mono-caractère + locale globale ; icu4x est niveau chaîne + locale explicite + sans état. Réconciliation :
- classification mono-code-point (
isX/iswX) →icu_properties(mapping direct) ; - casse simple mono-caractère (
towupper(c)rend un code-point) → mapping simple 1→1 ; la casse pleine/locale (1→N, règles turques/grecques) sort par les fonctions niveau chaîne (wcs*, ou extensions Air) ; - collation/normalisation/segmentation = intrinsèquement niveau chaîne → fonctions
strcoll/strxfrm/wcscoll+ extensions Air (normaliser, segmenter) exposées au C.
D3 — Locale globale POSIX réconciliée avec le « zéro globale » d’Air
POSIX exige setlocale/une locale globale ; Air veut zéro globale + locale explicite
(ADR-046 D4). On fournit donc une locale courante thread-local (même modèle que errno-in-TCB,
ADR-047/ADR-049) qui alimente les appels icu4x explicites. Interne en BCP-47 ; les noms de
locale POSIX (fr_FR.UTF-8…) sont mappés en BCP-47.
D4 — Garder le byte-level rapide ; icu4x là où il ajoute de la correction
Les chemins triviaux et chauds restent simples et rapides (fast-path ASCII d’isdigit,
strcmp=memcmp, strlen…). icu4x est sollicité pour les opérations locale-aware/Unicode
(collation, casse pleine, normalisation, segmentation, propriétés non-ASCII), pas pour le
byte-level. On n’alourdit pas le commun.
D5 — Écarts à la lettre POSIX documentés (transparence = valeur d’Air)
Là où Air fait Unicode-correct au lieu du quirk C-locale, l’écart est documenté par fonction (doc polyglotte, ADR-027). Le développeur sait exactement ce que fait la fonction — c’est la promesse « une libc qui fait ce qu’elle dit ».
D6 — Conséquences sur le périmètre ADR-046 (ce qui devient obsolète)
libmn’est PLUS « exclu v1 » : icu4x exige unlibmno_std (math decalendrical_calculationspour les calendriers — incontournable — et du LSTM deicu_segmenter). ⇒ ADR-046 D7 « libm exclu de la v1 » est OBSOLÈTE :libmno_std est un prérequis de la i18n Air. (Décision « libm réécrit vs vendoré » = question ouverte.)- Le wide-char/wcs n’est PLUS « hors périmètre » :
wchar/wcs*/towctransest précisément la surface chaîne où la puissance icu4x s’expose. ⇒ ADR-046 D7 « hors wide-char lourd » est OBSOLÈTE dans sa forme absolue : le wcs i18n-aware fait partie du cœur (adossé à icu4x).
D7 — Un seul socle icu4x, trois consommateurs (dual-face étendu)
La même i18n couche 1 (icu4x Air) sert : la libc C (cet ADR), le futur std de
*-linux-air (ses méthodes char/str, qui sinon dupliqueraient des mini-tables), et le
pal. Un i18n maîtrisé, trois surfaces (prolonge le motif dual-face, ADR-046/047).
Conséquences
- L’i18n correcte est par défaut dans la libc Air ; le développeur C n’embarque plus icu4x ; les faiblesses POSIX ne sont plus imposées.
- Prérequis débloqués :
libmno_std (D6), le port icu4x couche 1 (audit : socle no_std calcul-pur + petites crates utilitaires vendorables + pipeline de données CLDR/UCD). - Taille : une libc qui bake des tables CLDR est plus grosse que la locale minimale musl ; mitigeable (tables feature-gated / chargeables) — décision d’implémentation.
- Compat : du code C dépendant du comportement exact C-locale peut différer ; risque faible pour les cibles v1 (OpenSSH, ASCII-centré), couvert par D5 (documentation).
Alternatives écartées
- Locale POSIX minimale (façon musl/glibc) : c’est la faiblesse historique qu’on rejette.
- Laisser le bolt-on icu4x au développeur (statu quo C et Rust std) : Air fait mieux — i18n correcte au socle.
- icu4x seulement aux couches hautes (UI/formats) : raterait la surface C ; or c’est là que des décennies de programmes ont souffert.
Questions ouvertes
libm: réécrire unlibmAir no_std, ou vendorerlibm(exception 80 % nommée) ? (Bloque calendar + LSTM — premier maillon.)- LSTM : segmentation LSTM (floats + modèle) ou dictionnaire seul en v1 ?
- Datagen : réutiliser celui d’icu4x (pragmatique) ou réimplémenter (host, std) ? Sources CLDR/UCD amont dans tous les cas.
- Calendrier de réalisation : la i18n Air vient après le runtime/libc de base, ou en parallèle ?
Licence du document : MPL 2.0.
ADR-054 — Scission d’air-base-lib : un cœur air-base-core sans i18n pour la fermeture runtime
Statut : Accepté (2026-06-30, ratifié par le BDFL). RFC de correction d’architecture
(ADR-015). Companion d’ADR-052
(layering air-runtime couche 1) et d’ADR-050 (cible
*-linux-air). Précise ADR-053 (la libc s’appuie sur icu4x) en
traçant la frontière de compilation d’icu4x. Rectifie une affirmation erronée de l’en-tête
d’air-base-lib (« icu4x … aucune feature std »).
Catégorie : Architecture (layering, fondateur). Toute évolution structurante = RFC.
Contexte
Le constat empirique (sonde de build, étape 6)
L’étape 6 du programme *-linux-air consiste à recâbler rt/ sur l’objet couche 1 air-runtime
(ADR-052), donc à builder la fermeture d’air-runtime pour *-linux-air (-Z build-std
core+alloc, no_std, nightly). Une sonde (2026-06-30, speedy, x86) a tranché empiriquement la
faisabilité longtemps redoutée (couplage air-base-lib → icu4x) :
error[E0463]: can't find crate for `std`
--> icu_provider-2.2.0/src/lib.rs:198:5
|
198| extern crate std;
= note: the `x86_64-unknown-linux-air` target may not support the standard library
cargo tree -e features donne la chaîne exacte :
air-memory / air-thread / air-process
→ air-base-lib (feature "default")
→ icu_calendar (feature "default")
→ std
Deux faits en découlent :
- Ce n’est pas
libm. Le build meurt sur la featurestdd’icu4x avant d’atteindre la moindre crate à math flottante (calendrical_calculations).libmest donc hors du chemin critique de l’étape 6 ; il ne concerne que le futur port i18n de la libc (ADR-053). - La cause est un couplage de layering :
air-memory/air-thread/air-processne consomment d’air-base-libqu’AirError(et, pour deux d’entre eux, l’horloge monotone etAirPath) — mais en dépendant d’air-base-lib, ils compilent tout l’i18n (icu_normalizer/casemap/segmenter/ collator/calendar). La fondation runtime traîne la pile Unicode entière pour un type d’erreur.
La rectification d’un postulat erroné
L’en-tête d’air-base-lib/src/lib.rs affirme : « icu4x est no_std + alloc par conception
(données compiled_data baked-in, aucune feature std) ». C’est faux : les deps icu du
workspace (Cargo.toml, [workspace.dependencies]) n’ont pas default-features = false, donc la
feature default d’icu — qui inclut std — est active. Cela passait inaperçu car air-base-lib
n’a jamais été compilé que pour des cibles *-linux-gnu où std existe (l’extern crate std d’icu
résolvait sans bruit). La cible no_std *-linux-air révèle le postulat.
Cartographie du couplage icu (mesurée)
Module air-base-lib | icu ? | Consommé par le runtime ? |
|---|---|---|
error, id, path, encoding, log | 0 | error (partout), path (air-process) |
time — AirInstant/AirDuration (monotone) | 0 | air-thread, air-process |
time — AirDateTime/AirCalendar (calendaire) | icu_calendar | non |
string — AirString/AirLocale | icu_* | air-process (API Command) |
Le « cœur sans-icu » (error/id/path/time-monotone/encoding/log) est nettement séparable de
l’i18n (string + calendaire). Seul accroc : air-process::Command prend arg(&AirString) /
env(&AirString, &AirString), ce qui tire AirString (icu).
Décision
D1 — Créer air-base-core (couche 1, no_std, zéro icu)
Extraire d’air-base-lib un cœur sans i18n dans une nouvelle crate crates/air-base-core :
error ([AirError], [AirErrorKind], [AirResult], ResultContextExt, absorption d’Errno),
id, path ([AirPath], octets), time monotone ([AirInstant], [AirDuration]),
encoding (base64/hex), log ([AirLog]). Aucune dépendance icu ; core + alloc + couche 0
(air-sys-types ; air-sys-syscall uniquement pour l’horloge monotone, comme aujourd’hui).
D2 — air-base-lib conserve l’i18n et ré-exporte le cœur (compatibilité)
air-base-lib garde les types i18n : string ([AirString]/[AirLocale] + segmentation/
normalisation), et la partie calendaire de time ([AirDateTime]/[AirCalendar]). Elle dépend
de air-base-core + icu4x, et ré-exporte tout air-base-core (pub use air_base_core::…) de
sorte que air_base_lib::AirError, air_base_lib::AirPath, etc. continuent de fonctionner à
l’identique. Les 7 consommateurs non-runtime (air-config, air-device, air-filesystem,
air-crypto, air-socket, air-config-compile, air-base-capi) ne changent pas ; air-base-capi
(ABI C) accède toujours à AirError via air_base_lib:: → frontière ABI intacte (ADR-045).
D3 — Le runtime bascule sur air-base-core
air-memory, air-thread, air-process dépendent désormais d’air-base-core (et non plus
d’air-base-lib). Leur fermeture ne compile plus aucun code icu ⇒ build *-linux-air
débloqué, sans libm pour l’étape 6.
D4 — air-process::Command : args/env en octets (AirPath), plus AirString
Command::arg/args/env prennent désormais &AirPath (octets) au lieu de &AirString. Ce
n’est pas un simple contournement du couplage icu : c’est plus correct au sens du Principe 3
(« zéro présomption d’encodage »). Sur Unix, execve(2) prend char *const argv[] / envp[] : des
chaînes d’octets terminées NUL, non garanties UTF-8. Représenter args/env en octets est fidèle
à la sémantique kernel (doctrine « kernel = bible »). Changement d’API publique d’air-process,
au rayon d’impact nul (aucun appelant externe d’air-process::Command à ce jour). Les appelants
futurs passeront des octets (AirPath/&[u8]) ; l’i18n ne descend plus dans la couche process.
D5 — time.rs scindé selon la frontière icu
La partie monotone (AirInstant/AirDuration, zéro icu) part dans air-base-core::time ; la
partie calendaire (AirDateTime/AirCalendar, icu_calendar) reste dans air-base-lib::time.
Les deux familles sont déjà indépendantes (horloge monotone vs mur).
D6 — Frontière de compilation d’icu4x : hors périmètre de cet ADR
Rendre icu4x lui-même compilable en no_std pour *-linux-air (default-features = false sur les
deps icu du workspace + sélection des features no_std, puis la question libm) est nécessaire pour
la libc (ADR-053) mais pas pour l’étape 6. Cet ADR déplace ce travail dans le chantier libc/
i18n (futur), où il sera traité avec la décision libm (réécriture vs vendoring, ADR-024). En
attendant, l’en-tête erroné d’air-base-lib est rectifié (le default d’icu tire std ; le
build no_std d’icu reste à faire pour la libc).
Conséquences
- Étape 6 débloquée : la fermeture
air-runtimecompile pour*-linux-airsans icu ni libm (à reprouver par la sonde après le split — confirmation empirique attendue). - Compatibilité : ré-export D2 ⇒ zéro changement pour les 7 consommateurs non-runtime et l’ABI C.
- Architecture plus saine : la fondation système (erreur/temps/chemin/id) ne dépend plus de l’i18n ; tout consommateur qui n’a pas besoin d’Unicode cesse de compiler icu (bénéfice au-delà du runtime — temps de build, surface).
libmclarifié : confirmé hors du chemin de l’étape 6 ; reste un maillon du port icu4x futur (calendrier + LSTM), tranché plus tard (ADR-024).- Couverture :
air-base-coreest couche 1 ⇒ 100 % lignes/branches (Principe 1), commeair-base-libaujourd’hui. Les fichiers déplacés gardent leurs tests (unit + property + doctests + fuzz le cas échéant). - Dette rectifiée : le postulat « icu sans
std» est corrigé dans la doc.
Alternatives rejetées
- Option B — forcer icu en
no_stdau niveau workspace (default-features = false+ features no_std sur les deps icu). Plus large (affecte le build stable de tous les consommateurs), et recule seulement le mur : on retomberait surlibm(jamais atteint par la sonde). Nécessaire un jour pour la libc, mais disproportionné pour débloquer l’étape 6. Déféré (D6). - Isoler le cœur d’
AirString(conteneur UTF-8 sans-icu + extension icu pour normalisation/ segmentation), pour garder l’APICommanden « chaîne ». Refonte d’AirStringplus invasive, et moins correcte que des octets pour args/env (Principe 3). Rejeté au profit de D4. - Déplacer l’i18n hors d’
air-base-lib(renommer le cœur enair-base-lib, l’i18n enair-base-text). Plus « propre » sur les noms, mais casse tous les consommateurs d’AirString(air_base_lib::AirStringdisparaîtrait). Rejeté : D1+D2 (extraire le cœur, ré-exporter) obtient le découplage sans breakage.
Mise en œuvre (esquisse, code headless après ratification)
- Créer
crates/air-base-core; y déplacererror.rs,id.rs,path.rs,encoding.rs,log.rs, et la partie monotone detime.rs. Tests déplacés avec. air-base-lib: retirer ces modules, dépendre d’air-base-core,pub use air_base_core::…(ré-export), garderstring.rs+ partie calendaire detime.rs.air-memory/air-thread/air-process: dépendanceair-base-lib→air-base-core(importsair_base_lib::→air_base_core::).air-process::Command:AirString→AirPath(args/env) ; adapter tests/doctests.- Couverture 100 %
air-base-core+air-base-lib; barrière 2-arches verte ;DEPENDENCIES.md,docs/SUMMARY.md, registre ADR, INDEX à jour ;mdbook buildOK. - Reprouver la sonde : fermeture
air-runtimecompile pour*-linux-air(carbon x86 + raspi/CI).
ADR-055 — air-thread/air-process en #![no_std] : crate air-env + spawn ergonomique gardé per-cible
Statut : Accepté (2026-06-30, ratifié par le BDFL). RFC de structure (ADR-015).
Implémente ADR-048 (couche 1 std-free) et
ADR-052 D7.3 (le spawn ergonomique migrera vers air-runtime).
Companion d’ADR-054 (qui a retiré icu de la fermeture
runtime) et d’ADR-050 (cible *-linux-air).
Catégorie : Architecture (layering, no_std). Toute évolution structurante = RFC.
Contexte
ADR-054 a levé le blocage icu de la fermeture air-runtime → *-linux-air. La re-sonde a
révélé le 2ᵉ mur : air-thread et air-process ne sont pas #![no_std] et fuient std :
- air-process (3 usages, tous
command.rs) :std::ffi::{CStr,CString},std::os::unix::ffi::OsStringExt,std::env::vars_os()(héritage d’environnement dansbuild_envp). Le reste travaille déjà en octets (ADR-054 D4). - air-thread :
sync.rs/channel.rs/runtime_primitivessont déjà no_std en code lib (std seulement sous#[cfg(test)]). Le blocage estthread.rs:AirThreadBuilder::spawnenrobestd::thread, délibérément (en-tête dethread.rs) :spawn(F: FnOnce()->T)exécute une closure arbitraire qui peut toucher du code glibc ; sur un thread Air sans TCB glibc-correct ce seraitunsound(corruption tas, errno croisé). Cette contrainte est celle de l’HÔTE (glibc), pas de la cible : sur*-linux-airil n’y a pas de glibc, c’estair-runtimequi fournit le TCB/TLS (ADR-052).
Deux obstacles structurels : (1) air-process a besoin de l’environnement mais ne peut pas
dépendre d’air-runtime (cycle : air-runtime → air-process pour exit_process) ; (2) le spawn
ergonomique Air-natif relève d’air-runtime (TCB/TLS) et d’un allocateur réel (boxer la
closure ; aujourd’hui placeholder AbortOnAlloc), donc indisponible immédiatement.
Décision
D1 — Nouvelle crate air-env (couche 1, no_std) : porteuse de l’environnement
Créer crates/air-env (couche 1, #![no_std], zéro dépendance — l’environnement est manipulé
en octets bruts (&[u8]/*const *const u8), sans aucun type transverse ; réconciliation étape 6 :
le libellé initial « deps air-sys-types seulement » était une borne haute, l’implémentation a
finalement ZÉRO dépendance) qui porte la table
d’environnement de processus : static ENVIRON: AtomicPtr, set_environ(envp) (appelé au
démarrage), get(&[u8]) -> Option<&'static [u8]>, vars() -> impl Iterator<Item=(&[u8],&[u8])>
(octets, Principe 3). Vit sous air-runtime ET air-process ⇒ aucun cycle.
air-runtime::env devient un mince ré-export/wrapper d’air-env (compat ; bootstrap appelle
air_env::set_environ). air-process::build_envp lit air_env::vars().
Amendement (2026-07-03, décision BDFL, pré-sceau
couche-1-v1.0). La borne « zéro dépendance » de D1 est relevée :air-env(etair-runtime) dépendent désormais d’air-base-corepour rendre les types chaîne-OSAirOsStr/AirOsString. Dépendance acyclique (air-base-core ne dépend que de la couche 0) et fondatrice (vocabulaire de base couche 1). Les signatures de lecture sont remodelées à leur forme finale scellable :get(&AirOsStr) -> Option<AirOsString>(copie possédée, race-safe façonstd::env::var_os— au lieu d’un&'static [u8]que réécrirait un futurset_var) etvars()en paires possédées ;air-runtime::argsreste&'static AirOsStr(argv immortel, zéro-copie). Motif : audit de sceau-readiness (Flag 1+2). Consigné dansDEPENDENCIES.md.
D2 — air-process passe #![no_std]
#![cfg_attr(not(test), no_std)] + extern crate std sous cfg(test). std::ffi::CStr →
core::ffi::CStr ; std::ffi::CString → alloc::ffi::CString. build_envp lit l’environnement
via air_env (la face hôte gnu peut rester sur std::env::vars_os derrière
#[cfg(not(target_env="air"))] si utile aux tests ; la face cible lit air_env). Plus aucun
std::os::unix.
D3 — air-thread passe #![no_std] ; thread.rs gardé per-cible
#![cfg_attr(not(test), no_std)]. sync.rs/channel.rs/runtime_primitives passent tels quels
(std uniquement sous cfg(test)). thread.rs (spawn ergonomique sur std::thread) est gardé
#[cfg(not(target_env="air"))] : il reste disponible sur l’hôte (gnu, sound sous glibc) et
n’est pas compilé pour *-linux-air. Le crate compile donc no_std pour la cible (surface =
sync/channel/primitives/erreurs). Le spawn ergonomique Air-natif (closure sur TCB Air) est un
chantier distinct d’air-runtime, gaté sur l’allocateur réel (ADR-052 D7.3) — hors de cet
ADR.
D4 — Portée : « compile » ≠ « run »
Après D1–D3, la fermeture air-runtime compile no_std pour *-linux-air (objectif étape 6,
à reprouver par la re-sonde). Exécuter réellement Command/build_envp/le spawn ergonomique
sur la cible reste bloqué par l’allocateur AbortOnAlloc (toute alloc abort) : c’est le
chantier suivant (allocateur Air réel, « malloc échoue plutôt que hang »). Un binaire Air qui
ne fait que démarrer + spawn discipliné (entrée extern "C", sans alloc) n’est pas concerné.
Conséquences
- Étape 6 (build) débloquée : plus de fuite
stddans la fermeture (à confirmer empiriquement). - Cycle évité par
air-env(sous les deux).air-runtime::env/air-processinchangés côté appelants (ré-export). air-threadsur l’hôte inchangé : le spawn ergonomique std reste pour gnu (tests, outils). Sur la cible, air-thread n’expose pas (encore) de spawn ergonomique — assumé, fourni plus tard parair-runtime+ allocateur.- Couverture :
air-env(couche 1) = 100 % ; lethread.rscfg-gaté n’est compilé que sur gnu (couvert là). Ne PAS toucher l’--ignore-filename-regex/--excludesans décision (ADR-035). - Dette d’exécution clarifiée : l’allocateur réel devient le prochain prérequis pour faire tourner (vs compiler) les features runtime.
Alternatives rejetées
- Environnement dans
air-base-core(au lieu d’air-env) : moins de crates, mais l’environ est un global mutable étranger au caractère « types purs » d’air-base-core. Rejeté pour la netteté (responsabilité unique). - air-process dépend d’air-runtime pour l’environ : cycle (
air-runtime → air-process). Rejeté. - Réimplémenter le spawn std-free maintenant (au lieu du cfg-gate) : exige résoudre le cycle
air-thread↔air-runtime et l’allocateur réel ; relève d’un RFC propre (le doc de
thread.rsle dit). Disproportionné pour débloquer l’étape 6. Déféré (chantier allocateur).
Mise en œuvre (esquisse, code headless après ratification)
crates/air-env: extraireENVIRON/set_environ/get/varsd’air-runtime::env;air-runtime::envré-exporte/wrappe ;bootstrapappelleair_env::set_environ. 100 % cov.air-process:#![no_std];core/alloc::ffi;build_envp→air_env(cfg face hôte). Tests/doctests adaptés. 100 % cov.air-thread:#![no_std];thread.rs→#[cfg(not(target_env="air"))]; reste tel quel. 100 % cov.- Re-sonde
*-linux-air(carbon x86 + raspi/CI) : fermetureair-runtimecompile sans std. - Doc : ADR-055, registre, SUMMARY, INDEX, DEPENDENCIES (air-env + maj process/thread), renvois.
ADR-056 — Allocateur global Air (air-alloc) : inspiré de dlmalloc, simple, possédé
Statut : Accepté (2026-06-30, ratifié par le BDFL). RFC de structure (ADR-015).
S’appuie sur l’audit docs/notes/audit-dlmalloc-fr.md (2026-06-30). Companion d’ADR-049
(runtime) / ADR-052 (couche 1) ; fournit le malloc/free/
realloc de la future libc C-ABI (ADR-046/ADR-047).
Catégorie : Architecture (couche 1, fondateur). Toute évolution structurante = RFC.
Contexte
Le runtime air-runtime est implémenté, mesuré et prouvé on-target (étape 6). Mais l’allocateur
est un placeholder AbortOnAlloc (rt/crates/air-rt/src/lib.rs : toute allocation ⇒
exit_process(102)). Conséquence : un binaire *-linux-air compile et démarre + spawn du code
discipliné, mais ne peut pas exécuter de code qui alloue (Vec/String/Box, Command,
build_envp, closure boxée). L’allocateur réel est le prérequis du « run réel » et un maillon de
la libc Air.
L’audit dlmalloc (note de travail) a tranché la posture (décision BDFL) : ni vendoring boîte-
noire, ni réinvention — s’inspirer du design éprouvé de dlmalloc en restant simple, et posséder la
politique Air (sémantique d’échec, gardien post-fork, instrumentation). Cet ADR fige le design v1
et les 9 décisions ouvertes par la note.
Modèle mental (cadrage BDFL) — producteur / consommateur : air-alloc produit de la mémoire
(à partir du noyau) ; air-memory (arènes/pools/slabs) la consomme. Les noms restent air-alloc
air-memory(la symétrie*-producer/*-consumerne serait qu’esthétique).
Décision
D1 — Crate dédiée air-alloc (couche 1, no_std), dépendances minimales
Nouvelle crate crates/air-alloc, #![no_std], placée sous air-memory::{arena,pool,slab,backing}
(qui sont clients du global allocator). Dépendances minimales (anti-cycle) : air-sys-types
(Errno, MapFlags/ProtectionFlags) + air-sys-syscall (mem pour mmap/munmap, futex pour
le verrou). Ne dépend ni d’air-memory, ni d’air-thread, ni d’air-runtime.
D2 — Mémoire brute : appel système direct (air-sys-syscall::mem)
air-alloc obtient ses segments par mmap directement via air-sys-syscall::mem (le plus bas,
zéro détour). On ne réutilise pas air-memory::raw_mapping (qui ferait dépendre le producteur de
son consommateur — smell architectural). La duplication éventuelle avec raw_mapping sera
réconciliée plus tard (extraction d’une crate-socle si elle se justifie).
D3 — Verrou futex maison dans air-alloc
v1 = une arène globale unique protégée par un verrou futex propre à air-alloc (mot d’état +
air-sys-syscall::futex), pas l’AirMutex d’air-thread (pour ne dépendre de rien en couche 1).
La logique d’arène reste mono-thread (modèle Dlmalloc<A> : Send, non Sync) ; le verrou est la
couche au-dessus.
D4 — Cœur emprunté à dlmalloc (plancher de correction, non négociable)
- Étiquettes de frontière (en-tête
taille | bits occupé, footer recopié) → fusion O(1) des voisins libres (anti-fragmentation) et taille stockée par bloc (réponse aufree(ptr)C sans taille). - Étagères par classe de taille + table de bits (plus petite étagère non vide en O(1)) : smallbins exacts pour les petits objets (cas dominant).
- Segments de 64 Ko demandés au noyau et sous-alloués (un
mmappar segment, pas par allocation — essentiel sur Pi 4, ADR-014). - Très grande demande (≥ seuil) → cartographie
mmapdédiée, rendue parmunmapà la libération. Réintroduit consciemment (le port Rust 0.2.14 l’a laissé vestigial — cf. audit §2). - Reliquat privilégié (
dv) + frontière (top), free-lists intrusives (métadonnées en-bande → bootstrap sain, aucun autre tas requis).
D5 — Simplifications v1 (optimisations différées, jamais la correction)
- Grands blocs : liste triée simple (pas le treemap digital de dlmalloc — sa partie la plus subtile/risquée). Recherche O(n) sur les grands libres (rares) ; réintroduction d’un index rapide en v2 si mesure (Principe 5).
- Pas de cache par-thread en v1 (arène globale verrouillée) → v2 (perf concurrente mesurée).
- Reddition minimale :
munmapdes cartographies dédiées à la libération + rognage léger detop; pas de reddition fine multi-segments (différée). - Alignement naturel (16 octets =
max_align_t) seul en v1 ; l’alignement fort (aligned_alloc/posix_memalign, sur-allocation+rognage) est différé (vient avec la libc C).AMENDEMENT 2026-07-11 — D5 (alignement fort) LEVÉ. L’alignement fort (
align > 16, puissance de deux arbitraire) est désormais implémenté dans l’arène (techniquememalignde dlmalloc : sur-allocation + détachement d’un chunk préfixe + ajustement des en-têtes,deallocate/reallocatelead-aware). Déclencheur : le PALstdsafe (ADR-088) —std::sync::mpsc::channelalloue un bloc aligné ligne-de-cache (align 64) ; l’ancien refus déterministealign > 16 → Nonefaisait échouerGlobalAlloc(abort SIGILL). Ce n’est pas un changement de décision (D5 était un report explicite, « vient avec la libc C ») mais son achèvement, avancé carstden a besoin. Tests (matrice 32/64/128/256/page × tailles,realloc,dedicated,alloc_zeroed, OOM) + property-based + fuzz étendus ;align = 16inchangé (lead = 0). - Garde-fou : invariant « granularité = puissance de deux ≥ alignement » (sinon le résidu de reddition corrompt les bits de flag — bug documenté dans dlmalloc). L’arithmétique tailles/flags est impitoyable : on emprunte le design éprouvé de Lea pour ces parties précisément.
D6 — Réglages de taille (constantes réglables)
- Granularité de segment = 64 Ko (valeur dlmalloc) ; constante ajustable.
- Seuil « cartographie dédiée » = 256 Ko (valeur C de dlmalloc) ; réglable, à ajuster après mesure Pi 4.
D7 — Politique d’échec : « échoue franchement, ne se bloque pas »
- Réserver d’avance : pas de
MAP_NORESERVE. Le noyau engage la mémoire à la demande → l’échec (ENOMEM) remonte aummap, le plus tôt et le plus déterministe possible. Coût assumé : plus gourmand sur petite machine (cohérent avec la raison d’être d’Air : la prévisibilité prime). - Sur échec :
allocrendnull(RustGlobalAlloc) /Errdéterministe, sans boucle, sans hang. - Honnêteté (à documenter dans le code) : un déterminisme total est impossible sous Linux (la politique globale de surengagement n’appartient pas à Air) ; la borne est documentée.
D8 — Gardien post-fork : reset natif via air-runtime::fork
Le deadlock classique (verrou tenu au fork → enfant mono-thread bloqué) est résolu nativement :
air-alloc expose une fonction de reset de son verrou ; air-runtime (qui dépend d’air-alloc)
enregistre un handler child qui remet le mot futex à l’état déverrouillé dans l’enfant, intégré à
reset_after_fork_in_child. Sens des dépendances : air-runtime → air-alloc (jamais l’inverse),
zéro cycle. Plus simple et plus sûr que l’atfork acquire-around-fork de dlmalloc.
D9 — Instrumentation (fuites / doubles libérations) différée en v3
Le mode diagnostic LIB_AIR_DEBUG (table des allocations vivantes, bootstrap-safe, dans une
cartographie dédiée) est reporté en v3. La conception v1 réserve la place (ne ferme pas la
porte : en-tête extensible sous cfg ou table latérale), sans en payer le coût en v1.
D10 — API & étalement
- v1 — face Rust :
core::alloc::GlobalAlloc(alloc/dealloc/realloc/alloc_zeroed— l’optimisation « pagesmmapdéjà à zéro » de dlmalloc est reprise). RemplaceAbortOnAllocen#[global_allocator]dansrt/. Débloque le run réel (Command/spawn/tout code allouant) sur*-linux-air. - v2 — perf (mesurée) : arènes/cache par-thread, index rapide des grands bins si le profil le justifie, reddition/segments plus fins.
- v3 — instrumentation + gardien complet : table live-allocs, double-free/fuite, canaris,
comptabilité par-composant (cf.
AirMemoryTracker). - Face C-ABI (future, ADR-046) :
malloc/free(sans taille, résolu par l’en-tête)/realloc/calloc/aligned_alloc— même tas, deux faces ([[libc-air-dual-face]] ; précédentc_malloc/c_freede dlmalloc-rs).
D11 — Mandat de test (Principe 1, couche 1)
- Logique host-testable à 100 % : l’arène est testée sur un backing fourni (buffer hôte /
mmaphôte injecté à la place du vrai syscall — patron de dlmalloc-rs). Couverture 100 % lignes+branches. - Property-based : roundtrip
alloc/free; alignement respecté ; aucun chevauchement entre blocs vivants ; fusion (deux libres adjacents → un) ;reallocpréservemin(ancien,nouveau)octets. - Fuzzing (cargo-fuzz) : séquences aléatoires
alloc/free/realloc/calloc, modèle fantôme (non-chevauchement + contenu), différentiel possible. - Stress concurrent : N threads sous le verrou (deadlock/corruption).
- Frontière TARGET-ONLY (ADR-035) : seul le câblage réel (
#[global_allocator], reset-fork on-target) se valide par le selftestrt/; la logique est host-testable et doit l’être à 100 % (ne pas gonfler les exceptions de couverture).
Conséquences
- Run réel débloqué sur
*-linux-air(tout code allouant) — le grand verrou restant après l’étape 6. - Maillon libc posé (le
malloc/freede la libc C-ABI viendra se brancher sur le même tas). - Possession de la politique (échec déterministe, gardien
fork, futur diagnostic) sans réinventer l’algèbre d’allocation ni dépendre d’une boîte noire. - Couche 1 enrichie d’une crate fondatrice à tester avec le plus grand soin (l’arithmétique d’allocation est impitoyable — d’où le mandat property+fuzz+stress).
Alternatives rejetées
- Vendorer dlmalloc (boîte noire) : politique Air (gardien
fork, instrumentation, échec déterministe) difficile à greffer ; et le port Rust a un chemin mmap-direct vestigial qu’un vendoring aveugle aurait hérité. Rejeté au profit de l’inspiration possédée. - Réinventer from-scratch sans modèle : l’arithmétique tailles/flags + coalescence est un nid à corruptions ; on emprunte le design éprouvé de Lea pour le cœur.
air-allocsurair-memory::raw_mapping/ verrouAirMutex: font dépendre le producteur de ses consommateurs / d’air-thread; rejeté (D2/D3) au profit de l’appel direct + verrou maison.- treemap / cache par-thread / instrumentation en v1 : optimisations/fonctions différées (D5/D9) pour réduire la surface de bug de la fondation.
Mise en œuvre (esquisse, code headless après ratification — staging v1)
crates/air-alloc(no_std, deps air-sys-types + air-sys-syscall) : verrou futex maison ; arène (étiquettes de frontière, smallbins+bitmap, liste triée des grands,dv/top) ; backingmmapdirect (segments 64 Ko + dédié ≥ 256 Ko) ;GlobalAlloc; reset-fork exposé. 100 % cov + property + fuzz + stress.air-runtime: enregistre le handlerchildqui reset le verrou d’air-alloc(dep air-runtime → air-alloc).rt/air-rt: remplaceAbortOnAllocparair_alloc::GlobalAllocatoren#[global_allocator].- Selftest
rt/: exercer une allocation réelle (Vec/String) on-target (exit code), 2 arches. - Doc : ADR-056, registre, SUMMARY, INDEX, DEPENDENCIES (air-alloc).
ADR-057 — libm Air (air-libm) : vendoring de libm (rust-lang) en surface math couche 1
Statut : Accepté (2026-07-01, par délégation d’autonomie BDFL pour ce lot ; gravé pour revue
a posteriori). RFC de structure (ADR-015). Companion d’ADR-053
(la libc Air s’appuie sur icu4x — dont la math flottante no_std) et d’ADR-024
(règle des 80 % / exceptions nommées). S’appuie sur l’audit docs/notes/audit-icu4x-rust-pur-fr.md
(§3 « le SEUL primitif système manquant — math flottante no_std (libm) » ; §8 « ce qu’Air N’A PAS :
un libm no_std »).
Catégorie : Architecture (couche 1, fondateur). Toute évolution structurante = RFC.
Contexte
L’audit icu4x (docs/notes/audit-icu4x-rust-pur-fr.md) a établi un fait net : le runtime d’icu4x est
déjà no_std et de calcul pur (Air a core + alloc), et le seul primitif système qui lui
manque est une bibliothèque mathématique flottante no_std — un libm. Deux consommateurs
incontournables du périmètre i18n d’Air en dépendent (§3) :
calendrical_calculations(dépendance d’icu_calendar) — math calendaire (astronomie des calendriers lunaires/solaires). Non contournable : le calendrier en a besoin.icu_segmenter/src/complex/lstm— math du modèle LSTM (segmentation par IA des écritures sans espaces : thaï/khmer/lao/birman), activée par la featureauto/lstmque le projet retient.
Tous deux passent par core_maths, une fine façade no_std sur la crate libm. Air ne fournit
aujourd’hui aucun libm no_std (la math flottante était explicitement hors de la v1 libc,
ADR-046). C’est, selon l’audit, le vrai manque systémique pour la libc /
i18n future. Une décision était requise (audit §8) : (a) fournir un libm Air, (b) restreindre
le périmètre. (b) ne suffit pas seule — même sans LSTM, le calendrier garde besoin de libm. Air
aura donc besoin d’un libm no_std.
Il n’y a aucun consommateur aujourd’hui : cet ADR pose une fondation (une bibliothèque en
attente de consommateurs), comme on coule des fondations avant d’élever les murs. Le câblage effectif
(icu4x → core_maths/libm sur *-linux-air) viendra avec la réimplémentation i18n (ADR-053).
Décision
D1 — Vendorer la crate libm (rust-lang) comme exception nommée (ADR-024, modèle icu4x)
Air vendore libm (la crate rust-lang) sous le régime des exceptions explicitement nommées
à la règle des 80 % du Principe 6 (ADR-024 ;
docs/EXCEPTIONS.md), exactement comme icu4x (ADR-016) et la crypto auditée (ADR-034). Justification
de l’exception :
- Rust pur,
no_std, zéro dépendance runtime.libmest un portage defdlibmen Rust pur,#![no_std], sans aucune dépendance transitive — il respecte la cible*-linux-airet la politique « zéro surface C/C++ » (#81 ; aucun*-sys, aucuncc). - Maintenu par le projet Rust, qualité
std.libmest fusionné danscompiler-builtins: c’est lelibmque lastdRust utilise elle-même sur les ciblesno_std/wasm. Sa correction (ULP, cas limites NaN/inf/dénormaux) est éprouvée et continûment testée par l’amont. - Réimplémentation disproportionnée et risquée. Réécrire un
libmcorrect from scratch (dizaines de fonctions transcendantes, précision ULP, sous-flots) serait énorme et porteur d’un risque de bugs numériques précisément là où Air ne peut pas se permettre l’à-peu-près. Le bénéfice « moins de code externe » serait annulé par le coût « plus de code maison moins testé » — le même raisonnement que pouricu4xet la crypto. - Licence permissive.
libm0.2.x est publié sousMIT(liste blanchedeny.toml; l’amontrust-lang/compiler-builtinsest dual MIT/Apache-2.0).
D2 — Surface exposée par une crate couche 1 air-libm (no_std, ré-export)
La math d’Air est exposée par une nouvelle crate crates/air-libm, #![no_std] (couche 1,
publish = false), qui ré-exporte la surface de libm (pub use libm::*;). Cette frontière
nommée donne à Air un point de contrôle unique sur sa surface math (un seul endroit où la version
est pinnée, auditée, documentée), sans réinventer ni masquer l’amont. air-libm n’a pas de logique
propre en v1 : c’est délibéré (une façade de ré-export), pour ne pas dupliquer ni dériver de l’amont.
D3 — Pas de consommateur en v1 (fondation)
Aucune crate Air ne consomme air-libm aujourd’hui. La crate est posée en prévision de la libc /
i18n (ADR-053) : c’est une fondation, pas un service actif. La preuve de viabilité est apportée par le
build *-linux-air (la cible réelle) et par les tests de fumée (D4), pas par un consommateur.
D4 — Mandat de test (Principe 1, couche 1 = 100 %)
air-libm n’ayant quasiment pas de code propre (ré-export), le 100 % de couverture de la couche 1
est atteint de façon non équivoque : il n’existe aucune ligne de logique air-libm susceptible
d’être manquée (un pub use ne génère pas de région exécutable). Des tests de fumée à valeurs
connues exercent néanmoins la surface ré-exportée (sqrt, cbrt, cos, exp, pow, floor,
fabs, log, hypot, sin, tan, …, avec tolérance epsilon sur les transcendantes — jamais d’==
nu) pour prouver que la passerelle fonctionne. libm lui-même (la dépendance externe) n’est pas
mesuré (externe, comme icu4x / la crypto auditée).
Conséquences
- Air dispose d’une surface math
no_stdnommée et pinnée — la dernière brique système qui manquait à la réimplémentation i18n (ADR-053) est en place, prête à être câblée. - Une exception de dépendance de plus, explicitement consignée (
docs/EXCEPTIONS.md+crates/air-libm/DEPENDENCIES.md), auditée parcargo deny(licence) etcargo audit. - Zéro coût runtime tant qu’aucun consommateur ne tire
air-libm: la crate est compilée et testée, mais aucune binaire produit ne l’embarque encore.
Alternatives rejetées
- Réécrire
libmfrom scratch. Énorme, risque ULP/cas-limites élevé, pour une qualité de tests et de revue inférieure à l’amontrust-lang. Disproportionné (D1). - Dépendance
libmnon nommée (déclarée comme une dépendance ordinaire sans entrée d’exception). Viole le Principe 6 / ADR-024 : sous le seuil des 80 %, toute dépendance structurante exige une exception explicite. Rejeté au profit du vendoring nommé (modèleicu4x). - Restreindre le périmètre i18n pour éviter
libm. Insuffisant : le calendrier (non contournable) garde besoin de math flottante (audit §3/§8). Rejeté. - Câbler un consommateur dès maintenant. Prématuré : la réimplémentation i18n (ADR-053) n’est pas
faite. On pose la fondation et on prouve le build
*-linux-air, sans inventer d’usage.
Mise en œuvre (esquisse)
crates/air-libm(no_std, deplibm = "=0.2.x") :pub use libm::*;+ doc de module (rôle, attribution amont) + tests de fumée 100 %.- Workspace : ajout aux
members+libmdans[workspace.dependencies](pin strict, repro ADR-025). - Doc : ADR-057,
EXCEPTIONS.md(entréelibm),DEPENDENCIES.md, registre ADR, SUMMARY, INDEX. - Preuve de build
*-linux-air(crate-sonde jetablert/, non committée).
ADR-058 — loom : activation du modèle de concurrence (outil de vérification test-only)
Statut : Accepté (2026-07-01, par délégation d’autonomie BDFL pour ce lot ; gravé pour revue
a posteriori). RFC de structure (ADR-015). Companion de
ADR-030 (dépendances test-only, jamais livrées), ADR-021
(EINTR remonté, la boucle EST la reprise), ADR-031 (la vraie garantie
= couverture per-crate, pas un plancher global), et de la roadmap
docs/roadmap-couche1-libc-pal-fr.md (§3.1).
Catégorie : Outillage de vérification (test-only). Pas de surface livrée, pas d’impact ABI.
Contexte
La variance run-to-run de la couverture lignes (95,89 ↔ 96,12 %, qui a forcé le plancher
96→94, #189) provient de branches de contention concurrentes (chemins LOCKED/
LOCKED_CONTENDED des primitives futex) dont l’exécution dépend de l’ordonnancement : une
ligne est couverte certains runs seulement. Un test classique ne peut pas forcer
déterministement un entrelacement précis.
loom (projet tokio-rs) est l’outil Rust de référence pour
cela : sous --cfg loom, il remplace le modèle mémoire (atomiques, UnsafeCell, threads) par
un ordonnanceur qui explore exhaustivement tous les entrelacements d’un modèle borné, et
signale toute violation (course de données, non-exclusion). Il prouve la sûreté d’un protocole
concurrent, là où un test ordinaire ne fait qu’échantillonner un ordonnancement.
Le pré-câblage existait déjà (workspace : cfg(loom) déclaré + alias d’atomique commuté ;
air-sys-syscall : un modèle loom_refcount dormant), mais la dépendance n’était pas
activée — le commentaire du Cargo.toml de tête notait explicitement « décision de gouvernance
Principe 6 / ADR-024 en attente ». Cette ADR tranche cette attente.
Décision
-
Activer
loomcomme outil de modèle de concurrence, dépendance test-only tirée uniquement sous--cfg loom([target.'cfg(loom)'.dependencies]), hors du graphe de build normal — donc jamais livrée (ADR-030). Ce n’est pas une exception à la règle des 80 % (ADR-024, qui vise les dépendances de production) :loomest un outil de vérification, au même titre queproptest(déjà dev-dep du workspace, sans entréeEXCEPTIONS.md). Il n’entre donc pas dansdocs/EXCEPTIONS.md. LicenceMIT(liste blanchedeny.toml). -
Idiome d’aliasing (recommandation loom, déjà l’intention du commentaire workspace) : le même code de production est exercé sous loom et en production, via un alias d’atomique commuté
core/loom. Seul l’AirMutexd’air-threadest modélisé dans ce premier lot ;AirRwLock/AirSemaphore(et les autres primitives) suivront — d’où un alias local à l’AirMutex, pas global. -
Shim de blocage sous loom. loom ne modélise pas
futex(2). Le chemin contendu (futex_wait) est remplacé, sous--cfg loom, par un réveil coopératif (loom::thread::yield_now) et le réveil (futex_wake) par un no-op : la boucle du chemin lent relit l’état à sa prochaine planification (unfutex_waita toujours le droit de revenir « spurious » — la boucle EST la reprise, ADR-021 §2). Ce modèle est sound pour la SÛRETÉ (l’exclusion mutuelle ne dépend que de la sémantique atomiqueCAS/swap, fidèlement modélisée par loom). -
Garde-fou de fidélité (non négociable). loom prouve la sûreté (exclusion mutuelle, absence de course sur la donnée protégée) ; il ne prouve pas la vivacité (le réveil
futexréel), qui reste validée on-target (selftest 2 arches + stress). Un modèle loom ne remplace jamais la preuve on-target — il ajoute une garantie déterministe sur le protocole atomique. (Piège du mock : une couverture verte sur un modèle simplifié ne vaut pas exécution réelle.) -
Tests réguliers hors du run loom. Les modules
#[cfg(test)]d’air-threadqui instancient des primitives sont gardés#[cfg(all(test, not(loom)))](ils créeraient des atomiques loom horsloom::model→ panique). Sous--cfg loom, seulmod loom_airmutexs’exécute. -
Invocation (documentée, pas dans la barrière bloquante pour l’instant) :
RUSTFLAGS="--cfg loom" cargo test -p air-thread --lib --release loom_airmutexUn job CI loom optionnel (hors barrière pré-merge) pourra suivre quand plusieurs primitives seront modélisées.
Conséquences
- Positif : les branches de contention de l’
AirMutexsont désormais couvertes par construction (tous les entrelacements), pas au hasard de l’ordonnancement → attaque directe de la variance de couverture. Base réutilisable (le shim futex-loom sert aussiair-alloc,AirRwLock,AirSemaphore). - Coût : sous
--cfg loom(jamais un build normal), les tests réguliers sont désactivés ; un recompile complet sous le cfg est nécessaire. Le pré-câblageloom_refcountdormant d’air-sys-syscallémet quelques warnings sous loom (pré-existant, hors périmètre de ce lot). - Neutre : zéro impact sur les builds de production, la couverture mesurée (le code
cfg(loom)n’est pas compilé hors loom), l’ABI, la cible*-linux-air.
Alternatives rejetées
- Rendez-vous déterministe par test (barrières/atomiques dans le test) : marche au cas par cas (déjà employé, #187) mais ne systématise pas et n’explore pas l’espace des entrelacements. Complémentaire, pas substitut.
- Réécrire un ordonnanceur maison : refaire loom, coûteux et moins éprouvé.
shuttle(AWS) : exploration randomisée-rejouable plutôt qu’exhaustive ; loom (exhaustif, borné) est préférable pour prouver la sûreté d’un petit protocole.
Suite
- Étendre les modèles loom à
AirRwLock,AirSemaphore, puis au verrou futex d’air-alloc(même shim). Puis, quand plusieurs modèles existent, envisager un job CI loom optionnel. - Nettoyer les warnings loom dormants d’
air-sys-syscall(loom_refcount) — hors périmètre ici.
ADR-059 — Stratégie i18n : Air utilise icu4x (pas de réimplémentation), possède l’API + les données
Statut : Accepté (2026-07-01, ratifié BDFL en session sur décision data-backée). RFC de
structure (ADR-015). Companion de ADR-053
(la libc Air s’appuie sur icu4x — affirmé), ADR-016 (icu4x = exception i18n),
ADR-024 (règle des 80 % / exceptions nommées),
ADR-050 (cible *-linux-air). S’appuie sur l’audit
docs/notes/audit-icu4x-rust-pur-fr.md et sur la vérification empirique ci-dessous.
Catégorie : Architecture (couche 1, i18n / stratégie). Toute évolution structurante = RFC.
Contexte
La roadmap de clôture de la couche 1 (docs/roadmap-couche1-libc-pal-fr.md §2.A) plaçait l’i18n
« Rust pur » comme long pole bi-consommateur (libc et PAL). L’intention initiale était de
réimplémenter icu4x en Rust possédé Air (API + structures + tables aux idiomes Rust, en réutilisant
les outils icu4x pour le datagen). L’audit audit-icu4x-rust-pur-fr.md avait toutefois établi que le
runtime icu4x est déjà no_std et de calcul pur (core + alloc, zéro syscall/horloge/thread),
et laissait une question ouverte empirique (§9) : la fermeture icu4x compile-t-elle réellement pour
*-linux-air ? — « non fait, à vérifier ».
Vérification empirique (2026-07-01, carbon)
Sonde jetable auto-contenue (hors dépôt) : les 6 composants pinnés (icu_normalizer,
icu_casemap, icu_segmenter avec auto/LSTM, icu_collator, icu_locale_core,
icu_calendar) en default-features = false + compiled_data, build-std = ["core","alloc"],
toolchain nightly pinnée, cible x86_64-unknown-linux-air.json.
Résultat : EXIT=0 (build --release, 33 s). Compilent pour la cible : les 6 composants, tout le
socle (zerovec/yoke/zerotrie/potential_utf/writeable/icu_collections/icu_provider/
icu_locale/icu_properties), calendrical_calculations, core_maths et libm. Deux
conditions triviales :
default-features = falsesur lesicu_*— évite la featurestdpar défaut (la cause du « mur std » constaté antérieurement, quand les deps du workspace n’avaient pas ce réglage).- build
--release— évite un repli de loggingstd::eprintlnDEBUG-only d’icu_provider(#[cfg(all(not(feature = "logging"), debug_assertions, feature = "alloc", not(target_os = "none")))]), inactif horsdebug_assertions. Air compile la cible en release (panic=abort, ADR-025).
⇒ Réimplémenter le socle icu n’est PAS une nécessité technique. Ce serait un effort de plusieurs
mois pour reproduire à la main ~15 crates de logique Unicode ULP-correcte (zéro-copie, tries,
collation UCA, LSTM, astronomie calendaire) qui compilent déjà no_std pour la cible.
Décision — Option V
- Air UTILISE icu4x (extension de l’exception nommée ADR-016 ;
docs/EXCEPTIONS.md). Aucune réimplémentation en bloc du socle icu. La réécriture éventuelle reste en réserve, par composant, et seulement si un besoin doctrinal précis l’exige (jamais par principe). - Air POSSÈDE l’API Air-facing :
air-string(dual-face — la face que voient la libc C-ABI et le PAL Rust). L’AirString/AirLocale/AirDateTimed’air-base-liben sont l’assise ; ce qui manque est la face C-ABI (libc), pas le moteur. - Air POSSÈDE (à terme) le pipeline de données :
compiled_data(tables baked amont) suffit pour démarrer ; un datagen Air (host, réutilisant le datagen icu4x sur sources CLDR/UCD) est optionnel et différé — à décider quand Air voudra maîtriser ses propres tables. - Réglages de consommation gravés : sur la cible, les deps
icu_*se consomment endefault-features = falseet se buildent en release. (Le host/gnu peut garderstd.)
Nature de ce qu’Air embarque (Rust pur, pas ICU4C)
À ne pas confondre : ICU4C est la bibliothèque i18n historique en C/C++ ; icu4x est une
réimplémentation from scratch en Rust pur no_std (unicode-org) — aucun binding vers ICU4C,
aucun FFI, aucun C. Ce qu’Air compile dans son binaire cible est donc du code machine issu de
Rust uniquement. Vérifié :
- Repose sur
core+alloc, rien d’autre. Toutes les crates icu déclarentextern crate alloc;(elles allouent —Vec/String/Boxpour les sorties) ; zéro IO (pas de fichier, réseau, syscall, horloge, thread). L’allocateur global sur la cible =air-alloc(ADR-056). stdmécaniquement exclu. La cible*-linux-airn’a pas destd(build-stdne bâtit quecore+alloc) → tout usagestd= erreur de compilation. La sonde a compilé en release ⇒ zérostdatteint (garantie du compilateur, pas une promesse). Le seulstdnon-test du clos est le repli loggingeprintlnd’icu_provider,debug_assertions-only (inactif en release) ; tout le reste (std::collections/sync::LazyLock/panic/…) est#[cfg(test)](host, hors cible).- Forme du runtime = « tables
const+ fonctions pures ». Les données Unicode/CLDR sont générées à la build (outil host, datagen) et embarquées enconstRust (compiled_data, en.rodata) ; les crates zéro-copie (zerovec/yoke/potential_utf) voient ces tables sans les copier ni les allouer. Le calcul (lookup de trie, collation UCA, arithmétique calendaire, matrices LSTM →libm) est du purcore/allocpar-dessus ces tables. L’allocation ne sert qu’aux résultats, jamais aux tables. ⇒ conforme à la politique Air « zéro surface C/C++ » (gatecheck-c-surface). libm: icu atteint la math viacore_maths→ le cratelibm(rust-lang,=0.2.16) — le même que vendoreair-libm(ADR-057), unifié en une seule copie ;libmest du Rust pur (portagefdlibm), pas la libm C du système. icu ne passe pas par la façadeair-libm(qui reste la surface math Air publique, non requise par icu).
Conséquences — la roadmap §2.A est rétrécie
Le « long pole » cesse d’en être un. Le travail restant devient du no_std / nettoyage :
air-base-lib→# = le chantier principal (débloque la fermeture couche 1 complète, i18n comprise, pour*-linux-air).- deps
icu_*d’air-base-lib→default-features = false(1 ligne × 6). air-stringdual-face : ajouter la face C-ABI (libc) au-dessus de l’AirStringexistant.- pipeline datagen Air : optionnel / plus tard.
Abandonnées (inutiles puisque icu4x compile tel quel) : ex-2.A.1 (vendoring des 6 utils),
ex-2.A.2 (port du socle), ex-2.A.3 (port de calendrical_calculations). Note libm :
air-libm (ADR-057) n’est pas requis par icu (icu tire le crate libm via core_maths
directement) ; air-libm reste utile comme surface math Air publique, mais la dépendance i18n
est satisfaite en amont.
Alternatives rejetées
- Option R — réimplémenter le socle en bloc : coût (mois) et risque de correction (ULP)
disproportionnés pour reproduire du code amont maintenu et déjà
no_std-compatible cible. Contraire au Principe 5 (ne pas sur-produire) et à l’esprit de l’exception ADR-016. - Option Hybride — posséder d’emblée certains composants (ctype/properties) : prématuré ; on peut basculer un composant vers du possédé plus tard, au cas par cas, sans le décider maintenant.
Suite
- Graver (ce document) + rétrécir la roadmap §2.A + consigner la vérification dans l’audit.
- Chantier
air-base-lib#, puis re-sonder la fermeture couche 1 complète pour*-linux-air. - Face C-ABI d’
air-stringquand la surface libc la réclamera.
ADR-060 — Descellement couche-0-vX : famille termios/tty (pour la libc et air-terminal)
Statut : Accepté (2026-07-02, par délégation d’autonomie BDFL ; gravé pour revue a posteriori). RFC de structure (ADR-015). Companion de ADR-051 (descellement précédent, même mécanique), ADR-021 (conventions couche 0), ADR-046 / ADR-047 (libc). Prépare l’objet couche 1 air-terminal (ADR-061).
Catégorie : Couche 0 (extension du socle / re-sceau, comme ADR-051).
Contexte
Clore la couche 1 pour la libc exige termios — l’interface POSIX de discipline de ligne
(POSIX.1-1988, racines System V ~1983), terminal-agnostique et stable. La couche 0 scellée ne
l’a pas encore. Deux consommateurs l’imposent : la famille libc termios (#20 de l’audit musl ;
tcgetattr/tcsetattr/PTY pour OpenSSH — prompts en mode raw, sshd alloue des PTY), et isatty
(requis par stdio, dérivé de TCGETS). C’est donc un descellement additif de plus, dans l’esprit
d’ADR-051 : jalons antérieurs conservés, on ajoute une famille, on re-scelle en couche-0-vX.
Le type Termios (air-sys-types/src/terminal.rs, #[repr(C)], NCCS/ControlChar/bitflags/
BaudRate, identique x86_64/aarch64 — asm-generic) existe déjà (branche feat/termios-couche0).
Il manque les wrappers d’ioctls typés + le PTY + les tests 100 %.
Décision
Desceller la couche 0 et y ajouter la famille termios/tty complète (passe unique — éviter un
second re-sceau), sous forme de fonctions dédiées typées (jamais d’ioctl(fd, REQ, …) générique —
ADR-021 conv. 3), dans un module air-sys-syscall/src/terminal.rs, consommant le type Termios
d’air-sys-types. Les 5 groupes :
| Groupe | Fonctions typées (ioctl noyau) | Rôle |
|---|---|---|
| Attributs | tcgetattr (TCGETS), tcsetattr(action) (TCSETS/TCSETSW/TCSETSF) | cœur termios (raw/canonique) |
| Contrôle de ligne | tcsendbreak (TCSBRK/TCSBRKP), tcdrain, tcflush (TCFLSH), tcflow (TCXONC) | les tc* POSIX |
| Taille fenêtre | get_winsize/set_winsize (TIOCGWINSZ/TIOCSWINSZ) | plein écran, SIGWINCH |
| PTY | ouvrir /dev/ptmx, pty_number (TIOCGPTN), pty_unlock (TIOCSPTLCK), pty_peer (TIOCGPTPEER) | allocation maître/esclave (sshd, air-terminal) |
| Session / job-control | tcgetpgrp/tcsetpgrp (TIOCGPGRP/TIOCSPGRP), set_ctty (TIOCSCTTY), clear_ctty (TIOCNOTTY), session_id (TIOCGSID) | terminal de contrôle, groupes de premier plan |
Conventions couche 0 (ADR-021) — à respecter strictement :
- Fonctions dédiées typées, une par opération ; pas de wrapper ioctl générique.
Option<T>au lieu des sentinelles (ex.tcsetpgrp(fd, Pid)typé ; jamais de-1magique).EINTRremonté à l’appelant (tcdrainpeut le rendre) — aucun retry auto en couche 0.- RAII pour les FD du PTY (
OwnedFd) ; aucune fuite.Result<T, Errno>(Errno =NonZeroI32). - Pas d’alloc dans le happy path (buffer
Termios/winsizesur la pile,#[repr(C)]). - Identique x86_64/aarch64 (
asm-generic— pas de divergence de disposition). - Couverture 100 % (lignes + branches), y compris chemins
EFAULT/ENOTTY(harnais + PTY réel).
Ce qui reste en couche 1 (dérivé, pas couche 0) : isatty (= tcgetattr réussit / ENOTTY),
ttyname (= /proc/self/fd), et l’objet ergonomique air-terminal (ADR-061).
Conséquences
- Re-sceau
couche-0-vX(version suivante ; marqueur appliqué à l’atterrissage du code, jalons antérieurs conservés, comme ADR-051). Mise à jour du registre de scellement couche 0. - Débloque
stdio(isatty) et la famille libctermios, et l’objet couche 1air-terminal(ADR-061). - UNSUPPORTED.md : les ioctls tty legacy non wrappés (ex.
TIOCSTIinjection — dangereux ;TCGETA/TCSETAtermio pré-POSIX) restent hors périmètre, justifiés.
Alternatives rejetées
- Wrapper ioctl générique
ioctl(fd, TCGETS, &mut t): viole ADR-021 conv. 3 (typage per-op). - Descellement minimal puis additif : deux re-sceaux au lieu d’un ; la passe complète (doctrine « clore proprement ») est préférée.
ADR-061 — Architecture AirTerminal : modèle canonique + codecs (vision)
Statut : Accepté (2026-07-02, par délégation d’autonomie BDFL ; ADR de vision — gravé pour revue a posteriori). RFC de structure (ADR-015). Companion de ADR-060 (termios couche 0), ADR-046 (libc), ADR-001 (AirCom / IPC), ADR-003 (compositeur), ADR-007 (air-console), ADR-008 (air-tui), ADR-040 (Cap’n Proto).
Catégorie : Architecture (vision terminal, couche 1 → 2). Aucun engagement d’implémentation
au-delà de la forme (modèle + trait) ; les codecs riches et air-codec-1 sont couche 2, différés.
Contexte — le terminal legacy n’est plus adapté
Le PTY est un seul canal d’octets non typé : sortie appli (texte + séquences d’échappement),
entrée (frappes + souris encodée en fausses séquences + collage + focus), et négociation
(requêtes/réponses) — tout multiplexé en-bande. Modèle « téléscripteur » (POSIX termios 1988,
ECMA-48/VT102 1976-91), terminal-agnostique, incapable de décrire l’interaction riche (souris
multi-boutons, molette, tactile, pression, gestes, biométrie — terminfo n’a pas de vocabulaire
pour ça ; kitty & co. ont greffé des séquences faute d’autre canal). Air, qui possède tout le
stack (libc, IPC, compositeur, apps), peut faire mieux — non en encodant mieux la souris en
séquences, mais en n’ayant pas à le faire.
Résultat clé de la réflexion : sur un canal, « propre » et « legacy » ne cohabitent pas (le seul mécanisme est la greffe de séquences ignorables — la laideur même). ⇒ on sépare ; la cohabitation se fait au niveau système (objet), pas octet.
Décision — modèle canonique + codecs
-
AirTerminal(objet couche 1) = un tag (Legacy|Air) + un codec + un transport. Il représente le terminal-pair avec lequel Air converse. -
Un modèle interne canonique Air (la lingua franca) : les codecs ne se parlent jamais ; ils traduisent tous ce modèle. Conçu une fois, riche et tourné vers l’avenir ; chaque type de terminal = un codec de plus, jamais une refonte. Le codec legacy est un adaptateur dégradant (il jette ce que xterm ne sait pas), pas une contrainte sur le modèle.
#![allow(unused)] fn main() { #[non_exhaustive] enum TermIn { Keyboard(KeyboardEvent), Mouse(MouseEvent), Screen(ScreenEvent), /* +Touch/Gesture/Device… */ Unknown(RawEvent) } #[non_exhaustive] enum MouseEvent { Button{..}, Wheel{..}, Motion{..}, /* +Pressure/Tilt… */ Unknown{ subtype:u16, payload } } enum TermOut { Text{run,attrs}, Cursor{..}, Clear{..}, Scroll{..}, SetMode{..}, AttachSurface{ rect, surface_id } } // ⟵ la clé struct TermCaps { colors, mouse, keyboard_proto, surfaces:bool, .. } trait TerminalCodec { fn decode_in(&mut self, wire)->Vec<TermIn>; fn encode_out(&mut self, &TermOut)->Bytes; fn caps(&self)->TermCaps; } } -
Discipline unique d’extensibilité — « Annoncer → Sélectionner → dégrader-sur-inconnu ». Tout ce qui est extensible (catégories/sous-types d’events, champs, types de surface, codecs) suit : (1) un registre à IDs stables ; (2) chaque bout annonce ses capacités ; (3) le consommateur choisit dans l’intersection ; (4) l’inconnu est catégorisé et ignoré, jamais fatal. Mécanique Rust/fil :
#[non_exhaustive](compat à la recompilation, quand on ajoute une variante) + varianteUnknown(compat runtime, quand on reçoit plus récent que soi) + schéma évolutif Cap’n Proto (ADR-040 — ajouter un champ = compatible, inconnu skippé) pour l’axe « nouveau champ ». (Flux haute fréquence → batch par frame ; capnp donne la discipline d’évolution, pas forcément l’encodage par-événement.) -
AttachSurface= énumérer → sélectionner → lier. Une appli ne dessine pas une image en séquences : elle interroge les surfaces offertes (QuerySurfaces→SurfaceOffer{kinds}), choisit celle qu’elle sait piloter (intersection offert ∧ compris), lie (AttachSurface→SurfaceReady{surface_id}) et reçoit ses événements tagués. Le compositeur Air fournit une vraie surface (GPU, événements typés) — impossible sur Unix (pas d’autre canal), trivial sous Air.SurfaceKindest lui-même un registre extensible (même discipline). -
termios= affaire du codec legacy uniquement (discipline de ligne du PTY). Le chemin Air-natif esttermios-free (eventsKeytypés + éditeur-ligne userspace si besoin). -
La passerelle tombe toute seule : coller n’importe quel côté-appli à n’importe quel côté-display = composer deux codecs via le modèle (
bash[ansi]↔modèle↔display[kitty] ;air-cli[air]↔modèle↔xterm[ansi, dégrade]). L’interop legacy = « un bout utilise le codec ansi ». Découverte du tag distant : un handshake « es-tu Air ? » ignorable par le legacy (la seule greffe résiduelle, bornée), puis bascule sur le canal propre.
Discipline de portée (ne pas bloquer le sceau)
- Ce que la libc / le sceau couche 1 exige =
termios(couche 0, ADR-060) + un minceair-terminal(couche 1) dont le premier (et seul, au sceau) codec estansi/termios. - Le modèle canonique + le trait
TerminalCodec+ la règle d’extensibilité +AttachSurfacese gravent maintenant (design ; c’est l’assurance anti-double-travail). Le trait peut entrer dans la couche 1 (interface stable) ; les codecs riches,air-codec-1,AttachSurfaceimplémenté = couche 2, différés (conçus quand air-console/air-tui les réclament — pas avant, pour ne pas sur-designer). termcap/terminfo= hors libc (c’est ncurses/libtinfo, couche 2) ; si Air le fournit un jour, base xterm-family only (98 % du marché 2026), pas la base terminfo legacy complète.
Conséquences
air-terminalest bâti comme le premier codec d’un cadre déjà juste, pas un wrapper jetable.- L’interaction riche (souris fine, tactile, gestes, biométrie, graphique) passe par le stack
device/entrée d’Air (air-device, compositeur) via
AttachSurface/events typés — pas par le terminal. Le terminal reste texte, borné, compatible.
ADR-062 — Sceau couche-1-v1.0 : gel de l’API Rust de la couche 1
Statut : Accepté (2026-07-03, autorisation BDFL explicite). RFC de
structure (ADR-015). Tag git couche-1-v1.0 posé sur le commit de sceau.
Companion des audits pré-sceau (docs/notes/audit-face-libc-cloture-fr.md,
audit-face-pal-cloture-fr.md) et de la roadmap §5.4. Applique le modèle de
re-sceau de la couche 0 (ADR-051,
ADR-048) et la gouvernance de
versionnement (ADR-012).
Catégorie : Sceau de couche fondatrice. Gèle une API Rust source-level ; n’affecte pas la C-ABI (régime propre, voir §Hors périmètre).
Contexte
La passe de production complète de la couche 1 (roadmap §2 cœur, §4 mono-toit dont
l’async §4-K, §5.3 P0) est terminée : les deux audits pré-sceau (face-libc,
face-PAL) n’ont plus de manque bloquant, et les 4 chantiers P0 fondateurs
(AirFile #214, air-poll #215, termios/PTY #216, air-signal #217) sont livrés. La
couche 1 a deux toits à construire dessus — la libc C-ABI (ADR-046/047/053)
et le PAL Rust (la std cible *-linux-air, ADR-049/052). Pour leur donner une
assise stable et éviter le va-et-vient sceau/descellement subi sur la couche 0,
on fige l’API Rust de la couche 1 avant de bâtir les toits.
Décision
Ce qui est SCELLÉ — l’API Rust publique des crates couche 1
On gèle en couche-1-v1.0 la surface Rust publique (types, signatures de
fonctions/méthodes, traits, ré-exports) des crates :
air-base-core, air-base-lib, air-crypto, air-socket, air-filesystem,
air-memory, air-alloc, air-device, air-env, air-stdio, air-terminal,
air-poll, air-signal, air-thread, air-process, air-runtime (async
inclus, §4-K), air-config-schema, air-config-compile, air-config,
air-libm.
Après le sceau, cette surface n’évolue que par descellement additif v1.x
(RFC, ADR-015) : on ajoute, on ne casse pas ; les jalons antérieurs sont
conservés (précédent ADR-051). Les implémentations internes ne sont pas gelées
(optimisation, refactor libres tant que l’API publique tient).
Audit de sceau-readiness (préalable, fait). Une passe dédiée a vérifié, pour
chaque additif prévu, s’il remodèlerait une signature existante (rupture) ou
serait purement additif. Résultat : tous additifs sauf env/args, qui ont été
remodelés à leur forme finale AVANT ce sceau (types chaîne-OS AirOsStr/
AirOsString ; air_env::get rend une copie possédée race-safe ; args::get reste
&'static AirOsStr) — sinon un futur set_var/os_str les aurait cassés. Les
destructeurs TLS, Condvar/Once, getsockopt/setsockopt, mman fichier-adossé,
stat/fstat, mkstemp, spawn ergonomique et l’instrumentation IO (ADR-063) sont
confirmés purement additifs.
Primitives unsafe nommées (actées). La règle « zéro unsafe exposé » de la
couche 1 a quelques exceptions irréductibles de bas niveau, nommées et
documentées (# Safety + // SAFETY:) : air-runtime::spawn (spawn de thread brut,
extern "C" fn) et air-env::set_environ (publication de la table d’environnement au
démarrage). Elles sont dans la surface scellée, en tant que primitives runtime
assumées. (La face sigaction-de-faute, elle, est hors couche 1 — ADR-064 §2.)
Ce qui n’est PAS scellé — les toits et l’interne
- La C-ABI :
air-base-capiet la future libc C-ABI vivent au-dessus de la couche 1 et ont leur propre régime de stabilité (zonesair-stable/air-internal/air-experimental, ADR-012). Sceller la couche 1 ≠ figer les signatures C. - Le PAL
std::sys(*-linux-air, ADR-049 phase 3) : consommateur, au-dessus. - Le toolchain C/C++ (clang air, compiler-rt, libc++…) : au-dessus.
- Les crates couche 0 (
air-sys-types,air-sys-syscall) : déjà scellées (couche-0-v1.8), régime propre. - Zones test-only et experimental : évolution libre.
Manques connus → additifs v1.x (non bloquants, déjà tranchés)
Spawn ergonomique §2.B.2 (dans air-runtime), Condvar/Once, mutation d’env
(set_var…), newtype os_str, destructeurs TLS, gap aarch64 crypto
(cpufeatures→libc, #14), instrumentation IO debug (ADR-063), moteur regex
(§4-I). Chacun se traitera en descellement additif, jamais en refonte.
(Rappel décision : la face sigaction/handler-de-faute n’est pas un additif —
elle est hors couche 1 Rust par principe, ADR-064 §2 ; les fautes → action par
défaut.)
Conséquences
- Pose du tag git
couche-1-v1.0sur le commit de sceau (après autorisation). - Toute évolution de l’API Rust couche 1 passe désormais par un RFC de descellement
additif (
couche-1-v1.x), sur le modèle couche 0. - Les toits (libc C-ABI, PAL) peuvent démarrer sur une assise stable.
- La stabilité ABI (10 ans, air-abi-check/air-symver, ADR-012) s’applique aux
zones
air-stablede la C-ABI — pas à l’API Rust en tant que telle (le gel Rust est source-level, garanti par revue + RFC).
Alternatives rejetées
- Sceller au fil de l’eau (comme subi en couche 0). Rejeté : on transforme les descellements structurels en additifs mineurs via une passe complète d’abord.
- Inclure la C-ABI dans le gel Rust. Rejeté : la C-ABI a son propre régime (ADR-012) ; la mélanger figerait prématurément des signatures C encore en design.
- Différer le sceau jusqu’aux additifs v1.x. Rejeté : ils sont non bloquants et additifs par construction ; attendre retarderait les toits sans réduire le risque.
Suite
- Autorisation BDFL → bascule Statut = Accepté, commit de sceau, tag
couche-1-v1.0, note de scellement dansINDEX.md+etat-avancement.md. - Démarrage des toits : ABI C (§4-J), libc C-ABI et PAL
std::sys; puis regex (§4-I).
ADR-063 — Instrumentation IO couche 1 (mode debug) : registre de handles cross-langage & cohérence fork
Statut : Accepté (2026-07-03, décision BDFL directe). RFC de structure
(ADR-015). Prérequis de conception du sceau
couche-1-v1.0 (ADR-062, réservé, à venir) : cet ADR fige
la doctrine de l’instrumentation ; son seam étant interne, l’implémentation
peut atterrir de façon additive avant ou après le sceau. Companion de
ADR-019 (Result lossless), ADR-021
(EINTR remonté, enfant clone3 = jamais de malloc), et de la vision « libc dual-face
traçante » (docs/notes/).
Catégorie : Doctrine d’architecture couche 1. Aucune surface livrée par défaut (feature compilée hors release), aucun impact ABI, zéro coût dans le build standard.
Contexte
Air possède les deux faces d’accès aux ressources IO : le code Rust consomme
l’API couche 1 (AirFile, air-socket, air-stdio) ; le code C d’un développeur
consomme la libc Air (C-ABI : FILE*, open/socket), elle-même bâtie sur
les mêmes briques couche 1. Ces deux faces partagent une vérité de bas niveau :
la table des descripteurs du noyau, par processus.
Cette position — que ni la glibc ni musl n’exploitent proprement — permet une
capacité distinctive : en mode debug, offrir au développeur une vue unifiée,
process-globale, cross-langage de tout ce qui est ouvert (fichiers, sockets,
FILE*), par qui (le thread ouvreur), et ce qui n’est ni fermé ni flushé —
particulièrement quand il passe par du C, où le compilateur ne l’aide pas.
En release, la couche 1 doit rester fidèle à ses principes : pas d’allocation
dans le happy path, no_std-compatible, zéro globale. L’instrumentation
ne peut donc pas être un état global toujours-présent : elle est opt-in.
Décision
1. Registre de handles IO, opt-in, mode debug
La couche 1 héberge un registre de handles — une table fd → enregistrement,
process-globale, protégée par un verrou fork-safe — alimentée par toutes les
briques qui ouvrent un descripteur :
AirFile(fichiers),air-socket(sockets), les pidfd/signalfd/timerfd, et la coucheFILE*de la libc C (au-dessus de la couche 1) qui s’y enregistre aussi.
Chaque enregistrement porte : le type (fichier / socket / FILE* / autre), la
provenance = le thread ouvreur (Tid via gettid, couche 0) + un site
d’ouverture (au minimum le nom logique de l’appel ; enrichissable), les flags
d’ouverture, l’origine langage (Rust API vs face C), les verrous détenus
sur le handle (§5), et — pour les FILE* — l’état de tampon (propre / sale,
pour le diagnostic de flush).
Le registre n’est ni une source de vérité ni un cache de correctness : la
vérité reste la table du noyau. C’est un miroir de diagnostic. fstat//proc/self/fd
restent l’oracle ; le registre y ajoute la provenance que le noyau ne connaît pas.
2. Gating — zéro coût par défaut
Deux verrous cumulatifs :
- Compile-time : feature Cargo
io-instrumentation(nommage définitif à l’impl). Désactivée en release ⇒ les points d’instrumentation se compilent en no-op (aucun code, aucune globale, aucun verrou) : la couche 1 resteno_std/zéro-alloc/zéro-globale. - Runtime : quand la feature est compilée, un interrupteur d’environnement
(
AIR_IO_DEBUG, cohérent avec la face C) active effectivement l’enregistrement. Un build debug non instrumenté à l’exécution reste donc silencieux et quasi gratuit.
3. Cohérence clone3/fork() parent↔enfant — exigence dure
Le registre doit rester cohérent à travers un fork() (clone3 sans
CLONE_VM) :
- État copié = état correct. L’enfant hérite l’espace d’adressage et les fd
ouverts du parent : le registre, copié tel quel, décrit déjà la réalité de
l’enfant. On n’invente rien ; on annote les entrées comme « héritées de
PID p » (la provenance
Tiddu parent est conservée comme historique). - Verrou ré-initialisé côté enfant. Un
fork()concurrent d’un thread tenant le verrou du registre laisserait l’enfant avec un verrou verrouillé à jamais (problème classique threads+fork). Le chemin de fork couche 1 (air-runtime/air-process, qui pilotentclone3) ré-initialise le verrou du registre immédiatement côté enfant — même discipline que le verrou deair-allocet queair-stdio::reset_after_fork. - Fenêtre fork→exec async-signal-safe. Entre
clone3etexecve, aucune allocation, aucun verrou bloquant (ADR-021 : enfantclone3= jamais demalloc). Toute mise à jour du registre dans cette fenêtre est soit différée, soit async-signal-safe (ré-init de verrou + marquage, sans alloc). exec= remise à zéro naturelle.execveremplace l’image : le registre (en mémoire) disparaît avec elle ; les fdCLOEXECsont fermés par le noyau. Aucune action requise — seul le cas fork sans exec (l’enfant continue du code Air/Rust) exerce la copie+ré-init ci-dessus.
4. Diagnostics développeur
Sur requête explicite (API de diagnostic), à la fermeture incohérente, et à la
terminaison du processus (dump type atexit) — et optionnellement avant un
fork :
- Fuites : descripteurs encore ouverts en fin de vie (hors exception §6).
- Flush :
FILE*à tampon sale non flushé (perte de données) — signalé à l’exit, et avant unfork()(piège classique : le tampon dupliqué provoque une double sortie). Le suivi du tampon vit dans la coucheFILE*(face C) ; il s’enregistre auprès du même registre couche 1. - Verrous non libérés : un verrou fichier acquis et jamais explicitement relâché, détenu jusqu’à la fermeture du handle / la fin du thread / la fin du processus (§5).
- Chaque diagnostic nomme la provenance (thread + site + langage), pour pointer précisément le C fautif.
5. Rust comme C — encourager la libération explicite (close, unlock)
Fermeture. En Rust, le Drop d’AirFile/socket ferme le fd par défaut
(RAII, best-effort, erreur avalée). C’est sûr mais opaque : une erreur de
close (EIO NFS…) est perdue. L’instrumentation distingue la fermeture
explicite (close(self), qui restitue l’erreur — cf. AirFile::close) de la
fermeture implicite par Drop, et signale les secondes comme « fermeture
implicite — préférez un close() explicite pour observer l’erreur ». Diagnostic de
faible sévérité (nudge de style, jamais une erreur) : le Drop reste un filet de
sécurité correct.
Verrous fichier. Un développeur ouvre un fichier, seek, pose un verrou,
garde le fichier ouvert… et ne l’unlock jamais, laissant le noyau le relâcher à
la fermeture du fd, à la fin du thread ou du processus. Le noyau nettoie certes
(les verrous ne fuient pas au sens ressource), mais cette libération implicite
est un code-smell : elle masque l’intention, prolonge la contention pour les
autres processus/threads en attente du verrou, et peut engendrer des blocages
subtils. L’instrumentation suit chaque verrou détenu — variante
(OFD fcntl — variante moderne préférée, ADR-021 / flock / verrou POSIX
record legacy / lease), type (partagé/exclusif), plage (offset+longueur
pour les record locks), Tid acquéreur et site — et signale, en
Rust comme en C, tout verrou relâché implicitement (par close/fin de
thread/fin de process) plutôt que par un unlock explicite. Même philosophie
que le nudge close, faible sévérité.
Nuance fork (cohérence §3). Les verrous OFD et flock sont attachés à la
description de fichier ouverte : ils sont hérités avec le fd à travers
fork() — le registre les annote « hérités » côté enfant. Les verrous POSIX
record (legacy) ne sont pas hérités par l’enfant : le registre ne doit
jamais rapporter un verrou POSIX record fantôme dans l’enfant. Cette asymétrie
est une raison de plus de préférer les verrous OFD (doctrine « variantes modernes »).
6. Exception stdin/stdout/stderr (fd 0/1/2)
Les trois descripteurs standard sont de sémantique OS : durée de vie =
processus, fermés par le noyau à l’exit. Ils ne sont jamais rapportés comme
fuites. (Leur flush FILE* en sortie normale relève du chemin atexit standard de
la stdio, pas d’un diagnostic de fuite.)
7. Seam interne ⇒ additif, non bloquant pour le sceau
Les points d’instrumentation (registre::on_open/on_close/on_flush) sont internes
aux crates ouvreuses : ils ne changent aucune signature publique. Les ajouter à
AirFile/air-socket/air-stdio (déjà sur la voie du sceau) est donc purement
additif et non-ABI-breaking, y compris après le sceau. Cet ADR fige la
doctrine maintenant pour la cohérence ; l’implémentation est séquencée à part.
Conséquences
- Nouvelle crate couche 1 (registre) sous feature, dont dépendent (dep interne, optionnelle) les crates ouvreuses. Zéro dépendance externe.
- La couche
FILE*de la libc C (au-dessus de la couche 1) devra s’enregistrer et suivre l’état de tampon — donc ce chantier a une part couche 1 (registre + hooks Rust) et une part face C (flushFILE*), cette dernière atterrissant avec la stdio C. - Le chemin de fork couche 1 gagne une responsabilité de ré-init (sous feature) —
testée façon
air-fault(§3.2 roadmap) + un test on-target fork/thread réel. - Valeur produit : une libc « qui fait ce qu’elle dit » et traçable, avantage net sur glibc/musl pour le développeur C.
Alternatives rejetées
- Registre toujours-actif (même en release). Rejeté : viole zéro-alloc/
no_std/ zéro-globale du happy path couche 1, et coûte un verrou paropen/close. - Suivi par thread (TLS) sans vue globale. Rejeté : les fd sont process-globaux (partagés entre threads, hérités au fork) ; le développeur veut une vue process. Le TLS ne sert qu’à du scratch par-thread éventuel, pas au registre.
- Dupliquer la table du noyau comme source de vérité. Rejeté : la vérité reste le noyau ; le registre n’ajoute que la provenance. Pas de risque d’incohérence autorité-vs-miroir.
- Warnings de fuite sur 0/1/2. Rejeté : sémantique OS (§6).
Suite
- Implémentation : crate registre (feature) + hooks
AirFile/air-socket/air-stdio(open/close et pose/levée de verrou) + ré-init fork couche 1 ; puis suivi flushFILE*avec la stdio C. Testsair-fault+ fork/thread on-target (dont l’asymétrie d’héritage OFD vs POSIX record). - À référencer depuis la roadmap couche 1 (chantier additif) et l’INDEX.
ADR-064 — Doctrine des signaux couche 1 : signalfd primaire, fautes → action par défaut (zéro unsafe), helper « bloquer partout + drainer »
Statut : Accepté (2026-07-03, décision BDFL directe). Companion de
ADR-020 (signalfd par défaut) qu’il étend à la
face couche 1 (roadmap §5.3 P0.4) et à la face libc C. RFC de structure
(ADR-015). Cohérent avec
ADR-021 (EINTR remonté, jamais de retry auto)
et avec air-poll (paramètre signal_mask de ppoll).
Catégorie : Doctrine d’architecture couche 1 (face signaux). Surface Rust
scellée en couche-1-v1.0 ; la C-ABI (sigaction/signal) vit au-dessus.
Contexte
Les signaux sont un mécanisme maintenu par le noyau : ensembles pending
(par-thread + par-process ; bitmask coalescé pour les standards, file siginfo pour
les temps-réel), masque bloqué (par-thread), dispositions/handlers
(par-process), décision et frame de délivrance, actions par défaut — tout est
état noyau et piloté noyau. Le userland ne fournit que le code du handler que le
noyau appelle et les wrappers libc. Il n’existe aucun état de signal détenu en
userland.
Linux offre deux façons de consommer ce même état :
- Asynchrone —
sigaction: le noyau détourne le thread vers un handler userland (frame sur la pile). Puissant mais soumis à l’async-signal-safety (handler réentrant, ensemble d’appels autorisés minuscule), non intégrable à une event loop. - Synchrone —
signalfd: on bloque les signaux et on les lit comme un flux d’octets sur un fd, pollable (ppoll/epoll). Pas de handler réentrant : on traite les signaux dans la boucle d’événements normale. C’est le défaut posé par ADR-020.
Décision
1. signalfd = face primaire de la couche 1
Pour les signaux asynchrones/externes (SIGINT, SIGTERM, SIGCHLD, SIGHUP,
SIGWINCH, SIGUSR1/2, temps-réel…), la voie normale est signalfd : consommation
synchrone, thread-safe, déterministe, intégrable à air-poll (le fd
signalfd rejoint l’ensemble surveillé par poll). La couche 0 fournit déjà
signalfd (primauté ADR-020).
2. Fautes synchrones → action par défaut ; la couche 1 Rust n’expose AUCUN handler (décision BDFL 2026-07-03)
Les signaux synchrones de faute — SIGSEGV, SIGFPE, SIGBUS, SIGILL —
ne peuvent pas être différés vers une event loop (traps sur l’instruction fautive).
La seule façon de les traiter serait d’installer un handler — geste
irréductiblement unsafe : la précondition « le handler est async-signal-safe »
est portée par l’appelant et invérifiable par le compilateur (ré-entrance à un
point arbitraire ; malloc/verrou dans le handler = deadlock). std elle-même
n’expose pas sigaction pour cette raison.
Décision : la couche 1 Rust reste 100 % SAFE — elle n’expose AUCUN handler de faute (le runtime installe néanmoins un handler interne minimal de barrière, non exposé — voir §7). Une faute synchrone signifie qu’un invariant amont est déjà violé : la réponse est l’action par défaut du noyau — terminate + core dump, point. Le noyau le fait déjà par défaut ; la couche 1 n’a donc rien à installer. Posture fail-fast (ne rien exécuter dans un contexte corrompu — c’est aussi le choix le plus sûr : les handlers de crash sont une surface d’exploitation classique).
Poser SIG_IGN/SIG_DFL reste safe (constantes noyau, aucun code utilisateur,
aucune ré-entrance) et peut être exposé ; seul l’enregistrement d’un handler
custom est proscrit en couche 1. (Rappel noyau : SIG_IGN sur une vraie faute
matérielle est de toute façon inopérant — Linux force l’action par défaut.)
Corollaire dual-face — comportement figé de la libc C sur les fautes (décision
BDFL 2026-07-03) : les programmes C appellent sigaction. La libc C-ABI
(au-dessus de la couche 1) fournit le symbole sigaction — le code C compile,
lie, et l’appel RÉUSSIT (retour succès, pas d’EINVAL). Mais sur les signaux de
faute (liste à figer ; cœur : SIGSEGV/SIGFPE/SIGBUS/SIGILL) le handler
enregistré n’est JAMAIS installé côté noyau ni appelé : le résultat d’une faute est
toujours l’action par défaut — core dump + terminate, point. On fournit
sigaction pour la compatibilité source, pas pour rendre la main sur les fautes.
Ce n’est pas un manquement à « la libc fait ce qu’elle dit » : Air assume une
position POSIX fonctionnelle, non-conformante (SHOULD→MUST), et ce
comportement est documenté explicitement (transparence). Rationnel : fail-fast
- sécurité (aucun code utilisateur dans un contexte corrompu ; un handler de crash
est une surface d’exploitation). (Détail d’impl différé : si
sigaction(…, NULL, &old)doit refléter la disposition « posée » par l’appelant pour l’illusion d’API, ou rendreSIG_DFL; et le sort desigactionsur les signaux non-faute — émulation via signalfd + dispatch — reste une décision séparée, hors fautes.)
Conséquence assumée : une std/PAL bâtie sur couche 1 perd le message «\ stack
overflow » (qui repose sur un handler SIGSEGV+sigaltstack) → SIGSEGV+core
brut à la place. Acceptable.
3. Helper clé-en-main « bloquer dans tous les threads + drainer via signalfd »
Le pattern signalfd correct est délicat : il faut bloquer le set de
signaux dans tous les threads (sinon la délivrance asynchrone court-circuite le
fd — un signal process-directed part vers n’importe quel thread qui ne le bloque
pas), puis lire depuis le signalfd (souvent depuis un thread/section dédiée). La
couche 1 fournit ce pattern clé-en-main : l’appelant n’a pas à réimplémenter la
gymnastique masque-par-thread + drainage. C’est la valeur ajoutée « fait ce qu’il
dit » d’Air.
4. Pas de SA_RESTART forcé
EINTR est remonté à l’appelant (cohérent air-poll/AirFile, ADR-021
conv.2) : Air ne relance jamais silencieusement un syscall interrompu.
5. Dual-face
La face Rust/PAL penche signalfd (event-loop-friendly, sûre). La face libc
C (au-dessus de la couche 1) fournit sigaction/signal fidèles POSIX pour le
code C (OpenSSH…), honnêtes mais avec avertissements sur les pièges
async-signal-safety. Même brique couche 1, deux faces (motif dual-face habituel).
6. La couche 1 ne maintient aucun état de signal
Elle expose celui du noyau (kernel = bible). La couche 0 a déjà
signalfd/kill/tgkill/sigprocmask ; P0.4 re-expose en couche 1 : création
signalfd + drainage, masquage (sigprocmask/pthread_sigmask), envoi
(kill/tgkill, raise), sigsuspend, et le helper (§3) — aucun handler de
faute (§2). Le masque est par-thread, les dispositions par-process : le
helper gère cette asymétrie pour l’appelant.
7. Fautes synchrones — barrière de poison, handler interne minimal, voies SAFE (décision BDFL 2026-07-03)
Précision essentielle de §2 : « pas de handler de faute » vise le développeur.
Le runtime Air installe, lui, un handler interne — non exposé, prouvé
async-signal-safe, machinerie interne (au même titre que air-runtime::spawn ou le
TLS). Le développeur ne gagne aucun pouvoir : son sigaction sur les fautes
reste inerte (§2, face libc). Distinction : handler développeur (interdit) ≠
handler interne runtime (minimal, contrôlé).
Causes racines des 4 fautes (exceptions matérielles CPU → signal)
Le CPU trappe sur l’instruction fautive ; le noyau, s’il ne peut pas résoudre
(demand paging / COW sont résolus silencieusement), appelle force_sig_fault
(qui force la délivrance — d’où : une vraie faute est inignorable), avec
si_addr/si_code riches — capturés dans le core dump.
| Signal | Cause racine (noyau) | si_code typiques |
|---|---|---|
| SIGSEGV (11) | adresse non mappée (ptr nul/sauvage, UAF, débordement de pile) ; violation de permission (write en RO, exec de page NX, PROT_NONE) | SEGV_MAPERR/SEGV_ACCERR ; SEGV_PKUERR, SEGV_MTE* (arm64) |
| SIGBUS (7) | adresse valide mais accès physique impossible : non-aligné (arm64), mmap au-delà de l’EOF du fichier, erreur mémoire HW (ECC/MCE) | BUS_ADRALN, BUS_ADRERR, BUS_MCEERR_* |
| SIGFPE (8) | division entière par 0 (cas quasi-exclusif), INT_MIN/-1 ; exceptions FP si démasquées (masquées par défaut) | FPE_INTDIV, FPE_INTOVF, FPE_FLT* |
| SIGILL (4) | opcode invalide/indéfini (ud2 de unreachable/UB ; instruction non supportée), instruction privilégiée en user | ILL_ILLOPC, ILL_PRVOPC, … |
(À distinguer : SIGTRAP = débogage ; SIGABRT = auto-infligé par abort()/canari
__stack_chk_fail/panic=abort — pas des fautes matérielles.) Toutes ces
racines = un bug amont (sauf SIGBUS/MCE = panne HW) : le process est déjà
incohérent.
Principe : une barrière de poison (sortir au plus vite, ne rien corrompre)
Quand une de ces 4 fautes survient, le seul objectif est de filer au plus vite vers l’abort + core dump du noyau, sans plus rien toucher — pour (1) préserver l’état mémoire au moment de la faute (core dump fidèle = « la vraie situation » remise au développeur), (2) éviter une double faute, (3) jamais de retry ni de « pseudo-service » dans un espace corrompu. Le handler interne :
- Arme un flag atomique « faute fatale » (process-global +
Tidfautif + n° signal +si_addr) — un simple store, async-signal-safe. - Garde anti-ré-entrance : une faute pendant le handler →
_exitimmédiat. - Au plus une ligne de crash minimale (
writebrut : signal/adresse/thread ; backtrace best-effort, marqué non-fiable — la pile peut être corrompue). - Restaure
SIG_DFLet re-lève → core dump + terminate. « Core dump toujours » (§2) tient ; le handler ne fait que l’annoter, jamais l’empêcher.
Sémantique de barrière côté runtime : une fois le flag armé, le code Air honore
le poison — les primitives runtime (allocateur, I/O fichier, syscalls
d’état) entrées avec le flag armé refusent et filent vers l’abort : aucune
allocation, ouverture, lecture/écriture n’est empilée. « Dégradé » = suppression +
minimum vital, pas d’effort supplémentaire. (Rappel : SIG_DFL sur ces
signaux ne lance de toute façon ni atexit ni destructeurs — rien ne pollue
par défaut ; le flag garantit qu’on ne le fait pas non plus dans la fenêtre
multi-thread de teardown.)
Ce flag n’est pas un miroir de l’état de signal du noyau (§6) : c’est un verrou de mort à sens unique, dérivé, propre au runtime.
Voies SAFE offertes au développeur (« si le noyau le dit, on le dit aussi »)
Sans jamais exposer d’unsafe :
air_runtime::fault_state() -> Option<FaultInfo>: lecture sûre (atomic) de l’état (signal,si_addr,Tid) — un superviseur / un enfant post-forksait qu’une faute a eu lieu, sans handler.userfaultfd(2): pour les fautes de page récupérables (paging applicatif, write-barriers de GC, JIT) — un fd pollable (intégrableair-poll), à la place d’un handlerSIGSEGV. C’est la voie de récupération SAFE, fidèle à la doctrine fd-first.- La ligne de crash minimale (automatique).
Placement
air-runtime porte l’état de faute (process-global, il détient déjà errno/TLS/
atfork) ; air-thread fournit le Tid fautif. Implémentation additive
(le seam étant interne, non-ABI) — le handler interne + fault_state() peuvent
atterrir avant ou après le sceau.
Conséquences
- P0.4 livre une crate/module « face signal » couche 1 (nommage à l’impl) : signalfd
- drainage pollable, masque, envoi, helper « bloquer partout ». Aucun handler de
faute (fautes → action par défaut, §2) → zéro
unsafeexposé.
- drainage pollable, masque, envoi, helper « bloquer partout ». Aucun handler de
faute (fautes → action par défaut, §2) → zéro
- Le paramètre
signal_maskd’air-pollgagne son ergonomie de construction ici (le typeSignalMaskcouche 0, enrichi d’un constructeur couche 1). - La libc C (au-dessus) construit
sigaction/signalsur cette brique. - Zéro dépendance externe ;
no_stdrespecté (drainage =readsur un fd).
Alternatives rejetées
sigaction-first (modèle classique). Rejeté : async-signal-safety, non intégrable à l’event loop, réentrance ; c’est précisément ce qu’ADR-020 fuit.- Exposer un
sigactionde faute (même restreint) en couche 1 Rust. Rejeté (décision BDFL, §2) : il est irréductiblementunsafe, or la couche 1 Rust reste 100 % safe. Les fautes synchrones prennent l’action par défaut (terminate + core) ; toutsigactionpour du code C vit dans la face libc C-ABI au-dessus (régime FFI/unsafe assumé). - Laisser l’appelant gérer le pattern « bloquer partout ». Rejeté : trop d’erreurs subtiles (un thread non bloquant fait fuir un signal hors du fd) — la couche 1 doit offrir le pattern correct clé-en-main.
- Forcer
SA_RESTART. Rejeté (ADR-021 : EINTR remonté).
Suite
- Implémentation P0.4 (voir roadmap §5.3). Tests on-target (signaux réels,
multi-thread, fork) +
air-fault. Puis face Csigaction/signalau-dessus. - Barrière de poison (§7, additif) : handler interne minimal (
air-runtime+Tidd’air-thread), flag atomique de faute honoré par l’allocateur et l’I/O fichier,air_runtime::fault_state()(surface SAFE), et exposition deuserfaultfd(2)comme voie de récupération pollable. Tests on-target (faute réelle provoquée en enfant forké : SIGSEGV/SIGFPE/SIGILL/SIGBUS → flag armé + core dump).
ADR-065 — Descellement additif couche-1-v1.1 : AirRuntime::errno_location
Statut : Accepté (2026-07-03, autorisation BDFL — l’additif errno_location
et la délégation des re-sceaux additifs ont été approuvés explicitement). RFC de
structure (ADR-015). Applique le modèle de
re-sceau additif du sceau couche 1 (ADR-062),
précédent couche 0 ADR-051.
Catégorie : Descellement additif de la couche 1 scellée (couche-1-v1.0 →
couche-1-v1.1). Aucune rupture ; jalons antérieurs conservés.
Contexte
Le premier toit posé sur la couche 1 scellée — la libc C-ABI scopée OpenSSH
(roadmap docs/roadmap-libc-openssh-fr.md, jalon M0) — révèle, exactement comme
anticipé (ADR-062 : « la libc est le driver des additifs v1.x »), un premier
besoin non couvert : __errno_location() (le errno C-ABI) doit rendre l’adresse
stable, par-thread de l’errno que la couche 1 possède déjà (#[thread_local]
d’air-runtime), pour que la libc et le PAL partagent un seul canal errno
(zéro globale, vision Air).
Décision
Descellement additif couche-1-v1.1 : on ajoute à air-runtime (crate
scellée) :
AirRuntime::errno_location() -> *mut i32(adresse par-thread de l’errno) ;- la méthode support
location()dans les trois branches de stockage de l’errno ; thread_control_block::errno_location.
Purement additif : aucune signature publique existante n’est modifiée ; seule la
surface grandit. Host-testé (2 tests). Les jalons couche-1-v1.0 sont conservés
intégralement.
Gouvernance — délégation des re-sceaux additifs (BDFL, 2026-07-03)
Le BDFL a délégué au superviseur les tags de re-sceau additif-seulement
(option B) : tout descellement purement additif révélé par la production des toits
est traité par le superviseur (ADR de descellement + tag couche-1-v1.x), sans
autorisation au cas par cas. Seuls sont remontés au BDFL : les changements
non-additifs (rupture) et les compromis de sécurité. Cet ADR-065 et le tag
couche-1-v1.1 s’inscrivent sous cette délégation (l’additif lui-même a néanmoins été
approuvé explicitement, étant le premier).
Conséquences
- Tag git
couche-1-v1.1posé sur le commit de merge M0 (signé). air-runtimegagne une méthode ; les consommateurs decouche-1-v1.0restent compatibles (additif).- Précédent posé : les additifs futurs révélés par la libc →
couche-1-v1.2, etc., chacun avec son ADR de descellement, sous la délégation ci-dessus.
Alternatives rejetées
- Faire la libc lire l’
errnoen direct de la couche 0. Rejeté : la couche 1 est le seul consommateur de la couche 0 (doctrine) ; la libc passe par la couche 1. - Une globale
errnodédiée à la libc. Rejeté : zéro globale libc (vision Air) ; canalerrnounique partagé libc ⇄ PAL, per-thread.
Suite
- Tag
couche-1-v1.1après merge M0. Note de re-sceau dansINDEX.md+etat-avancement.md.
ADR-066 — Descellement additif couche-0 : rt_sigaction non-faute (async réel) + rt_sigpending + trampoline rt_sigreturn ; brique d’install de handler en couche 1
Statut : Accepté (2026-07-04, décision BDFL directe — délivrance async réelle
des signaux non-faute autorisée ; sous la délégation des re-sceaux additifs du
superviseur, ADR-065). RFC d’extension de
couches scellées (ADR-015). Tranche la question
laissée ouverte par ADR-051
(§ Périmètre reporté : « rt_sigaction/sigaltstack … nécessite une décision
vis-à-vis d’ADR-020 »). Companion et extension
d’ADR-064 (doctrine signaux couche 1) ; le
sort inerte des 4 fautes reste inchangé (ADR-064 §2). Conventions :
ADR-021.
Catégorie : Descellement additif de la couche 0 scellée (couche-0-v1.8 →
couche-0-v1.9) et additif de la couche 1 scellée (couche-1-v1.1 →
couche-1-v1.2). Aucune rupture ; jalons antérieurs conservés.
Contexte
Le toit libc C-ABI et le PAL std::sys (couche 2) ont besoin de sigaction
pour les signaux gérables (non-faute : SIGINT/SIGTERM/SIGCHLD/SIGWINCH/
SIGUSR1-2/temps-réel…). Or :
- La couche 0 n’expose
rt_sigactionque pour les 4 fautes, viasynchronous_handler— et inerte (ADR-020/ADR-064 : le noyau force l’action par défaut sur une faute). Aucune voie d’install réel pour un signal non-faute. - Sur x86_64 sans libc, un handler qui revient exige un
sa_restorer: à la fin du handler, le noyau saute à cette adresse, dont l’unique rôle est d’appelerrt_sigreturn(restaure le contexte pré-signal). La libc/glibc fournit ce trampoline ; Air, sans libc, doit le fournir lui-même. Sur aarch64, le retour passe par le VDSO — pas desa_restorer. sigpending(3)de la libc n’a pas de wrapper couche 0.
Décision BDFL (2026-07-04) : pour les signaux non-faute, la délivrance est
async réelle — rt_sigaction installé, le noyau appelle le handler C. Les 4
fautes (SIGSEGV/SIGBUS/SIGFPE/SIGILL) restent inertes → coredump
(ADR-064). Cette PR pose les fondations (couches 0 et 1) ; rien en couche 2
n’est écrit ici — elle prépare le terrain.
Décision
1. Additif couche 0 — air-sys-syscall::signal (+ types air-sys-types)
Re-ouverture additive de la couche 0 → re-sceau prévu couche-0-v1.9 :
- Sous-module
async_handler(à côté desynchronous_handler, qui n’est pas touché) :unsafe fn install(signal, handler_sa: usize, flags: SigActionFlags, mask: SignalMask) -> Result<PreviousDisposition, Errno>—handler_sa=SIG_DFL(0)/SIG_IGN(1)/adresse de handler ; une fonction dédiée typée (ADR-021 §3),Result/Option, EINTR remonté, sans alloc.unsafe fn restore(signal, previous) -> Result<(), Errno>.- Trampoline
rt_sigreturnfourni viacore::arch::global_asm!(mov $15,%rax; syscall) sur x86_64 uniquement :installpose alorsSA_RESTORER(0x0400_0000) +sa_restorer = &trampoline. Sur aarch64, aucunsa_restorer(retour VDSO). Bloc asm justifié// SAFETY:(jamais appelé depuis Rust ; c’est le noyau qui y saute,%rspsur le sigframe).
rt_sigpending() -> Result<SignalMask, Errno>(wrapper simple, 2 arches).- Types (
air-sys-types::signal::async_handler) :SigActionFlags(bitflags typé —SA_SIGINFO/SA_RESTART/SA_NOCLDSTOP/SA_NOCLDWAIT/… ;SA_RESTORERnon exposé, posé en interne) etPreviousDisposition(opaque 32 octets, même buffer ABI quePreviousHandlermais type distinct — étanchéité faute/non-faute).
API entièrement unsafe (contrat async-signal-safe porté par l’appelant, comme
synchronous_handler). Réservé aux signaux non-faute : router une faute ici est
proscrit — les fautes relèvent de synchronous_handler (inerte).
2. Additif couche 1 — air-signal (brique dual-face)
Re-ouverture additive de la couche 1 → re-sceau prévu couche-1-v1.2 :
- Module
handler:unsafe fn install_handler(signal, disposition, flags, mask) -> AirResult<PreviousDisposition>+unsafe fn restore_disposition(...), oùDisposition = Handler(fn-ptr) | Default | Ignore,flags: SigActionFlags(ré-exporté typé). C’est LA brique que la libc (sigaction) et le PAL consomment — jamais la couche 0 en direct (empilement strict ADR-052). pending() -> AirResult<SignalMask>(viart_sigpending).
Nuance vs ADR-064. ADR-064 §2/§5 écrivait « la couche 1 Rust n’expose AUCUN
handler ; tout sigaction vit dans la face libc au-dessus ». Cet ADR précise :
la brique d’installation de handler non-faute vit en couche 1 (air-signal,
unsafe localisé), la face libc C se contentant de l’habiller en C-ABI au-dessus.
La règle inchangée d’ADR-064 : aucun handler de FAUTE en couche 1 (les 4
fautes → défaut). C’est l’unique unsafe exposé d’air-signal ; tout le reste
(signalfd, masque, envoi, attente) demeure SAFE.
3. Additif couche 1 — air-thread : primitives futex(2) sur mémoire externe
Module raw_futex : fn futex_wait(word: &AtomicU32, expected, timeout: Option<AirDuration>, scope) -> AirResult<()> et fn futex_wake(word, count, scope) -> AirResult<u32> — ré-export typé (AirResult) de air_sys_syscall::futex.
AirMutex/AirRwLock possèdent leur mot ; la libc pthread_mutex_t/cond_t et
le PAL opèrent sur de la mémoire opaque fournie par l’appelant C : ces primitives
leur permettent de bâtir un objet de synchro sur un &AtomicU32 externe. API
SAFE (mot vivant), host-testable. Distinct de runtime_primitives (façade
Errno-brute TARGET-ONLY du join d’air-runtime).
Stratégie de test (100 %, sans exception nouvelle, ADR-035)
- Couche 0 — chemin déterministe via auto-
tgkill, dans le thread appelant (pas defork, donc couverture réelle — pas de CHILD-EXIT) : install un handler posant unAtomicBool→ auto-tgkill(self)sur SIGUSR1 débloqué → le noyau exécute le handler et en revient (sur x86_64, atteindre l’assert prouve le trampolinert_sigreturn) →restore. Sérialisation parMutex(la disposition est process-wide).SIG_IGN/SIG_DFL(round-trip), bras d’erreur (install(SIGKILL) → EINVAL),rt_sigpending(SIGUSR1 bloqué + généré → pendant, drainé viasignalfdavant restauration du masque). - Couche 1 —
air-signal:install_handler/restore_disposition(auto-raise, in-thread,Mutex),Default/Ignore,SIGKILL → InvalidInput,pending().air-thread:futex_wait(WouldBlock/TimedOut/négatif→sondage),futex_wake(0 sans waiter ; rendez-vous 2 threads sur un mot partagé).
Aucune entrée COVERAGE-EXCEPTIONS.md nouvelle n’est requise pour le corps des
wrappers (couverts en thread). Seul le décodage d’erreur reste couvert par les tests
d’errno existants ; les bras EFAULT-inatteignables suivent le précédent STRUCTURAL
de la famille.
Re-sceaux (tags posés par le superviseur, après validation 2 arches)
couche-0-v1.9(async_handler + rt_sigpending + trampoline). Validé sur x86 (barrière) ET aarch64 (CI) — vérification indépendante de tout asm/numéro (NRrt_sigaction13/134,rt_sigreturn15,rt_sigpending127/136) avant push, leçonc_char/x19.couche-1-v1.2(air-signalinstall_handler/pending; air-threadraw_futex) après validation couche 0.
Cet ADR ne crée aucun tag ; il note les re-sceaux planifiés (INDEX + etat-avancement). Sous la délégation d’ADR-065, ces re-sceaux additifs relèvent du superviseur ; l’additif async lui-même a été approuvé BDFL (délivrance async).
Conséquences
- Débloque la libc
sigaction/signalet les handlers non-faute du PAL, sans qu’ils touchent la couche 0 (empilement strict). - Réintroduit de l’asm minimal, audité
// SAFETY:: dispatchrt_sigaction/rt_sigpending(2 arches) + trampolinert_sigreturn(x86_64). air-signalgagne ununsafeexposé (install_handler), assumé et documenté ;air-threadgagne une surface SAFE (raw_futex).- Les 4 fautes restent inertes (ADR-064 §2) : aucune régression de doctrine.
Alternatives rejetées
signalfdpour tout, y compris les gérables (statu quo ADR-020). Insuffisant pour la compat source C : un programme C appellesigactionet attend un handler réellement appelé. La face libc l’exige.- Exposer l’install de handler seulement dans la libc (couche 2), en tapant la couche 0. Rejeté : viole l’empilement (couche 2 → couche 1 → couche 0). La brique d’install doit vivre en couche 1.
sigactionasync réel aussi pour les fautes. Rejeté (ADR-064 §2, décision BDFL) : fail-fast + sécurité ; les fautes → coredump.- Réutiliser
SA_RESTORERde glibc /wrfsbase. Sans objet (pas de libc) ; le trampoline Air est la voie portable correcte, commearch_prctlpour%fs(ADR-051). FUTEX_WAIT-based objets possédés uniquement (AirMutex). Insuffisant : la libc opère sur mémoire externe ; d’oùraw_futex.
Suite
- Validation couche 0 (x86 barrière + aarch64 CI) → tag
couche-0-v1.9; puis validation couche 1 → tagcouche-1-v1.2(superviseur). - Couche 2 (hors périmètre de cette PR) : la libc bâtit
sigaction/signalsurair-signal::install_handler, etpthread_mutex_t/cond_tsurair-thread::raw_futex. Note de re-sceau dansINDEX.md+etat-avancement.md.
ADR-067 — air-account (couche 1) : gestion des comptes (/etc/passwd + /etc/shadow + /etc/group), bindée par la libc
Statut : Accepté (2026-07-05, ratification BDFL). RFC de structure (ADR-015). Applique le modèle de re-sceau additif de la couche 1 (ADR-062) sous la délégation d’ADR-065.
Catégorie : Nouvelle crate couche 1 + descellement additif de la couche 1
scellée (couche-1-v1.2 → couche-1-v1.3). Aucune rupture d’ABI ni de signature
existante ; les jalons antérieurs sont conservés intégralement.
Contexte
Le principe fondateur (rappel BDFL, 2026-07-05)
Il n’y a pas une implémentation C et une implémentation Rust. Il y a une seule implémentation Rust — proposée en couche 1, réutilisable par des développeurs Rust via
air-base-lib— qui est ensuite bindée en libc C.
La libc (couche 2) ne porte aucune logique métier : elle n’est que la frontière
ABI C (disposition des struct, empaquetage de buffer, convention errno) posée sur
une brique couche 1. C’est le prolongement de la doctrine libc « une brique, deux
faces » (BDFL) : f() fidèle-POSIX + air_f() restituant le Result, toutes deux
au-dessus de la même brique couche 1.
Le défaut à corriger (M4 comptes, PR #228)
Le jalon M4 comptes (getpwnam_r/getpwuid_r, crate air-libc-pwd) a violé ce
principe : le parseur /etc/passwd a été écrit dans la crate libc. C’est une
logique métier (comprendre le format d’un fichier système) qui n’a rien de spécifique
au C et doit donc vivre en couche 1, réutilisable par tout code Rust — pas seulement
par la libc. La correction fait l’objet du présent ADR.
Le besoin réel
La cible OpenSSH exige une famille comptes complète : getpwnam/getpwuid
(mot de passe), getgrnam/getgrgid (groupes), getspnam (shadow), initgroups
(groupes supplémentaires d’un utilisateur). sshd s’en sert pour résoudre
l’utilisateur cible puis larguer ses privilèges (initgroups + setgroups +
setresgid/setresuid). Il faut donc une brique couche 1 qui comprenne les trois
bases de comptes, indépendamment de la libc.
Ce qui n’est PAS concerné (précision, contre une erreur d’analyse antérieure)
Les identifiants/privilèges (uid/gid) n’ont aucun manque couche 0 : air-sys- syscall::process expose déjà get_resuid/set_resuid/get_resgid/set_resgid
(triplets ResUid/ResGid typés) et get_groups/set_groups. Le seul manque de ce
côté est une surface fine couche 1 (à-la-carte), traité séparément (cf. § Suite),
et ne relève pas de cet ADR.
Décision
Une nouvelle crate couche 1 : air-account
Crate indépendante (crates/air-account), spécialisée dans les bases de comptes
système. Elle porte trois codecs et une couche d’accès.
(a) Codecs purs — zéro I/O, zéro unsafe, fuzzés (entrée = fichier système,
surface d’attaque au sens du Principe 3) ; decode et encode symétriques :
- codec
/etc/passwd→PasswdEntry { name, passwd, uid: Uid, gid: Gid, gecos, dir, shell }(7 champs,uid/gidvalidésu32checked). - codec
/etc/group→GroupEntry { name, passwd, gid: Gid, members: Vec<AirOsString> }(4 champs ; le 4ᵉ est une liste de membres séparés par,). - codec
/etc/shadow→ShadowEntry { name, hash, last_change, min, max, warn, inactive, expire }(9 champs ; sensible — cf. § Sécurité).
(b) Couche d’accès — lit les fichiers via air-filesystem (couche 1, lecture
bornée) et offre le lookup, en types riches (Result/Option lossless) :
lookup_user_by_name / lookup_user_by_uid -> AirResult<Option<PasswdEntry>>
lookup_group_by_name / lookup_group_by_gid -> AirResult<Option<GroupEntry>>
groups_of_user(name) -> AirResult<Vec<Gid>> // socle d'initgroups
shadow_by_name(name) -> AirResult<Option<ShadowEntry>> // root-only
Le graphe de dépendances
air-account (couche 1 ; deps : air-filesystem, air-sys-types, air-base-core)
├── importée par air-process (résolution user->uid/gid/groupes avant drop_privileges)
├── ré-exposée par air-base-lib (surface Rust de première classe, pour les devs)
└── bindée par air-libc-pwd / air-libc-grp / air-libc-shadow (couche 2, ABI C)
air-account ne dépend PAS d’air-process : c’est air-process qui l’importe
(sens imposé). Aucun cycle (air-filesystem ignore air-account).
La libc devient un pur binder
air-libc-pwd (et les futures air-libc-grp/air-libc-shadow) ne gardent que l’ABI C :
getpwnam_r(name, …) -> air_account::lookup_user_by_name(name)? -> PACK borné dans le buffer C
getgrnam_r -> lookup_group_by_name getspnam_r -> shadow_by_name
initgroups(user,gid)-> air_account::groups_of_user(user) + [gid] -> set_groups (couche 1 creds)
La face Air (dual-face) tombe gratuitement : air_getpwnam(...) -> code surfaçant
le Result, puisque air-account le calcule déjà.
air-base-lib surface air-account
air-base-lib ré-exporte l’API d’air-account afin qu’un développeur Rust dispose
de la gestion des comptes de première classe, sans passer par la libc C. C’est la
matérialisation du principe : une implémentation, deux consommateurs (dev Rust + libc).
Réciprocité avec air-process
air-process gagne un drop_to_user(name) : résout via air-account
(lookup_user_by_name + groups_of_user) puis construit un AirPrivilegeDrop et
appelle drop_privileges — exactement le geste de sshd. drop_privileges (orchestré,
vérifié, seccomp/Landlock) reste inchangé et recommandé.
Sécurité (/etc/shadow)
shadow_by_name lit des hachages de mot de passe. Postures obligatoires :
- Root-only : la lecture échoue
EACCESsans privilège (comportement kernel viaair-filesystem) — jamais de dégradation silencieuse. - Zéro fuite :
ShadowEntry.hashn’est jamais journalisé ni inclus dans unDisplay/Debugpar défaut (redaction) ; la comparaison de mot de passe (temps constant) n’appartient pas àair-account(relève deair-crypto). - Zéro copie durable : les tampons contenant le hachage sont zeroïsés après usage côté brique (pas de rémanence heap).
Ces points étant des considérations de sécurité, ils sont remontés au BDFL (la délégation d’ADR-065 réserve au BDFL les compromis de sécurité) ; le présent ADR les consigne pour ratification explicite.
Gouvernance
- Nouvelle crate couche 1 + surface additive → re-sceau
couche-1-v1.3(tag signé) au merge de l’implémentation. Purement additif : aucune signature scellée existante n’est modifiée ;air-process/air-base-libgrandissent seulement. - Sous la délégation d’ADR-065 (additifs révélés par les toits traités par le
superviseur), avec remontée explicite ici du volet sécurité
/etc/shadow. - Le refactor de #228 (déplacer le parseur
air-libc-pwd→air-account) est non-rupturant : l’ABI C (getpwnam_r/getpwuid_r,struct passwd, symbolesnm -D) reste identique ; seule l’implémentation interne bouge d’une couche.
Conséquences
- Création de
crates/air-account(codecs + accès), fuzz-cibles pour les 3 codecs. air-processimporteair-account(drop_to_user) ;air-base-lible ré-exporte.air-libc-pwdre-câblé en binder ; ajout des bindersair-libc-grp(groupes) etair-libc-shadow(shadow) sur la même brique.- Tag
couche-1-v1.3posé au merge (signé) ; note dansINDEX.md+etat-avancement.md. - Registre
docs/libc-conformance.md:getpwnam_r/getpwuid_rre-documentés « bind d’air-account» ; ajoutgetgrnam_r/getgrgid_r/getspnam_r/initgroups.
Alternatives rejetées
- Parseur dans la crate libc (ce que faisait #228). Rejeté : viole le principe « une impl Rust bindée en C » ; la logique n’est pas réutilisable par les devs Rust ; duplication à venir (pal, outillage).
- Loger la logique comptes dans
air-process. Rejeté :air-processgère l’exécution de processus ; la lecture des bases de comptes est une préoccupation distincte et réutilisable → crate dédiée qu’air-processimporte. - Modèle NSS (bases pluggables : LDAP, SSSD…). Différé :
air-accountv1 cible les fichiers (/etc/*) — le cas OpenSSH courant. Une abstractionAccountSource(fichiers | NSS | …) est un additif ultérieur, non requis maintenant (Principe 5 : pas de généralité spéculative). - Additif couche 0 pour les credentials uid/gid. Sans objet : la couche 0 les a
déjà (
get_resuid/set_resuid/… typés). Cf. § Contexte.
Suite
- Implémenter
air-account(3 codecs + accès + fuzz), puis refactor #228 en binder, puis bindersgrp/shadow+initgroups. - Surface fine credentials (
air-process::credentials:AirProcessCredentials:: current(),set_uids/set_gidsà-la-carte sur la couche 0) — ADR séparé (même famille OpenSSH, mais sujet distinct des bases de comptes). - Tag
couche-1-v1.3après merge. Note de re-sceau dansINDEX.md+etat-avancement.md.
ADR-068 — Surface fine credentials (uid/gid) en couche 1 (air-process), bindée par la libc
Statut : Accepté (2026-07-05, ratification BDFL — y compris la sémantique POSIX setuid/setgid du § dédié).
(ADR-015). Sous la délégation
d’ADR-065 (additifs révélés par les toits).
Catégorie : Descellement additif de la couche 1 scellée (couche-1-v1.3 →
couche-1-v1.4). Aucune rupture de signature existante.
Contexte
La séparation de privilèges d’OpenSSH exige la famille unistd/grp des
identifiants : getuid/geteuid/getgid/getegid (contrôles), setuid/setgid/
setresuid/setresgid (largage de privilèges), getgroups/setgroups,
initgroups. sshd démarre root, résout l’utilisateur (getpwnam, fait) puis
largue dans un ordre strict.
Ce que la couche 0 a DÉJÀ (aucun additif couche 0)
air-sys-syscall::process expose, en variantes modernes typées (ADR-021) :
get_resuid/set_resuid (triplet ResUid), get_resgid/set_resgid (ResGid),
get_groups/set_groups. Rien à ajouter en couche 0.
Le manque : une surface FINE en couche 1
air-process (couche 1) n’expose que drop_privileges (la séquence orchestrée
setgroups→setresgid→setresuid + vérification + seccomp/Landlock). Les briques
à-la-carte (lire l’identité courante, poser un seul triplet uid/gid, poser les
groupes) — les 1:1 dont la libc a besoin — ne sont pas exposées. C’est un manque
couche 1, pas couche 0.
Décision
Module air-process::credentials (couche 1)
Surface mince et typée sur la couche 0 (zéro réimplémentation) :
pub struct AirProcessCredentials { pub uid: ResUid, pub gid: ResGid }
impl AirProcessCredentials { pub fn current() -> AirResult<Self>; } // get_resuid + get_resgid
pub fn set_uids(new: ResUid) -> AirResult<()>; // = set_resuid
pub fn set_gids(new: ResGid) -> AirResult<()>; // = set_resgid
pub fn supplementary_groups(buf: &mut [Gid]) -> AirResult<&[Gid]>; // = get_groups
pub fn set_supplementary_groups(groups: &[Gid]) -> AirResult<()>; // = set_groups
drop_privileges reste la voie recommandée pour le largage réel (elle vérifie +
pose seccomp/Landlock) ; ces briques à-la-carte sont plus bas niveau, pour la
conformité libc.
Binders libc (couche 2)
- Getters (infaillibles) :
getuid→current().uid.real,geteuid→.effective,getgid/getegid(idemgid). setres*/groupes (explicites, non ambigus) :setresuid(r,e,s)→set_uids,setresgid→set_gids,getgroups/setgroups→les fns de groupes.initgroups(user, gid)=air_account::groups_of_user(user)+gid→set_supplementary_groups.
Sémantique POSIX setuid/setgid — REMONTÉE AU BDFL
setuid(uid) a une sémantique conditionnelle au privilège (source de bugs
sécurité historiques) :
- Privilégié (
euid == 0) : pose real + effective + saved =uid(largage irréversible — ce que veutsshd). - Non privilégié : pose uniquement l’effectif, et seulement si
uid ∈ {real, saved}; sinonEPERM.
Proposition : implémenter exactement cette sémantique au-dessus de set_uids
(idem setgid/setresgid). Comme c’est le point sécurité-sensible de cet ADR,
il est remonté : à ratifier avant implémentation de setuid/setgid (les
getters + setres* + groupes, eux, sont non ambigus et peuvent précéder).
Gouvernance
- Module
air-process::credentials= additif → re-sceaucouche-1-v1.4(signé) au merge.drop_privilegesinchangé. - Sous la délégation ADR-065 ; volet sécurité (
setuid/setgidprivilégié) remonté explicitement au BDFL.
Conséquences
air-processgagnecredentials(getters + set à-la-carte).- Binders libc :
getuid/geteuid/getgid/getegid/setresuid/setresgid/getgroups/setgroups(+setuid/setgidaprès ratification) +initgroups. - Tag
couche-1-v1.4; registredocs/libc-conformance.mdmis à jour.
Alternatives rejetées
- Faire la libc appeler
drop_privileges. Rejeté :drop_privilegesest une opération composée (tout ou rien, + seccomp/Landlock) ; la libc a besoin des primitives 1:1 POSIX, pas de l’orchestration. - Réimplémenter les syscalls uid/gid en couche 1/2. Sans objet : la couche 0 les
a déjà (
set_resuid/…), la couche 1 ne fait que les surfacer finement. - Additif couche 0. Sans objet (déjà présent).
Suite
- Après ratification :
air-process::credentials, binders libc,initgroups,air-process::drop_to_user(résout viaair-account+drop_privileges). Tagcouche-1-v1.4. Note de re-sceau dansINDEX.md+etat-avancement.md.
ADR-069 — Registre de handles fd-général (couche 1, air-handle) + moteur socket, façades PAL & libc
Statut : Accepté (2026-07-05, ratification BDFL).
Contexte de déclenchement : jalon M4 réseau de la libc C-ABI scopée OpenSSH. La question « comment la libc socket se pose-t-elle sur la couche 1 ? » a révélé un enjeu plus large que le réseau : la cohérence de l’état des descripteurs entre le code Rust et le code C au sein d’un même processus.
Contexte
La vision (rappel)
Le but d’Air n’est pas une énième libc (sinon musl suffirait) : c’est offrir au développeur C, sous la forme d’une libc standard, un moteur Rust robuste, sûr et observable — garanties qu’une libc « sans état » ne peut pas donner (détection de fuites de fd, couture de sécurité réseau, évolutivité des mécanismes internes sans re-concevoir la libc). L’état userland est donc le produit, pas un accident.
L’invariant dur : Rust et C cohabitent dans le même exécutable
On peut mixer Rust et C dans un même binaire. Il ne doit donc jamais exister deux
chemins — un « chemin Rust » (objets air-socket appelant la couche 0 en direct) et
un « chemin C » (passant par un moteur). Les deux mondes doivent avoir une vue
unique et synchronisée de l’état des descripteurs. Sinon : la couture de sécurité
devient contournable (il suffit d’écrire en Rust), et l’état diverge du kernel.
Ce qui existe aujourd’hui (analyse)
- Couche 0 (
air-sys-syscall::{net,fs}) : familles socket et duplication complètes, fd-centrées, typées. Duplication = deux primitives :fs::dup_fd(F_DUPFD_CLOEXEC, alloue le plus petit numéro libre) etprocess::dup3(cible un numéro exact, atomique). - Couche 1 : les objets (
AirFile,AirTcpSocket,AirUdpSocket,AirUnix*) appellent la couche 0 en direct et ne maintiennent AUCUN état userland (AirFile::duplicate=dup_fd+ wrap). Ils sont donc cohérents trivialement (rien de partagé ne peut diverger), mais sans moteur, sans registre, sans couture. - Libc (M2) :
dup/dup2/dup3+fcntl(F_DUPFD/F_DUPFD_CLOEXEC/F_GET/SET_FD/FL)— facades sans état sur la couche 0 (motif « shim C variadique + logique Rust mesurée » pourfcntl).
Pourquoi dup2/fcntl sont le point de rupture
Dès qu’on introduit un état userland (le registre), dup2(old, new) le stresse :
- Alias —
newetoldpointent la même description ouverte (mêmes status flags, même offset). Deux numéros, un objet. Un registre « fd → état indépendant » se trompe : il faut modéliser description partagée (status flags/offset) vs numéro de fd (FD_CLOEXECpar-fd). - Fermeture silencieuse de la cible — si
newétait enregistré,dup2le détruit atomiquement ; le registre doit évincer cette victime avant d’insérer l’alias, sinon fuite d’entrée et double-close.
fcntl rejoue le problème : fcntl(F_DUPFD) = duplication (même souci d’alias) ;
fcntl(F_SETFL, O_NONBLOCK) mute les status flags de la description → partagés
par tous les fd aliasés (donc à modéliser au niveau description, jamais caché par-fd).
Décision
1. Une nouvelle crate couche 1 : air-handle (registre de handles fd-général)
Un registre process-global unique (un static), clé = vrai fd kernel, plus bas
dans le graphe que tous les producteurs de fd (air-filesystem, air-socket,
air-process, air-poll en dépendent). Deps : couche 0 (air-sys-syscall,
air-sys-types) + air-base-core (AirError). no_std + alloc.
Modèle d’une entrée :
fd (RawFd) → HandleEntry {
description: Rc<OpenDescription>, // partagée entre alias (dup) — refcount userland miroir du kernel
kind: HandleKind, // File | Socket | Pipe | Adopted(external)
cloexec: bool, // PAR-FD (F_SETFD)
metadata / hooks… // provenance, politique
}
OpenDescription { owned_fd: OwnedFd (une seule fois par description), status flags, … }
- Le registre POSSÈDE l’
OwnedFd(D1). Il est l’unique propriétaire RAII : les façades (objets Rust, code C) ne tiennent que le numéro de fd + la responsabilité d’appelerclose. Fuite = entrée résiduelle → détectable (LIB_AIR_DEBUG). - Vrai fd kernel rendu au C (invariant d’interop :
poll/select/fcntl/SCM_RIGHTSl’exigent). Le registre enveloppe le fd kernel, il ne le remplace pas par un handle opaque.
2. Points de passage UNIQUES
Toute opération de cycle de vie passe par le registre : create (open/socket/pipe/
accept), duplicate, close, set_fd_flags (CLOEXEC, par-fd),
set_status_flags (par-description). Aucun producteur de fd de couche 1 n’appelle
plus la couche 0 en direct pour ces opérations.
3. Le duplicateur unique (D6)
duplicate(old_fd, target: Option<RawFd>, cloexec: bool) -> AirResult<RawFd>
target = None→dup_fd(plus petit numéro libre) ;target = Some(n)→dup3.- Si
targetdésignait un fd enregistré, évincer la victime d’abord (le kernel l’a fermée atomiquement). - Enregistrer le nouveau fd comme alias (même
Rc<OpenDescription>queold). - Les 5 portes C (
dup/dup2/dup3/fcntl(F_DUPFD)/fcntl(F_DUPFD_CLOEXEC)) et le Rust (AirFile::duplicate, etc.) convergent tous vers cette seule primitive.
4. Identité de description : best-effort (D2)
L’alias n’est connu que quand c’est nous qui dupliquons. Un fd adopté
(hérité du parent, 0/1/2, provenance externe) = description singleton
(adoption-à-l’usage). Limite honnête ; suffisante (aucune couche userspace ne peut
reconstruire les alias qu’elle n’a pas créés).
5. Re-domiciliation TOTALE dès le lot fondateur (D5)
Tous les objets couche 1 producteurs de fd — AirFile, pipes, AirTcpSocket,
AirTcpListener, AirUdpSocket, AirUnixStream/Listener/Datagram — sont
re-domiciliés sur le registre : ils délèguent création/duplication/fermeture au
registre. API publique préservée (refactor interne à comportement constant).
L’adoption-à-l’usage ne sert que pour les fd vraiment externes.
6. Le moteur socket = spécialisation politique par-dessus le registre
AirSocketEngine n’est pas un acteur parallèle : c’est une couche de politique
au-dessus du registre pour les entrées kind = Socket (hooks connect/bind/send,
câblage du résolveur AirNameResolver — réutilisé tel quel pour getaddrinfo).
Consommé par deux façades : les objets Rust (AirTcpSocket… pour le PAL/devs Rust)
et la libc C. Une impl, deux faces (doctrine ADR-068/dual-face).
7. Concurrence & no_std (D3/D4)
- Registre partagé entre threads et langages → un
Mutex(verrou couche bas-niveau : futex couche 0 ou verrou interne, sans cycle versair-thread). Shardage après mesure (Principe 5). (Différent de l’errno, qui est thread-local : la vue des handles doit être commune.) no_std+alloc: map allouée + verrou. Assainir au passage la featurestdd’air-sys-typesdansair-socket(aujourd’hui activée inconditionnellement → tirestddans le graphe) pour garder le chemin de productionno_std-propre on-target.
8. La libc reste une façade mince
Les shims C-ABI (socket/bind/connect/…/dup2/fcntl/close) routent vers le
registre/moteur ; la logique irréductiblement C (parsing du blob sockaddr,
inet_pton, byte-order) vit dans une crate logique mesurée + fuzzée
(air-libc-socket). Les dup/dup2/dup3/fcntl/close déjà livrés (M2) sont
re-pointés vers le registre.
Gouvernance
- Descellement ADDITIF de la couche 1 (délégation ADR-065 : additifs révélés par les
toits libc) → re-sceau
couche-1-v1.x. Ajout de la crateair-handle; refactor à comportement préservé (API publique inchangée) des crates fd de couche 1 scellées. - ADR ratifié BDFL AVANT tout code (ADR-015 : conception structurante par RFC, jamais par raccourci dans le code).
- Périmètre borné OpenSSH ; élargissements (verrous
fcntl, options socket rares, drapeauxAI_*exotiques) = additifs ultérieurs documentés.
Conséquences
Positives :
- Vue unique Rust/C garantie par construction (un registre, clé = fd kernel, un seul
chemin ;
dup2/fcntlcohérents via description partagée + éviction). - Couture de sécurité réseau : tout trafic socket passe par les hooks du moteur → politiques (destinations, filtrage, quotas) ajoutables sans toucher la libc.
- Robustesse/observabilité : propriété RAII centralisée → détection de fuites et de
double-close (
LIB_AIR_DEBUG), inventaire des handles vivants. - Évolutivité : le registre/moteur est l’interface stable ; ses internes évoluent librement derrière les façades.
Coûts / risques :
- Refactor large de crates couche 1 scellées (toutes les faces fd re-domiciliées).
- Verrou global du registre sur le chemin des ops fd (mitigé : ops non ultra-chaudes ; shardage après mesure).
- Limite irréductible : du code faisant des syscalls bruts (contournant objets ET libc) reste invisible jusqu’à l’adoption-à-l’usage — l’isolation dure reste seccomp/Landlock, pas le registre.
Alternatives rejetées
- Libc → couche 0 en direct : le plus court, mais aucune couture de sécurité, casse « libc via couche 1 », deux chemins.
- Registre socket-only :
dup2/fcntlopèrent sur tous les fd → deux régimes (sockets suivis, fichiers non), cas mixtes ambigus. Rejeté au profit du fd-général. - Handle synthétique (int opaque = clé de table) : casse l’interop C
(
poll/select/SCM_RIGHTS) → trahit « libc C standard ». - Libc sans état : c’est musl ; nie la valeur d’Air.
Suite (séquencement des lots)
air-handle: registre + duplicateur + close + adoption + hygiènestd; re-domiciliation de tous les objets fd couche 1 ; re-pointagedup/dup2/dup3/ fcntl/closelibc. → re-sceau couche 1.AirSocketEngine: politique + ops fines socket, sur le registre.air-libc-socket:sockaddr/inet_pton/byte-order (mesuré + fuzzé).- Shims socket bruts → moteur.
getaddrinfo/getnameinfo→ résolveur.poll/select.
ADR-070 — Descellement additif couche-0 : codec sockaddr public pour la face libc (socket_addr_from_bytes / socket_addr_to_bytes)
Statut : Accepté (2026-07-06, décision BDFL — exposition du codec sockaddr
existant plutôt que duplication côté libc ; sous la délégation des re-sceaux
additifs du superviseur, ADR-065, et
dans le cadre du descellement couche 0 pour la libc
ADR-051). RFC d’extension de couches
scellées (ADR-015). Sert l’implémentation du
jalon M4 réseau (ADR-069,
Lot 2). Conventions : ADR-021.
Catégorie : Descellement additif de la couche 0 scellée (couche-0-v1.9 →
couche-0-v1.10). Aucune rupture ; aucun symbole existant modifié ; jalons antérieurs
conservés. Tag couche-0-v1.10 DIFFÉRÉ (groupé avec le re-sceau couche-1-v1.6,
décision d’ordonnancement BDFL).
Contexte
La libc C-ABI d’Air doit convertir, aux frontières bind/connect/getsockname/
getpeername/accept, entre le struct sockaddr brut que présente un programme C
(octets + socklen_t) et le SocketAddr typé qu’utilisent le moteur socket
(ADR-069) et la couche 0.
Or la couche 0 possède déjà ce codec, dans les deux sens, mais en pub(crate) :
socket_addr_to_raw(&SocketAddr) -> (RawSockaddrStorage, u32)(sérialisation) ;raw_to_socket_addr(&[u8], u32) -> Option<SocketAddr>(désérialisation, validante : famille connue + longueur suffisante).
Deux options se présentaient (cf. décision BDFL) :
- Réimplémenter le layout
sockaddr_in/sockaddr_in6/sockaddr_undans la libc (air-libc-socket) — dupliquerait la connaissance du layout kernel, avec un risque de divergence à maintenir en phase. - Exposer le codec couche 0 existant — une seule autorité de layout (doctrine kernel = bible), zéro duplication.
Décision
Option 2. La couche 0 expose deux fines fonctions publiques, qui délèguent aux
codecs internes inchangés (aucune logique nouvelle, aucun unsafe) :
#![allow(unused)]
fn main() {
// crates/air-sys-syscall/src/net.rs
pub fn socket_addr_from_bytes(bytes: &[u8], length: u32) -> Option<SocketAddr>;
pub fn socket_addr_to_bytes(address: &SocketAddr, out: &mut [u8]) -> Option<u32>;
}
socket_addr_from_bytes= exposition deraw_to_socket_addr(le sens hostile : octets non fiables venus du C → adresse typée, validée ouNone).socket_addr_to_bytes= exposition desocket_addr_to_rawsans fuiter le type de stockage interne (RawSockaddrStoragerestepub(crate)) : copie bornée dans le buffer de l’appelant, rend lasocklen_teffective ouNonesi le buffer est trop court.
Les shims C bind/connect/getsockname/… (Lot 3) appellent ces fonctions
directement ; il n’y a pas de couche de re-hébergement du codec dans
air-libc-socket (qui ne porte que inet_pton/inet_ntop + byte-order, Lot 2.a).
Conséquences
- Positif : une seule implémentation du layout
sockaddr, testée et fuzzée (fuzz_air_sockaddr_decode— octets +socklen_tarbitraires, invariants no-panic + round-trip IP). Couverture 100 % des deux fonctions. - ABI : purement additif — les symboles
air-stableexistants sont intacts ; aucune garantie ABI n’est rompue. Re-sceaucouche-0-v1.10(tag différé). - Limitation connue (héritée, hors périmètre) :
raw_to_socket_addraccepte un nom Unix abstrait plus long quesun_path(107 octets) quesocket_addr_to_rawborne — asymétrie pré-existante du codec couche 0 (déjà employée pargetsockname/getpeername). Le fuzz restreint donc son invariant de round-trip aux familles IP (layout fixe). Durcissement éventuel = descellement additif ultérieur.
Alternatives rejetées
- Réimplémenter le codec dans
air-libc-socket: duplication du layout kernel, divergence possible (rejetée — cf. Contexte option 1). - Rendre
RawSockaddrStoragepublic : fuiterait un détail de représentation ([u8; 128]aligné) dans la surface stable, sans bénéfice — l’API par tranche (&mut [u8]) est plus sûre et suffit à la libc.
ADR-071 — Descellement additif couche-0 : setsockopt/getsockopt à valeur entière publics pour la face libc
Statut : Accepté (2026-07-07, décision BDFL — exposer les helpers d’options entières existants plutôt que les redéfinir côté libc ; sous la délégation des re-sceaux additifs du superviseur, ADR-065, cadre libc ADR-051). RFC d’extension de couches scellées (ADR-015). Sert le jalon M4 réseau (ADR-069, Lot 3.d). Compagnon d’ADR-070.
Catégorie : Descellement additif de la couche 0 scellée. Aucun symbole existant
modifié. Tag de re-sceau couche-0-v1.10 DIFFÉRÉ et GROUPÉ avec ADR-070 (les deux
descellements couche 0 du jalon M4 sont scellés ensemble) et le re-sceau
couche-1-v1.6.
Contexte
Les shims libc setsockopt/getsockopt doivent poser/lire les options de socket que
bind/connect/OpenSSH utilisent (SO_REUSEADDR, TCP_NODELAY, SO_KEEPALIVE,
SO_ERROR, IP_TOS, IPV6_V6ONLY…). La quasi-totalité de ces options sont des
valeurs entières (optlen == 4).
La couche 0 possède déjà les helpers getsockopt_i32/setsockopt_i32 (au-dessus des
syscalls bruts), mais en pub(crate) — consommés par les setters typés
(set_so_reuseaddr, set_tcp_nodelay…). La face libc a besoin d’un accès générique
par level/optname bruts (le C fournit ces entiers).
Décision
La couche 0 expose deux fines fonctions publiques déléguant aux helpers internes inchangés :
#![allow(unused)]
fn main() {
// crates/air-sys-syscall/src/net.rs
pub fn set_socket_option_int(sock: BorrowedFd, level: i32, optname: i32, value: i32) -> Result<(), Errno>;
pub fn get_socket_option_int(sock: BorrowedFd, level: i32, optname: i32) -> Result<i32, Errno>;
}
level/optname sont les entiers bruts du C (validés par le kernel). La libc
(air-libc-socketio::sockopt) les appelle et rend la sémantique POSIX
(setsockopt/getsockopt).
Conséquences
- Positif : une seule implémentation du chemin
getsockopt/setsockopt(kernel = bible) ; couverture 100 % des deux fonctions. - ABI : purement additif ; re-sceau
couche-0-v1.10(tag différé, groupé ADR-070). - Limitation (D-M4.1) : les options à structure (
SO_LINGER,SO_RCVTIMEO…) ne sont pas exposées ici ; les shims libc rendentEINVALpouroptlen != 4. Élargissement (variante par octets) = descellement additif ultérieur si un consommateur l’exige. Décision guidée par le scope OpenSSH (options entières).
Alternatives rejetées
- Exposer une variante générique par octets (
&[u8]) : plus large, mais introduit une conversion de longueurusize → u32au bras d’erreur inatteignable (une option > 4 Gio n’existe pas), nuisant à la couverture 100 % de la couche scellée, sans bénéfice pour le scope M4. Reporté jusqu’à un besoin réel. - Réimplémenter le chemin d’option dans la libc : dupliquerait l’appel syscall brut ; rejeté (une seule autorité, cf. ADR-070).
ADR-072 — CI : x86_64 (speedy) runner primaire ; aarch64 en cross-check + natif sur main (révise ADR-037)
Statut : Accepté (2026-07-07, décision BDFL directe). Révise ADR-037 (« x86 sorti de la CI, aarch64 seul runner »). Sert l’invariant tier-1 x86_64 ET aarch64 (ADR-014). Compose avec le filtrage de couverture ADR-036.
Contexte
ADR-037 avait fait du raspi-srv-2 (aarch64) le seul runner CI, x86_64 étant validé
hors-CI par la barrière pré-merge. À l’usage, le Pi est lent : l’étape llvm-cov
FULL prend ~25-30 min, et chaque lot de la campagne libc (qui ajoute une crate ⇒ touche
Cargo.toml/Cargo.lock racine ⇒ déclenche le chemin FULL d’ADR-036) paie ce coût.
La CI de PR est devenue le frein principal de l’itération.
Or le code est full Rust : les seules différences x86 ↔ aarch64 sont (1) les
numéros de syscall (couche 0, scellée — aucune évolution dans la campagne libc) et
(2) la signedness de char (c_char = i8 x86 / u8 aarch64), différence
compile-time qui a causé des bugs FFI récurrents dans la libc. La première est un
risque runtime rare ; la seconde un risque compile fréquent.
Décision
x86_64 (speedy, self-hosted, déjà enregistré) redevient le runner CI primaire. La
CI de PR (fmt/clippy/test + couverture conditionnelle ADR-036, supply-chain,
loom) tourne sur air-x86_64. L’invariant aarch64 est tenu par deux filets :
cross-check-aarch64(sur speedy, chaque PR) :cargo check --target aarch64-unknown-linux-gnu --workspace --all-targets— filet compile-time (signednessc_char, layouts, signatures FFI), ~1 min, sans linker.aarch64-native(sur raspi, hors PR :push main/ hebdo / scellement) :fmt/clippy/cargo test --workspacenatifs — filet runtime (numéros de syscall, comportement réel), garant qu’aucune régression aarch64 n’atteintmain.
La barrière x86 pré-merge (cargo xtask barrier) reste le garde-fou local. La
couverture est mesurée sur x86_64 (les planchers 96 %/78 % y sont tenus, cf. barrière).
Conséquences
- CI de PR : ~30 min → ~2-5 min. Le Pi ne bride plus l’itération.
- Filet
c_charconservé (le bug le plus fréquent) sur chaque PR, à coût quasi nul, sans le Pi. - aarch64 natif toujours vérifié avant qu’un état n’atteigne
main(garde-fou (b) d’ADR-036 transposé) et au scellement des couches. - ADR-037 révisé : le choix « aarch64 seul runner » est remplacé ; speedy est réactivé (il était resté enregistré « pour l’ouverture publique »).
- Limite acceptée : une régression runtime aarch64 introduite par une PR n’est
détectée qu’au merge
main(jobaarch64-native), pas sur la PR. Acceptable : la couche 0 (syscalls) est scellée et ne bouge quasiment plus ; tout descellement couche 0 reste validé nativement (il déclenchemain/scellement).
Alternatives rejetées
- Garder aarch64 seul runner (statu quo ADR-037) : le frein d’itération est réel.
- Runners GitHub-hosted : pas de toolchain pinné ni d’aarch64 natif fiable (raison d’être du self-hosted, ADR-037/ADR-014 inchangée).
- Cross-check aarch64 SANS validation native : perdrait le filet runtime (syscalls) ;
on le garde sur
main/scellement.
ADR-073 — Doctrine de configuration binaire : pas de conf en texte clair, CLI dédié exigeant le mot de passe administrateur
Statut : Accepté (2026-07-07, décision BDFL directe). Doctrine transverse à tous
les composants Air porteurs de configuration. S’appuie sur la Charte (valeurs) et les
Principes d’ingénierie (méthode). Premier consommateur :
ADR-074 (air-sshd). Rime avec la posture
« prévisible / fait ce qu’il dit » et « sur-sécuriser puis dégraisser » (Principe 5).
Catégorie : Doctrine de sécurité transverse (nouvelle). N’altère aucune couche scellée ; cadre les composants à configuration présents et futurs.
Contexte — le modèle de menace
Les démons et outils Unix classiques se configurent par des fichiers texte en clair
(~/.ssh/config, /etc/ssh/sshd_config, /etc/…). Ces fichiers sont, par
construction, lisibles et modifiables par quiconque possède les droits du
propriétaire — c’est-à-dire par un attaquant qui a pris le shell d’un utilisateur.
Le scénario qui nous préoccupe :
- Un attaquant obtient le shell d’un utilisateur (phishing, exploit applicatif, clé
compromise…). Il lit immédiatement
~/.ssh/config(hôtes, options, agents,ProxyCommand…) — renseignement gratuit. - Il modifie cette conf (ajout d’un
ProxyCommand, d’unLocalForward, d’une directiveInclude…) pour détourner ou intercepter les connexions futures. - Si le shell dispose d’un
sudopermissif, il réécrit la conf du démon (sshd_config:PermitRootLogin,AuthorizedKeysCommand,ForceCommand…) → compromission durable de la machine.
Le fichier de configuration en clair est la surface d’attaque. Un simple éditeur suffit ; aucune compétence, aucun outil, aucune ré-authentification n’est requis.
Décision
Tout composant Air porteur de configuration utilise un fichier de configuration BINAIRE, jamais du texte en clair. Trois règles :
-
Format binaire. La configuration effective d’un composant Air est un fichier binaire (schéma versionné, cf. les schémas capn’proto déjà employés ailleurs dans Air). Il n’est ni lisible ni modifiable avec un éditeur de texte.
-
Manipulation par un CLI dédié, sous ré-authentification. La configuration ne se modifie que via un outil en ligne de commande dédié qui exige la ressaisie du mot de passe administrateur de la machine avant d’appliquer un changement. La possession du shell (voire d’un
sudopermissif) ne suffit plus à réécrire la conf : il faut un secret que l’attaquant n’a pas et une action explicite. (Le mécanisme d’autorisation exact — dérivation, stockage, vérification du secret administrateur — est hors périmètre de cet ADR et fera l’objet d’un RFC dédié.) -
Compatibilité par sérialiseur / désérialiseur. Quand l’interopérabilité l’exige (importer une conf tierce, exporter pour un outil externe), le CLI fournit un sérialiseur (import : texte tiers → binaire Air) et un désérialiseur (export : binaire Air → texte). Le texte n’est alors qu’un format d’échange transitoire, jamais la source de vérité vivante du composant.
Objectif assumé — retarder, pas rendre impossible
Le but est fonctionnel : retarder et complexifier la capacité d’un attaquant à modifier une configuration pour compromettre la machine — défense en profondeur, pas prétention à l’inviolabilité absolue. Un attaquant qui obtient aussi le mot de passe administrateur et l’outil peut encore agir ; mais on supprime la modification triviale par simple accès au système de fichiers, qui est aujourd’hui le mode opératoire par défaut. On élève le coût, on réduit la surface, on force une action authentifiée et traçable.
Conséquences
- Positif : la conf n’est plus modifiable par simple accès au FS ; l’attaquant doit franchir une ré-authentification administrateur ; le renseignement passif (lecture de la conf en clair) est supprimé ; les changements passent par un point unique outillable (audit, journalisation, validation de schéma).
- Chaque composant à conf doit fournir : un schéma binaire versionné, un codec (lecture au démarrage), et l’import/export texte de compatibilité.
- Dépendance : le mécanisme d’autorisation administrateur (règle 2) reste à spécifier (RFC) ; les composants peuvent démarrer par le format binaire + codec + import/export, l’autorisation venant s’y brancher.
- Ergonomie : éditer une conf devient une commande explicite, pas un
vim. C’est un choix assumé (rigueur > commodité, posture Air). - Cohérence : renforce la valeur « composant prévisible qui fait ce qu’il dit » — la conf effective est exactement celle validée par le CLI, pas un texte éditable à l’insu du composant.
Alternatives rejetées
- Statu quo (conf texte en clair) : c’est précisément la surface d’attaque à éliminer.
- Conf texte chiffrée au repos : une fois déchiffrée pour lecture, elle redevient éditable ; et la gestion de clé de déchiffrement recrée le problème. Le binaire + CLI + ré-auth adresse la modification, pas seulement la confidentialité au repos.
- Restreindre par ACL / permissions seules : n’arrête pas le propriétaire du
shell (ni un
sudopermissif). Orthogonal, insuffisant seul. - Signer la conf texte : détecte la falsification mais ne l’empêche pas, et complexifie sans supprimer l’éditabilité triviale.
ADR-074 — Vision air-sshd : démon SSH natif d’Air, wire-compatible OpenSSH mais pas opensshd (async io_uring, conf binaire, profil algorithmique moderne)
Statut : Accepté (2026-07-07, décision BDFL directe — vision cadrante ; le détail d’implémentation viendra par sous-ADR/RFC au fil des phases). S’appuie sur la Charte, les Principes d’ingénierie, le périmètre de connectivité tier-1 (ssh), et ADR-073 (configuration binaire). Consomme le socle réseau couche 1 d’ADR-069.
Catégorie : Vision d’un composant (couche 2+). Cadre un objectif majeur ; n’altère aucune couche scellée.
Contexte — pourquoi ne pas simplement porter OpenSSH
L’objectif intermédiaire « compiler OpenSSH sur Air » a été une fonction de forçage :
il a servi à révéler et prioriser ce qui manquait fonctionnellement en couche 1 pour
un vrai démon réseau (registre de handles, moteur socket, poll/select, résolution de
noms). Cet objectif est atteint — mais porter OpenSSH n’est pas la fin visée.
OpenSSH, tel qu’implémenté, présente des défauts que la posture Air veut éliminer :
- Sa configuration est en texte clair (
~/.ssh/config,sshd_config) — surface d’attaque directe (cf. le modèle de menace d’ADR-073 : lecture/modification triviale par un shell compromis, réécriture de la conf du démon soussudopermissif). - Sa base de code C historique porte tout un héritage d’algorithmes legacy (RSA-sha1,
diffie-hellman-group1, modes CBC, etc.) que la charte Air veut ne pas faire cohabiter avec le moderne. - Le porter impliquerait la façade libc C et une
libcryptoC-ABI — coûteux, et orthogonal à l’objectif.
Décision
Air fournit son propre démon SSH, air-sshd : compatible OpenSSH « on the wire »
(protocole SSH-2, RFC 4251-4254, de sorte qu’un client ssh standard s’y connecte)
mais PAS opensshd — une réimplémentation from-scratch en Rust, avec quatre partis
pris :
-
Asynchrone, sur le socle Air.
air-sshdest bâti sur la runloop + io_uring d’Air (couche 0 Temps 2b réseau) et le socle couche 1 (air-socket/air-handle/air-poll/air-runtime). Pas de thread-par-connexion bloquant : boucle d’événements. -
Configuration binaire (ADR-073).
air-sshdlit sa configuration depuis un fichier binaire, manipulé par un CLI dédié sous ré-authentification administrateur. La compatibilitésshd_configtexte, si fournie, passe par import/export — jamais par une conf texte vivante. -
Crypto native Rust, directe.
air-sshdutilise l’API Rust d’air-cryptodirectement — aucunelibcryptoC-ABI, aucune façade libc. Le socle crypto est celui d’Air, sûr et audité côté Rust. -
Profil algorithmique moderne UNIQUEMENT — sûr par construction.
air-sshdn’annonce et n’accepte que les algorithmes modernes, et refuse explicitement le legacy (pas de RSA-sha1, pas dediffie-hellman-group1, pas de CBC-EtM). Cible initiale, entièrement couverte parair-cryptoaujourd’hui :- KEX :
curve25519-sha256(X25519 + SHA-256). - Clé d’hôte / signature :
ssh-ed25519(Ed25519). - Chiffrement authentifié :
chacha20-poly1305@openssh.cometaes256-gcm@openssh.com. - MAC (pour les modes non-AEAD) :
hmac-sha2-256/512.
Le support RSA / ECDSA-NIST / DH-group est une extension de compatibilité client éventuelle et assumée comme non-souhaitée — pas un prérequis. « Le propre et le legacy ne cohabitent pas » (Charte).
- KEX :
Conséquences
air-sshdn’a besoin ni de la façade libc C ni d’unelibcryptoC-ABI. Ces deux chantiers (les 55 manques libc, les ~120 symboles libcrypto révélés par la sonde OpenSSH) appartiennent à la piste distincte « compiler du C contre Air / clang air » et sont différés — ils ne sont pas sur le chemin critique d’air-sshd.- Le socle réseau couche 1 (ADR-069) est exactement ce que
air-sshdconsomme — le travail de la campagne réseau reste pleinement investi. Les shims socket libc restent un sous-produit utile à la piste C-toolchain. - Compatibilité = « on the wire », pas la conf. Un client
sshmoderne se connecte àair-sshd; en revanche la configuration est propriétaire/binaire (non compatiblesshd_config, mais importable/exportable — ADR-073). C’est un choix de sécurité assumé. - Découpage en phases (chacune un sous-ADR/RFC + jalon) :
- Couche transport SSH-2 : échange d’identification (
SSH-2.0-…),KEXINIT(négociation, n’annonçant que le moderne), KEXcurve25519-sha256, dérivation de clés, protocole de paquets binaire chiffré (ChaCha20-Poly1305), le tout async. ssh-userauth: authentificationpublickeyEd25519 d’abord.ssh-connection: canaux,pty-req/shell/exec, redirections.
- Couche transport SSH-2 : échange d’identification (
- Provenance d’exécution :
air-sshds’inscrit dans la doctrine « exécution par provenance » d’Air (le shell servi, les binaires lancés) — à articuler au fil des phases.
Alternatives rejetées
- Porter/compiler OpenSSH : conserve la surface d’attaque de la conf texte, tire la
dépendance C +
libcrypto, et impose l’héritage algorithmique legacy. C’était une fonction de forçage, pas la cible. - Enrober opensshd (wrapper) : reste opensshd, avec ses partis pris de conception.
- Supporter les algorithmes legacy pour une compat client maximale : affaiblit la posture de sécurité ; contraire à la Charte. La compat legacy, si un jour nécessaire, sera un ajout explicite et délimité, jamais le défaut.
ADR-075 — Fourniture du PAL : std::sys::pal::air via rust-src patché + build-std, puis upstream Tier-3
Statut : Accepté (2026-07-07, décision BDFL directe). Mise en œuvre des phases 3-4 du
programme *-linux-air (ADR-049 /
ADR-050 / ADR-052).
S’appuie sur l’allocateur (ADR-056) et l’audit
face-PAL (docs/notes/audit-face-pal-cloture-fr.md). Détaillé côté exécution par la note
docs/notes/roadmap-pal-cloture-fr.md.
⚠️ AMENDÉ par ADR-076 (2026-07-08). Le portage a montré que
std(cible unix/linux) EXIGE une libc (os::unixen est indissociable). Option A retenue : plus de palairpur ;stdréutilise le palunix+ la libc d’Air (libair) fournit les symboles (zéro glibc). Cette ADR reste valable pour le principe « rust-src reproductible », mais le pal custom est abandonné.
Catégorie : Décision d’outillage / toit au-dessus des couches scellées. N’altère aucune API couche 0/1 scellée (le PAL consomme la couche 1 ; les manques se comblent en additif, ADR-051).
Contexte
Le PAL (Platform Abstraction Layer) = le backend std::sys qu’une toolchain
*-linux-air doit fournir pour que le std Rust compile et tourne nativement sur Air,
sans tirer la glibc dans nos binaires et nos tests.
Le bring-up sous std est fait et prouvé 2 arches : _start/self-reloc static-PIE,
TLS/errno/TCB, spawn clone3, allocateur réel air-alloc (ADR-052/056). L’audit
face-PAL conclut à zéro gap fondateur : chaque primitive std::sys a sa brique
couche 1 (fs AirFile, net AirTcp/Udp/Unix, process, thread, time, env, args, stdio,
path, alloc, TLS), et les 10 crates que le PAL compose sont #![no_std].
Reste la question de mise en œuvre : comment fournir concrètement le backend std::sys
pour air ? Rust n’offre pas de mécanisme stable de « pal enfichable » : -Z build-std
reconstruit std depuis les sources rustc ; ajouter un backend air implique donc de
modifier ces sources.
Décision
On fournit le PAL en maintenant un rust-src patché — un fork minimal du sous-arbre
library/std de rustc qui ajoute un backend std::sys::pal::air — construit via
-Z build-std=std sur le nightly épinglé. Quatre principes :
-
Le PAL COMPOSE la couche 1, il ne re-syscall pas.
std::sys::pal::airmappe chaque primitive sur la brique couche 1 correspondante (air_runtime,air_alloc,air_thread,air_filesystem::AirFile,air_socket,air_process,air_env,air_stdio,air_base_core). Il ne réimplémente rien et n’appelle jamais la couche 0 en direct (ADR-052 : seule la couche 1 consomme la couche 0). Aucune libc, aucune FFI. -
Hors-arbre jusqu’à la démo, puis upstream Tier-3. Le patchset
std+ les specs de cible vivent dansrt/(workspace nightly, hors barrière stable — ADR-037/049) jusqu’à ce questdbootstrappe et tourne sur 2 arches. On enregistre alors{x86_64,aarch64}-unknown-linux-aircomme cibles rustc Tier-3 (compile, sans garantie de test automatisé côté projet Rust), ce qui ouvre à termerustup target add. -
Nightly épinglé, rebase discipliné. Le patch suit le nightly daté (ADR-025 pour la repro de la vérification ;
rt/reste hors des garanties repro du produit jusqu’à l’upstream). Un bump de nightly = re-rebase du patch PAL — coût de maintenance assumé et localisé (le patch est petit : un backendsys/pal/air+ colleos/thread_local). -
Gate CI nightly en phase 4. Le carve-out actuel de
rt/(hors barrière/couverture) est refermé par une lane CI nightly dédiée qui construitstd+ le « hello std » sur 2 arches. La logique de runtime déjà redomiciliée en couche 1 (ADR-052) reste, elle, sous les 100 % + barrière 2-arches obligatoires.
Conséquences
- Positif :
stdnatif sur Air (fs/net/thread/process/println!) sans glibc, sur une fondation Rust pure jusqu’à la couche 1 ; chemin d’upstreamclair (Tier-3) ; le PAL n’altère aucune API scellée. - Briques additives couche 1 à livrer (audit face-PAL, sous 100 %, descellement additif
ADR-051) :
Condvar,Once, destructeursthread_local!(register_dtor), mutation d’env (set/unset), newtypeos_str. Aucune n’est fondatrice (toutes constructibles sur des primitives déjà publiques). - Coût de maintenance : un fork
rust-srcà rebaser à chaque bump de nightly — délibéré, circonscrit au backendpal/air. - Caveat de fermeture (aarch64) :
cpufeatures(viaair-crypto) lielibc(getauxval) ; le PAL ne dépend pas d’air-crypto(il lie couche-0getrandompourrandom), donc ce point reste un chantier de fermeture séparé, pas un bloquant du PAL. - Portée : le PAL est le socle du
stdd’Air (Rust natif). La libc C-ABI (pour compiler du C contre Air) reste une piste distincte (ADR-046/047), non requise ici.
Alternatives rejetées
- Eyra /
c-scape(palunixstandard + shim libc en Rust pur) : donnerait « du Rust sans glibc », mais repasse par une couche en forme de libc et par le palunix(avec ses hypothèses) — contraire à la doctrine « pal directement sur la couche 1 pure » (pal-rust-couche1-et-clang-air). Rejeté. stdsur notre libc C-ABI (palunixpointé surlibair_c) : réintroduit une frontière FFI/unsafeque le pal Rust-pur élimine, et crée une circularité (la libc s’appuie en partie sur la couche 1). Rejeté pour le pal ; la libc C-ABI sert les programmes C, pas lestdRust.- Rester
no_stdpartout : renonce à l’ergonomiestd(fs/net/thread/process) pour le userland Air. Contraire à l’objectif « full Rust natif ». Rejeté. - Attendre un support
airdans LLVM/rustc en amont d’abord : inverserait l’ordre — on prouve la cible hors-arbre puis on upstream (ADR-049), pas l’inverse.
ADR-076 — std Rust natif sur la libc d’Air : cible env = "musl" + vendor = "air" (chantier B, option A)
Statut : Accepté (2026-07-08, décision BDFL directe). Amende ADR-050 (choix env = "air" → env = "musl") et amende ADR-075 (« pal pur, zéro libc » → « std sur la libc d’Air »). Met en œuvre le chantier B de la roadmap PAL (docs/notes/roadmap-pal-cloture-fr.md).
Catégorie : Décision de toolchain (toit std). N’altère aucune API couche 0/1 scellée.
Contexte
Objectif du chantier B : produire la toolchain Rust *-linux-air avec std natif, pour que nos exécutables et tests soient full Rust, sans tirer la glibc.
En amorçant le portage, un mur d’architecture est apparu : std — pour une cible target_family = "unix" + target_os = "linux" (ce qu’Air est, ADR-004/050) — compile son API publique std::os::unix / std::os::linux, indissociable de libc (extern crate libc, types libc::*, helpers sys::cvt, sys::linux). Un « pal sur couche 1 pure, sans libc » (l’ambition d’ADR-075) ne suffit pas : la structure de std exige une libc.
Par ailleurs, target_env = "air" (ADR-050 D1, « 3ᵉ saveur de libc ») n’est reconnu par aucun crate de l’écosystème (libc, std) : la crate libc échoue à compiler pour env = "air" (elle ne sélectionne aucun module d’environnement). Or Air est ABI-musl — son llvm-target est déjà *-linux-musl (ADR-050 D-1.3).
Décision — option A
std utilise son pal unix, et les symboles libc sont fournis par la libc pure-Rust d’Air (air-libc, libair_c) au lieu de la glibc. Concrètement :
-
La cible porte
env = "musl"(au lieu de"air") dans son spec JSON. Alorslibc,stdet tout cratecfg(musl)/cfg(unix)compilent pour Air sans le moindre patch (ils voient une cible musl, ce qu’Air est). C’est le modèle éprouvé d’eyra/mustang (cibles à base musl). -
L’identité d’Air passe par
vendor = "air". Le code Air qui doit savoir « je bâtis la cible Air réelle » (couche 1 :errno#[thread_local], destructeursthread_local,yield_now/set_thread_name, feature nightlythread_local, sélections per-cible) utilise désormaiscfg(target_vendor = "air")au lieu decfg(target_env = "air"). Déclaré valide dans[workspace.lints.rust](check-cfg). Renommage mécanique, sémantiquement invariant (faux sur l’hôte gnu, vrai sur la cible Air). -
Le lien résout les symboles libc vers
libair_c(statique), pas la glibc :-nostdlib+ CRT Air +-lair_c.-lunwind= stub (la cible estpanic = abort, l’unwinder n’est jamais invoqué).
« Sans glibc » ≠ « sans libc ». L’objectif est zéro glibc, avec notre libc (pure Rust, couche 1 → C-ABI). La campagne air-libc-* (déjà en production, cf. la libc scopée OpenSSH) devient aussi le socle de std : les deux grands chantiers (libc et toolchain) convergent.
Conséquences
build-std=stdcompile ENTIÈREMENT pour Air, sans un seul patch delibc/std(prouvé le 2026-07-08 :libcconfig musl +std+std_detect+ unhello-stdcompilent ; ne reste que le lien, qui réclame-lc/-lunwind). L’option A est validée.- Validation on-target préservée : selftest
rtx86-air = 42 ET aarch64-air = 42 après le renommagecfg(couche 1 intacte viavendor = "air"). Barrière verte. - Cible de production du chantier B : compléter
air-libcpour couvrir la surfacestd::sys::unix(superset des 55 manques révélés par la sonde OpenSSH), configurer le lien (libair static + CRT +-lunwindstub), et faire TOURNERhello-stdon-target 2 arches. C’est désormais le cœur du chantier (le portagestdest acquis ; reste la complétion de libair). - Ce qui est abandonné vs ADR-075 : le pal
aircustom (on réutilise le palunixdestd). Le patchset rust-src d’ADR-075 devient quasi vide (l’env = "musl"fait tout compiler). - Réversibilité : le renommage
cfget le champenvdu spec sont localisés ; aucun impact sur les couches scellées.
Alternatives rejetées
- (B) Cible non-unix (os custom) :
stdne compilerait pasos::unix(pal pur possible), mais coût écosystème massif (tout cratecfg(unix)exclut Air) et perte de la logique unix destd. Rejeté. - (C) Patcher
std/libclourdement pour découpleros::unixd’Air, en gardantenv = "air": gros patch fragile (38+ cfgmusldanslibc), dette de rebase à chaque nightly. Rejeté au profit deenv = "musl"(zéro patch). - Garder
env = "air": rejeté — non reconnu par l’écosystème, force le portage à se battre contrelibc/std.
ADR-077 — Managers de domaine en couche 1 (FileManager/NetworkManager/TerminalManager…) : surface objet médiatrice des toits (libc + PAL)
Statut : Accepté (2026-07-08, décision BDFL directe). Complète ADR-052 (objets couche 1), renforce la stratification (Principe 1). Encadre un descellement additif de la couche 1 (RFC, ADR-015/051/065).
Catégorie : Architecture des toits sur couche 1. Ajoute de l’API couche 1 (additif) ; n’altère aucune API existante.
Contexte
Règle non négociable : les toits (libc C-ABI, PAL std::sys) s’appuient EXCLUSIVEMENT sur la couche 1, jamais sur la couche 0 (air-sys-syscall). Seule la couche 1 parle à la couche 0.
Un audit (campagne libc-pour-std, chantier B) révèle deux crates libc qui violent la règle : air-libc-fileio (8 appels air_sys_syscall::{fs,process,ipc,system}) et air-libc-socketio (9 appels air_sys_syscall::net — codec sockaddr + sockopt, descellés en couche 0 pour la libc par ADR-070/071 : le pattern même à proscrire). Les 4 autres crates libc sont déjà propres.
Par ailleurs, la couche 1 est incomplète pour la surface visée : air-filesystem a une API riche AirFile (fd) mais pas les opérations par chemin (mkdir/unlink/rename/symlink/readlink/chmod/chown/access), ni chdir/getcwd ; air-socket/air-handle n’exposent pas le codec sockaddr ni les sockopt.
Décision
1. Ce qui manque en couche 1 s’implémente via une VRAIE API OBJET — des Managers de domaine. La couche 1 expose, par domaine, un objet Manager médiateur qui est LA surface consommée par les toits :
AirFileManager(air-filesystem) — tout l’I/O fichier : ouverture, ops par chemin (mkdir/unlink/rename/symlink/readlink/chmod/chown/access/hardlink/mkfifo/truncate/sync), répertoires, métadonnées par chemin,chdir/getcwd, fabrique de temporaires.AirNetworkManager(air-socket/air-handle) — tout le réseau : sockets, codecsockaddr↔ octets,sockopt, résolution de noms, transfert.AirTerminalManager(air-terminal) — termios/tty/PTY (cf. ADR-060/061).- … (par domaine : Process, Time…), au fil des besoins.
Le Manager prend/rend des types couche 1 (AirPath, Mode, AirResult, newtypes FD), jamais des types C. Il coordonne les briques existantes (AirFile, registre air-handle, AirSocket…) et la couche 0. La libc et le PAL bindent le Manager (le shim libc n’y ajoute que l’adaptation C-ABI : CStr↔AirPath, errno↔AirResult). Une brique couche 1 → 2 faces ([ADR-051] doctrine dual-face).
2. Correction en UN SEUL cycle de descellement. (a) Desceller la couche 1 une fois ; (b) implémenter les Managers manquants (AirFileManager, AirNetworkManager) et recâbler air-libc-fileio + air-libc-socketio dessus, en retirant leur dépendance air-sys-syscall ; (c) tests (barrière verte, couverture couche 1 à 100 %) ; (d) si OK, re-sceller la couche 1 une fois (couche-1-v1.x). Pas de descellements multiples en série.
Conséquences
- ADR-070/071 révisés en pratique : le codec
sockaddret lessockoptrestent en couche 0 (ils y sont légitimes comme wrappers typés), mais la libc ne les appelle plus directement — elle passe parAirNetworkManager(couche 1), qui, lui, peut consommer la couche 0. La frontière proscrite (libc→couche 0) disparaît. - Fondation des 71 symboles
std: la campagne libc-pour-stdbind désormais les Managers ; les ops manquantes s’ajoutent au Manager (couche 1), puis au shim — jamais un raccourci couche 0. - Coût : descellement + re-sceau couche 1 (additif, gouverné) ; extension d’
air-filesystem/air-socket(tests 100 %) ; recâblage de 2 crates libc (ABI C inchangée). - Réversibilité : purement additif côté couche 1 ; les toits gagnent une surface plus propre. Aucune API scellée retirée.
Alternatives rejetées
- Free-functions couche 1 éparses (au lieu de Managers objet) : rejeté — la directive BDFL exige une API objet cohérente par domaine (découvrabilité, invariants portés par l’objet, parité libc/PAL).
- Grand-fathering de
air-libc-fileio/socketio: rejeté (choix BDFL « remédier maintenant »). - Laisser la libc taper la couche 0 (statu quo ADR-070/071) : rejeté — casse la stratification, l’instrumentation et la doctrine dual-face.
ADR-078 — Descellement additif couche-1-v1.7 : additifs crypto pour air-tls (AES-128-GCM, ECDSA/ECDH P-256/P-384, RSA-PSS vérif., ML-KEM-768 hybride)
Note de renumérotation (2026-07-09).
couche-1-v1.7a été attribuée aux Managers de domaine (ADR-077) etcouche-1-v1.8à l’additifair-process::spawn_process(socleposix_spawn, chantier B) — décisions BDFL, tous deux mergés & scellés. Ce descellement crypto deviendracouche-1-v1.9au moment de sa ratification (le titre ci-dessous et les mentionsv1.6 → v1.7restent tels quels jusque-là, pour ne pas préempter la ratification).
Statut : Proposé — ratification BDFL requise. Ce descellement touche la
surface cryptographique ; il sort donc de la délégation automatique
« additif-seulement » du superviseur (ADR-065
§Gouvernance : « seuls sont remontés au BDFL … les compromis de sécurité ») et exige
une ratification explicite + KAT-gate avant tag. RFC de structure
(ADR-015). Applique le modèle de re-sceau
additif de la couche 1 (ADR-062) et la discipline
crypto (ADR-034). Consommateur cible :
la contingence air-tls maison (ADR-042
§Contingence ; spec docs/specs/layer-2/air-tls.md).
Catégorie : Descellement additif de la couche 1 scellée (couche-1-v1.6 →
couche-1-v1.7) + extension des dépendances crypto (exceptions structurantes à la
règle des 80 %, ADR-024/ADR-034). Aucune
rupture d’ABI ni de signature publique existante ; jalons antérieurs conservés.
Contexte
La spec air-tls maison (TLS 1.3 pur Rust, gardée
ouverte par ADR-042 §Contingence) et l’audit s2n-tls
établissent qu’une pile TLS interopérable avec le WebPKI réel exige des primitives
absentes du cœur v1 d’air-crypto (qui n’expose qu’Ed25519 / X25519 / AES-256-GCM /
ChaCha20-Poly1305 / SHA-2/3 / HKDF / HMAC / Argon2). Concrètement, il manque :
- AES-128-GCM — la suite TLS 1.3 mandatory-to-implement
TLS_AES_128_GCM_SHA256(RFC 8446 §9.1) ; - ECDSA P-256/P-384 (signature + ECDH) — la grande majorité des chaînes serveur
WebPKI et les groupes
secp256r1/secp384r1; - RSA-PSS (vérification) —
rsa_pss_rsae_sha256/384/512(RFC 8446 §4.2.3), sans quoi la majorité des certificats WebPKI actuels ne valide pas ; - ML-KEM-768 — l’échange hybride post-quantique
X25519MLKEM768(consensus de déploiement 2024-2025).
air-crypto étant scellée (couche-1), tout ajout passe par un descellement
additif (modèle ADR-062, précédents ADR-065/066/067). Comme il s’agit de crypto,
ADR-034 s’applique (exemption de vendoring, KAT-gating, fast-lane sécurité) et la
décision remonte au BDFL (surface sécurité).
Décision
Descellement additif couche-1-v1.7 : on ajoute à air-crypto (crate scellée)
la surface et les dépendances suivantes — purement additives (aucune signature
existante modifiée). Détail d’API dans la spec air-crypto §« Additifs planifiés ».
| Additif | Surface publique ajoutée | Crate (RustCrypto, pur Rust) | KAT |
|---|---|---|---|
| AES-128-GCM | AirAeadAlgorithm::Aes128Gcm, AirAead<16> | aes-gcm (déjà présente) | NIST GCM |
| ECDSA P-256/P-384 | AirEcdsaSigningKey/AirEcdsaVerifyingKey, AirEcCurve | p256, p384 | NIST CAVP (FIPS 186-4) |
| ECDH NIST | AirEcdhNistPrivateKey/AirEcdhNistPublicKey | p256/p384 (feat. ecdh) | NIST |
| RSA-PSS vérif. seule | AirRsaPssVerifyingKey (clé publique only) | rsa | RFC/NIST PSS |
| ML-KEM-768 | AirMlKem768 + clés/encapsulation | ml-kem (FIPS 203) | NIST ACVP / FIPS 203 |
Chaque primitive entre KAT-gated (vecteurs officiels verts avant merge,
ADR-034) et quand un consommateur atterrit — la contingence air-tls maison en
tête. L’ADR autorise la surface ; l’implémentation suit le besoin (comme
ADR-042 diffère l’application de son exception à l’implémentation).
Sécurité (décisions gravées — c’est le cœur de la remontée BDFL)
- RSA : vérification seule, jamais d’opération privée. Air n’expose aucune génération / signature / déchiffrement RSA — uniquement la vérification de signatures de certificats (opération publique). L’advisory RUSTSEC-2023-0071 « Marvin » (canal temporel sur l’opération privée RSA) ne s’applique donc pas. PKCS#1 v1.5 chiffrement (Bleichenbacher) : omis ; seul RSA-PSS vérif..
- ECDSA à nonce déterministe (RFC 6979). Élimine par construction la classe « nonce faible/réutilisé » (désastres PS3/Bitcoin). Jamais de nonce ECDSA tiré d’un PRNG.
- ML-KEM en hybride uniquement.
X25519MLKEM768combine les deux secrets (→ HKDF) ⇒ sécurité ≥ max(X25519, ML-KEM) : si ML-KEM (récent) tombait, X25519 protège ; réciproquement contre un ordinateur quantique. ML-KEM seul refusé tant qu’il n’a pas l’ancienneté d’X25519. - Zéro-C maintenu. Les 5 crates sont pur Rust →
check-c-surfacereste vert. À re-vérifier au bump la réserveaarch64/cpufeaturesdéjà documentée pouraes-gcm(docs/EXCEPTIONS.md;p256/p384en arithmétique pure Rust n’aggravent pas cette réserve). no_std-strict conservé :default-features = false, features aléa exclues (l’aléa vient toujours d’AirRandom→getrandom(2)couche 0).
Gouvernance
- Hors délégation additive automatique (ADR-065 §Gouvernance) : surface crypto = compromis de sécurité potentiel → ratification BDFL explicite requise avant tag, contrairement aux descellements additifs neutres (ADR-070/071).
- Exceptions 80 % nommées :
p256/p384/rsa/ml-kemajoutées à l’entréeair-cryptodedocs/EXCEPTIONS.mdet auDEPENDENCIES.mdde la crate (déjà instruites, non actives avant ratification). - Licences :
MIT OR Apache-2.0(RustCrypto) — liste blanchedeny.toml. À confirmer parcargo denyau moment de l’ajout.
Conséquences
- Tag
couche-1-v1.7posé après ratification BDFL + implémentation KAT-gated + barrière 2 arches (x86_64 + aarch64). Note de re-sceau dansINDEX.md+etat-avancement.md. - Débloque la contingence
air-tlsmaison sur l’interop WebPKI réelle (les 3 suites AEAD × X25519/NIST/hybride × Ed25519/ECDSA/RSA-vérif.). N’impacte pas la pile de production (rustls + aws-lc-rs, ADR-042), qui embarque sa propre crypto. - Précédent : les additifs crypto révélés par les protocoles réseau (
air-quic,air-ssh) suivront le même régime (descellement additif + ADR-034 + remontée BDFL).
Alternatives rejetées
- Exposer RSA privé / signature RSA. Rejeté : rouvre la surface Marvin, inutile (Air ne signe/déchiffre jamais en RSA ; Ed25519/ECDSA suffisent côté Air).
- ML-KEM seul (non hybride). Rejeté : primitive trop récente pour être portée seule ; l’hybride est le seul régime accepté.
- Réécrire ces primitives en propre. Rejeté : dogme « zéro crypto maison » (ADR-034) — on enrobe des crates auditées.
- Rester au cœur v1 (rien ajouter). Rejeté : sans ECDSA/RSA-vérif./AES-128, une
pile TLS maison ne valide quasiment aucun certificat WebPKI réel → contingence
air-tlsnon viable. - Attacher ces additifs à
air-tls(couche 2) plutôt qu’àair-crypto. Rejeté : violerait la doctrine (les primitives vivent en couche 1, jamais dispersées ; cf. note réseau §2.4 « le Handshaker consommeair-crypto»).
Suite
- Ratification BDFL de cet ADR, puis implémentation KAT-gated à l’atterrissage du
premier consommateur (
air-tlsmaison). Tagcouche-1-v1.7ensuite. - Câblage : registre ADRs,
SUMMARY.md,INDEX.md. Mise à jourEXCEPTIONS.md+crates/air-crypto/DEPENDENCIES.mdau moment de l’ajout effectif. - Étape suivante de conception : spec
air-network(substrat L2 oùair-tlsse branche).
ADR-079 — air-netlink (couche 1) : transport netlink générique, crate indépendante + descellement additif couche-0-v1.11 (AF_NETLINK + sockaddr_nl)
Statut : Accepté (décision BDFL, 2026-07-07 — option B, couche 1). RFC de
structure (ADR-015). Applique le modèle de
nouvelle crate couche 1 + descellement additif (ADR-062),
précédents ADR-069 (crate air-handle)
et ADR-051/ADR-070/ADR-071
(descellements additifs couche 0).
Catégorie : Nouvelle crate couche 1 (air-netlink) + descellement additif
couche 0 (AF_NETLINK/sockaddr_nl). Aucune rupture d’ABI ni de signature ; jalons
antérieurs conservés.
Contexte
La spec air-network (substrat de connexion L2) a
besoin, pour sa conscience du chemin (AirNetworkPathMonitor ≈ NWPathMonitor),
d’observer l’état réseau du noyau : interfaces, adresses, routes, liens up/down.
C’est du plan de contrôle réseau — le domaine de rtnetlink (NETLINK_ROUTE).
La couche 0 n’expose qu’un socket NETLINK_KOBJECT_UEVENT spécifique (hotplug,
consommé par air-device), pas de netlink générique ; air-socket a
délibérément retiré netlink (décision 2026-06-15 : « netlink relève de la
configuration kernel, concern distinct → futur crate air-netlink »).
Deux options ont été analysées :
- (A) Étendre
air-device— réutilise l’infra netlink existante, mais conflate deux protocoles très différents (uevent = broadcast texte read-only vs rtnetlink = requête/réponse + dump + attributs TLV, surface hostile), exige un descellement lourd d’une crate scellée dédiée aux devices, et disperse un transport que d’autres consommateurs veulent (WireGuard, DHCP, routesair-config, wifi nl80211). - (B) Crate indépendante
air-netlink— responsabilité unique, transport réutilisable par tous les consommateurs, fuzzable en isolation (le TLV kernel est la surface hostile), cohérent avec la décisionair-socketdéjà écrite ; coût = une crate + un petit additif couche 0.
Décision
Option B, couche 1. On crée une crate
air-netlink(couche 1) : un transport netlink générique (message framing, codec d’attributs TLV, corrélation requête/réponse/dump), suivant le patron sans-IO 9-composants (note réseau §2.2). Elle est réutilisable parair-network(path monitor), les daemons WireGuard/DHCP (L5),air-config(projection de routes), et le futur wifi (nl80211) / firewall (nftables).
Placement couche 1 (pair d’air-socket), pas couche 2. Un transport kernel-facing
générique est au niveau d’air-socket (L1) ; ce sont ses consommateurs qui vivent
plus haut (air-network L2, daemons L5). Cela révise la mention prospective
« couche 2 » de la spec air-socket (référence approximative), à réconcilier par grep.
Descellement additif couche 0 requis (couche-0-v1.11). Les syscalls génériques
socket/bind/sendmsg/recvmsg existent déjà (air-sys-syscall::net). Il manque
seulement :
SocketDomain::Netlink = 16(AF_NETLINK) dans l’enum typé (air-sys-types::net, aujourd’huiUnix=1/Ipv4=2/Ipv6=10) ;- un type/codec
SocketAddrNetlink(sockaddr_nl:nl_family,nl_pad,nl_pid,nl_groups— 12 octets) exposé pourbind.
Aucun nouveau syscall. Additif modéré (modèle ADR-070 sockaddr codec).
air-device inchangé. Son socket uevent NETLINK_KOBJECT_UEVENT reste tel quel
(pas de descellement de air-device). Une migration future de ce socket sur
air-netlink est possible mais non requise (non bloquante) — notée en suite.
Gouvernance
- Décision structurante (nouvelle crate + touche couche 0 scellée) → ratification BDFL (présent ADR), pas la délégation additive neutre.
- Tags posés après implémentation + barrière 2 arches (x86_64 + aarch64,
ADR-072) :
couche-0-v1.11(additifAF_NETLINK) puiscouche-1-v1.x(crateair-netlink). - Zéro-C, zéro
unsafeexposé ; parsing TLV borné + fuzzé (surface hostile kernel), couverture 100 % (couche 1).
Conséquences
- Nouvelle crate
crates/air-netlink(couche 1) ; specdocs/specs/layer-1/air-netlink.md. - Descellement couche 0 :
SocketDomain::Netlink+SocketAddrNetlink→couche-0-v1.11. air-network::AirNetworkPathMonitordébloqué (rtnetlink lien/adresse/route + abonnement multicastRTNLGRP_*).- Réconciliation doc : mention « couche 2 » d’
air-socketcorrigée en « couche 1 ». - Précédent : les autres familles netlink (nl80211, nftables, genetlink) se poseront au-dessus de ce transport, sans le réécrire.
Alternatives rejetées
- (A) Étendre
air-device— conflate uevent (broadcast texte) et rtnetlink (plan de contrôle TLV) ; descellement lourd d’une crate scellée hors périmètre ; disperse un transport multi-consommateurs. Rejeté. air-netlinken couche 2 — un transport kernel générique appartient à L1 (pair d’air-socket) ; le placer en L2 créerait des dépendances descendantes tordues pourair-config(L1) et compliquerait la réutilisation. Rejeté.- Réécrire un parseur netlink par consommateur — duplication d’une surface de parsing hostile ; contraire à la règle des 80 % et au patron sans-IO. Rejeté.
Suite
- Spec
air-netlink(couche 1) — faite (docs/specs/layer-1/air-netlink.md). - Implémentation : additif couche 0 (
AF_NETLINK/sockaddr_nl, RFC de descellement) puis crateair-netlink; tags après barrière 2 arches. - Migration éventuelle du socket uevent d’
air-devicesurair-netlink(non bloquante). - Câblage : registre ADRs,
SUMMARY.md,INDEX.md; note « à résoudre » d’air-networkmise à jour.
ADR-080 — Additifs air-socket : multicast UDP typé + codec DNS étendu (SRV/TXT/PTR) — descellement additif couche-1-v1.x + couche-0-v1.12 (setsockopt structuré)
Statut : Accepté (délégation additive, ADR-065 §Gouvernance — additifs neutres, non-sécurité). RFC de structure
(ADR-015). Modèle : descellements additifs
ADR-070/ADR-071
(couche 0) et ADR-062 (couche 1). Consommateur cible :
air-mdns (L2) / air-network::AirBrowser.
Catégorie : Descellement additif de la couche 1 scellée (air-socket) +
additif couche 0 (setsockopt structuré). Aucune rupture ; jalons conservés.
Contexte
La spec air-mdns (mDNS/DNS-SD, L2) a révélé deux
manques dans air-socket (L1, scellée) :
- Multicast UDP.
air-socket::udpnote « multicast à ajouter typé au besoin ». mDNS exige de joindre un groupe multicast (224.0.0.251:5353/ff02::fb). Or rejoindre un groupe =setsockopt(IP_ADD_MEMBERSHIP, ip_mreq)— une option à structure, que la couche 0 ne sait pas poser : ADR-071 n’a exposé que les sockopts à valeur entière (optlen == 4), excluant explicitement les options structurées (SO_LINGER,ip_mreq… — limitation D-M4.1). - Codec DNS.
air-socket::dnsne décode que les enregistrements A/AAAA (pour la résolution) et son codec de noms (compression bornée) est privé. mDNS a besoin du même codec de noms + des types SRV/TXT/PTR (DNS-SD). Le réécrire dansair-mdnsdupliquerait une surface de parsing hostile — contraire à la règle des 80 % (ADR-024) et au patron sans-IO (« le parseur hostile, décodé une fois »).
Décision
Deux additifs, purement additifs (aucune signature existante modifiée).
(1) Couche 0 — couche-0-v1.12 : setsockopt structuré
Lève la limitation D-M4.1 d’ADR-071 : ajouter un wrapper setsockopt à optval
octets borné (setsockopt_bytes(sock, level, optname, &[u8])) — permet de poser
ip_mreq/ipv6_mreq (et, plus tard, SO_LINGER/timeouts structurés). Réutilise le
syscall setsockopt déjà appelé en interne (le helper setsockopt_i32 existe) →
aucun nouveau syscall, juste une variante à buffer. level/optname restent les
entiers bruts (validés kernel), l’optval est un slice de longueur bornée fournie par
l’appelant.
(2) Couche 1 — air-socket (descellement additif couche-1-v1.x)
- Multicast UDP typé sur
AirUdpSocket:join_multicast_v4/join_multicast_v6(viaip_mreq/ipv6_mreq+setsockopt_bytescouche 0),leave_multicast_*,set_multicast_loop,set_multicast_ttl/_hops,set_multicast_interface— fonctions dédiées typées (jamais d’setsockoptbrut exposé, cf. ADR-021 conv. 3). - Codec DNS exposé + étendu : promouvoir le codec de noms (encodage/décodage,
compression suivie avec borne — l’invariant anti-boucle existant) en module
public sans-IO
air-socket::dnsréutilisable, et ajouter les types SRV/TXT/PTR (+ A/AAAA existants). Le résolveur A/AAAA existant est inchangé (il consomme le même codec).
Zéro-C maintenu (check-c-surface vert) ; zéro unsafe exposé ; parsing borné.
Gouvernance
- Additifs neutres (non-sécurité) → délégation additive (ADR-065) : traités par le superviseur (ADR de descellement + tags), sans ratification au cas par cas.
- Tags après implémentation + barrière 2 arches (ADR-072) :
couche-0-v1.12(setsockopt_bytes) puiscouche-1-v1.x(air-socketmulticast + DNS codec). - Fuzz : le codec DNS étendu re-fuzzé (
fuzz_dns_parsecouvre déjà les noms compressés ; étendre aux records SRV/TXT/PTR).
Conséquences
air-mdnsdébloqué : joint le groupe multicast (pilote I/O) + réutilise le codec DNS (noms + SRV/TXT/PTR) sans duplication.air-socketgrandit (multicast + DNS public) ; consommateurs existants inchangés.- Précédent : les futures options structurées (
SO_LINGER, timeouts) réutiliserontsetsockopt_bytes(couche 0).
Alternatives rejetées
- Dupliquer le codec DNS dans
air-mdns— duplique une surface de parsing hostile, double le fuzzing, viole la règle des 80 %. Rejeté. - Construire les octets
ip_mreqet les pousser via unsetsockoptentier détourné — impossible (optlen == 4only) ; un vraisetsockoptstructuré est de toute façon nécessaire. Rejeté. - Mettre le multicast/DNS-SD ailleurs qu’
air-socket— le multicast UDP est du ressort du socket (L1) ; le codec DNS y vit déjà. Rejeté.
Suite
- Section « Additifs planifiés » ajoutée à la spec
air-socket. - Implémentation :
couche-0-v1.12(setsockopt_bytes) puisair-socket(multicast + DNS codec) ; tags après barrière 2 arches. Câblage : registre, SUMMARY, INDEX.
ADR-081 — Additifs crypto pour air-quic et air-ssh : primitives bas niveau (bloc AES, keystream ChaCha20, Poly1305) + RSA PKCS#1 v1.5 vérif. — descellement additif couche-1-v1.x
Statut : Proposé — ratification BDFL requise. Comme ADR-078,
touche la surface cryptographique ⇒ hors délégation additive automatique
(ADR-065 §Gouvernance) : ratification
explicite + KAT-gate (ADR-034) avant
tag. RFC de structure (ADR-015). Consommateurs :
air-quic + air-ssh.
Catégorie : Descellement additif de la couche 1 scellée (air-crypto) +
extension de dépendances crypto (exceptions 80 % ADR-024/034). Aucune rupture ;
complète ADR-078.
Contexte
ADR-078 a instruit les additifs crypto d’air-tls (AES-128-GCM, ECDSA/ECDH, RSA-PSS
vérif., ML-KEM-768). Les specs air-quic et
air-ssh — postérieures — révèlent des besoins que la
façade AEAD anti-mésusage d’air-crypto (nonces gérés, pas de primitive nue) ne
couvre pas :
- QUIC — header protection (RFC 9001 §5.4). Protéger l’en-tête d’un paquet QUIC exige un masque dérivé d’un échantillon du payload chiffré, calculé par un bloc AES brut (ECB single-block) pour les suites AES, ou un keystream ChaCha20 pour la suite ChaCha. Ce sont des primitives sous la couche AEAD.
- SSH — signatures RSA (
rsa-sha2-256/512). SSH utilise RSASSA-PKCS#1 v1.5 (vérification de clé d’hôte), différent du RSA-PSS d’ADR-078. - SSH —
chacha20-poly1305@openssh.com. Construction custom (deux clés ChaCha20 : une pour la longueur, une pour les données ; Poly1305 séparé) — exige ChaCha20 keystream + Poly1305 one-shot exposés, pas l’AEAD RFC 8439. - SSH — kex post-quantique. Résolu sans nouvel additif : voir §Décision.
Décision
(1) Module bas niveau air_crypto::lowlevel (opt-in, expert-only)
On ajoute un module explicitement bas niveau exposant les primitives nues requises par les implémenteurs de protocole (QUIC/SSH), séparé de la façade haut-niveau (qui reste le défaut anti-mésusage) :
#![allow(unused)]
fn main() {
pub mod lowlevel { // « advanced » : documenté expert-only, pas le chemin par défaut
/// Bloc AES brut (ECB single-block) — QUIC header protection (RFC 9001 §5.4.3).
pub struct AirAesBlock { /* aes crate */ }
impl AirAesBlock { pub fn new(key: &AirSymmetricKey<16|32>) -> Self;
pub fn encrypt_block(&self, block: [u8;16]) -> [u8;16]; }
/// Keystream ChaCha20 (RFC 8439 §2.4) — QUIC HP (ChaCha) + SSH cipher.
pub struct AirChaCha20 { /* chacha20 crate */ }
/// Poly1305 one-shot (RFC 8439 §2.5) — SSH chacha20-poly1305@openssh.
pub struct AirPoly1305 { /* poly1305 crate */ }
}
}
- Backends :
aes,chacha20,poly1305(RustCrypto) — déjà présents en transitif (viaaes-gcm/chacha20poly1305) ; ici promus dépendances directes. Zéro-C (pur Rust ; réserveaarch64/cpufeaturesd’aes-gcminchangée). - KAT : vecteurs RFC 8439 (ChaCha20/Poly1305), NIST (AES bloc).
(2) RSA PKCS#1 v1.5 — vérification seule (air_crypto::AirRsaPkcs1VerifyingKey)
#![allow(unused)]
fn main() {
/// Vérif. RSASSA-PKCS#1 v1.5 (SHA-256/512) — SSH rsa-sha2-256/512. Clé PUBLIQUE only.
pub struct AirRsaPkcs1VerifyingKey { /* rsa crate (déjà ADR-078) */ }
}
- Backend :
rsa(déjà additif ADR-078 pour le PSS). Aucune opération privée RSA ⇒ hors Marvin (RUSTSEC-2023-0071). PKCS#1 v1.5 signature ≠ chiffrement : la vérif. de signature v1.5 est sûre (Bleichenbacher vise le chiffrement v1.5, omis).
(3) Kex PQ SSH — réutilise ML-KEM-768 (ADR-078), sntrup761 différé
OpenSSH récent (≥ 9.9, 2024) fait de mlkem768x25519-sha256 son kex par défaut.
air-ssh réutilise ml-kem-768 (déjà instruit ADR-078) via
mlkem768x25519-sha256 — aucun nouvel additif. sntrup761x25519 est différé
(pas d’implémentation pure-Rust de calibre confirmé) ; à instruire si/quand une crate
RustCrypto sntrup761 vettée existe. La spec air-ssh est corrigée en ce sens.
Sécurité (cœur de la remontée BDFL)
- Baisse de garde anti-mésusage — bornée et assumée. Exposer des primitives nues
(bloc AES, keystream, MAC) affaiblit la posture « AEAD à nonce géré ». Mitigation :
module
lowlevelséparé, opt-in, documenté expert-only ; la façade haut-niveau reste le défaut. Les consommateurs sont des implémenteurs de protocole audités (QUIC/SSH maison), pas du code applicatif. - RSA vérif. seule (hors Marvin) ; PKCS#1 v1.5 chiffrement toujours omis.
- PQ en hybride only (ML-KEM via
mlkem768x25519, jamais seul) — cohérent ADR-078. - Zéro-C maintenu ;
no_std-strict (features aléa exclues,AirRandomcouche 0).
Gouvernance
- Surface crypto ⇒ ratification BDFL (comme ADR-078), hors délégation ADR-065.
- KAT-gated (ADR-034) ; exceptions 80 % :
aes/chacha20/poly1305promues directes dansEXCEPTIONS.md+DEPENDENCIES.md;rsa/ml-kemdéjà ADR-078. - Tags : rejoint le lot d’additifs crypto (
couche-1-v1.7, ADR-078) ou uncouche-1-v1.xsubséquent, après implémentation KAT-gated + barrière 2 arches.
Conséquences
air-quic: header protection réalisable (lowlevel::AirAesBlock/AirChaCha20).air-ssh:rsa-sha2(PKCS#1 v1.5 vérif.),chacha20-poly1305@openssh(lowlevel), kex PQ via ML-KEM-768. Specair-sshcorrigée (PQ =mlkem768x25519).- Précédent : tout protocole exigeant une primitive sous-AEAD passe par
lowlevel(opt-in), jamais par un affaiblissement de la façade par défaut.
Alternatives rejetées
- Mettre la construction header-protection / SSH-AEAD dans
air-crypto— pousserait de la connaissance protocolaire en couche 1 (primitives). Rejeté :air-crypto= primitives, la construction vit en couche 2 (air-quic/air-ssh). - Exposer un ECB général — trop dangereux (ECB mésusable). Rejeté :
AirAesBlocksingle-block, nommé/documenté pour la header-protection, pas un mode ECB. - Introduire
sntrup761maintenant — pas de crate pure-Rust vettée ; ML-KEM-768 (mlkem768x25519) couvre le besoin PQ SSH moderne. Différé. - Réécrire ces primitives — dogme zéro-crypto-maison (ADR-034). Rejeté.
Suite
- Section « Additifs planifiés — support
air-quic/air-ssh» ajoutée à la specair-crypto. Correctionair-ssh(PQ =mlkem768x25519,sntrup761différé). - Ratification BDFL puis implémentation KAT-gated. Câblage : registre, SUMMARY, INDEX,
EXCEPTIONS.md.
ADR-082 — Additifs crypto : AEAD à nonce explicite (record/packet), BLAKE2s (WireGuard), PBKDF2 (SASL/SCRAM) — descellement additif couche-1-v1.x
Statut : Proposé — ratification BDFL requise (surface crypto, hors délégation
additive automatique ADR-065). RFC de
structure (ADR-015). Complète ADR-078/ADR-081
sous la même discipline (ADR-034, KAT-gate).
Consommateurs : air-tls, air-quic, air-wireguard, air-ssh, air-mail.
Catégorie : Descellement additif de la couche 1 scellée (air-crypto). Aucune
rupture ; complète les additifs crypto.
Contexte
La plomberie (air-wireguard, air-mail) et une relecture des piles TLS/QUIC/SSH
révèlent trois besoins non couverts par le cœur v1 ni par ADR-078/081 :
- AEAD à nonce EXPLICITE (le point important). La façade
AirAeadv1 est anti-mésusage : elle génère un nonce aléatoire interne et rendnonce‖ct‖tag(specair-crypto§5). Or toutes les piles de session protègent leurs enregistrements/paquets avec un nonce déterministe =IV ⊕ compteur: TLS 1.3 record (RFC 8446 §5.3), QUIC packet (RFC 9001 §5.3), WireGuard transport (compteur), SSH aes-gcm. Le nonce n’est pas aléatoire et n’est pas préfixé au message. ⇒ il manque un AEAD où l’appelant fournit le nonce. - BLAKE2s —
air-wireguard: hash + MAC keyed (MAC1/MAC2, cookie) + KDF (HMAC-BLAKE2s) du handshake Noise.air-cryptov1 ablake3(différé, tirecc), pasblake2. - PBKDF2-HMAC-SHA256 —
air-mail: mécanisme SASLSCRAM-SHA-256.air-cryptoa HKDF/Argon2, pas PBKDF2.
Décision
(1) air_crypto::lowlevel::AirAeadExplicitNonce — AEAD à nonce fourni
#![allow(unused)]
fn main() {
pub mod lowlevel { // opt-in expert-only (déjà introduit par ADR-081)
/// AEAD (AES-128/256-GCM | ChaCha20-Poly1305) où **l'appelant fournit le nonce**.
/// Pour les protocoles à nonce DÉTERMINISTE (TLS record, QUIC packet, WireGuard, SSH-GCM).
pub struct AirAeadExplicitNonce { /* aes-gcm | chacha20poly1305 */ }
impl AirAeadExplicitNonce {
pub fn seal(&self, nonce: &[u8; 12], plaintext: &[u8], aad: &[u8]) -> AirResult<Vec<u8>>; // ct‖tag
pub fn open(&self, nonce: &[u8; 12], sealed: &[u8], aad: &[u8]) -> AirResult<Vec<u8>>;
}
}
}
- Backends :
aes-gcm/chacha20poly1305(déjà présents) — seule l’API à nonce explicite s’ajoute. KAT : NIST GCM, RFC 8439. - Décision anti-mésusage : dans
lowlevel(opt-in) précisément parce que le nonce explicite est dangereux (réutilisation = catastrophe). La façadeAirAead(nonce aléatoire géré) reste le défaut applicatif. Contrat documenté : l’appelant DOIT garantir l’unicité du nonce par clé — ce que les key schedules TLS/QUIC/WireGuard garantissent par construction (compteur/séquence jamais réutilisé).
(2) BLAKE2s — hash + MAC keyed + HMAC (WireGuard)
#![allow(unused)]
fn main() {
pub enum AirHashAlgorithm { /* … */ Blake2s /* additif */ }
/// MAC keyed BLAKE2s (MAC1/MAC2 WireGuard) + HMAC-BLAKE2s (KDF Noise).
pub struct AirBlake2sMac { /* blake2 (RustCrypto) */ }
}
- Backend :
blake2(RustCrypto) — pur Rust, ZÉRO-C (≠blake3qui tirecc; c’est la raison du report de blake3, sans objet pour blake2). KAT : RFC 7693.
(3) PBKDF2-HMAC-SHA256 (SASL SCRAM)
#![allow(unused)]
fn main() {
pub struct AirPbkdf2;
impl AirPbkdf2 {
pub fn derive_sha256(password: &[u8], salt: &[u8], iterations: u32, out: &mut [u8]) -> AirResult<()>;
}
}
- Backend :
pbkdf2(RustCrypto), pur Rust. KAT : RFC 6070. Optionnel : requis pourSCRAM-SHA-256;OAUTHBEARER/XOAUTH2(token) l’évitent.
Sécurité (cœur de la remontée BDFL)
- AEAD à nonce explicite = le sujet sensible. Isolé dans
lowlevel(opt-in, expert-only), contrat d’unicité de nonce documenté ; la façade à nonce géré reste le défaut. Sans cet additif, TLS/QUIC/WireGuard/SSH ne peuvent pas protéger leurs enregistrements correctement (ils ne doivent PAS préfixer un nonce aléatoire). - BLAKE2s / PBKDF2 : primitives standard auditées (RustCrypto), pur Rust, zéro-C.
no_std-strict (features aléa exclues,AirRandomcouche 0) ; KAT-gated.
Gouvernance
- Surface crypto ⇒ ratification BDFL (comme ADR-078/081).
- Exceptions 80 % :
blake2,pbkdf2ajoutées (EXCEPTIONS.md+DEPENDENCIES.md) ;aes-gcm/chacha20poly1305déjà présents. - Tags : rejoint le lot d’additifs crypto (
couche-1-v1.7, ADR-078) ou uncouche-1-v1.xsubséquent, après implémentation KAT-gated + barrière 2 arches.
Conséquences
- Correction rétroactive :
air-tls(record),air-quic(packet),air-ssh(aes-gcm),air-wireguard(transport) utilisentlowlevel::AirAeadExplicitNoncepour la protection d’enregistrement/paquet — pas la façadeAirAeadà nonce aléatoire. Les specs concernées le notent. air-wireguard: BLAKE2s disponible ;air-mail: SCRAM-SHA-256 disponible.
Alternatives rejetées
- Détourner la façade
AirAead(nonce aléatoire) pour les protocoles — faux : ces protocoles imposent un nonce déterministe (IV⊕compteur), non préfixé ; un nonce aléatoire casserait l’interop et gonflerait chaque record. Rejeté. - Exposer le nonce explicite dans la façade par défaut — affaiblirait la posture
anti-mésusage pour tout le code applicatif. Rejeté : réservé à
lowlevel. blake3au lieu deblake2— WireGuard spécifie BLAKE2s ; et blake3 tirecc(surface C). Rejeté.
Suite
- Section « Additifs planifiés » d’
air-cryptoétendue. Notes correctives dansair-tls/air-quic/air-ssh/air-wireguard(record/packet AEAD) etair-mail(SCRAM). Ratification BDFL puis implémentation KAT-gated. Câblage registre/SUMMARY/ INDEX/EXCEPTIONS.
ADR-083 — Additifs couche 0 pour la plomberie réseau : TUN (TUNSETIFF) + adjtimex/clock_adjtime — descellement additif couche-0-v1.x
Statut : Accepté (délégation additive, ADR-065 §Gouvernance — additifs neutres). RFC de structure (ADR-015). Modèle : descellements additifs couche 0
ADR-051/ADR-060/ADR-070/ADR-071.
Consommateurs : air-wireguard (TUN), air-ntp (adjtimex).
Catégorie : Descellement additif de la couche 0 scellée. Aucune rupture ; jalons conservés.
Contexte
Les specs de plomberie (air-dhcp/air-ntp/air-wireguard) ont flagué des « additifs
couche 0 ». Vérification faite sur le socle réel, la plupart existent déjà — seuls
deux manquent réellement :
| Besoin (spec) | État réel couche 0 | Additif ? |
|---|---|---|
| Régler l’horloge (NTP) | clock_settime EXISTE (time.rs) — step possible | adjtimex seul (slew) |
| Socket ICMPv6 brut (SLAAC) | SocketType::Raw EXISTE + socket(domain, ty, protocol: i32) générique | NON (déjà possible) ; ICMP6_FILTER = setsockopt_bytes (ADR-080) |
SO_BROADCAST (DHCPv4) | sockopt entier EXISTE (ADR-071) | NON (wrapper couche 1) |
| Interface TUN (WireGuard) | absent | TUNSETIFF (ioctl dédié) |
⇒ Le périmètre réel est deux additifs : adjtimex (discipline fine d’horloge) et le
TUN (/dev/net/tun + TUNSETIFF).
Décision
(1) Famille time — adjtimex/clock_adjtime (discipline fine)
Ajouter le wrapper adjtimex (via clock_adjtime(2), moderne, par-clock) —
ajustement graduel (slew) de l’horloge, que NTP préfère au saut brut
(clock_settime, déjà présent). Type TimexBuffer (struct timex, #[repr(C)]
identique x86/arm), fonction dédiée typée, Result<TimexState, Errno>, EINTR
remonté. Privilégié (CAP_SYS_TIME — appliqué par le kernel, le wrapper n’élève rien).
(2) TUN — /dev/net/tun + TUNSETIFF (ioctl dédié typé)
Ajouter un wrapper tun_set_iff (ioctl(fd, TUNSETIFF, &ifreq)) — fonction dédiée
typée (ADR-021 conv. 3, jamais d’ioctl générique), sur /dev/net/tun ouvert par
l’open existant. Types InterfaceRequest (ifreq borné : nom d’interface ≤ IFNAMSIZ,
flags TunFlags : IFF_TUN/IFF_NO_PI/IFF_MULTI_QUEUE). RAII sur le FD.
Privilégié (CAP_NET_ADMIN).
Zéro nouveau paradigme : adjtimex = un syscall de plus dans time ; TUN = un ioctl
dédié de plus (comme la famille terminal, ADR-060). Types #[repr(C)] identiques
2 arches (asserts de layout). Option<T> où sentinelle, EINTR remonté, RAII FD.
Gouvernance
- Additifs neutres (le privilège est appliqué par le kernel ; le wrapper ne fait qu’exposer le syscall) → délégation additive (ADR-065), comme ADR-060/070/071.
- Tags après implémentation + barrière 2 arches (ADR-072) :
couche-0-v1.x(séquentiel après les additifs en attentev1.11/v1.12). - Couverture 100 % couche 0 (hors exceptions PRIVILEGE documentées pour les chemins
exigeant
CAP_*, ADR-035) ;// SAFETY:sur chaque blocunsafe;check-syscalls(numérosadjtimex/clock_adjtime, 2 arches).
Conséquences
air-ntp: discipline slew possible (adjtimex) en plus du step (clock_settimedéjà là).air-wireguard: interface tunnel créable (tun_set_iff).- Corrections de specs :
air-dhcp(ICMPv6/SO_BROADCAST ne sont pas des additifs couche 0),air-ntp(clock_settimedéjà présent → seuladjtimexmanque),air-wireguard(TUN = additif couche 0 précis). - ICMPv6 brut (SLAAC) : réalisé en couche 1 (
air-socket: socketRaw/Ipv6typé +ICMP6_FILTERviasetsockopt_bytesADR-080) — sans additif couche 0.
Alternatives rejetées
- Ioctl TUN générique — interdit (ADR-021 conv. 3) ; wrapper dédié typé.
settimeofdaylegacy — remplacé parclock_settime(déjà présent) +clock_adjtime(moderne, par-clock). Rejeté.- Ajouter un socket ICMPv6/
SO_BROADCASTen couche 0 — inutile : le socle générique (Raw+socket+ sockopt entier/structuré) les couvre déjà. Rejeté.
Suite
- Specs
family-time(adjtimex) + une famille/extensiontundocumentées à l’implémentation. Correctionsair-dhcp/air-ntp/air-wireguard. Tagscouche-0-v1.xaprès barrière 2 arches. Câblage registre/SUMMARY.
ADR-084 — Ratification air-url : couche 4 élargie (« frameworks développeur : UI + fondation ») + nom air-url
Statut : Accepté (2026-07-07, ratification BDFL). RFC de structure
(ADR-015). Amende la macro-architecture
(périmètre de la couche 4). Companion de la note
api-reseau-strategie (qui posait la question) et
de la spec air-url.
Catégorie : Architecture (placement de couche + nommage) / amendement macro-archi.
Note de procédure. La ratification suit l’hypothèse principale de la note (couche 4 élargie) ; le nom
air-urlest retenu (descriptif, vocabulaire Air, non copié d’Apple). Le BDFL peut réviser le nom (alternatives :air-web,air-fetch) sans rouvrir la décision de couche.
Contexte
La spec air-url (équivalent fonctionnel de URLSession : sessions, tâches
data/download/upload/WebSocket, cache, cookies, auth, transferts en arrière-plan) a été
rédigée en laissant deux points ouverts (note api-reseau-strategie) : la couche
et le nom. air-url est une commodité applicative (URL-loading haut niveau bâti
sur air-http), pas un mécanisme système ni un protocole filaire — exactement comme
URLSession vit chez Apple dans Foundation, pas dans le socle réseau.
La couche 4 était jusqu’ici définie « UI seulement » (air-ui graphique / air-tui
console). Placer air-url y élargit ce périmètre.
Décision
- Couche 4, périmètre élargi. La couche 4 passe de « frameworks UI » à
« frameworks développeur : UI + fondation ». Elle héberge, à côté des frameworks
d’interface (
air-ui/air-tui), les commodités applicatives de fondation — dontair-url(URL-loading), sur le modèle de Foundation d’Apple (UIKit et Foundation coexistent au niveau « framework développeur »). - Nom :
air-url. Descriptif (chargement d’URL), vocabulaire Air, jamais « URLSession » (pas de copie d’API/nom Apple, décision de la note). Alternatives consignées :air-web,air-fetch(révisables par le BDFL sans rouvrir la couche).
Conséquences
- Macro-architecture amendée : la section « Couche 4 » inclut désormais les
frameworks de fondation (pas seulement UI).
air-urly figure. - Précédent : les futures commodités applicatives (au-dessus des services système,
destinées aux développeurs d’apps
.airapp) se placent en couche 4 fondation (ex. formats, i18n applicative, etc., à instruire au cas par cas). air-url: spec passée de « couche à confirmer » à couche 4 (fondation) ratifiée ; bâtie surair-http(L2) +air-network(L2).- Non affecté :
air-http/air-network/air-tls… restent en couche 2 (frameworks système / protocoles). L’élargissement ne concerne que la couche 4.
Alternatives rejetées
- Haut de couche 2. Placerait un confort applicatif parmi les frameworks
système / protocoles (transport, TLS, HTTP). Mélange les registres ; la couche 2 est
« mécanisme système »,
air-urlest « commodité développeur ». Rejeté au profit de la cohérence de registre (Foundation ≠ socle réseau). - Nouvelle couche dédiée (entre 2 et 4). Sur-ingénierie ; la couche 4 « frameworks développeur » couvre naturellement UI et fondation. Rejeté.
- Nom
URLSession/air-url-session. Copie de l’API/nom Apple, contraire à la note (« couverture fonctionnelle, pas copie d’API »). Rejeté.
Adoption
- Amendement macro-architecture (section couche 4) appliqué. Spec
air-urlmise à jour (statut ratifié). Câblage registre/SUMMARY/INDEX. Le nom de crateair-urlest retenu (révisable par le BDFL, sans impact sur la décision de couche).
ADR-085 — Descellement additif couche-0 cumulé : primitives manquantes pour la face libc (chantier B — std + OpenSSH)
Statut : Accepté (ratification BDFL 2026-07-08). Périmètre implémenté (100 %
couverture, 2 arches) et re-scellé couche-0-v1.11 (2026-07-09, tag signé posé sous
délégation ADR-065) : chroot,
kill_process_group (→ killpg), sigaltstack (+ type AltStack), sendfile,
fchmod/fchown/futimens, munmap_raw. pause sans additif (réutilise
ppoll). Descellement additif de la couche 0 scellée couche-0-v1.10, sous la
délégation des re-sceaux additifs du
superviseur (ADR-065), cadre libc
ADR-051, RFC d’extension de couches
scellées (ADR-015). Sert le chantier B (lier
std sur libair, ADR-076) et la campagne
libc scopée OpenSSH (ADR-046/ADR-047).
Catégorie : Descellement additif de la couche 0. Aucun symbole existant
modifié. Contrairement aux descellements par-symbole (ADR-070/ADR-071),
celui-ci cumule en un seul acte tout le manque couche 0 restant de la campagne
(directive BDFL 2026-07-08 : un ADR, un descellement, une production, un
re-sceau). Tag de re-sceau couche-0-v1.11 DIFFÉRÉ jusqu’à ce que tout le
périmètre ci-dessous soit implémenté (100 % couverture, 2 arches) et ratifié.
Contexte
La campagne libc (fichier, env, process de base) a épuisé les symboles bindables sur
la couche 0 existante — 34/71 symboles std fournis sans toucher la couche 0. Les
symboles restants se répartissent en trois classes :
-
Bindables sur l’existant couche 0 (aucun additif) — livrés au fil de l’eau :
readv/writev/preadv/pwritev/splice(I/O vectorisée),execve/execveat(execvp= recherchePATH+execve, couche 1),gethostname/sethostname, etkillpg(=kill(2)avec unPidnégatif = groupe ;air_sys_syscall:: signal::killtransmet déjàpid.as_raw()brut au syscall,Pid::try_from_rawn’exclut que0).setsid/waitidsont présents (leur difficulté est de couverture — enfant forké, cf.docs/COVERAGE-EXCEPTIONS.md— pas un manque). -
Manque en couche 0 → objet du présent ADR (§ Décision).
-
Relève de la couche 1 (descellée, additifs libres — ADR-062) :
getauxval(stockage de l’auxvcapturé au_startparair-runtime+ table de requête ; TARGET-ONLY — l’auxvn’existe qu’à l’entrée du programme cible),__xpg_strerror_r(strerror_r— mise en forme d’Errno),__tls_get_addr(TLS dynamique, runtime), etsysconf(agrégat : constantes +getauxvalpour_SC_PAGESIZE/AT_PAGESZ+sched_getaffinitypour_SC_NPROCESSORS_ONLN). Non traités ici.
Multiplier les descellements par-symbole (7+ ADR distincts) alourdirait la gouvernance. La BDFL tranche : cumuler.
Décision
La couche 0 expose les fonctions publiques suivantes, toutes conformes aux
conventions couche 0 (ADR-021) :
Result<_, Errno>, Option<T> au lieu des sentinelles, FD emprunté (BorrowedFd),
EINTR remonté, aucune allocation happy-path, une fonction dédiée typée par
opération (jamais de wrapper multiplexé). Regroupées par famille :
1. Mémoire — libération par (adresse, longueur)
Le C fait munmap(void *addr, size_t len) ; la couche 0 n’offre que
munmap(mapping: Mapping) RAII (champs privés addr/len, pas de constructeur
public). La face libc possède l’adresse et la longueur, pas l’objet Mapping.
#![allow(unused)]
fn main() {
// crates/air-sys-syscall/src/mem.rs
/// `munmap(2)` par (adresse, longueur) brutes — pour la face libc, qui gère la vie du
/// mapping par (addr, len) (comme le C), non par l'objet RAII `Mapping`.
///
/// # Safety
/// `address`/`length` désignent une région **effectivement mappée** (contrat C).
pub unsafe fn munmap_raw(address: NonNull<u8>, length: usize) -> Result<(), Errno>;
}
(mmap et mprotect sont déjà bindables : mmap_anonymous/mmap_file rendent un
Mapping dont on extrait l’adresse — la couche 1 air-memory::raw_mapping neutralise
déjà le Drop ; mprotect(NonNull, len, prot) est déjà public.)
2. Métadonnées par descripteur
fchmodat/fchownat (par chemin) existent ; les variantes par fd (que fchmod/
fchown C exigent) manquent.
#![allow(unused)]
fn main() {
// crates/air-sys-syscall/src/fs.rs
pub fn fchmod(fd: BorrowedFd<'_>, mode: Mode) -> Result<(), Errno>;
pub fn fchown(fd: BorrowedFd<'_>, uid: u32, gid: u32) -> Result<(), Errno>;
/// Horodatages **par fd** (`utimensat(fd, NULL, …)`) — socle de `futimens`. La variante
/// `utimensat` existante prend un `&CStr` (jamais `NULL`) ; celle-ci opère sur le fd.
pub fn futimens(fd: BorrowedFd<'_>, atime: UtimeValue, mtime: UtimeValue) -> Result<(), Errno>;
}
3. Confinement
#![allow(unused)]
fn main() {
// crates/air-sys-syscall/src/process.rs (ou fs.rs)
pub fn chroot(path: &CStr) -> Result<(), Errno>;
}
4. Ordonnancement / affinité CPU
#![allow(unused)]
fn main() {
// crates/air-sys-syscall/src/process.rs
/// Masque d'affinité CPU du processus `pid` (`None` = courant). `CpuSet` = newtype
/// couche 0 (bitset borné) — jamais un `cpu_set_t` brut. Socle de `sched_getaffinity`
/// et de `sysconf(_SC_NPROCESSORS_ONLN)`.
pub fn sched_getaffinity(pid: Option<Pid>, set: &mut CpuSet) -> Result<(), Errno>;
pub fn sched_setaffinity(pid: Option<Pid>, set: &CpuSet) -> Result<(), Errno>;
}
5. Attente d’un signal (pause)
pause(2) n’a pas de numéro de syscall sur aarch64 (seulement x86_64). Une fonction
couche 0 portable l’implémente via rt_sigsuspend avec le masque courant (aarch64) /
SYS_pause (x86_64).
#![allow(unused)]
fn main() {
// crates/air-sys-syscall/src/signal.rs
/// Suspend le thread jusqu'à la délivrance d'un signal (portable 2 arches). Rend
/// toujours `Err(EINTR)` en succès (POSIX `pause` rend `-1`/`EINTR`).
pub fn pause() -> Result<Infallible, Errno>;
}
6. Pile de signal alternative (sigaltstack)
#![allow(unused)]
fn main() {
// crates/air-sys-syscall/src/signal.rs
/// Installe/interroge la pile alternative de signal. `AltStack` = newtype couche 0
/// (base + taille + drapeaux typés), jamais un `stack_t` brut. Requis par `std`
/// (gestion du débordement de pile via `SIGSEGV` sur pile alternative).
pub fn sigaltstack(new: Option<&AltStack>, old: Option<&mut AltStack>) -> Result<(), Errno>;
}
7. Transfert zéro-copie (sendfile)
#![allow(unused)]
fn main() {
// crates/air-sys-syscall/src/fs.rs (ou un module io)
/// `sendfile(2)` : copie `count` octets de `in_fd` vers `out_fd` dans le kernel.
/// `offset` : `Some` = position explicite mise à jour (rendue) ; `None` = position
/// courante de `in_fd`. Rend le nombre d'octets transférés.
pub fn sendfile(
out_fd: BorrowedFd<'_>,
in_fd: BorrowedFd<'_>,
offset: Option<&mut u64>,
count: usize,
) -> Result<usize, Errno>;
}
8. ioctl — pas de descellement couche 0 (résolution doctrinale)
Le C fournit ioctl(fd, request, …) variadique et multiplexé — exactement ce
qu’ADR-021 conv. 3 interdit en
couche 0 (« pas de wrapper générique pour syscalls multiplexés »). On préserve la
doctrine : la couche 0 ne reçoit aucun ioctl générique. Le symbole libc ioctl
est un dispatcher de la face libc (couche 1/toit) qui route chaque request
connu vers l’opération typée correspondante déjà exposée :
TIOCGWINSZ/TIOCSWINSZ→ famille termios/tty couche 0 (ADR-060) ;FIONBIO→set_status_flags/fcntl(O_NONBLOCK);FIONREAD/SIOCGIFxxx… → op typée dédiée si requise par le périmètre OpenSSH (ajout ponctuel, typé, jamais unioctlbrut) ;requestinconnu →ENOTTYdéterministe (jamais un comportement deviné).
Ainsi la couche 0 reste pure ; seule la libc porte la multiplexion (frontière C,
unsafe localisé). Cette route est notée ici mais implémentée côté libc, hors
du présent descellement.
Ce que ce descellement N’inclut PAS
- Couche 1 (descellée, additifs libres) :
getauxval(tableauxvdansair-runtime),sysconf(agrégat),strerror_r,__tls_get_addr. Traités par additifscouche-1-v1.xau fil des lots libc, sans ADR de descellement (la couche 1 est descellée jusqu’au re-sceau groupécouche-1-v1.7). ioctl: dispatcher libc, pas couche 0 (§ 8).- Toute opération hors périmètre
std/OpenSSH : différée (UNSUPPORTED.md).
Conséquences
- Tests : chaque additif = 100 % lignes + branches, property-based / fuzz si entrée
externe, validé 2 arches (x86_64 + aarch64 —
pause/sigaltstackdivergent par arch).munmap_raw/sendfile/sched_*/sigaltstack/pause: harnais custom (mappings réels, enfants forkés, masques de signaux) comme le reste de la couche 0. - Re-sceau : après implémentation complète et validation 2 arches, le
superviseur pose le tag
couche-0-v1.11(additif ; aucun symbole existant touché, ABI préservée). Mise à jour d’UNSUPPORTED.md(retrait des entrées désormais couvertes) et de la specfamily-*concernée. - Débloque : le lot mémoire libc (
mmap/mprotect/munmap— critique pour questdlie : allocateur, piles de threads, pile de signal) et les symboles fichier/ process/signal restants, sur les Managers de domaine (AirFileManager/AirProcessManager/…, ADR-077).
Alternatives rejetées
- Descellements par-symbole (façon ADR-070/071) : 7+ ADR, gouvernance lourde, re-sceaux multiples. Rejeté (directive BDFL de cumul).
ioctlgénérique en couche 0 : viole ADR-021 conv. 3. Rejeté (dispatcher libc).munmapvia un registre couche 1 (addr →Mapping) : non fidèle (échoue sur unmunmappartiel ou d’une région étrangère), verrou global sur chaquemmap/munmap. Rejeté au profit dumunmap_rawfidèle au kernel.
ADR-086 — Introspection thread cible pour la face libc : TLS dynamique, bornes de pile, auxv (chantier B — M5, Lot C)
Statut : Accepté (ratification BDFL 2026-07-09). Additif couche 1 servant le
jalon M5 (lier std sur libair, ADR-076).
Re-scellé couche-1-v1.9 le 2026-07-10 (périmètre implémenté 100 % couverture, 2 arches ;
tag signé sous la délégation des re-sceaux additifs du superviseur
ADR-065, cadre RFC d’extension de couches
scellées ADR-015). Numérotation : le Lot C prend
couche-1-v1.9 (prêt le premier) ; l’earmark couche-1-v1.9 d’ADR-078 (crypto/air-tls,
non ratifié) glisse en couche-1-v1.10 à sa ratification (décision BDFL 2026-07-10).
Catégorie : Additif couche 1 (aucun symbole existant modifié) + convention de la face libc cible (couche 2). S’inscrit dans les Managers de domaine (ADR-077) : le toit libc binde le manager couche 1, jamais la couche 0.
Contexte
Le lien de hello-std contre libair (M5) révèle 7 symboles pthread/unistd
indéfinis sur la cible :
- TLS dynamique POSIX :
pthread_key_create,pthread_key_delete,pthread_setspecific(std les utilise pour la registration des destructeurs TLS à la sortie de thread ; le stockage TLS lui-même passe par le#[thread_local]natif, déjà installé par [air_runtime::thread_local_storage],has-thread-local: true). - identité de thread :
pthread_self. - détection de pile :
pthread_getattr_np,pthread_attr_getstack,pthread_attr_getguardsize(std les appelle une fois à l’init pour poser la garde de débordement de pile du thread principal).
Ces symboles existent déjà en couche 2 (air-libc-thread/air-libc-capi) mais sont
gardés #[cfg(not(target_vendor = "air"))] — hôte uniquement, leurs implémentations
s’appuyant sur std (backend thread/TLS). Leur version cible était marquée
« REMONTÉE » (ADR-055 D3, M3 Phase B) faute d’un socle
couche 1. Cet ADR fournit ce socle.
État de la couche 1 : le TCB ([air_runtime::thread_control_block]) porte
tid/errno/canari + le TLS ELF statique ; il n’expose ni registre de clés
POSIX dynamiques ni accesseur de bornes de pile. La couche 0 fournit prlimit
(RLIMIT_STACK) mais la couche 1 ne le binde pas.
Décision
1. Additif couche 1 — capture du contexte de démarrage (re-sceau couche-1-v1.9)
-
air-process: méthodeAirProcessManager::resource_limit(pid, resource) -> AirResult<Rlimit>(bindeprlimitcouche 0 en lecture seule). Socle degetrlimitet de la mesure de pile. -
air-runtime::process_context(nouveau module) : au bootstrap ([start::bootstrap]), capture une fois (a) l’adresse du vecteur auxiliaireauxvet (b) les bornes de la pile principale (pointeur de pile initial arrondi au sommet de page +RLIMIT_STACKsoft, via la couche 1). Expose :auxval(entry_type) -> Option<usize>— socle degetauxval;main_thread_stack() -> Option<StackBounds>— socle de la détection de pile.
La logique pure (marche
auxv, arithmétique de bornes) est isolée et host-testable ; la capture l’est aussi (pile synthétique + lecture réelle deRLIMIT_STACK).air-runtimene syscall jamais (ADR-052) : la limite passe parair-process.Fusion
getauxval(ex-Lot E) : la même capture d’auxvsertgetauxval— un seul additif bootstrap couvre les deux besoins.
2. TLS dynamique per-thread cible = #[thread_local] natif, pas un slot TCB
Le pointeur des valeurs TLS per-thread (vecteur clé → valeur) est porté par un
#[thread_local] de couche 2 (air-libc-thread), sur le même modèle que l’errno
cible ([thread_control_block::errno], #[thread_local] natif prouvé sur les 2 arches).
La table globale des clés (allocateur + destructeurs, déjà en AirMutex) est
partagée hôte/cible.
Rejeté — slot dans le TCB (design initialement envisagé) : sur aarch64
(Variant I), les champs du TCB au-delà de 0x10 recouvrent le bloc #[thread_local]
(cf. [ThreadControlBlock::init], qui n’y écrit ni tid ni canari) ; un slot
dynamic_tls y corromprait le TLS. Le #[thread_local] natif est correct sur les
deux Variants sans toucher le layout du TCB — additif couche 1 réduit au seul
bootstrap.
3. pthread_self cible = air_thread::current_tid()
Façade directe sur gettid (couche 1, disponible sur toute cible) — aucun additif.
4. Détection de pile cible
pthread_getattr_np(self) + pthread_attr_getstack/getguardsize du thread
principal lisent air_runtime::main_thread_stack(). Bornes approximatives
assumées : std raffine lui-même via /proc/self/maps — nos valeurs ne font
qu’amorcer le calcul de garde.
5. Threads spawnés — câblés, validation différée
Le rejeu des destructeurs TLS à la sortie de thread et la détection de pile
per-thread spawné sont TARGET-ONLY et non exercés par hello-std
(mono-thread) ; le spawn natif panique sur aarch64 (dette connue). Les crochets sont
posés proprement (couche 1) ; leur validation runtime est différée avec note, sans
bloquer M5.
Conséquences
- Positives : socle couche 1 minimal (bootstrap seul) débloquant 7 symboles
std;getauxvalobtenu par la même capture ; TLS dynamique correct sur les 2 arches sans risque de layout ;resource_limitréutilisable pargetrlimit. - Coût : un module couche 1 de plus + un re-sceau additif (
couche-1-v1.9) ; bornes de pile approximatives (mitigées par le raffinagestd). - Alternatives rejetées : slot TCB (§2, faux sur aarch64) ; lecture des bornes via
/proc/self/mapsen couche 1 (dépendance file I/O au bootstrap, superflue puisque std raffine) ; dégrader la garde de pile (perte de sécurité).
Portée du re-sceau couche-1-v1.9
air-process::resource_limit + air-runtime::process_context (auxval,
main_thread_stack, StackBounds, capture). Tag signé posé en fin de Lot C, une
fois la face libc cible (C.2) implémentée et vérifiée on-target.
ADR-087 — syscall générique (raw_syscall) : escape hatch temporaire pour le pal std (M5, Lot D) — À RETIRER
Statut : Accepté (ratification BDFL 2026-07-09) — explicitement TEMPORAIRE. Additif
couche 0 servant le jalon M5 (lier std sur libair,
ADR-076). Re-scellé couche-0-v1.12 le
2026-07-10 (additif, tag signé sous délégation
ADR-065). Exception consignée à
docs/EXCEPTIONS.md.
Catégorie : Additif couche 0 + entorse assumée à ADR-021 conv. 3 (« pas de wrapper générique pour syscalls multiplexés ») et à la règle « la libc binde la couche 1, jamais la couche 0 » (ADR-077). Aucun symbole existant modifié.
Contexte
Le lien de hello-std contre libair (M5) laisse un symbole indéfini après les Lots
A–C : syscall. La libc C-ABI d’Air doit fournir long syscall(long number, ...)
parce que le pal unix de Rust std l’appelle en direct :
futex—syscall(SYS_futex, …): aucune fonction libcfutex()n’a jamais existé (glibc/musl y accèdent tous par le syscall brut) ; c’est la base desMutex/Condvar/Once/parkde std sur Linux.gettid—syscall(SYS_gettid): pas de wrapper libc garanti avant glibc 2.30 (2019) ; std le fait en brut par portabilité.
syscall(2) est par nature une trappe non typée (un numéro + 6 mots bruts
transmis au noyau). On ne peut ni la rendre typée sans la dénaturer, ni router proprement
les numéros (les flags d’opération futex de std rendent tout routage fragile).
Décision
- Couche 0 : ajouter
air_sys_syscall::raw_syscall::raw_syscall(number, a1..a6) -> c_long— l’instructionsyscall/svc 0brute (2 arches), rendant la valeur brute du noyau. C’est l’équivalent du__syscallde musl. - Libc : shim
air_libc_capi::syscallcible-only (#[cfg(target_vendor = "air")], non-variadique 7-params commeopen/fcntl) qui binderaw_syscalldirectement et applique la convention__syscall_ret(-errno∈[-4095, -1]⇒errno+-1).
Ceci est une entorse assumée : (a) ADR-021 conv. 3 interdit les génériques — mais
syscall(2) est l’escape hatch générique, pas un multiplexeur d’ops typées ; (b) la
libc ne doit pas taper la couche 0 — mais ce shim le fait, faute d’API typée pour futex.
Statut TEMPORAIRE + plan de retrait
Le BDFL refuse cet appel générique traversant la couche 0 de façon permanente. Accepté
uniquement comme débloquant M5. Action item amont (équipe Rust) : faire que le pal
unix de std n’ait pas besoin d’un syscall générique sur *-linux-air — router
futex/gettid vers des fonctions typées Air, ou fournir un pal Air dédié. Dès l’amont
résolu, raw_syscall et le shim syscall disparaissent (et cette exception avec).
Conséquences
- Positives : débloque le dernier symbole de M5 ; escape hatch correct (tout numéro, future OpenSSH) et confiné (un seul consommateur cible-only).
- Coût : une entorse doctrinale consignée (
EXCEPTIONS.md) + un re-sceau additif (couche-0-v1.12) + une dette explicite à résorber en amont. - Périmètre runtime M5 quasi nul :
hello-stdmono-thread n’appelle jamaisfutex(contention uniquement) ; le symbole doit seulement exister pour lier. - Alternatives rejetées : router
SYS_futex/SYS_gettiddans le shim (fragile — décodage des flags futex de std ; casse sur tout autre numéro) ; renvoyerENOSYSpartout (stopgap qui ne prépare pas OpenSSH).
Portée du re-sceau couche-0-v1.12
air_sys_syscall::raw_syscall. Tag signé posé sous délégation additive
(ADR-065).
ADR-088 — std Rust sur Air safe : PAL custom sur la couche 1, sans libc C
Statut : Accepté (2026-07-10, décision BDFL). Amende ADR-076
(abandonne l’« option A : std sur la libc C libair_c » comme état-cible). Re-amende
ADR-050 (identité de cible : Air n’est plus « déguisé »
en unix). Raffine ADR-077 (AirProcessManager →
AirTaskManager). Résorbe ADR-087
(l’escape hatch raw_syscall disparaît). Instruit par la note d’étude
etude-std-pal-air-safe-fr.md.
Catégorie : Décision de toolchain (toit std) + raffinement d’un Manager couche 1.
N’altère aucune API couche 0 scellée ; la couche 1 (interne, non contractuelle pour les
apps — ADR-077) évolue par re-sceau additif.
Contexte
Le jalon M5 (2026-07-10) a prouvé qu’un programme std compile et tourne sur Air, par
le chemin d’ADR-076 (« option A ») : std réutilise
le PAL unix et lie une libc C (libair_c, crates air-libc-*), zéro glibc.
Mais ce chemin contredit la doctrine fondatrice (« l’unsafe Rust vit en couche 0
uniquement », macro-architecture §1) : la frontière C (extern "C" / #[no_mangle] /
FFI / errno in-band) est unsafe par nature et a même forcé l’escape hatch raw_syscall
(ADR-087). On a donc retiré de l’unsafe
pour le réintroduire par une libc C sous std. Incohérent avec un « userland Rust safe ».
La note d’étude a disséqué le std::sys réel (nightly
piné) et établi que le pivot est techniquement dégagé : (a) std::sys moderne accueille
les backends non-libc (précédent hermit, PAL ~171 lignes) ; (b) la couche 1 d’Air couvre
déjà tous les sous-systèmes en Rust safe (à quelques additifs mineurs près) ; (c) le contrat
est borné (bras cfg_select à ajouter, ~15 modules air.rs, un module futex qui débloque
toute la synchro std) ; (d) tous les backends existants (hermit_abi, xous asm, uefi
r-efi) bindent une frontière unsafe — Air serait le premier backend std safe de bout en
bout.
Décision
std repose sur un PAL Air custom et safe, bindant la couche 1 Rust, sans lib C ni
bindings C.
-
PAL custom safe. On implémente
std::sys::pal::air+ les modulessys/<domaine>/air.rs(fs, fd, net, thread, alloc, stdio, time, random, env, args, exit, pipe…) qui bindent les Managers de la couche 1 en Rust safe (AirResult<T>), zéro FFI. Le modulefutexsuffit à rendreMutex/Condvar/RwLock/Once/Parkerdestdgratuits. -
Invariant de layering (absolu). Le PAL est un toit : il s’assoit sur la couche 1, JAMAIS la couche 0 (instable, ADR-077 + doctrine de stabilité). Tout besoin du PAL est exposé par le Manager adéquat, qui s’appuie en interne sur la couche 0. Les manques (
current_exe,DirEntrytypé,park/unpark, certaines options socket) sont ajoutés dans les Managers (additifs couche-1v1.x). -
Identité de cible :
target_os = "air", Air n’est plus déguisé en unix. Ceci revient sur le choixenv = "musl"d’ADR-076 (fait exprès pour réutiliser le PALunix+ une libc). Air ne doit pas être capté par les brastarget_family = "unix"destd::sys(sinon il retombe surpal/unix→ libc). Les champs précis du spec JSON (os/env/abi/family) relèvent de la tâche de reprofilage de la cible (premier lot du chantier).hermit/xoussont des PRÉCÉDENTS STRUCTURELS, pas un modèle à cloner.hermitest un unikernel (noyau Rust propre, cibles*-unknown-hermit),xousun micro-noyau — ni l’un ni l’autre n’est Linux. Air est un userland Linux : son PAL binde la couche 1 → couche 0 → kernel Linux. On n’emprunte àhermitque (a) le patron « PAL mince +sys/*/air.rs» et (b) le précédent prouvant questd::sysaccepte un backend non-libc. Pas son noyau, pas sa cible, pas sa frontière C-ABI unsafe (hermit_abi). -
libair_csurvit, mais découplée destd. La libc C (air-libc-*) reste le produit pour les vrais développeurs C (porter des outils Unix), Linux-conforme, pas POSIX (cf. ADR-046, doctrine « symbole déprécié/inerte pour une abomination typepthread_cancel»). Maisstdne passe plus par elle. Deux produits, deux publics. -
AirTaskManagerremplaceAirProcessManager(raffine ADR-077). « Kernel = bible » : le kernel ne connaît que latask(struct task_struct) ; le process POSIX est un thread group (tasks partageant untgid), le thread est une task.AirTaskManagermodéliseAirTask+AirThreadGroup(tgid), médie surair-process+air-thread+ les concerns tâche/fork/TLS d’air-runtime, et produit côté userland les notions process/thread (getpid= tgid,gettid= pid de task). Il expose spawn/join,getpid/gettid,yield,park/unpark,set_name, et lefutex(primitive de parking, nature inter-process documentée). Faux ami écarté : lestruct task_groupdu kernel = cgroup d’ordonnancement CPU, sans rapport — relève d’AirSystemManager. -
Résorption de dettes. Avec le PAL custom bindant la couche 1,
stdn’appelle plussyscall/futex/gettiden C brut →raw_syscall(ADR-087) disparaît. La detteGlobalAlloc-safe (macro-architecture §8) est traitée dans ce chantier (air-allocderrière le hook, zéro FFI ; le rendre safe est un sujet potentiel d’RFC amont). -
TLS statique natif. Voie « native » (
#[thread_local]ELF, déjà acquise M5 —__tls_get_addrnon requis), pas les clésosfaçonpthread_key. Reste à fournir le hook de fin de thread (guard::enable) pour les destructeurs. -
M5 (option A) = marchepied de faisabilité, conservé comme preuve que
stdcompile+tourne sur Air. Ce n’est plus l’état-cible.
Conséquences
- Stratégie de sceau couche 1 : desceller UNE fois, tout faire, resceller UNE fois (comme le
sceau
couche-1-v1.0: « combler/remodeler puis sceller », pas de churn au fil de l’eau). On descelle la couche 1 pour toute la durée du chantier PAL, on y réalise tout le nécessaire, et on re-scelle une seule fois quand le critère est atteint (voir jalon).- Lot 1 (fait en premier — seul changement cassant) : renommage
AirProcessManager→AirTaskManager(codeair-process/air-thread/air-runtimemédiés +resource_limitd’ADR-086) avec, dans le même lot, l’exposition defutexetgettidviaAirTaskManager(ils existent déjà —air-thread::raw_futex,current_tid— il s’agit de les surfacer). + propagation doc (macro-architecture, ADR-077/085/086, INDEX, suivi, registre, libc-conformance). - Lots suivants (tous additifs, ne cassent rien) : les manques révélés par le PAL
(
current_exe,DirEntrytypé,park/unpark, options socket…) ajoutés dans le Manager adéquat, au fil de l’implémentation. - Re-sceau final unique au jalon de succès. Couche 1 « en flux » seulement pendant le
chantier (host-first, exploratoire) ; les additifs ne cassent ni
libair_cni les consommateurs existants.
- Lot 1 (fait en premier — seul changement cassant) : renommage
- Perte de la compatibilité
unix-family « drop-in » pour les crates tierscfg(unix)** — **assumée** : (i) le **modèle d'exécution par provenance** d'Air confine/recompile déjà tout binaire étranger (la libc Air + rustc + signature étant l'ancre) — le « drop-in » maximal n'a jamais été l'objectif ; (ii) la charte privilégie un **userland safe et prévisible** sur la compatibilité de masse ; (iii)hermit/xous` prouvent la viabilité du modèle non-unix. - Développement host-first : prototypage seul, downstream, via cible JSON custom +
-Z build-std+rust-srcpatché (tree hors-arbrert/, carve-out ADR-030) — aucun RFC requis pour prouver. - Ordre d’implémentation (du plus facile au plus dur, chacun bindant son Manager) :
exit/random/time/os_str/args/env→fd/stdio→futex+sync→fs→thread+thread_local→process→net. Manques couche 1 comblés en additifsv1.x. - Jalon de succès :
hello-stdre-tourne sur le PAL safe, 2 arches, sanslibair_cliée — preuve que le userland Rust est safe de bout en bout (unsafeconfiné couche 0). - RFC amont après la preuve : upstreaming Tier-3 de
*-linux-air(ADR-050) ; conversation Rust Teams libs sur un backendstd::syssafe de première classe et unGlobalAllocsafe. - Réversibilité : le patchset
rust-srcet le spec de cible sont localisés (hors-arbrert/) ; aucun impact sur la couche 0 scellée. La couche 1 évolue par re-sceau additif.
Alternatives rejetées
- Rester en option A (
stdsur la libc Clibair_c, ADR-076) : rejeté — réintroduit l’unsafevia la frontière C sousstd(et imposeraw_syscall), en contradiction directe avec la doctrine « userland Rust safe ». C’est la raison même du pivot. - Rester
unix-family + insérer un brasairavantunixpartout : rejeté — fragile (un brasunixoublié capte Air → libc silencieusement), et ne règle pas le fond (l’objectif est ne pas réutiliser le palunix). Le modèlehermit(target_ospropre) est plus sûr. - Un
AirThreadManagerséparé, ou garderAirProcessManager: rejeté — le kernel ne sépare pas task et process (tout esttask, le process = thread group) ; un Manager « process » qui ignore les threads, ou un split thread/process artificiel, s’éloignerait du modèle noyau.AirTaskManager(task + thread group) est fidèle et produit process/thread. - Fork downstream permanent de
std::sys: rejeté comme état final — on prototype downstream, mais la cible est l’upstreaming Tier-3 pour ne pas rebaser éternellement.
ADR-089 — Réservation du SecurityManager : sécurité active per-process (couche 1)
Statut : Accepté (2026-07-10, décision BDFL) — ADR de réservation d’architecture.
Grave l’existence, le rôle, le placement et le seam du SecurityManager ;
le moteur (politique, règles, détection, enforcement concret) fera l’objet d’un ADR
ultérieur. Companion ADR-032 (préservation des données),
ADR-077 (Managers), ADR-073
(config binaire).
Catégorie : Architecture (sécurité, couche 1). N’altère aucune API scellée ; introduit un seam (interface + défaut permissif) câblé additivement.
Contexte
Air est secure-first, au service de la préservation des données confiées par l’utilisateur (charte ; ADR-032). La sécurité compte dès la couche 1, à deux étages :
- Sécurité passive — ce que le kernel offre (contrôle d’accès fichiers par uid/gid, DAC, capabilities). Nécessaire mais insuffisante : elle ne protège pas contre l’abus dynamique d’un exécutable déjà autorisé (un binaire lancé par l’utilisateur qui tente d’allouer des pages énormes, boucle en fork-bomb de shells, ou subit un flood réseau).
- Sécurité active — une décision au moment de l’opération : autoriser ou refuser, et le
journaliser. C’est ce qui manque, et c’est l’objet du
SecurityManager.
On façonne les Managers de domaine en ce moment (ADR-077,
ADR-088 — AirTaskManager vient d’être posé).
Réserver dès maintenant le point de consultation évite un rétro-fit transverse coûteux et
risqué une fois les Managers stabilisés (Principe d’ingénierie 5 : sur-sécuriser puis mesurer,
jamais l’inverse ; Principe 4 : validation en amont).
Décision
-
Existence & rôle. Le
SecurityManager(couche 1) est l’oracle de sécurité active d’Air. Tous les Managers sensibles consultent leSecurityManagerAVANT une opération sensible. Il répond une décisionAllow/Denyet journalise la raison du oui/non dans les logs machine (AirLog/journald) — traçabilité obligatoire. -
Placement : per-process, embarqué par
libair— PAS un daemon. Un démon central devrait répondre à tous les process → goulot d’étranglement + point de défaillance unique. LeSecurityManagervit dans chaque process, fourni parlibair, et est sollicité par les autres Managers du même process. La décision est locale ; l’enforcement passe par les mécanismes kernel partagés que la couche 0 expose déjà (famille security : eBPF — map de drop réseau —, cgroups — quotas mémoire/CPU —, seccomp, Landlock) : une décision locale a donc un effet machine-wide via le kernel. Les règles proviennent de la configuration binaire (ADR-073). -
Seam (ce qu’on pose maintenant). Une interface
SecurityManager—check(operation) -> Decision { Allow | Deny(motif) }, le motif étant journalisé — avec un défaut PERMISSIF (allow-all + log). Les Managers sensibles (AirTaskManager,AirNetworkManager,AirFileManager,AirMemoryManager…) consultent unSecurityManager. Le câblage de la consultation est incrémental (ajouté quand on touche chaque Manager), pas un big-bang. Tant que le moteur n’existe pas, le défaut permissif rend la consultation sans effet (mais la place et la trace de log existent). -
Exemples cadrant le besoin (non normatifs, pour la spéc du moteur) : > 50 tentatives de connexion en < 5 s depuis une même IP → poser dynamiquement une règle eBPF qui drop cette source ; exécutable qui tente d’allouer des pages énormes ou boucle en fork-bomb → refus + log.
-
Explicitement DIFFÉRÉ (ADR ultérieur). Le moteur : format des règles, détection d’abus, rate-limiting, intégration concrète eBPF/cgroups, agrégation d’états, politique par défaut. À aligner sur
air-sshd(ADR-074), premier vrai consommateur de sécurité active (flood de connexions).
Conséquences
- Les Managers sensibles gagnent, au fil de l’eau, un point de consultation à défaut permissif — coût quasi nul, zéro effet fonctionnel tant que le moteur est absent.
- Ne bloque pas le chantier PAL (ADR-088) : le
PAL binde les Managers ; ce sont les Managers qui consultent le
SecurityManager, pas le PAL. On peut poursuivre le reprofilage de la cible et l’implémentation du PAL. - À terme, le
SecurityManagerest le bras actif du « rempart devant le kernel » (doctrine userland Rust safe) : la sécurité passive (kernel) + la sécurité active (SecurityManager) se cumulent (defense in depth). - Placement per-process ⇒ le manager ne porte pas d’état machine-wide en propre ; l’agrégation et l’enforcement transverses passent par le kernel (maps eBPF, cgroups).
Alternatives rejetées
- Démon de sécurité central : rejeté — goulot d’étranglement (répond à tous les process), point de défaillance unique, latence sur chaque opération sensible.
- Ne rien réserver, décider plus tard : rejeté — rétro-fitter la consultation dans chaque Manager une fois l’API stabilisée est coûteux et risqué (Principe 5). Le coût de la réservation maintenant est quasi nul.
- Sécurité passive kernel seule (DAC) : insuffisante contre l’abus dynamique d’un exécutable déjà autorisé (flood, fork-bomb, huge-alloc) — d’où le besoin d’un étage actif.
- Tout spécifier maintenant (moteur inclus) : rejeté — dériverait le chantier PAL en cours ;
le moteur mérite son propre ADR, informé par
air-sshd.
ADR-090 — Binding de la couche 1 dans std par rustc-dep-of-std : découplage plutôt que fork, sans descellement de la couche 0
Statut : Accepté (2026-07-11, décision BDFL). Met en œuvre ADR-088
(le PAL std custom safe bindant la couche 1). S’appuie sur ADR-054
(scission air-base-core / i18n). Encadre l’usage des exceptions 80 %
ADR-016/ADR-059 (icu4x) et
ADR-034 (RustCrypto) dans la fermeture de std.
Relève de la doctrine dépendances ADR-024 (règle 80 %).
Catégorie : Décision de toolchain (comment le toit std consomme la couche 1). N’altère
aucune API couche 0 scellée ni couche 1 en sémantique ; ce sont des additifs
build-config + une discipline de layering.
Instruit par la mesure (host-first, exploratoire, branche feat/pal-safe-air-target,
incréments 0→3 — voir rt/DECISIONS.md et etude-std-pal-air-safe-fr.md).
Contexte
ADR-088 pose que std repose sur un PAL safe bindant les Managers couche 1. Restait la
question de mécanique : comment un Manager couche 1 (Rust safe) entre-t-il dans le graphe de
dépendances de std ? La reconnaissance (incréments 0→3) a mesuré la réponse :
- Le motif est
rustc-dep-of-std(précédentlibc/hashbrown/hermit-abi) : une crate qui entre dans la fermeture destdest bâtieno_stdavec une featurerustc-dep-of-std(passthrough verscore/compiler_builtins), car elle ne peut pas dépendre destd(elle est ce à partir de quoistdest bâtie — sinon cycle). - Coût côté Air = quasi nul. Binder
air-allocréel (incrément 2) n’a touché que 3Cargo.toml(featurerustc-dep-of-stdsurair-alloc/air-sys-syscall/air-sys-types, +38 lignes) — zéro source.rs, zéro changement sémantique, zéro gate de couverture. Les crates Air visent déjàno_std(Principe 4). Le descellement formel de la couche 0 n’est donc PAS nécessaire. - Le vrai mur est externe. Toute dépendance externe tirée dans la fermeture de
stdqui n’a pasrustc-dep-of-stdamont heurte l’erreur « two cores » (E0463) et impose un fork vendored (le casbitflags, incrément 2). L’audit (incrément 3) a chiffré le binding naïf de tous les Managers à ~71 forks (33 icu4x viaair-base-lib+ 37 RustCrypto viaair-crypto+bitflags) — intenable. - Mais ce fardeau est accidentel, donc évitable. Les deux grappes lourdes n’entrent dans
stdque par couplage de chemin d’import : (a)AirError/AirResult/AirPathsont définis dansair-base-core(icu-free) ;air-base-libne fait que les ré-exporter en y ajoutant icu4x — un Manager qui importe depuisair-base-libtraîne inutilement 33 crates ; (b)AirRandom::filln’appelle quegetrandom(couche 0) — le rattacher à un chemin crypto traîne inutilement 37 crates. Ni icu4x ni RustCrypto ne sont utilisés parstd(agnostique à la locale ; pas de crypto dans le PAL).
Décision
Les Managers couche 1 entrent dans std par rustc-dep-of-std (build-config), et la fermeture
de std est gardée minimale par découplage, jamais par fork des exceptions 80 %.
-
Mécanisme :
rustc-dep-of-std, pas de descellement. Une crate Air dans la fermeture destdreçoit une featurerustc-dep-of-std(build-config) et resteno_std. Aucun changement sémantique d’une API couche 0 scellée ou couche 1. Pas de re-tag de la couche 0. -
Discipline de découplage (absolue) —
air-base-core, jamaisair-base-lib. Tout bras PALsys/<domaine>/air.rs, et tout Manager destiné à la fermeture destd, importe ses types transverses (erreurs, chemins, temps monotone, os_str, log, encodages) depuisair-base-core(icu-free), JAMAISair-base-lib(qui tire icu4x).air-base-libetAirString+icu4x restent le domaine applicatif (couche 2 / apps, consommateurs destd, au-dessus). -
Interdiction des exceptions 80 % lourdes dans la fermeture de
std. Binder un Manager dansstdinterdit de tirer icu4x (ADR-016/059) ou RustCrypto (ADR-034) — ou toute exception 80 % à grande grappe — dans sa fermeture. Ces exceptions vivent au niveau consommateur (couche 2libair, applications), au-dessus destd, oùstd::fs+String+AirString- icu4x cohabitent librement. Elles ne descendent jamais dans la fondation.
-
AirRandom/randomsur un chemin sans crate crypto. Le brassys/random/airest rattaché à un chemin qui n’entraîne pas RustCrypto (isolation — feature minimale surair-crypto, ou micro-surface dédiée).AirRandom::fillne fait qu’appelergetrandom. -
Frontière
no_std= relation àstd, pas un numéro de couche. Composant à partir duquelstdest bâtie (couche 0, couche 1, backend PAL) ⇒no_stdpour toujours (dépendance destd, ne peut en dépendre). Consommateur destd(couche 2libair, applications) ⇒stdcomplet.String/Vec(types d’alloc, =std::String) sont disponibles dès la couche 1 ;AirString(i18n) est un type applicatif, jamais requis par le PAL. -
Politique de fork des dépendances externes (règle 80 %). Une dépendance externe sans
rustc-dep-of-stdamont, tirée dans la fermeture destd, impose un fork vendored localisé (patch + script, façonbitflags, réversible). On minimise ce nombre : auditer avant de binder, préférer le découplage (déplacer l’import) ou le retrait / sous-module (règle 80 %, ADR-024) au fork. On ne forke PAS icu4x ni RustCrypto (exceptions ratifiées, réimplémentation exclue) — on les garde hors de la fermeture destd. -
Industrialisation par vagues. (a) Vague 0 : binding réel du noyau gratuit (
air-base-core,air-thread/futex+sync,air-handle,air-stdio,air-env, +air-alloc), fermeture propre = 1 seul fork (bitflags). (b) Vague 1 :air-filesystem/air-socket/air-process/air-runtimeaprès redirection d’imports →air-base-core+ additifcurrent_exe. (c) Vague 2 :randomisolé + correction du bug spawn natif aarch64 (thread2 arches). Jalon :hello-stdtourne sur le PAL safe, 2 arches, sanslibair_cliée (objectif ADR-088).
Conséquences
- La couche 0 scellée reste intacte (build-config seul) ; la couche 1 évolue par re-sceau
additif (
current_exe, éventuelReadDirparesseux) — pas de refonte. - Chaque programme Air garde une fondation minimale :
stdne traîne ni icu4x ni RustCrypto (ni leurs tables de données baked-in) — cohérent avec les budgets mémoire (Pi 4) et la fondation safe/auditable d’ADR-088. En static-PIE, unhello worldne paie pas l’Unicode. - Dette de fork bornée à 1 (
bitflags) tant que la discipline de découplage tient ; toute nouvelle dép externe dans la fermeture destdpasse par l’audit (règle 80 %). air-base-libconserve son rôle d’API i18n historique, au niveau consommateur — non déprécié, simplement hors du cheminstd.- Caveat outillage : le motif
rustc-dep-of-stdlaisse des façadesrustc-std-workspace-*dansCargo.lock(jamais compilées feature OFF) — vérifiercargo deny/audit/macheteet la repro bit-pour-bit (ADR-025) avant tout merge surmain. - Lien RFC amont : ce mécanisme downstream (fork
rust-src+rustc-dep-of-std) est ce que l’étage 2 de l’étude RFC (etude-rfc-trait-backend-std-fr.md, backend hors-arbre via-Z build-std-platform) viserait à remplacer côté amont. La preuve downstream nourrit l’RFC.
Alternatives rejetées
- Poser
stdsurair-base-lib(avec icu4x) : rejeté — fait entrer tout icu4x (~33 crates- tables de données) dans la fondation de chaque programme, pour une capacité que
stdn’utilise jamais (agnostique à la locale) ; risque de cycle (defaultd’icu4x tirestd— ADR-054) ; ~33 forksrustc-dep-of-stdà maintenir. Le découplage versair-base-corel’évite à coût nul.
- tables de données) dans la fondation de chaque programme, pour une capacité que
- Forker les 33 icu4x / 37 RustCrypto pour
rustc-dep-of-std: rejeté — ~71 forks + dette de re-sync, dont 37 crypto sécurité-sensibles (re-audit à chaque sync). Intenable et inutile (deux grappes non utilisées parstd). - Descelller/refondre la couche 0 pour la rendre dépendance de
std: rejeté comme inutile — la mesure (incrément 2) montre que seule une feature build-config est requise ; les crates sont déjàno_std. Un descellement sémantique serait un risque gratuit. - Réimplémenter un Unicode/crypto maison pour éviter icu4x/RustCrypto : rejeté — exceptions 80 % ratifiées (ADR-016/059/034) ; hors sujet, car le problème n’est pas la dépendance mais son placement (consommateur, pas fondation).
- Attendre l’RFC amont (backend
std::sysde première classe) avant d’avancer : rejeté comme bloquant — on prouve downstream d’abord, on propose l’RFC ensuite (stratégie ADR-088).
ADR-091 — Motif d’architecture réseau sans-IO : anatomie canonique à 9 composants, obligatoire
Statut : Accepté (2026-07-11, décision BDFL). Rend NORMATIVE la §2 de la note de travail
reseau-architecture-crates-fr.md (jusqu’ici
« exploratoire, non engageant »). Toute implémentation réseau maison d’Air DOIT s’y conformer.
Catégorie : Architecture (réseau, couches 1–2, fondateur). Toute évolution structurante passe par un RFC (ADR-015). S’appuie sur ADR-019 (erreurs), ADR-038 (sync-first / async opt-in), ADR-024 (dépendances 80 %). Encadre ADR-042 (TLS) et ADR-043 (SSH).
Contexte
Le portage std sur Air safe est bouclé (ADR-088) ; la phase suivante est la couche 2 —
runloop air-async → framework réseau Air → air-sshd (la démonstration de la couche 2).
Avant d’écrire ces protocoles à état (SSH, TLS, QUIC, HTTP), il faut graver comment on
architecture le code réseau, pour une raison de fond :
Le motif sans-IO est le SEUL qui permette de tester l’ensemble de la pile réseau indépendamment du réseau. On fuzz et on property-teste le cœur d’un protocole en lui jetant des octets arbitraires, sans aucun setup réseau — c’est la surface hostile (le réseau est la première surface de brèches), isolée et directement testable.
La note reseau-architecture-crates-fr.md a mûri ce motif (§2) ; il est déjà éprouvé — le
client DNS d’air-socket (codec pur crate::dns fuzzé, séparé du pilote I/O resolver.rs) en est
le précurseur. Il devient normatif ici, avant que SSH/TLS/QUIC ne s’écrivent, pour que toute
la pile réseau naisse sur la même anatomie testable.
Décision
Tout crate réseau maison d’Air suit le motif sans-IO et l’anatomie canonique à 9 composants ci-dessous. C’est obligatoire, uniforme, et vérifié en revue.
-
Séparation sans-IO (règle absolue). Un protocole se décompose en deux étages nets :
- Cœur sans-IO (pur) — machine à états
octets entrants → événements + octets sortants. Aucun socket, aucun async, aucune horloge, aucune allocation cachée. Pur calcul (parse, sérialise, transitions, erreurs protocolaires). Viseno_std + alloc. - Pilote I/O (mince) — la seule partie qui touche le monde extérieur : câble le cœur à
air-socket(synchrone) et/ouair-runtime/air-async(asynchrone). Reste fin.
- Cœur sans-IO (pur) — machine à états
-
Anatomie canonique — les 9 composants, uniformes dans CHAQUE crate. Un lecteur doit retrouver les mêmes cases d’un crate à l’autre (« on sait toujours où regarder ») ; chaque composant est testable en isolation. 8 des 9 sont purs (le cœur sans-IO) :
# Composant Frontière Pur ? 1 Framer délimite le flux en trames ✅ 2 Codec (dé)sérialise le contenu d’une trame ↔ messages typés ✅ 3 StateMachine transitions du protocole (légalité, événements, émissions) ✅ 4 Handshaker établissement (kex/handshake) — sécurité-critique ✅ + air-crypto5 Flow Controller back-pressure / fenêtrage ✅ 6 Multiplexer multiplexage de flux/canaux ✅ 7 Timer Manager échéances (timeouts/retransmission/keepalive) ✅ horloge injectée 8 Session Context état/params/secrets par connexion (état partagé explicite) ✅ 9 Extension hooks points d’extension (ALPN, extensions TLS, négo algos…) ✅ Communication par événements/messages typés (pas d’état mutable partagé), sauf le Session Context (#8) qui est l’état partagé explicite, passé par référence. Chaque composant expose une interface étroite (trait).
-
Absence intentionnelle, jamais un trou. Tous les protocoles n’exercent pas les 9 (ex. un client NTP : Framer + Codec + StateMachine minimale + Timer). Un composant absent par conception est marqué explicitement (type unité / commentaire
// NO MULTIPLEXING: …) — l’absence est visible et intentionnelle. Ainsi « je retrouve toujours les mêmes cases » reste vrai. -
Structure de crate type.
air-<proto>/src/proto/= le cœur sans-IO (un fichier par composant :framer.rs/codec.rs/state.rs/handshake.rs/flow.rs/mux.rs/timers.rs/session.rs/ext.rs+mod.rsassemblantfeed(&[u8]) -> Events/poll_transmit() -> Option<&[u8]>/handle_timeout(now), pilotés sans I/O) ;src/io/= le pilote (seul endroit qui touche sockets + horloge réelle) ;fuzz/= une cible de fuzz par composant parseur. -
Régime de test drastique — par composant, en isolation (la première ligne).
- Fuzz obligatoire sur chaque parseur (Framer/Codec/Handshaker) : octets hostiles → jamais de panic, jamais de lecture hors-borne, jamais d’alloc non bornée, jamais de trame malformée émise.
- StateMachine : property + model-based (transitions illégales rejetées ; couverture
état×événement). Timer : virtual clock déterministe — jamais de
sleepréel en test. - Session Context : zeroize des secrets au drop, zéro fuite inter-sessions.
- Pilote I/O : intégration loopback réelle. Interop/conformité : vecteurs rejoués
contre les implémentations de référence (comme les KAT d’
air-crypto). - Couverture 100 % (couches fondatrices).
-
Neutralité sync/async (ADR-038). Le même cœur sans-IO se pilote en synchrone (boucle bloquante sur
air-socket) ou en asynchrone (air-runtime/air-async). On ne se verrouille pas sur l’async ; le choix vit dans le pilote, pas dans le cœur. -
Composabilité — cœurs sans-IO empilés. Les protocoles s’empilent par composition de cœurs : le pilote d’un cœur N consomme l’API du cœur N−1 (pas son I/O). L’I/O réel n’existe qu’au bas de la pile (
air-http/h3 → air-quic → air-tls → air-socket/UDP). -
Règles de codage (héritées des Principes et des couches 0/1). Parsing défensif (Principe 3 :
get()jamais l’indexation,&[u8]vs&strstrict,checked_*, anti-boucle) ; aucune fnunsafeexposée (// SAFETY:sur tout bloc interne) ; erreursAirError(ADR-019, protocolaires dans le cœur / transport dans le pilote) ; back-pressure explicite ; zéro-C (check-c-surface) sauf l’exception TLS nommée et étroite d’ADR-042 (le C ne touche que les primitivesaws-lc-rs, jamais le protocole ni le parseur). -
Portée. Obligatoire pour toute implémentation réseau maison :
air-tls,air-quic,air-http,air-ssh/air-sshd,air-mdns, WireGuard, DHCP, NTP, etc.air-socket(L1, sockets BSD) et le client DNS (déjà sans-IO) sont conformes / précurseurs. Un nouveau crate réseau qui n’exhibe pas les 9 composants (ou leurs absences marquées) ne passe pas la revue.
Conséquences
- Sécurité par construction : la surface hostile n°1 (parseurs réseau) est isolée, fuzzée, testée sans réseau. L’uniformité (« on sait où regarder ») + l’isolation stricte sont nos deux leviers de sécurité réseau.
- Testabilité déterministe : le cœur est une fonction ; pas de réseau ni d’horloge réelle dans les tests unitaires/property/model-based. La pile entière est testable indépendamment.
no_std-friendly : le cœur pur peut viserno_std + alloc; le couplagestd/OS reste dans le pilote — cohérent avec l’ethos couches 0/1.- Prérequis de la couche 2 : ce motif conditionne
air-async(le pilote async), le framework réseauair-network, etair-sshd. On l’écrit avant eux. - La note reste l’élaboration tactique : liste des protocoles (§1), points durs (§3), ordre de
priorité (§4) demeurent exploratoires dans la note ; seule la §2 (le motif) devient normative
ici. Les choix par protocole (granularité
air-http, périmètre SSH v1, vendoring QUIC…) restent ouverts, tranchés par specs/ADR au fil de l’eau. - Précédent industriel assumé :
quinn-proto/h2(Rust),hyper-h2/quic-go— le sans-IO est l’état de l’art pour séparer état protocolaire et I/O.
Alternatives rejetées
- Stack monolithique async (cœur + I/O mêlés) : rejeté — non fuzzable sans réseau (la surface hostile n’est pas isolable), verrouille l’async (contre ADR-038), non composable, et rend la couverture 100 % illusoire.
- Architecture ad hoc par protocole : rejeté — perd l’uniformité « on sait toujours où regarder », multiplie les revues, dilue la discipline de fuzz sur la surface la plus dangereuse.
- Réutiliser des piles réseau C existantes (OpenSSL/…): rejeté — viole le zéro-C sur la surface hostile n°1 (memory-unsafety là où c’est le plus grave) ; l’unique exception C est TLS-primitives via aws-lc-rs (ADR-042), étroite et nommée, jamais le protocole/parseur.
- Laisser la note « exploratoire » : rejeté — SSH/TLS/QUIC vont s’écrire ; sans motif normatif avant, ils divergeraient, et le levier de test/sécurité serait perdu au moment où il compte le plus.
ADR-092 — air-async : découpage réacteur (couche 1) / exécuteur (couche 2) de la runloop io_uring
Statut : Accepté (2026-07-11, décision BDFL). Raffine et renomme ADR-038
(runtime async natif io_uring) : le runtime, placé « couche 2 » par ADR-038, se scinde en un
réacteur couche 1 (air-uring) et un exécuteur couche 2 (air-async) ; le nom air-async
remplace air-event. Met en œuvre ADR-091 (air-async = le
pilote I/O async du motif sans-IO). Instruit par le rapport de design
design-air-async-fr.md (incrément 0, squelette prouvé).
Catégorie : Architecture (couche 2 / couche 1, fondateur — la runloop). Toute évolution
structurante passe par un RFC (ADR-015). S’appuie sur
ADR-022/ADR-028 (io_uring
couche 0), ADR-044 (précédent air-poll), et le gate
cargo xtask check-layers.
Contexte
Le portage std sur Air safe est bouclé (ADR-088). La couche 2 commence, et sa pièce maîtresse
est la runloop asynchrone (ADR-038 : runtime async natif io_uring, sans tokio, io_uring seul moteur
de readiness — nommée air-event, « couche 2 »). L’incrément 0 de design (squelette prouvé
on-host) a fait émerger une contrainte de layering que le gate check-layers (métadonnée
[package.metadata.air] layer) rend désormais dure :
Le réacteur touche io_uring, qui est couche 0 (
air-sys-syscall::io_uring, non transverse). Orcheck-layersinterdit à une couche 2 de dépendre de la couche 0 (M ∈ {N-1, N}). Un runtime async monolithique en couche 2 ne compile donc pas dans notre discipline.
Décision
La runloop se scinde en deux crates, seul découpage cohérent avec le layering et ADR-038 :
-
air-uring— le réacteur, COUCHE 1. Rempart safe au-dessus de l’unsafeio_uring de la couche 0 (exactement commeair-pollexposeppoll, ADR-044). Il possède tous les types io_uring (SQE/CQE,SubmissionToken, buffers possédés) et mappetoken → Waker.layer = 1. -
air-async— l’exécuteur, COUCHE 2. Futures /block_on/ spawn / timers — le pilote I/O async du motif sans-IO (ADR-091), consommé par le futur framework réseau. Il ne nomme aucun type io_uring (jetons opaques,Errno).layer = 2. ADR-038 est respecté : l’exécuteur reste couche 2 ; seul le réacteur descend en couche 1. -
Nommage :
air-asyncremplaceair-event(amende ADR-038). L’exécuteur couche 2 s’appelleair-async; le réacteur couche 1 s’appelleair-uring. La specair-event(couche 2) d’ADR-038 devient la specair-async; les référencesair-eventde la macro-architecture, de l’INDEX et des specs sont propagées enair-async(suivi de cohérence documentaire, ci-dessous). -
Modèle d’exécution (aligné ADR-038) — le socle est déjà en couche 0. Le modèle owned-buffer (« le buffer survit à la complétion », façon
tokio-uring) est déjà implémenté dansair-sys-syscall::io_uring(submit_read/write(Vec<u8>)→into_buffer_result,SubmissionTokenopaque anti-ABA,submit_timeout/IORING_OP_TIMEOUT,register_eventfd).air-uring/air-asyncne réinventent rien. v1 : mono-thread (IoUringestSend + !Sync), un CQE → un réveil deWaker, timers =IORING_OP_TIMEOUT(io_uring seul, pastimerfd/epoll — ADR-038). Preuve incrément 0 :block_on(timeout)traverse exécuteur L2 → réacteur L1 → io_uring L0 → CQE → Waker → Ready,check-layersvert. -
Frontière nette (invariant de revue).
air-uring= tout l’io_uring ;air-async= zéro type io_uring. Un consommateur (framework réseau, cœur de protocole sans-IO) ne dépend que d’air-async.
Conséquences
- Le gate
check-layersa produit l’architecture : la contrainte de layering (métadonnée + gate, mergés #328) a forcé un découpage propre plutôt que de le laisser à la vigilance humaine — validation concrète de cette discipline. air-uring(couche 1) est un nouvel additif couche 1 (la couche 1 reste évolutive — le sceaucouche-1-v2.0ne fige pas l’ajout de briques ; RFC/descellement additif au besoin).air-async(couche 2) inaugure la couche 2.- Prérequis de la suite : le framework réseau, l’IPC, et
air-sshd(démonstration couche 2) consommentair-async; les cœurs de protocole sans-IO (ADR-091) s’y branchent par le pilote I/O. - Questions ouvertes (incrément 1 et suivants) : sûreté d’annulation au drop (le buffer doit
vivre jusqu’au CQE d’annulation — le piège io_uring), corrélation
token → tâcheen O(1) (le squelette balaie unVec; prod = index par slot, éventuel RFC couche 0), spawn multi-tâches + roue de timers, éveil inter-thread (eventfd+msg_ring) et bascule thread-per-core, intégration boucle hôte (register_eventfd+as_fd()), back-pressure de soumission (EBUSY). - Suivi de cohérence documentaire : propagation
air-event → air-asyncdansspecs/layer-2/air-event.md(→air-async.md, contenu à réviser pour refléter le split),INDEX.md,macro-architecture-fr.md, le schémaair-couches-crates.svg. Traité en passe doc dédiée (le contenu de la spec doit refléter le découpage réacteur/exécuteur, pas un simple rename).
Alternatives rejetées
- Runtime async monolithique en couche 2 (ADR-038 littéral) : rejeté — ne compile pas sous
check-layers(couche 2 → io_uring couche 0 = saut interdit). Contourner en rendant io_uring « transverse » banaliserait la frontière kernel-unsafe la plus sensible. - Tout en couche 1 (réacteur + exécuteur) : rejeté — l’exécuteur/futures est de la surface applicative (couche 2 par ADR-038) ; le mettre en couche 1 brouille « couche 1 = rempart safe sur le kernel / couche 2 = surface haute ».
- Garder le nom
air-event: rejeté par le BDFL —air-asyncdit mieux ce qu’est l’étage haut (l’exécuteur de futures) ;air-uringnomme sans ambiguïté le réacteur. ADR-038 amendé en conséquence. - Réimplémenter le modèle owned-buffer / réinventer io_uring : rejeté — déjà fait et prouvé en
couche 0 ;
air-uringl’enveloppe, ne le duplique pas.
ADR-093 — Couche transport SSH-2 d’air-sshd : cœur sans-IO air-ssh-proto, topologie de crates v1/v2, jeu d’algorithmes moderne, wire-compat OpenSSH
Statut : Accepté (2026-07-13, décision BDFL). Met en œuvre la
phase 1 d’ADR-074 (vision air-sshd) selon le
motif obligatoire d’ADR-091 (réseau sans-IO).
Intègre la directive BDFL du 2026-07-13 : air-sshd v1 lancé par systemd
(bon citoyen, a minima), v2 reposant sur le framework réseau air-network (une
stack « ssh ») et lancé par air-launchd ; air-sshd publie ses événements de
session sur AirCom ; configuration binaire via air-config. S’appuie sur
ADR-092 (air-async),
ADR-034 (air-crypto),
ADR-069 (air-socket/air-handle),
ADR-001 (AirCom), ADR-005 (systemd),
ADR-033/ADR-040/ADR-073
(configuration binaire).
Catégorie : Architecture (couche 2, premier service réseau). Toute évolution structurante passe par un RFC (ADR-015).
Contexte
ADR-074 découpe air-sshd en trois phases, chacune
instruite par un sous-ADR : (1) couche transport SSH-2, (2) ssh-userauth,
(3) ssh-connection (canaux/PTY/shell). Le présent ADR instruit la phase 1 — mais
fige la topologie de crates de tout air-sshd, car la directive BDFL impose des
contraintes qui traversent les phases et qui doivent être honorées dès la première
ligne (le découpage en crates est le seul choix qu’on ne peut pas rattraper après coup).
Cinq faits cadrent la décision :
- SSH n’utilise pas TLS. Le transport SSH-2 (RFC 4253) a son propre échange de
version, sa négociation (
KEXINIT), son KEX et son protocole de paquets binaire chiffré. Le framework génériqueair-network/air-tls(HTTP/QUIC) est hors du chemin critique de la v1 — mais la v2 l’utilisera (cf. §1.4). - Le socle est prêt.
air-async,air-socket,air-crypto(profil SSH moderne réellement câblé),air-config(~16 k lignes, ADR-033/040/073) et AirCom (inc.1→7, events pub-sub §7 +callsur objet) existent, scellés/testés.air-sshdn’a besoin ni de la façade libc C ni delibcryptoC-ABI (ADR-074). air-sshdest une fonction de forçage. Il produit des facilités partagées que tout service Air réutilisera — notamment l’hôte de service (§1.3) et le motif de publication d’événements sur AirCom (§5), pas des briques jetables propres à ssh.- Contrainte v1/v2 (directive BDFL). v1 :
air-sshdest lancé et supervisé par systemd (socket-activation +sd_notify, intégration a minima). v2 : il repose surair-network(une stack « ssh ») et est lancé parair-launchd(le superviseur natif d’Air, à venir). La conception des crates doit rendre ce passage v1→v2 propre — idéalement sans toucher au cœur protocolaire. - Observabilité (directive BDFL). Un consommateur AirCom doit pouvoir suivre les sessions : demande de connexion depuis telle machine, négociation ok/ko, shell lancé pour l’utilisateur X, session fermée — et interroger qui est connecté et où.
Décision
1. Topologie de crates — un cœur invariant, une périphérie swappable
Le motif sans-IO (ADR-091) est précisément ce qui rend le passage v1→v2 propre. On sépare ce qui ne change jamais (le protocole) de ce qui change (l’I/O, l’hôte).
1.1 air-ssh-proto — cœur PUR sans-IO (invariant v1↔v2)
Couche 2, no_std + alloc, #![forbid(unsafe_code)], zéro fd, zéro socket, zéro
horloge, zéro I/O. La machine octets entrants → messages typés + octets sortants du
protocole SSH-2 : Framer (paquets, RFC 4253 §6), Codec (SSH_MSG_*),
StateMachine (transport + rekey), Handshaker (version + KEXINIT + KEX +
dérivation), Timer (fenêtre de rekey). Flow/Mux/Session réservés (phase 3, canaux).
Il ne sait jamais d’où viennent les octets → réutilisé verbatim en v2, sans une
ligne changée. C’est l’invariant central.
1.2 air-sshd — le démon, scindé en deux responsabilités
- Pilote de transport d’octets (la seule pièce que la v2 remplace) — déplace les
octets entre le socket et le cœur, exécute les primitives
air-crypto, fournit l’aléa. Derrière un trait minceSshByteStream(lecture/écriture async d’octets ordonnés). v1 : implémenté surair-async/air-socket(io_uring). v2 : implémenté par la stack « ssh » d’air-network(§1.4). - Couche service (invariante v1↔v2) —
SessionRegistry(source de vérité des sessions vivantes, §5), publication AirCom (§5), configuration (air-config, §7), hôte de service (§1.3), et — aux phases 2/3 — privsep (air-process), PTY (air-terminal), politique de login (air-account).
1.3 air-service — hôte de service réutilisable (facilité partagée)
Petite crate couche 2 exposant un trait ServiceHost qui abstrait d’où vient le
socket d’écoute, comment notifier le readiness/état, et comment recevoir l’ordre
d’arrêt. v1 = implémentation systemd (§6). v2 = implémentation air-launchd.
air-sshd (comme tout futur démon Air) ne référence jamais systemd en dur — il
dépend de ServiceHost. C’est le seam v1→v2 de la supervision, et une facilité que
tout service Air réutilise (fonction de forçage, ADR-074).
1.4 v2 — la stack « ssh » d’air-network enveloppe le cœur
En v2, air-network gagne une option de protocole « ssh » : elle n’implémente pas SSH
— elle enveloppe air-ssh-proto comme couche transport/sécurité d’une AirConnection,
et fournit un SshByteStream au-dessus du substrat air-network. v1 et v2 pilotent le
même cœur. Rien à jeter : la transition = remplacer le pilote d’octets (§1.2) par la
stack air-network. (Instruit par un ADR compagnon quand air-network sera bâti.)
2. Jeu d’algorithmes — moderne uniquement, refus du legacy par construction
KEXINIT n’annonce que ces algorithmes (aucun legacy proposé → aucun downgrade) :
| Rôle | Algorithme(s) | Référence |
|---|---|---|
| Échange de clés (KEX) | curve25519-sha256 (+ alias curve25519-sha256@libssh.org) | RFC 8731 |
| Clé d’hôte / signature | ssh-ed25519 | RFC 8709 |
| Chiffrement (AEAD) | chacha20-poly1305@openssh.com (primaire), aes256-gcm@openssh.com (secondaire) | PROTOCOL.chacha20poly1305 / RFC 5647 |
| MAC | implicite (AEAD intégré) — aucun hmac-* négocié | — |
| Compression | none | — |
Refusés (jamais annoncés) : RSA/DSA (clé d’hôte), diffie-hellman-group*, chiffrements
CBC/CTR non-AEAD, hmac-sha1/hmac-md5, compression zlib.
3. Format de paquet — deux régimes gérés par le Framer
- Avant
NEWKEYS: paquet binaire en clair RFC 4253 §6 (packet_length | padding_length | payload | random padding), sans MAC. - Après
NEWKEYS: construction AEAD compatible OpenSSH —chacha20-poly1305@openssh.com(longueur chiffrée par une clé séparée, corps par une autre, tag Poly1305 ;PROTOCOL.chacha20poly1305) ouaes256-gcm@openssh.com(longueur en clair = AAD, RFC 5647). Le Framer bascule surSSH_MSG_NEWKEYS.
4. Échange de clés et dérivation
- Bannière :
SSH-2.0-Air_<version>\r\n(version injectée par le pilote). KEXINIT(§2), puis KEXcurve25519-sha256:SSH_MSG_KEX_ECDH_INIT(Q_C) /SSH_MSG_KEX_ECDH_REPLY(K_S, Q_S, sig) — RFC 5656/RFC 8731.- Hash d’échange
H=SHA-256(V_C || V_S || I_C || I_S || K_S || Q_C || Q_S || K); le serveur signeHavec sa clé d’hôte Ed25519 ;session_id = H(premier KEX). - Dérivation RFC 4253 §7.2 (
HASH = SHA-256, extension itérative). Rekey via le Timer du cœur (RFC 4253 §9), clés renouvelées sans interruption.
5. Publication AirCom des événements de session (directive BDFL)
air-sshd est le premier vrai citoyen AirCom : il publie le cycle de vie des
sessions et l’expose interrogeable. Deux facettes, sur un SessionRegistry unique
(source de vérité des sessions vivantes) :
- Flux d’événements (fire-and-forget, pub-sub AirCom §7) :
ConnectionRequested{peer},KexOk/KexFailed,AuthOk/AuthFailed{user, method, peer},SessionOpened{session_id, user, pty},ShellStarted{user},SessionClosed{session_id}… - État interrogeable (« qui est connecté, où ») :
air-sshdexpose un objet-service AirCom (list_sessions()/session_info(id)viacallsurAirObject, inc.7).
Le schéma d’événements/sessions (capnp) est figé dès la phase 1 ; les événements se
remplissent au fil des phases (P1 : ConnectionRequested, KexOk/KexFailed ;
P2 : Auth* ; P3 : SessionOpened/ShellStarted/SessionClosed).
Conséquence actée :
air-comdevient une dépendance de la v1 d’air-sshd(ADR-074 §4.3 disait « IPC pas requis pour un premier prototype » ; la directive BDFL prime). Coût nul : AirCom est livré (inc.1→7).
6. Hôte de service v1 = systemd (a minima), v2 = air-launchd
Implémentation systemd du trait ServiceHost (§1.3), minimale mais bon citoyen :
- Socket-activation :
sd_listen_fds(lireLISTEN_PID/LISTEN_FDS, fds à partir de 3) → le socket d’écoute vient de systemd (pas debinden propre si activé). sd_notify: datagramme AF_UNIX vers$NOTIFY_SOCKET(READY=1après binding des clés d’hôte,STOPPING=1,STATUS=…).- Arrêt propre :
SIGTERMviasignalfd(déjà en couche 0) → drain des sessions. - Unité systemd (
.socket+.service) fournie ; effort d’intégration au strict nécessaire pour être un bon citoyen (Type=notify, socket activation).
Ces deux protocoles (sd_listen_fds, sd_notify) sont triviaux en Rust safe (~35 l.
au total sur air-socket/air-env) — aucun binding libsystemd C. La v2 fournira une
implémentation air-launchd du même trait ServiceHost.
7. Configuration binaire (air-config, ADR-073) — existe déjà
air-config (capnp) est mature — on ne le bâtit pas, on définit un schéma
« sshd » par-dessus. Phase 1 : clés d’hôte (Ed25519), jeu d’algorithmes autorisé
(sous-ensemble du §2), paramètres d’écoute, bannière. Phase 2 : fichier de conf
binaire par-utilisateur dans le HomeDirectory (options user + clés autorisées ≈
authorized_keys), lu via air-config. Jamais de sshd_config texte (ADR-073) ;
import/export possibles.
8. Cible de compatibilité on-the-wire
Un client OpenSSH ssh de stock complète le handshake transport (atteint
SSH_MSG_NEWKEYS puis débute ssh-userauth) contre air-sshd. Critère d’acceptation de
la phase 1, prouvé par un test d’intégration exécutant un vrai client ssh. Le rôle
client d’air-sshd est hors phase 1.
9. Neutralité sync/async (ADR-038/092)
Pilote async 1ʳᵉ classe sur air-async ; façade bloquante de confort (harnais de test)
sur air-socket bloquant, pilotée par le même cœur (test croisé sync↔async, ADR-091).
10. Ordre des incréments (phase 1)
| Inc. | Contenu | Preuve |
|---|---|---|
| T.1 | Crates air-ssh-proto/air-sshd/air-service + traits de seam (SshByteStream, ServiceHost) + schéma capnp événements/sessions (figé) + échange d’identification + Framer paquet clair (RFC 4253 §6) + squelette Codec/StateMachine. Fuzz du parseur de paquets. Impl systemd minimale de ServiceHost. | banner échangé ; paquet clair round-trip ; ServiceHost systemd notifie READY |
| T.2 | KEXINIT + négociation (moderne only, first-match) + StateMachine transport (transitions illégales rejetées). Model-based tests. Événement AirCom ConnectionRequested publié. | négociation déterministe ; event visible d’un abonné AirCom |
| T.3 | KEX curve25519-sha256 (X25519, aléa injecté) + hash H + signature Ed25519 de la clé d’hôte + dérivation (RFC 4253 §7.2). Vecteurs de test. KexOk/KexFailed publiés. | H/clés conformes aux vecteurs |
| T.4 | NEWKEYS + régime chiffré chacha20-poly1305@openssh.com (+ aes256-gcm) + rekey. Wire-compat : un vrai client ssh atteint ssh-userauth. | ssh -v traverse le transport |
Chaque incrément : cœur ~100 % (unit + property + fuzz + model-based), pilote > 90 %, barrière verte, délégué à carbon puis re-vérifié et mergé (comme AirCom).
Conséquences
- Transition v1→v2 garantie par construction : le cœur
air-ssh-protoest réutilisé verbatim ; seuls le pilote d’octets (→ stackair-network) et l’implServiceHost(→air-launchd) changent. Aucune réécriture protocolaire. air-service= facilité partagée : tout démon Air (v1 systemd / v2 air-launchd) la réutilise ; c’est le modèle de service (prérequis §4.2 de la note d’archi) livré a minima.air-sshd= premier citoyen AirCom observable : events + objet-service interrogeable ;air-comdépendance v1 (assumée). Exerce concrètement AirCom (pub-sub §7 +callinc.7).- Zéro C, zéro
unsafedans le cœur ; chemin 100 % Rust safe (ni libc C nilibcrypto). - Arêtes de couche :
air-ssh-proto→air-crypto(2→1) ;air-sshd→air-async/air-socket/air-config/air-com/air-service/air-ssh-proto(2→2 et 2→1) ;air-service→air-socket/air-env/air-signal(2→1). Jamais 2→0 (check-layers). - Deux ADR compagnons à venir (v2) : (a) modèle de service /
air-launchd; (b) stack « ssh » d’air-networkenveloppantair-ssh-proto. - Sécurité : moderne only (aucun downgrade), AEAD, constant-time (
air-crypto), clé d’hôte Ed25519, aléa injecté et audité.
Alternatives rejetées
- Porter OpenSSH C / se lier à
libcrypto. Tranché par ADR-074. - Supporter le legacy (RSA/DSA,
dh-group14,hmac-sha1, CBC,zlib). Refusé (surface d’attaque, profil moderne ADR-074). - Attendre / passer par
air-network/air-tlsen v1. SSH n’est pas du TLS ; le framework générique est différé — mais la v2 l’utilisera via une stack « ssh » enveloppant le même cœur (§1.4), d’où la topologie choisie. - Câbler systemd en dur dans
air-sshd. Refusé : le traitServiceHost(air-service) est le seam v1(systemd)→v2(air-launchd) et une facilité partagée. - Réimplémenter SSH dans
air-networken v2. Refusé : la stack « ssh » enveloppeair-ssh-proto, elle ne le duplique pas. - Configuration texte façon
sshd_config/ serde. Refusé :air-configcapnp (ADR-073) existe ; serde le dupliquerait (règle des 80 %, ADR-024). - Cœur appelant le socket / tirant son aléa en interne. Violerait le motif sans-IO (ADR-091) et interdirait le fuzzing déterministe.
Références : RFC 4251 (architecture), RFC 4253 (transport SSH-2), RFC 5656 (ECC),
RFC 8731 (curve25519-sha256), RFC 8709 (ssh-ed25519), RFC 5647 (AES-GCM),
OpenSSH PROTOCOL.chacha20poly1305.
ADR-028 — Soundness and teardown of the io_uring module (S1/S2/S3)
Status: Accepted. Completes ADR-022 without modifying its 10 decisions.
Category: Architecture (layer 0).
Context
ADR-022 establishes the architecture of the air-sys-syscall::io_uring module (abstraction level 2, syscall/io_uring coexistence, three buffer mechanisms, etc.). The detailed specification of the module (master document ../specs/layer-0/io-uring-0-inventaire-en.md and Time 1 ../specs/layer-0/io-uring-1-core-en.md) surfaced three soundness decisions that were not present in ADR-022 and that determine all facade signatures. They must be enshrined as architecture decisions in their own right.
io_uring is asynchronous and concurrent with the kernel: a submitted buffer may be written by the kernel after the submission call returns, and until completion. Two classes of defects follow if the API is poorly designed:
- Allocation cost: finding, at completion, the buffer and metadata of an operation via its
user_datatends to impose a dynamic table (one allocation per operation) — in conflict with the rule “no heap allocation in the happy path” (CLAUDE.md, layer 0 conventions). - Use-after-free: freeing the ring (or a buffer) while an operation is still in flight leaves the kernel writing into freed memory — the major soundness defect of io_uring in Rust.
Furthermore, io_uring can execute operations that bypass seccomp filters (the operation is not a syscall), which raises a confinement issue for a capability-based system (ADR-001-fr.md AirCom, ADR-010-fr.md entitlements).
Decisions
S1 — In-flight operation state: pre-allocated slab
The IoUring owns a pre-allocated slab sized (by default) to the capacity of the completion queue (cq_entries). Each in-flight operation occupies a slot; the transferred buffer (ownership transfer model, ADR-022 Decision 3) is moved into it, without copy or reallocation. The SubmissionToken encapsulates a slot index + generation (compteur de génération anti-réutilisation); the kernel user_data encodes this index. A full slab causes a rejection (EBUSY) before any kernel call, providing structural back-pressure. Consequence: zero heap allocation per operation in the happy path.
S2 — Safe teardown: quiescing Drop + explicit shutdown()
shutdown(self)is the clean path: global cancellation (REGISTER_SYNC_CANCEL), draining remaining completions, then closing the FD and freeing memory; bounded by a timeout.Dropis a safety net: if in-flight operations remain, it quiesces (cancels + drains, in a blocking and best-effort manner) before releasing memory. The cost of a potentially blockingDropis acknowledged and documented; performance-sensitive usage callsshutdown().- Invariant: as long as an operation is in flight, its buffer lives in its slot (S1) and cannot be freed by the caller; the ring cannot be destroyed without quiescing. These invariants guarantee that no kernel write lands on freed memory.
Choice consistent with Engineering Principle 5 (“over-secure then trim after measurement, never the reverse”): an unsound Drop in layer 0 is unacceptable.
S3 — Confinement as a capability primitive
The triptych SETUP_R_DISABLED + REGISTER_RESTRICTIONS + REGISTER_ENABLE_RINGS is treated as a first-class citizen (Time 3f). A ring can be created disabled, restricted to an allowlist of opcodes / register-ops / SQE flags (default-deny), then enabled — the restrictions becoming immutable, enforced by the kernel. Layer 0 provides the mechanism (RestrictionSet, restrict, enable); the policy (translating the signed entitlements from ADR-010-fr.md into restrictions) lives in an upper layer. This is the io_uring building block of Air’s capability model, in defense in depth with seccomp/Landlock (family-security), closing the syscall-filter bypass path.
Relationship to other ADRs
- ADR-022: ADR-028 completes it. The 10 decisions of ADR-022 remain unchanged; S1/S2/S3 are added as soundness decisions for the same module.
- ADR-021-fr.md (layer 0 conventions): S1 implements “no heap allocation in the happy path” and the use of
Option/newtypes (typed token); the slab respects the no-allocation rule. - ADR-001-fr.md / ADR-010-fr.md: S3 materializes at the kernel level the AirCom capability model and signed entitlements.
- ADR-023-fr.md (async runtime): the layer 1 runtime relies on these invariants (slab, safe teardown) to provide safe async primitives.
Consequences
Benefits
- Memory safety by construction (no use-after-free, no buffer accessible while held by the kernel).
- Happy path without allocation, natural back-pressure.
- Kernel-enforced confinement aligned with Air’s security model.
Costs
- A potentially blocking
Drop(quiescing): acknowledged, documented, avoidable viashutdown(). - The slab imposes a bound on in-flight operations (
cq_entriesby default, adjustable): this is an explicit limit, not a defect.
Risks and mitigations
- Risk: quiescing in
Dropmasks a forgottenshutdown()call and incurs latency at destruction. Mitigation: documentation + lint/guidance to callshutdown()on the hot path. - Risk: undersized slab → frequent
EBUSY. Mitigation: explicitmax_inflight()on the builder;in_flight()metric.
Future status
ADR complementary to ADR-022, immutable in its three decisions (S1/S2/S3) unless subject to an RFC. Fine-grained filtering via BPF program (IORING_REGISTER_BPF_FILTER), available from kernel 6.12 onwards, may enrich S3 at a later stage via runtime detection, without amendment.
Document license: MPL 2.0 Status: Architecture document, complement to ADR-022.
Note d’exploration — libc-compat Air (piste, NON engagée)
Statut : exploration. Pas une décision, pas un ADR, pas de spec. Capture un raisonnement pour le futur. Aucune ressource engagée. À revisiter seulement quand les couches 0–2 seront solides. Datée 2026-06-12.
Idée
Fournir une libc pour « asseoir les outils C sur la stack Air » : le nécessaire
et suffisant d’une libc standard, implémenté par-dessus les couches 0/1 d’Air.
Modèle : musl, pas glibc (glibc = trop grosse, vieux bugs gardés pour compat,
et surtout LGPL → hors allowlist deny.toml ; musl = MIT, lean, éthos
proche d’Air). glibc n’est citée que comme la lib répandue, pas comme modèle.
Placement : NON couche 3 — altitude couches 0/1
Une libc est une fondation, ses dépendances pointent vers le bas (le
kernel) ; tout le userspace C dépend d’elle. La couche 3 (composition/rendu) est en
haut et dépend de la couche 2 (runtime/AirCom) — y poser une libc inverserait les
flèches. ⇒ brique transverse « libc-compat » ancrée aux couches 0/1, jumeau
d’air-base-lib tourné vers le monde C plutôt que vers les crates Rust d’Air.
Règle de fer : les crates d’Air ne dépendent jamais de cette libc-compat
(sinon on réintroduit la dépendance libc délibérément supprimée — couche 0 asm!
sans libc/rustix). C’est un sidecar d’hébergement, pas un socle commun.
Deux concepts à ne pas confondre
- (A) Fondation interne d’Air — déjà libc-free : couche 0 (
asm!) + couche 1 (Rust + ABI C). Air n’a pas besoin de libc pour lui-même. - (B) Libc de compatibilité —
libc.so+ en-têtes standard +ld.so+ CRT, pour héberger l’écosystème C existant par-dessus 0/1. C’est (B) la piste.
Fourche & périmètre (réalités)
- Source-compatible (recompiler, façon musl) ✅ vs ABI-compatible glibc (faire tourner des binaires existants bug-pour-bug) ❌ = bourbier de décennies.
- Périmètre réel ≫ ISO C (string/stdio/stdlib/math, modeste) : **linker dynamique
- CRT** (TLS,
__libc_start_main, par arche),malloc,pthreads(exige un futex synchrone — manque couche 0 déjà signalé parair-thread, cf. [[air-strategie-systemd-remplacement]] et la dette futex),dlopen,getaddrinfo(s’adosserait au résolveur DNS maison d’Air), NSS, locale,libm. Ordre de grandeur musl/glibc, pas un week-end.
- CRT** (TLS,
Valeur — analyse honnête (la vraie question)
Une libc de pure compatibilité n’apporte quasi rien à l’application elle-même : par construction, l’app ne peut pas distinguer glibc/musl/Air-libc (c’est le but d’un standard). Le coût servirait surtout à être invisible. La valeur réelle est ailleurs :
- Instrumentation debug/diagnostic (la plus tranchante à court terme — intuition
de Thierry). La libc est le goulot par où passent toute la mémoire, tous
les syscalls, toutes les chaînes. Donc l’endroit idéal pour : malloc durci de
debug (use-after-free, double-free, overflow, fuites avec backtrace + rapport à
la sortie — façon scudo/
MALLOC_CHECK_/electric-fence), traçage de syscalls en log structuré journald (mieux que strace), stdio/chaînes bornées (_FORTIFYtoujours actif en debug), diagnostics d’abort/assert nets. Valeur = expérience développeur, alignée sur l’objectif « Air = environnement de dev de qualité ». - Durcissement par défaut (builds release) — malloc durci, safe-linking,
pointer mangling, CFI : tout programme C sur Air hérite d’une sécurité de base
meilleure sans rien faire (modèle bionic/Android). Point d’application du
2ᵉ pilier [[air-pilier-integrite-binaires]] : c’est
ld.soqui vérifie la signature avant de mapper un exécutable/.so. - Cohérence —
getaddrinfo→ DNS maison, logs → journald, threads → futex Air : le programme C hérite des politiques Air de façon transparente. - Indépendance — ne pas dépendre de glibc/musl-externe (même logique que systemd/udev).
Le découplage clé (insight)
Les deux ambitions sont séparables :
- (B1) « Être la libc système de tout C » = énorme, faible valeur applicative, surtout plomberie + indépendance.
- (B2) « Une libc de debug/diagnostic qui tue » = petite, séparable,
LD_PRELOAD-able, marche même par-dessus glibc/musl, sans s’engager à remplacer la libc système. ROI court terme élevé, c’est exactement l’angle qu’a vu Thierry.
⇒ Si on touche un jour à ce sujet, commencer par (B2) (le sidecar diagnostic), pas par (B1).
Caveat : (B2) recoupe valgrind/ASan/sanitizers, qui existent et sont bons. L’avantage Air n’est pas une capacité nouvelle mais le zéro-friction + l’intégration (toujours actif en debug, sortie structurée Air, sans toolchain spécial ni recompilation lourde). Ne pas survendre.
Position
Même forme stratégique que la discussion systemd : énorme, à seamer et
différer (horizon V2+, après couches 0–2 solides). Si poursuivi : partir de
musl (MIT) + extensions Air, jamais d’une réécriture glibc-ABI ex nihilo ; garder
la libc minimale (le runtime Air reste à côté, déjà en ABI C — un programme C
linke libair-runtime.so/libair-aircom.so séparément) ; et attaquer d’abord la
tranche debug (B2), qui apporte de la valeur sans contrat à vie.
Liens
[[air-strategie-systemd-remplacement]] · [[air-pilier-integrite-binaires]] · ADR-024 (règle 80 % / licences) · dette couche 0 « futex synchrone » (air-thread).
Licence du document : MPL 2.0 Statut : note d’exploration, non engageante. Toute suite passerait par un ADR.
Note d’exploration — Air sur Android (Vendor Interface, racine de confiance, GPU)
Statut : exploration. Pas une décision, pas un ADR, pas de spec. Horizon 2ᵉ temps (après les couches fondatrices). Capture un raisonnement pour le futur ; aucune ressource engagée. Datée 2026-06-13.
But
Faire d’Air un socle portable pour téléphones/tablettes, posé sur le kernel Android fourni par le vendor, en amenant l’infrastructure minimale (systemd ou équivalent + services Air). Objectif central : le vendor (ex. Fairphone) n’a peu à faire — Air fournit le contrat d’intégration, le vendor branche son matériel. Résultat visé : un OS portable hors-Google (pas de GMS/Play), avec sa couche graphique.
Modèle : un « Air Vendor Interface » (analogue de Project Treble)
Ce qui a rendu Android portable entre fabricants : le split system/vendor +
des HAL à interface stable (HIDL→AIDL) + le VINTF. Air fait son équivalent,
natif et hors-Google : une surface d’intégration minimale, stable,
versionnée (sur la C-ABI / air-runtime + des traits HAL couche 1) que le
vendor implémente — et rien de plus. Même philosophie de joint que partout
ailleurs (DNS maison, air-device, air-keystore).
Frontière 80/20 — ce qui rend le travail vendor petit
Le 80 % « gratuit » : Air parle directement aux interfaces kernel standard
(couche 0 = syscalls natifs ; air-device = sysfs/uevent). Sur un kernel Android,
beaucoup est déjà standard côté kernel et le vendor a déjà les drivers :
DRM/KMS (affichage), evdev (entrée), ALSA/PipeWire (audio), USB,
sous-système TEE / Trusty (sécurité). Air consomme l’ABI kernel sans passer
par le userspace Android.
Le 20 % dur (là où l’effort vendor se concentre ; Air fournit des contrats propres, pas l’implémentation) :
- Modem / RIL (téléphonie) — très propriétaire ; le plus gros morceau.
- Caméra — HAL Android énorme/blob-lourd ;
libcamera= voie ouverte mais tuning vendor conséquent. - GPU userspace — driver GL/Vulkan : soit blob Adreno/Mali, soit Mesa (Panfrost Mali, Freedreno/Turnip Adreno). Le point sensible de l’indépendance graphique.
- Capteurs / empreinte, énergie/suspend (wakelocks Android vs runtime PM
Linux), secure key/attestation → backends
air-keystore.
Racine de confiance / TEE (cf. la discussion détaillée)
Sur Android, pas un « enclave » mais deux étages : TEE (TrustZone on-SoC ; Trusted OS QSEE/Qualcomm, Kinibi/Trustonic, ou Trusty/Google) et StrongBox (puce SE discrète, ex. Titan M). On y accède via un driver kernel (SMC vers TrustZone, ou SPI/I²C vers le SE) mais le protocole/le TA sont contrôlés par le vendor. Pas de chemin universel « kernel seul, sans vendor » vers les TEE propriétaires :
- GP TEE Client API (
libteec,/dev/tee0) ou Trusty (/dev/trusty-ipc) = utilisables depuis du userspace Linux sans SDK vendor — mais minorité des appareils commerciaux (OP-TEE / Trusty). - QSEE/Kinibi = blobs userspace propriétaires → dépendance vendor de fait.
- Plafond dur : on ne déploie pas son propre TA/applet sans les clés de secure-boot du vendor.
⇒ Dessin Air : air-keystore (fournisseur clés/attestation) à backends
enfichables (GP-TEE, Trusty, shim blobs vendor).
Point fort de la piste vendor (vs portage utilisateur). Un end-user qui déverrouille le bootloader perd l’attestation. Mais un vendor qui signe (Fairphone) peut livrer Air avec ses propres clés AVB et re-verrouiller → verified-boot intact, racine de confiance possédée par le vendor. Le problème d’attestation ne se pose donc pas pour un partenaire vendor.
Ne pas réinventer — appuis existants
- Halium / libhybris : userspace GNU/Linux sur kernel + HAL Android du vendor (pont glibc↔blobs bionic). Voie de transition « réutiliser les blobs au minimum » ; c’est ainsi que tournent Ubuntu Touch (UBports), droidian, Mobian. Air pourrait offrir un backend Halium.
- postmarketOS : philosophie inverse (kernel mainline, zéro blob) — cible idéale long terme, plus de portage kernel.
- Mesa (Panfrost/Freedreno/Turnip) : GPU libre, indispensable au hors-Google.
- Treble / VINTF : modèle de l’interface vendor.
Partenaire : Fairphone
Contacts existants ; support long, bootloader déverrouillable, esprit libre. Nuance : leurs /e/OS (Murena) et LineageOS restent de l’AOSP dégooglisé (de l’Android) — passer à Air (userspace Linux non-Android) est un saut bien plus grand que /e/OS. Mais comme vendor signataire, Fairphone garde une chaîne de confiance intacte (cf. ci-dessus) → premier partenaire idéal.
Esquisse de plan (2ᵉ temps)
- Définir l’Air Vendor Interface (stable, versionnée, C-ABI) + les traits HAL couche 1.
- Backends de référence pour les interfaces kernel standard (le 80 %).
air-keystore+ HAL Air à backends enfichables pour le 20 % dur ; option pont Halium pour réutiliser les blobs existants en transition.- Couche graphique hors-Google via Mesa (
air-wm/Wayland — couche 3 Air). - Modèle de sécurité vendor-signé (re-lock AVB clés vendor).
Liens
[[air-strategie-systemd-remplacement]] · [[air-pilier-integrite-binaires]] · [[air-piste-libc-compat]] · ADR-005 (systemd) · ADR-006 (profils base/desktop) · ADR-003 (compositeur Wayland) · ADR-010 (entitlements signés).
Licence du document : MPL 2.0 Statut : note d’exploration, non engageante. Toute suite passerait par un ADR.
Note de cadrage — stratégie d’API réseau Air (air-network + équivalent URLSession)
Statut : décisions de périmètre validées par le BDFL (2026-06-13), implémentation non engagée. Forward-looking : grave les cibles de couverture et le placement pour quand on spécifiera les couches 2/4. Toute mise en œuvre passera par des specs
- ADR le moment venu.
Décision (a) — couverture fonctionnelle d’Apple, pas copie d’API
Air vise, pour les développeurs, la même couverture fonctionnelle que les API réseau d’Apple — mais en aucun cas on ne copie leur API. Surface en vocabulaire Air idiomatique (Rust), pas de compat API/ABI Swift/Obj-C. Leur documentation sert de checklist de couverture et de nord de conception, rien de plus.
air-network (couche 2) — équivalent de Network.framework
- Renommage acté :
air-network→air-network(sweep grep sur toutdocs/, aucune référence cassée ;air-socket.md+macro-architecture-fr.md). - C’est le framework de connexion sémantique de couche 2 qu’
air-socket(L1) réserve déjà explicitement. Bâti surair-socket(sockets bas niveau + résolveur enfichable) + le runtime async io_uring (ADR-023) +air-tls(à spécifier). - Référence de couverture : https://developer.apple.com/documentation/network.
- Concepts à couvrir (exprimés en vocabulaire Air, pas en noms Apple) : connexion
(≈
NWConnection), listener (≈NWListener), découverte de service (≈NWBrowser/ Bonjour), conscience du chemin réseau (≈NWPath/NWPathMonitor: wifi/ cellulaire, lien expensive/constrained, better path), paramètres + pile de protocoles déclaratifs (≈NWParameters/NWProtocolStack: TCP/UDP/QUIC/TLS), TLS intégré par défaut. Bon fit Air : sécurité-d’abord + conscient du matériel/ réseau variés.
Équivalent URLSession — client HTTP/URL-loading haut niveau
- Apple construit
URLSessionau-dessus de Network.framework (cf. leur doc) ; Air fait pareil : un client haut niveau bâti surair-network. - À noter et implémenter (Thierry). Couverture à viser (vocabulaire Air) :
sessions + tasks (data/download/upload), cache, cookies, défis
d’authentification, transferts en arrière-plan, WebSocket (≈
URLSessionWebSocketTask). Référence : https://developer.apple.com/documentation/foundation/urlsession. - Couche d’intégration — hypothèse principale : couche 4 (« frameworks
applicatifs »), à condition d’élargir la couche 4 de « UI seulement »
(air-ui/air-tui) à « frameworks développeur (UI + fondation) » —
URLSessionest chez Apple dans Foundation, une commodité applicative, pas un mécanisme système. Alternative : haut de couche 2 (service fondamental). À trancher quand on spécifiera (probable clarification macro-archi / ADR). Nom Air à définir (ex.air-http/air-url-session).
À faire (le moment venu, pas maintenant)
- Spec
air-network(couche 2) avec la doc Network.framework comme checklist. - Spec
air-tls(déjà notée comme différée dansair-crypto). - Spec de l’équivalent URLSession (couche à confirmer — couche 4 élargie),
bâti sur
air-network. - Renommage
air-network→air-network(sweep) à intégrer au prochain landing doc.
Liens
air-socket (L1, réserve air-network couche 2) · air-crypto / air-tls différé ·
ADR-023 (runtime async io_uring) · ADR-016/ADR-024 (dépendances) ·
macro-architecture (couches 2 et 4).
Licence du document : MPL 2.0 Statut : note de cadrage, implémentation non engagée. Specs + ADR à produire.
Note de travail — Protocoles réseau d’Air & architecture des crates réseau purs
Statut : la §2 (motif sans-IO + anatomie à 9 composants) est désormais NORMATIVE via ADR-091 (2026-07-11) — obligatoire pour toute impl réseau maison. Le reste de ce document (§1 liste des protocoles, §3 points durs, §4 ordre de priorité) demeure DOCUMENT DE TRAVAIL exploratoire (tranché par specs/ADR au fil de l’eau ; cf.
docs/notes/api-reseau-strategie-fr.mdpour le cadrageair-network). Objets : (1) lister les protocoles réseau qu’Air doit implémenter, (2) [→ gravé ADR-091] comment on architecture le code de ces crates réseau « purs » (maison, zéro-C, fuzzés).
1. Périmètre & tiers
Reprend la décision de périmètre connectivité (discutée 2026-06-24/25) :
- Desktop + serveur = tier-1. Mobile = tier-2 éventuel (téléphonie hors socle).
- Écartés du socle : SIP, WebRTC, téléphonie (→ tier-2 mobile, ADR ultérieur).
Protocoles retenus (tableau de travail)
| Protocole | Couche | Tier | Dépend de | Crate cible | Transport / framing | Parseur hostile → fuzz | État |
|---|---|---|---|---|---|---|---|
| TCP / UDP / Unix | L1 | 1 | couche 0 net | air-socket | sockets BSD | (adresses) | ✅ livré |
| DNS (client) | L1 | 1 | air-socket | air-socket::resolver | UDP + repli TCP, RFC 1035 | ✅ fuzz_dns_parse | ✅ livré |
| TLS | L2 | 1 | rustls + aws-lc-rs + rustls-webpki, air-socket | air-tls (intègre rustls — ADR-042) | TLS 1.3 (rustls ; provider aws-lc-rs, exception C étroite) | ✅ (intégration + interop) | à spécifier |
| QUIC | L2 | 1 | air-tls, air-socket (UDP), air-runtime | air-quic | QUIC (RFC 9000), TLS 1.3 intégré | ✅ (paquets/frames) | à spécifier |
| HTTP 1/2/3 | L2 | 1 | air-tls (h1/h2), air-quic (h3), air-runtime | air-http (h1/h2/h3 = modules internes) | h1=TCP, h2=TCP/TLS, h3=QUIC | ✅ (parseurs h1/h2/h3) | à spécifier |
| SSH (+ scp/sftp) | L2 | 1 | air-crypto, air-socket, air-runtime | air-ssh (MAISON, différé — ADR-043 ; OpenSSH système en incubation ; client avant serveur) | RFC 4251+, SFTP, strict-kex anti-Terrapin | ✅ (Handshaker/Framer) | différé (ADR-043) |
| mDNS / DNS-SD | L2/L5 | 1 | air-socket (UDP multicast) | air-mdns (ou daemon) | RFC 6762/6763 | ✅ | à spécifier |
| WireGuard (VPN) | L2/L5 | 1 | air-crypto, air-socket, couche 0 | daemon + crate | UDP, Noise | ✅ (handshake) | à spécifier |
| DHCP (client) | L5 | 1 | air-socket | daemon .airservice | UDP broadcast, RA/SLAAC | ✅ | à spécifier |
| NTP / SNTP | L5 | 1 | air-socket | daemon .airservice | UDP | ✅ | à spécifier |
| WebSocket | L2 | 1 | air-http | module d’air-http | upgrade h1/h2 | ✅ | dérivé |
| WebDAV / IPP | L2+ | 1 | air-http | consommateurs HTTP | sur HTTP | (héritée) | dérivé |
| — | 2 | — | (interop seulement) | — | — | écarté tier-1 |
Note SMB (décision BDFL 2026-06-25)
SMB n’est PAS tier-1 (tier-2 au plus). Justification : conceptuellement, SMB n’apporte rien qu’un daemon HTTP/3 (interrogé par un client distant adéquat) ne puisse faire — tout ce que fait SMB (partage de fichiers, énumération, verrous, notifications) est transposable sur un transport HTTP/3. Donc le partage de fichiers Air-natif passera par HTTP/3 (daemon couche 5 + client), pas par l’implémentation du protocole SMB.
⚠️ Réserve honnête : SMB-tier-2 ne resterait justifié que pour l’interop avec l’existant (monter un partage Windows/NAS SMB déjà déployé). Ce besoin d’interop est réel côté desktop, mais différé ; il ne conditionne pas le socle.
2. Architecture du code des crates réseau purs (LE cœur de cette note)
Objectif : un patron d’architecture unique pour tous les crates réseau maison, qui maximise testabilité, fuzzabilité, zéro-C, et la cohérence avec ADR-038 (synchrone-first / async opt-in) et la pile Air (air-socket L1, air-runtime, air-network L2).
2.1 Principe directeur : sans-IO (séparer le protocole de l’I/O)
Chaque protocole se décompose en deux étages nets :
- Cœur sans-IO (pur) — une machine à états du protocole :
octets entrants → événements + octets sortants. Aucun socket, aucun async, aucune horloge, aucune allocation cachée. C’est une bibliothèque de pur calcul (parse, sérialise, transitions d’état, gestion des erreurs protocolaires). - Pilote I/O (mince) — câble le cœur sans-IO au transport réel : air-socket (synchrone) et/ou air-runtime (asynchrone). C’est la seule partie qui touche le monde extérieur ; elle reste fine.
Pourquoi ce patron (et pas un stack monolithique async) :
- Fuzzabilité triviale (Principe 3) : on fuzz le cœur en lui jetant des octets arbitraires — zéro setup réseau. C’est la surface hostile, isolée et directement testable. (Déjà éprouvé : le parseur DNS d’air-socket est sans-IO et fuzzé.)
- Testabilité déterministe : pas de réseau dans les tests unitaires/property ; le cœur est une fonction.
- Neutre sync/async (ADR-038) : le même cœur se pilote en synchrone (boucle bloquante sur air-socket) ou en asynchrone (air-runtime, modèle buffers possédés). On ne se verrouille pas sur l’async.
- Composable : les protocoles s’empilent par composition de cœurs sans-IO — un cœur HTTP/3 pilote un cœur QUIC qui pilote un cœur TLS, etc. (cf. §2.4).
no_std-friendly : le cœur pur peut viserno_std + alloc(cohérent ethos couche 0), le couplagestdrestant dans le pilote I/O.
(Référence d’industrie : le patron « sans-IO » — hyper-h2 (Python), quinn-proto/h2 (Rust), quic-go — sépare état protocolaire et I/O exactement ainsi.)
2.2 Anatomie canonique : les 9 composants (DÉCISION — uniforme dans CHAQUE crate)
Règle gravée. Tout crate réseau Air expose la même anatomie : un lecteur/ auditeur doit retrouver ces 9 composants à l’identique d’un crate à l’autre. Le réseau est la première surface de brèches ; cette uniformité (« on sait toujours où regarder ») + l’isolation stricte (chaque composant testable indépendamment) sont nos deux leviers de sécurité. 8 des 9 composants sont purs (sans-IO) ; ils constituent le cœur sans-IO de §2.1. Le pilote I/O (§2.1) les enveloppe.
| # | Composant | Responsabilité (frontière nette) | Pur ? | Régime de test drastique |
|---|---|---|---|---|
| 1 | Framer | Délimite le flux d’octets en trames (longueurs/délimiteurs/records) : « où commence/finit une trame » | ✅ | fuzz (longueurs tronquées/géantes/malicieuses) ; property : jamais de lecture hors borne, jamais d’alloc non bornée |
| 2 | Codec | (Dé)sérialise le contenu d’une trame ↔ messages typés (≠ Framer) | ✅ | fuzz octets→message (jamais de panic) ; round-trip decode∘encode = id sur valide ; bytes vs &str |
| 3 | StateMachine | Transitions du protocole : légalité, événement déclenché, quoi émettre | ✅ | property + model-based (transitions illégales rejetées, zéro panic) ; couverture état×événement |
| 4 | Handshaker | Établissement (TLS handshake / QUIC / SSH kex / Noise) — sécurité-critique (auth, accord de clés) | ✅ (logique) + air-crypto | KAT/vecteurs de conformité + négatifs (downgrade, handshake malformé) + interop |
| 5 | Flow Controller | Back-pressure / fenêtrage (crédits, windows) | ✅ | property (jamais dépasser la fenêtre, pas de deadlock, comptabilité des crédits équilibrée) ; adversarial (pair hors-fenêtre → rejet) |
| 6 | Multiplexer | Multiplexage de flux/canaux sur une connexion (streams h2/QUIC, canaux SSH) | ✅ | property (IDs sans collision/ABA, isolation inter-streams, équité) ; fuzz entrelacement de trames |
| 7 | Timer Manager | Échéances (timeouts, retransmission, keepalive) | ✅ via horloge injectée | virtual clock déterministe (l’échéance tire exactement quand prévu) ; jamais de sleep réel en test |
| 8 | Session Context | État/params négociés/secrets par connexion — état partagé explicite (lu/écrit par les autres) | ✅ | cycle de vie ; zeroize des secrets au drop (cf. air-crypto) ; zéro fuite inter-sessions |
| 9 | Extension hooks | Points d’extension (extensions TLS, ALPN, upgrade HTTP, négo d’algos SSH) — trait/registre | ✅ | hook invoqué correctement ; extension inconnue gérée gracieusement (ignorée/rejetée selon spec) ; aucun hook ne viole les invariants du cœur |
Communication & frontières. Les composants communiquent par événements/messages typés (pas d’état mutable partagé), sauf le Session Context (#8) qui est l’état partagé explicite, passé par référence. Chaque composant expose une interface étroite (trait) → testable en isolation avec des entrées simulées, sans réseau.
Uniformité même en l’absence d’un composant. Tous les protocoles n’exercent pas les 9 (ex. un client NTP : Framer + Codec + StateMachine minimale + Timer ; pas de Multiplexer ni Flow Controller ni Handshaker). Règle : un composant absent par conception est marqué explicitement (type unité / commentaire « NO MULTIPLEXING: … ») — l’absence est intentionnelle et visible, jamais un trou. Ainsi « je retrouve toujours les mêmes cases » reste vrai.
2.3 Structure interne type d’un crate air-<proto>
air-<proto>/
├── src/
│ ├── proto/ — CŒUR SANS-IO (= les 9 composants, §2.2), pur/fuzzable
│ │ ├── framer.rs (1. Framer)
│ │ ├── codec.rs (2. Codec)
│ │ ├── state.rs (3. StateMachine)
│ │ ├── handshake.rs (4. Handshaker — consomme air-crypto)
│ │ ├── flow.rs (5. Flow Controller)
│ │ ├── mux.rs (6. Multiplexer)
│ │ ├── timers.rs (7. Timer Manager — horloge injectée)
│ │ ├── session.rs (8. Session Context)
│ │ ├── ext.rs (9. Extension hooks)
│ │ └── mod.rs (assemble : `feed(&[u8]) -> Events`, `poll_transmit() -> Option<&[u8]>`,
│ │ `handle_timeout(now)`, pilotés SANS I/O)
│ ├── io/ — PILOTE : câble proto/ à air-socket (sync) / air-runtime (async) ;
│ │ SEUL endroit qui touche sockets + horloge réelle
│ ├── error.rs — AirError (ADR-019) ; erreurs protocolaires (cœur) vs transport (pilote)
│ └── lib.rs — surface publique ergonomique
└── fuzz/ — UNE cible de fuzz par composant parseur (Framer, Codec, Handshaker…)
2.4 Règles de codage (héritées des Principes & des couches 0/1)
- Zéro-C, maison : aucune dépendance C/
*-sys(check-c-surface) — sauf l’exception TLS gravée par ADR-042 :air-tls= rustls (protocole pur Rust, memory-safe) + aws-lc-rs (provider crypto AWS-LC, asm formellement vérifié + FIPS) + rustls-webpki (X.509 pur Rust). Exception C NOMMÉE et ÉTROITE : le C ne concerne que les primitives (aws-lc-sys), jamais le protocole ni le parseur X.509 (qui restent memory-safe).ringreste banni.air-tlsmaison (sur le patron sans-IO ci-dessous) gardé en contingence si rustls devient un passif. (Choix motivé : la combinaison la plus sûre disponible — memory-safety + vérif formelle + 1.3 ; cf. ADR-042 pour l’analyse.) - Parsing défensif (Principe 3) :
get()(jamais d’indexation paniquante), distinction stricte&[u8]vs&str,checked_*/saturating_*, pas de suivi de pointeur/longueur non borné (anti-boucle, comme le DNS sans compression). - Aucune fn
unsafeexposée ;// SAFETY:sur tout bloc interne. - Erreurs :
AirError(ADR-019) ; le cœur rend des erreurs protocolaires typées, le pilote mappe les erreurs transport. - Buffers & back-pressure : intégration avec air-runtime (buffers possédés) et air-memory (arenas/pools) pour éviter le churn d’allocation ; back-pressure explicite (reactive streams, cohérent AirCom).
- Couverture 100 % (couches fondatrices) ; fuzz obligatoire sur chaque parseur ; interop (cf. §2.5).
2.5 Composition / empilement
Les protocoles s’empilent ; les cœurs sans-IO se composent sans que chacun réinvente la gestion de connexion :
air-http (h3) → air-quic → air-tls 1.3 → air-socket (UDP) [async: air-runtime]
air-http (h2) → air-tls 1.3 → air-socket (TCP)
air-http (h1) → air-socket (TCP)
air-ssh → air-crypto + air-socket (TCP)
air-network(L2) = le substrat où ces crates se branchent : framework de connexion / listener / découverte / path-awareness (≈ Network.framework, cf. noteapi-reseau-strategie-fr.md). Il compose transport + TLS/QUIC ; les protocoles applicatifs (HTTP/SSH) sont au-dessus.- Le pilote I/O d’un cœur N consomme l’API du cœur N-1 (pas son I/O) : empilement sans-IO sur sans-IO, l’I/O réel n’existe qu’au bas de la pile.
2.6 Stratégie de test (transverse aux crates réseau)
Rappel : le régime de test par composant (les 9, §2.2) est la première ligne — chaque composant fuzzé/property-testé en isolation. Ci-dessous = le niveau intégration/système, en complément.
- Cœur sans-IO : unitaires + property-based + fuzz (octets hostiles → jamais de panic, jamais de trame malformée émise).
- Pilote I/O : intégration loopback réelle (comme air-socket).
- Interop / conformité : tester contre les implémentations de référence
(
air-httpvs curl/nginx ;air-sshvs OpenSSH ;air-quicvs ngtcp2/quiche) — vecteurs de conformité rejoués, comme les KAT d’air-crypto.
3. Points durs / à trancher (à remplir)
- TLS provider zéro-C : rustls + RustCrypto provider — valider l’exception 80 %, vérifier la couverture des suites nécessaires (TLS 1.3 d’abord ; TLS 1.2 ?).
- QUIC : ampleur (congestion, loss recovery, multi-stream) — vendoring partiel d’un cœur existant (quinn-proto) vs maison intégral ? (règle des 80 % / ADR-024.)
- Ampleur SSH (algorithmes, kex, sous-système SFTP) — périmètre v1.
- Granularité : confirmer
air-httpunique (h1/h2/h3 internes) vs crates séparés. - Daemons plomberie (DHCP/NTP/mDNS) : crate-lib +
.airservicecouche 5, ou tout-en-un ? Articulation avecair-config/air-network. - Async vs sync par protocole : lesquels exposent une API sync de 1ère classe (ADR-038) en plus de l’async ?
4. Ordre de priorité (proposé, à valider)
air-tls(socle de tout le sécurisé ; débloque HTTP/QUIC).air-quic(débloque HTTP/3).air-http(h1 → h2 → h3).air-ssh(+ sftp).- Plomberie : DHCP, NTP, mDNS (daemons couche 5).
- WireGuard.
- (tier-2) SMB interop — si/quand justifié.
Prérequis transverses : air-network (substrat L2) + air-runtime (async) à
implémenter avant/en parallèle des premiers protocoles.
Licence du document : MPL 2.0 — document de travail, exploratoire (non engageant).
Audit — bibliothèques requises pour une toolchain clang/LLVM C/C++ sur Air
Statut : note d’objectif. Ce document recense ce qu’Air devra fournir pour qu’un
clang air(et la toolchain LLVM associée) puisse compiler et linker des programmes C et C++. C’est un objectif post-phase-3, pas un travail en cours : il sert à savoir « ce qu’on a dans le viseur ». Rien ici n’est encore un ADR ni un contrat.
Cadre
- Le pal de la
stdRust d’Air repose sur la couche 1 (Rust pur), pas sur une libc. Passer par une libc (même la nôtre) =unsafe+ FFI au fondement, ce qu’Air refuse (l’unsafeest confiné, audité, à la couche 0). Cf. décision d’architecture (mémoire projet « pal Rust sur couche 1 + clang air »). - Les développeurs C et C++ sont servis séparément, via un
clang air: libc / libm / libstdc++→libc++ air, au-dessus de la couche 1 (clientes, jamais dedans), à parité avec la std-pal Rust. - Saveur retenue : tout-LLVM (décision 2026-06-28) →
libc++/libc++abi/libunwind(et non libstdc++ / libsupc++).
Étage bas — natif Air (Rust sur couche 1, exporté en ABI C)
| Composant | Rôle | Note |
|---|---|---|
| libc air | stdlib C (malloc/stdio/string/…) + headers système | Rust sur couche 1, ABI C. Motif « une brique, deux faces » (f() fidèle-POSIX + air_f() qui restitue le Result). |
| objets crt | démarrage C : Scrt1.o / crti.o / crtn.o (glue _start→main, init/fini) | Équivalent C d’air-rt. |
| libm air | math (sin/pow/…) | Rust, ou portage musl (cf. audit musl en cours). |
| ld air | dynamic loader | Seulement si dynamique ; différable (static-only au début). |
Runtimes LLVM (C/C++/asm, bâtis contre libc air → reposent sur couche 1 par transitivité)
| Composant | Rôle | Obligatoire ? |
|---|---|---|
compiler-rt builtins | équivalent libgcc : __divti3, soft-float, int 128 bits… | Oui |
compiler-rt profile (__llvm_profile_*) | couverture / PGO | Optionnel |
| compiler-rt sanitizers (asan/ubsan/tsan/msan) | -fsanitize= | Optionnel |
| libunwind (LLVM) | déroulage de pile ; requis pour exceptions C++ + backtraces | Oui (C++) |
| libc++abi | ABI C++ bas niveau (__cxa_*, exceptions, RTTI) ; dépend de libunwind | Oui (C++) |
| libc++ | stdlib C++ + headers ; dépend de libc++abi | Oui (C++) |
Ordre de build : libc air + crt + libm → compiler-rt builtins + libunwind → libc++abi → libc++.
Pour bâtir les binaires de la toolchain (qui tourneront sur Air)
- Un C++ de bootstrap + libc / libm / libc++.
- zlib / zstd (compression objets & debug info).
- Optionnels : libxml2, ncurses/terminfo (diagnostics colorés), libffi, libedit (REPL / lldb).
- Outils de build hôte (pas des libs runtime) : CMake, Ninja, Python.
Headers
- clang fournit ses resource headers (
stddef.h,stdarg.h,stdbool.h, intrinsics…). - libc air fournit les headers système (
stdio.h,stdlib.h,unistd.h, …).
Synthèse de la pile (C/C++)
couche 0 (interne, volatile)
└─ couche 1 (contrat gelé : briques Rust + export ABI C)
└─ libc air / libm air / crt air / ld air (natif Air, Rust)
└─ compiler-rt builtins · libunwind (runtimes LLVM)
└─ libc++abi → libc++ (C++ LLVM)
└─ clang air compile/linke C et C++
Audit icu4x — vers une réimplémentation Rust pur no_std sur Air
Note de travail (2026-06-30). Objectif : déterminer ce qui manque à Air pour
réécrire icu4x en Rust pur no_std sur types/syscalls/composants Air uniquement.
Base : version pinnée du projet (icu@2.2.0, calendar 2.2.1). Audit mené sur un
clone unicode-org/icu4x (tag icu@2.2.0) sur carbon. Périmètre : les 6 composants
pinnés + leur socle, runtime et datagen (frontière tracée).
0. Pourquoi cet audit
air-base-lib (couche 1) consomme icu4x → toute crate couche 1 qui dépend d’air-base-lib
(air-memory/air-thread/air-process, via AirError) tire icu4x transitivement. Cela a
soulevé, au moment de builder air-runtime pour *-linux-air, la question : icu4x est-il
compatible no_std/cible custom, et que faudrait-il pour en avoir une version maîtrisée full-Rust ?
1. Périmètre
6 composants pinnés ([workspace.dependencies] du projet) :
icu_normalizer, icu_casemap, icu_segmenter, icu_collator, icu_locale_core (2.2.0),
icu_calendar (2.2.1) — tous en features = ["compiled_data", …] (données baked-in).
Socle commun : zerovec, yoke, writeable, potential_utf, tinystr, litemap, et les
crates d’infrastructure icu : icu_provider, icu_collections, icu_properties, icu_locale,
calendrical_calculations.
2. Découverte centrale — le runtime icu4x est DÉJÀ no_std et de calcul pur
- Les 10 crates auditées sont
#![cfg_attr(not(any(test, doc)), no_std)]:no_stden production,stdseulement pour tests/doc. icu4x est no_std-natif par conception. - Le runtime (consommation des tables baked) est du CALCUL PUR : aucune occurrence de
std::{thread,fs,net,time,io,process},SystemTime,Instant::now,File,getrandom,libc::dans le code de production des 6 composants. (Seules traces : des commentaires décrivant un trait « no-std version ofstd::io::Write».) - Besoins système du runtime =
core+alloc. Rien d’autre. Zéro syscall, zéro horloge, zéro thread, zéro fichier. ⇒ Air n’a besoin de fournir aucun composant système (thread/fichier/horloge) au runtime icu4x. Air a déjàcore+alloc.
Conséquence directe sur l’étape 6 / le « blocage » initial : la crainte qu’icu4x empêche le
build *-linux-air est largement dissoute. icu4x étant no_std-pur-compute, il devrait
compiler pour *-linux-air (core+alloc) modulo libm (cf. §5). Le « bloat binaire » est
probablement moot (code mort éliminé par LTO si air-runtime n’utilise qu’AirError).
3. Le SEUL primitif système manquant — math flottante no_std (libm)
core_maths(façade no_std surlibm) est utilisé à deux endroits du périmètre :icu_segmenter/src/complex/lstm/matrix.rs— math du modèle LSTM (segmentation par IA pour thaï/khmer/lao/birman ; activé par la featureauto/lstm, que le projet active).calendrical_calculations(dép deicu_calendar) — math calendaire (astronomie des calendriers lunaires/solaires). Non contournable : le calendrier en a besoin.
- C’est le vrai manque systémique d’Air : pas de
libmno_std(exclu de la v1, ADR-046). Décision requise (cf. §8).
4. Inventaire par composant (runtime, default = compiled_data)
| Composant | Deps icu/socle | Deps externes (hors std/icu) | Notes |
|---|---|---|---|
icu_normalizer | icu_collections, icu_properties, icu_provider, zerovec, + icu_normalizer_data | smallvec, utf8_iter, utf16_iter, write16 ; harfbuzz-traits (optionnel) | NFC/NFD/NFKC/NFKD |
icu_casemap | icu_collections, icu_properties, icu_provider, icu_locale_core, potential_utf, writeable, zerovec, + icu_casemap_data | — (rien hors socle) | upper/lower/title/fold |
icu_segmenter | icu_collections, icu_locale, icu_provider, potential_utf, zerovec, + icu_segmenter_data | core_maths→libm (LSTM), utf8_iter | grapheme/word/line/sentence ; LSTM=floats |
icu_collator | icu_collections, icu_locale, icu_locale_core, icu_normalizer, icu_properties, icu_provider, zerovec, + icu_collator_data | smallvec, utf8_iter, utf16_iter | tri Unicode (UCA) |
icu_locale_core | zerovec, writeable, tinystr, litemap | displaydoc (proc-macro host) | identifiants de locale (BCP-47) |
icu_calendar | icu_locale, icu_locale_core, icu_provider, zerovec, tinystr, calendrical_calculations, + icu_calendar_data | calendrical_calculations→core_maths→libm ; ixdtf (optionnel) ; chrono/jiff/time (interop, optionnels) | calendriers (grégorien, etc.) |
Frontière nette : serde, databake, datagen = features non runtime (génération de
données / chargement non-baked). chrono/jiff/time/ixdtf/harfbuzz-traits = optionnels
(interop / parsing) — non requis pour le cœur.
5. Le socle icu (à réécrire aussi — tout no_std, calcul pur)
icu_provider (mécanique de fourniture de données : clés, requêtes, baked) · icu_collections
(structures Unicode : code-point tries, inversion lists) · icu_properties (propriétés de
caractères Unicode) · icu_locale (négociation de locale) · zerovec/yoke/writeable/
potential_utf (zéro-copie, vues empruntées sur les tables baked) · tinystr/litemap
(petites chaînes ASCII / petites maps) · calendrical_calculations (math calendaire, →libm).
C’est le gros du travail de réécriture — mais mécanique (no_std, calcul pur, zéro syscall).
6. Données / tables — format et provenance
- Chaque
icu_*_dataest une crate#![no_std]quiinclude!unmod.rsgénéré = données Rustconstbaked (viadatabake) compilées dans le binaire (+stubdatavide pour fournir les données autrement, + supportICU4X_DATA_DIRpour données custom). - Provenance (en-tête généré) : CLDR 48.2.0 + ICU 78.1 (icuexport) + LSTM v0.1.0.
- ⇒ Les « tables » du modèle BDFL = données const Rust baked, dérivées des sources Unicode/CLDR. Posséder ce format (ou un équivalent Air) + savoir les régénérer = un chantier en soi.
7. Datagen / outils — host, indépendants de no_std-Air
provider/source(icu_provider_source: « data provider based on CLDR and ICU data ») lit CLDR + export ICU (UCD) ;provider/exportbake en Rust const (databake) ou blobs.- Ce sont des outils de build
host(std libre), indépendants d’une lib/pal Air — commecbindgen/capnpc/le tool C++ capnp. Leno_std-Air ne les contraint pas (décision BDFL). - Dépendance amont inévitable : les sources CLDR + Unicode UCD (et modèles LSTM). Toute réimplémentation doit soit réutiliser le datagen icu4x pour produire nos tables, soit réimplémenter un datagen (std host) consommant les mêmes sources.
8. SYNTHÈSE — ce qui manque à Air (et décisions)
Runtime (consommateur de tables) :
- Rien côté système :
core+allocsuffisent (Air les a). Aucun composant Air (thread/fichier/horloge) requis. ✅ libmno_std= LE manque systémique (calendrical_calculations + LSTM segmenter). Décision : (a) fournir unlibmAir (réécrit ou vendoré, no_std) ; ou (b) restreindre le périmètre (sans LSTM → segmentation par dictionnaire seul ; mais le calendrier garde besoin de libm → (b) ne suffit pas seul). → Air aura besoin d’unlibmno_std.- Petites crates utilitaires
no_stdà vendorer/réécrire (règle 80 %, ADR-024) :smallvec,tinystr,litemap,utf8_iter,utf16_iter,write16. Petites, no_std, zéro syscall.displaydoc= proc-macro host (ou remplacer parDisplaymanuel). - Socle icu (
provider/collections/properties/locale+zerovec/yoke/writeable/potential_utf/calendrical_calculations) : à réécrire (gros mais mécanique, no_std pur).
Données / outils : 5. Format de tables : adopter le baked-const Rust (databake-like) ou un équivalent Air. 6. Datagen (host, std libre) : réutiliser celui d’icu4x ou le réimplémenter ; dans les deux cas, dépendance aux sources CLDR + Unicode UCD (amont, non maîtrisable — c’est la donnée de référence mondiale). Outils produits indépendamment d’une lib/pal Air.
Ce qu’Air N’A PAS, en une ligne : un libm no_std, une poignée de petites crates
utilitaires no_std (vendorables), et le pipeline de données (format de tables + datagen
host sur sources CLDR/UCD). Le reste (runtime no_std, socle calcul-pur) est du portage Rust
sans dépendance système.
9. Implication pour la décision A/B/C (couplage air-base-lib→icu4x, étape 6)
L’audit réduit fortement le risque qui motivait (A) : icu4x est no_std-pur-compute, donc le
build *-linux-air d’air-runtime (qui ne fait que tirer AirError, code icu4x mort →
éliminé) devrait passer modulo libm. Reste à vérifier empiriquement (build-std de la
fermeture pour *-linux-air) — non fait, sur décision « on prend le temps ». La scission
air-base-lib (A) reste un nettoyage d’archi sain (erreur transverse vs i18n lourde), mais
probablement pas un bloqueur de l’étape 6.
✅ VÉRIFIÉ EMPIRIQUEMENT (2026-07-01, ADR-059). Sonde jetable (carbon) : les 6 composants (dont
icu_segmenteravec LSTMauto) + tout le socle +calendrical_calculations/core_maths/libmCOMPILENT pourx86_64-unknown-linux-air(build-std=[core,alloc],--release,EXIT=0, 33 s). Deux conditions triviales : (1)default-features = falsesur lesicu_*(évite la featurestdpar défaut = le « mur std ») ; (2) build--release(le seulextern crate stdrestant, dansicu_provider, est un repli loggingeprintlndebug_assertions-only — inactif en release). ⇒ la réimplémentation du socle est INUTILE ; décision Option V (ADR-059) : Air utilise icu4x, possède l’APIair-string+ les données. Le libm i18n vient du cratelibmamont (viacore_maths), pas deair-libm.
10. Questions ouvertes (décisions BDFL à venir)
libm: réécrire unlibmAir no_std, ou vendorerlibm(exception 80 % nommée) ? Quand (avant l’i18n, car bloquant pour calendar + LSTM) ?- Périmètre LSTM : garder la segmentation LSTM (besoin floats + données modèle) ou dictionnaire seul pour la v1 ?
- Datagen : réutiliser le datagen icu4x pour produire des tables au format Air, ou réimplémenter ? (Réutiliser = pragmatique pour démarrer.)
- Vendoring vs réécriture des petites crates utilitaires (smallvec/tinystr/…).
- Quand : cet effort (i18n Air) est gros ; il se situe APRÈS le runtime/libc, ou en parallèle ?
Méthode : clone icu@2.2.0 sur carbon (~/icu4x-audit), inspection statique des Cargo.toml,
features, no_std, usages système, format de données, datagen. Aucun build lancé.
Audit dlmalloc — distillation pour l’allocateur global Air
Note de travail (2026-06-30). Objectif : étudier le design de dlmalloc pour
outiller la décision BDFL — ne pas vendorer en boîte noire, ne pas réinventer,
mais s’inspirer d’un design éprouvé en restant simple et posséder la politique
Air au-dessus. Source lue : crate rust-lang/dlmalloc-rs v0.2.14 (le port Rust
no_std que la std utilise comme allocateur par défaut de wasm32-unknown-unknown),
inspection statique de src/{lib,dlmalloc,unix,global}.rs — aucun build d’allocateur
lancé. Concepts canoniques rapportés à Doug Lea’s malloc (dlmalloc.c, domaine
public). Cette note ne tranche rien : l’ADR sera rédigé en session après elle.
0. Pourquoi cet audit
Air a besoin d’un allocateur global couche 1 (core::alloc::GlobalAlloc, no_std,
thread-safe, adossé à mmap). Il remplacera le placeholder AbortOnAlloc
(rt/crates/air-rt/src/lib.rs : tout alloc ⇒ exit_process(102), jamais invoqué car
le bootstrap selftest n’alloue pas) et, à terme, deviendra le malloc/free/realloc
de la libc C-ABI Air (ADR-046/047, famille stdlib/malloc, cible pilote OpenSSH).
La décision de design retenue par le BDFL est : inspiration, pas vendoring ni réinvention. Cette note sépare donc, sur du concret lu dans la source, le cœur à emprunter (correction) de ce qui se simplifie (optimisations), puis décrit la politique Air qu’on greffe par-dessus (fork-gardien, instrumentation, fail-not-hang).
1. Comment dlmalloc fonctionne (concis et juste)
Le chunk et les boundary tags. L’unité est le Chunk (#[repr(C)]) :
#![allow(unused)]
fn main() {
struct Chunk { prev_foot: usize, head: usize, prev: *mut Chunk, next: *mut Chunk }
}
head=taille | bits de flag(3 bits bas :PINUSE=prev occupé,CINUSE=ce chunk occupé,FLAG4). La taille est masquée parhead & !FLAG_BITS.prev_foot= footer : recopie la taille du chunk précédent quand celui-ci est libre (lu seulement siPINUSEest à 0). C’est la boundary tag qui permet de remonter au voisin gauche en O(1).prev/nextne servent qu’aux chunks libres : ils chaînent les free-lists et occupent en-bande la zone de payload du chunk libre (métadonnées intrusives, zéro structure annexe). La mémoire utilisateur commence àmem_offset = 2*size_of::<usize>().
Où vit la taille → la réponse au free(ptr) C-ABI. free(mem) ne reçoit pas la
taille. dlmalloc la retrouve depuis l’en-tête : from_mem(mem) = mem − 2*usize, puis
size = head & !FLAG_BITS. C’est exactement le contrat C : la taille est stockée par
bloc, juste avant le pointeur rendu. (overhead réel = 1 usize par chunk occupé, le
prev_foot du voisin servant de footer — astuce classique de Lea.)
Coalescence O(1). À la libération, pinuse/cinuse + prev_foot donnent
immédiatement l’état des deux voisins ; dispose_chunk/free fusionnent gauche et droite
sans parcours, mettent à jour les boundary tags, et rebinnent le chunk fusionné.
Bins par classe de taille. Deux familles, chacune indexée par un bitmap u32 qui
donne en O(1) la plus petite bin non vide (least_bit/trailing_zeros) :
- 32 smallbins (
smallmap) : classes exactes espacées de 8 octets (SMALLBIN_SHIFT=3), listes doublement chaînées circulaires. Couvre< 256octets. - 32 treebins (
treemap) pour les grands (>= min_large_size = 1<<8 = 256). Chaque treebin est un trie digital bit-à-bit (TreeChunk { chunk, child[2], parent, index }) indexé par les bits de poids fort de la taille ; les chunks de taille identique sont chaînés en liste sur le nœud. C’est le fameux treemap — recherche best-fit en O(log) sur la plage de tailles, pas O(n).
dv et top. dv (designated victim) = dernier reliquat de split, réutilisé en
priorité pour les petites demandes (réduit la fragmentation). top (wilderness) = chunk
frontière adossé à la mémoire système ; on y taille quand aucune bin ne convient.
Trait système (sbrk/mmap abstrait). dlmalloc-rs isole l’OS derrière un trait
Allocator (alloc(size) -> (base, size, flags), remap, free_part, free,
page_size, allocates_zeros, …). L’impl unix.rs utilise mmap(MAP_ANON|MAP_PRIVATE)
/ munmap / mremap, page_size() câblé à 4096. sys_alloc arrondit
demande + top_foot + align à la granularité (64 Kio par défaut), demande un gros
bloc au système, l’enregistre comme segment, étend top, puis taille. ⇒ un mmap
par segment de 64 Kio, pas par malloc (essentiel pour le matériel modeste, §3).
Le verrou n’est PAS dans le cœur. Dlmalloc<A> est Send mais pas Sync : la
logique d’arène est mono-thread. La feature global ajoute GlobalDlmalloc, un
static mut DLMALLOC protégé par un pthread_mutex global (global.rs/unix.rs).
Le verrouillage est donc une couche au-dessus — exactement la frontière où Air greffe
sa politique.
Surface double-face, même tas. dlmalloc-rs expose déjà deux API sur le même
allocateur : malloc/free/realloc layout-carrying (Rust GlobalAlloc, reçoit
Layout) et c_malloc/c_free/c_realloc/c_memalign layout-free (forme C, sans
taille au free). Précédent direct pour le dual-face Air ([[libc-air-dual-face]]).
2. ⚠️ Constat d’honnêteté : le port Rust a ZAPPÉ le mmap-direct des gros blocs
Le dlmalloc canonique (C) a un mmap_threshold (~256 Kio) : au-dessus, chaque
grosse demande reçoit son propre mmap dédié, rendu individuellement au free par
munmap. Le port Rust 0.2.14 ne l’implémente pas. malloc/sys_alloc ne font
que étendre des segments de granularité et tailler top ; la machinerie « chunk
mmappé » (Chunk::mmapped, mmap_resize, mmap_foot_pad, mmap_chunk_overhead)
existe dans les types mais est vestigiale : un chunk mmappé n’est créé que dans
mmap_resize, atteignable seulement si le chunk est déjà mmappé — donc jamais en
pratique. Conséquence : dans ce port, toute allocation, même multi-Mio, est taillée
dans des segments de 64 Kio. Air devra décider consciemment de réintroduire le chemin
« très grand → mmap direct » — il est quasi-correctness pour les allocations géantes
(une demande > taille de segment ne doit pas dépendre d’une croissance de top) et
indispensable au trim (rendre un gros bloc à l’OS sans fragmenter l’arène, §5c). Ne pas
le copier tel quel du port en pensant qu’il y est.
3. CŒUR à EMPRUNTER (non négociable pour la correction)
| Élément | Pourquoi c’est du socle, pas du luxe |
|---|---|
Boundary tags (head/prev_foot) + coalescence O(1) | C’est ce qui rend free(ptr) sans taille possible (taille en en-tête) et ce qui borne la fragmentation. Sans coalescence exacte, on a une bombe à fragmentation. C’est le plancher de correction. |
| Bins par classe de taille + bitmap | Sans bins, malloc est O(n) sur la liste libre → inacceptable. Smallbins exacts = zéro recherche pour le cas dominant (petits objets). Le bitmap donne la bin non-vide en O(1). |
mmap par gros chunks + sous-allocation | Un mmap par malloc = un syscall par malloc = mort sur Pi 4 (ADR-014). dlmalloc amortit sur 64 Kio. À garder absolument. |
Très grand → mmap direct (réintroduit, §2) | Une demande dépassant la granularité doit obtenir sa propre cartographie, rendue telle quelle au free. Correctness des allocations géantes + reddition propre à l’OS. |
| Free-lists intrusives (métadonnées en-bande) | Clé du bootstrap sain : l’allocateur stocke ses métadonnées dans la mémoire qu’il gère. Il n’a besoin d’aucun autre tas — juste de mmap brut. Résout le poule-et-œuf du #[global_allocator] (§5/§6). |
dv + top | Mécanique simple et peu coûteuse qui réduit nettement la fragmentation des petits objets. Garder. |
4. À SIMPLIFIER / ZAPPER (optimisations, pas correction)
| Simplification v1 | Coût (perf) | Pourquoi acceptable en v1 |
|---|---|---|
| Treemap des grands bins → liste triée simple (par taille, dans chaque classe ou globale) | malloc d’un grand bloc passe de O(log) à O(n) sur la liste des grands libres | Les grands blocs sont rares vs les petits (servis, eux, par les smallbins exacts en O(1)). Mesurer avant de complexifier (Principe 5). Le trie est l’optimisation la plus subtile/risquée de dlmalloc — l’éviter en v1 réduit drastiquement la surface de bug. |
| tcache / magazines par-thread → différé (v2 perf) | Contention du verrou global sous forte concurrence multi-thread | v1 = arène globale unique sous un AirMutex. Simple, correct, fuzzable. La perf concurrente est un chantier v2 mesuré, pas un prérequis de correction. |
Segments multiples fins, mremap, release_unused_segments agressif → minimal | Reddition mémoire à l’OS moins fine ; footprint qui décroît plus lentement | Garder un trim simple du top + munmap des très-grands. La gestion fine multi-segments est différable. |
FLAG4, compteurs release_checks, max_footprint, mallopt-like | — | Bruit non nécessaire à la v1. Garder head à 2 bits utiles (PINUSE/CINUSE). |
Garde-fou (plancher de correction). Les simplifications ci-dessus touchent la vitesse,
jamais : (a) les boundary tags, (b) la coalescence, (c) l’invariant que la granularité
est une puissance de deux ≥ alignement malloc (sinon le résidu de trim corrompt les
bits de flag empaquetés dans head — bug documenté dans la source, MIN_GRANULARITY).
L’arithmétique de tailles/flags est impitoyable : une erreur = corruption de tas, pas
une dégradation. C’est précisément pourquoi on emprunte le design éprouvé de Lea pour ces
parties-là plutôt que d’improviser.
5. Greffe de la POLITIQUE Air (le vrai intérêt — ce qu’on possède)
(a) Fork-gardien — intégration native, plus propre que dlmalloc. Le deadlock classique :
un thread tient le lock de l’allocateur au moment du fork, l’enfant (mono-thread) hérite
d’un lock verrouillé pour toujours. dlmalloc le contourne par
pthread_atfork(acquire, release, release) (unix.rs::enable_alloc_after_fork) : il
acquiert le lock avant le fork et le relâche des deux côtés. Air a mieux : air-runtime
possède déjà fork::register(AtForkHandlers { prepare, parent, child }) et
reset_after_fork_in_child. L’arène enregistre un handler child qui remet à zéro le
mot futex de son AirMutex (état UNLOCKED) directement dans l’enfant — au lieu de la danse
acquire-around-fork. Plus simple, plus sûr, et intégré au reset fork global d’air-runtime.
(Note : AirMutex n’expose pas aujourd’hui d’API de reset fork ; il faudra soit l’ajouter, soit
passer par air_thread::runtime_primitives::reset_futex_word. À cadrer.)
(b) Instrumentation LIB_AIR_DEBUG — table des allocations vivantes (fuites, double-free ;
vision pal-rust). Deux greffes possibles sur un design dlmalloc-like : (i) élargir l’en-tête
sous cfg(debug) (champs site d’appel / canari) — local au chunk, coalescence à adapter ;
(ii) table latérale ptr→métadonnée. ⚠️ Tension réelle : cette table ne peut pas
s’allouer sur le tas qu’elle surveille (récursion/poule-et-œuf) → elle doit vivre dans une
structure bootstrap-safe (adressage ouvert dans une cartographie mmap dédiée). À concevoir
comme un module activable, pas un coût permanent.
(c) « malloc échoue plutôt que hang » — null déterministe sur OOM. L’arène appelle
map_anonymous(...) -> Result<RawMapping, Errno> : un échec (ENOMEM) ⇒ on rend null
déterministiquement (ou Err), pas de boucle, pas de hang. Mais : sous Linux, un mmap
peut réussir puis fauter plus tard (overcommit → OOM-killer). Pour s’en rapprocher, choix à
trancher (analyse, pas décision) :
- Ne PAS utiliser
MAP_NORESERVE⇒ le noyau réserve le commit, et l’échec remonte aummapquand le commit manque (failure tôt, plus déterministe) — coût : charge le commit du segment entier d’emblée, moins ami de l’overcommit sur Pi 4. MAP_NORESERVE(+ éventuelMAP_POPULATE/pré-fault) ⇒ ami overcommit mais l’OOM réel se manifeste au fault, pas aumalloc— la sémantique « fail-not-hang » devient bornée par la politique overcommit globale du noyau, qu’Air ne contrôle pas.MapFlags::{NORESERVE, POPULATE}sont disponibles côtéair-sys-types. Honnêteté : un déterminisme OOM total sous Linux est illusoire ; on documente la borne.
(d) Alignement. L’alignement naturel = 2*size_of::<usize>() = 16 sur x86_64/aarch64 =
max_align_t du C. Couvre Layout::align() du cas Rust courant. Sur-alignement (align > 16) :
chemin memalign (sur-allocation + rognage du leader/trailer, comme dlmalloc) → support futur de
aligned_alloc/posix_memalign côté C-ABI.
6. Layering & placement
Recommandation : nouvelle crate air-alloc, couche 1, no_std. Elle doit se situer
sous air-memory::{arena,pool,slab,backing} — car ces modules consomment le global
allocator (Vec/Box via alloc) et seront donc clients d’air-alloc, jamais l’inverse.
Dépendances visées (minimales, pour éviter tout cycle) :
air-sys-types(Errno,MapFlags/ProtectionFlags) — types purs.- L’accès
mmapbrut. Choix à trancher : (a) appelerair-sys-syscall::memdirectement (le plus bas, zéro détour) ; ou (b) réutiliserair-memory::raw_mapping::{map_anonymous, unmap, protect_range}(syscall-direct,RawMappingenManuallyDropsansmunmapauto — adapté car l’allocateur possède lui-même les durées de vie). Risque de (b) : faire dépendreair-allocd’air-memory, qui par ailleurs est client de l’allocateur → smell architectural (pas un cycle de compilation, le#[global_allocator]étant câblé au binairert/, mais inélégant). Reco v1 : option (a) —air-alloctapeair-sys-syscallen direct ; on réconcilie la duplication avecair-memory::raw_mappingplus tard (ou on extraitraw_mappingdans une crate-socle partagée). - Verrou :
AirMutex(air-thread::sync) estno_stdet ne tire pasalloc(juste un mot futex +UnsafeCell) → utilisable sans cycle. Alternative ultra-prudente :air-allocembarque son propre petit verrou futex (viaair-sys-syscall::futex) pour ne dépendre de rien en couche 1. À trancher selon le graphe de dépendances réel d’air-thread. - Fork : l’intégration se fait par
air-runtimequi dépend d’air-alloc(et enregistre le handler), pas l’inverse — pour éviter le cycleair-alloc ↔ air-runtime.air-allocexpose juste une fonction de reset que le runtime appelle.
7. Contrat & API
Face Rust (core::alloc::GlobalAlloc) : alloc, dealloc, realloc, alloc_zeroed
(le calloc de dlmalloc saute le zéro-fill quand mmap rend déjà du zéro et que le chunk
n’est pas mmappé — MAP_ANON zéroe les pages neuves ; reprendre cette optimisation). C’est
l’impl qui remplace AbortOnAlloc et se branche en #[global_allocator] dans rt/.
Face C-ABI future (ADR-046) : malloc, free (sans taille — résolu par l’en-tête, §1),
realloc, calloc, aligned_alloc/posix_memalign (via memalign). Même tas, deux faces —
précédent c_malloc/c_free de dlmalloc-rs ([[libc-air-dual-face]]).
Thread-safety : v1 = un AirMutex<Arène> global. La logique d’arène reste mono-thread
(comme Dlmalloc<A> : Send, non Sync) ; le verrou est la couche au-dessus.
8. Staging proposé
- v1 — correct + simple + fuzzé. Boundary tags + coalescence, smallbins (bitmap),
liste triée pour les grands (pas de treemap),
dv/top,mmap64 Kio + sous-alloc, très-grand →mmapdirect,trimminimal, unAirMutexglobal, null sur OOM. Débloque : remplacement d’AbortOnAlloc, exécution réelle deCommand/spawnet de tout code allouant (Vec/String/collectionsstd) sur la cible*-linux-air. - v2 — perf (mesurée). Arènes/tcache par-thread, réintroduction d’un index de grands bins
(treemap ou skiplist) si le profil le justifie,
trim/segments plus fins. - v3 — instrumentation profonde + fork-gardien complet. Table live-allocs (
LIB_AIR_DEBUG), détection double-free/fuite, canaris, comptabilité par-composant (cf.AirMemoryTracker).
9. Mandat de test (Principe 1, couche 1)
- Couverture 100 % lignes + branches. L’arène est host-testable : on lui fournit un
backing en test (un buffer/
Vec<u8>hôte ou unmmaphôte) au lieu du vrai syscall — exactement le patron de dlmalloc-rs (implSystemtestée sur l’hôte ;tests/smoke.rsettests/eat_memory.rspilotent des séquences d’opérations aléatoires viaarbitrary+SmallRng). - Property-based : invariants — roundtrip
alloc/free; alignement demandé respecté ; aucun chevauchement entre blocs vivants ; coalescence (deux libres adjacents → un seul) ; classe de taille correcte ;reallocpréserve les octetsmin(old,new). - Fuzzing (cargo-fuzz) : séquences aléatoires
alloc/free/realloc/calloc/memalign, modèle fantôme (shadow) vérifiant non-chevauchement et contenu ; différentiel possible vs un allocateur de référence trivial, ou vs glibc sur l’hôte. - Stress concurrent : N threads martelant l’arène sous le verrou (détecte deadlock/corruption).
- Frontière TARGET-ONLY (gros enjeu couverture, ADR-035) : la LOGIQUE (sur backing fourni)
est host-testable et doit l’être à 100 %. En revanche le câblage réel comme
#[global_allocator], le#[thread_local]d’errno, et le reset-fork ne se valident qu’on-target (selftestrt/). Tracer nettement cette frontière pour ne pas gonfler artificiellement les exceptions de couverture.
10. Questions ouvertes (décisions BDFL à venir, pour l’ADR)
- Nom & placement : crate
air-allocdédiée, ou module d’air-memory? (Reco : crate dédiée, §6.) - Backing :
air-sys-syscall::memdirect vsair-memory::raw_mappingvs crate-socle extraite ? - Sémantique OOM :
MAP_NORESERVEou non ? pré-faultMAP_POPULATE? (arbitrage déterminisme vs amitié-overcommit Pi 4, §5c). - Tailles : granularité de segment initiale (64 Kio comme dlmalloc, ou plus petit sur RAM modeste ?) ; seuil mmap-direct pour les très-grands (256 Kio façon C ? réglable ?).
- Structure des grands bins en v1 : liste triée simple (reco) vs réintroduire le treemap.
- Verrou : réutiliser
AirMutexou embarquer un futex dédié dansair-alloc(anti-cycle) ? - Politique d’alignement : périmètre des API alignées (
aligned_alloc/posix_memalign) en v1 vs différé. - Instrumentation : stratégie de stockage de la table live-allocs (mmap dédié bootstrap-safe vs
en-tête élargi sous
cfg). - Périmètre v1 exact :
trim/reddition à l’OS incluse ou minimale ?
Méthode : lecture statique de dlmalloc-rs v0.2.14 (~/.cargo/.../dlmalloc-0.2.14/src/) +
concepts canoniques de Doug Lea malloc ; recoupement avec le code Air existant
(air-memory::raw_mapping, air-runtime::fork, air-thread::sync::AirMutex,
air-rt::AbortOnAlloc, air-sys-types::{Errno,MapFlags}, ADR-046/047). Aucun build
d’allocateur lancé. Cette note est exploratoire et non engageante — l’ADR fait foi.
Audit s2n-tls — relevé chiffré pour la contingence air-tls maison
Note de travail (2026-07-07). Objectif : mesurer s2n-tls (la meilleure pile TLS
tout-C, AWS) pour outiller la décision sur la contingence air-tls maison
(ADR-042 §Contingence) — ne pas vendorer, ne pas porter le C, mais connaître
précisément ce qu’on reprendrait en couverture fonctionnelle si l’on activait
l’option pur-Rust. Source analysée : aws/s2n-tls, tag effectif v1.7.5,
commit 632eb7cef3eb72a3a5466c62896b9c2879f30295 (2026-07-02), clone shallow +
approfondissement des tags. Analyse statique (compte de fonctions, LOC,
assembleur, dépendances CMake) — aucun build lancé. Cette note ne tranche
rien : elle alimente la spec docs/specs/layer-2/air-tls.md.
0. Pourquoi cet audit
ADR-042 a acté la pile de production
(rustls + aws-lc-rs + rustls-webpki) et gardé ouverte une contingence maison
(pur Rust, patron sans-IO). Pour instruire cette contingence de façon crédible, il
faut savoir ce que couvre la référence tout-C la plus sûre : combien de
surface, combien de code, combien d’assembleur, quelles dépendances, quelle
maintenance. C’est l’objet de ce relevé, dont la spec air-tls tire son inventaire
d’objets (~61) et son périmètre.
1. Volumétrie du code (cœur de bibliothèque, hors tests)
| Dossier | .c | .h | Lignes | Rôle |
|---|---|---|---|---|
tls/ | 125 | 95 | 44 903 (75 %) | protocole : handshake, record, extensions, state machine |
crypto/ | 29 | 27 | 8 359 | wrappers vers libcrypto (PAS de crypto : ni primitive, ni asm) |
utils/ | 14 | 21 | 4 054 | stuffer d’appoint, safety macros, mem |
stuffer/ | 7 | 1 | 1 784 | buffers de (dé)sérialisation bornés |
error/ | 1 | 1 | 931 | codes + messages |
| Cœur | 176 | 145 | 60 031 | — |
api/ (headers publics) | — | 12 | 5 398 | s2n.h + 11 headers unstable/ |
~60 k lignes de C portable pour le cœur. Les tests (volumineux) ne sont pas embarqués.
2. Surface API publique — 303 fonctions (macro S2N_API)
Décompte robuste : les occurrences S2N_API (303) et les identifiants s2n_*(...)
uniques (303) concordent exactement.
| Famille | Fn | Rôle | Objet air-tls correspondant |
|---|---|---|---|
s2n_connection_* | 91 | cycle de vie + état par connexion | TlsClient/TlsServer |
s2n_config_* | 75 | politique partagée | TlsClientConfig/TlsServerConfig |
s2n_client_hello_* | 25 | introspection ClientHello | ClientHello |
I/O (send/recv/negotiate/shutdown/peek/sendv/sendfile…) | 16 | handshake + transfert | TlsStream + cœur sans-IO |
s2n_psk_* / s2n_offered_psk_* | 14 | pre-shared keys (1.3) | Psk/OfferedPsk |
init/mem/rand global (s2n_init, s2n_cleanup, s2n_mem_*, s2n_rand_*, s2n_errno…) | 14 | bootstrap process-wide | majoritairement éliminé (RAII Rust : ni init/cleanup global, ni callbacks mem) |
s2n_cert_chain_and_key_* | 12 | chaîne + clé privée | CertifiedKey |
s2n_crl_* | 9 | listes de révocation | CertificateRevocationList |
s2n_fingerprint_* | 8 | JA3/JA4 | ClientHelloFingerprint |
s2n_async_pkey_* | 8 | offload signature (HSM) | AsyncSigner (trait) |
s2n_cert_* | 7 | trust store / validation | RootCertStore + ServerCertVerifier |
s2n_certificate_* | 5 | accès aux champs X.509 | Certificate |
s2n_offered_early_data_* | 4 | 0-RTT | EarlyDataConfig |
s2n_session_* | 3 | reprise | SessionTicket/SessionStore |
| Total | 303 | — | ~61 objets Rust (spec air-tls) |
Fonctions internes : ~1 695 symboles s2n_* uniques définis dans le cœur
.c (~1 751 blocs de définition). Ratio surface/interne ≈ 18 % exposé.
3. Assembleur pur — ZÉRO ligne (résultat structurant)
Recherche exhaustive sur tout le dépôt : 0 fichier .S/.s/.asm, 0 asm
inline (__asm__/asm volatile/asm(). Tout l’assembleur crypto vit dans
libcrypto (AWS-LC), jamais dans s2n-tls. s2n = C portable pur (-std=gnu99)
qui orchestre le protocole et délègue 100 % des primitives.
Corrobore exactement ADR-042 : « protocole memory-safe + primitives formellement vérifiées ». En maison, on transpose : protocole pur Rust (
air-tls) + primitivesair-crypto(RustCrypto). Le « 0 asm » de s2n confirme qu’un port de protocole n’a pas à toucher d’assembleur — l’asm est un problème de primitives, réglé parair-crypto.
4. Architectures processeur
| Tier | Plateformes CI | Arches |
|---|---|---|
| Tier 1 (build+tests garantis en CI) | Ubuntu, Amazon Linux 2/2023, NixOS, OpenBSD, FreeBSD, macOS | x86_64, aarch64, i686 |
| Tier 2 (build garanti) | Fedora, Ubuntu anciens | x86_64, aarch64 |
Couvre les deux cibles jour-1 d’Air (x86_64 + ARM64, ADR-014). C portable → aucune
dépendance d’architecture dans s2n lui-même (elle est dans libcrypto).
5. Cadence de mise à jour — très active
- 169 tags de release. Rythme ~2 releases/mois :
v1.7.5(2026-06-24),v1.7.4(2026-06-05),v1.7.3(2026-05-04),v1.7.2(2026-03-31)… - ~25 à 53 commits/mois sur 12 mois glissants (juin 2026 : 53). Équipe AWS financée, réponse sécurité rapide → fiabilité sur 10-20 ans (argument ADR-042 pour le provider aws-lc-rs).
6. Dépendances
libcrypto(obligatoire, enfichable) : AWS-LC (recommandé), OpenSSL1.0.2/1.1.1/3.0.x, LibreSSL, BoringSSL. Aucun crypto vendoré dans s2n.- pthreads (
find_package(Threads REQUIRED)). - Python3 : tests uniquement, pas à l’exécution.
- Bindings Rust officiels :
bindings/rust/(crates2n-tls) — pertinent si Air voulait wrapper s2n plutôt que le réécrire (option écartée par ADR-042 : le C du protocole reste exposé).
Fonctionnalités liées au provider : PQ key exchange + FIPS + sandboxing = AWS-LC uniquement.
7. Toolchain Linux
- Build system : CMake (préféré ; Makefile en repli). Standard
-std=gnu99. - Compilateur : gcc ET clang supportés et testés en CI (matrice ~5 clang / 3 gcc). clang privilégié pour sanitizers/fuzzing.
- Provider recommandé : AWS-LC (perf + sécurité + FIPS 140-3 + PQ). Option
S2N_INTERN_LIBCRYPTOpour figer/isoler une version (repro ADR-025, évite les collisions de symboles).
8. Ce que l’on retient pour air-tls maison
- Empreinte à reprendre : ~60 k lignes de protocole (dont
tls/= 45 k, la vraie masse). En maison, cette masse devient du Rust memory-safe ; les primitives (le reste) restent horsair-tls(dansair-crypto). - Zéro asm à porter : le port maison ne touche aucun assembleur — confirmé
par le 0 de s2n. L’asm est un sujet
air-crypto/RustCrypto (AES-NI/ARMv8 détectés automatiquement), pasair-tls. - Couverture fonctionnelle cible : les 303 fonctions publiques se replient
sur ~61 objets Rust (spec
air-tls§Inventaire) — le facteur ~5 vient des idiomes (builder au lieu de 75 settersconfig, une struct-connexion au lieu de 91 fonctionsconnection, RAII qui supprime les 14 fonctions init/mem/rand). - Périmètre sécurité : on n’importe que le moderne. s2n supporte encore des
provider OpenSSL
1.0.2/1.1.1(fin de vie) ;air-tlsne spécifie que TLS 1.3 — le legacy est omis par conception (cf. spec §Périmètre de sécurité). - Règle des 80 % (ADR-024) : un client TLS Air ne consommerait qu’une fraction
des 303 fonctions → au sens strict, vendoring exigé, exclu pour du crypto
(ADR-034). D’où soit l’exception nommée rustls (ADR-042, production), soit la
maison (
air-tls, zéro tierce) — les deux options que ce projet garde.
9. Provenance & reproductibilité de l’audit
Commandes clés (rejouables) : clone shallow aws/s2n-tls → git fetch --tags --deepen=3000 (cadence) ; find/grep pour LOC, .S/.s/.asm et asm inline ;
grep -c S2N_API api/ + extraction des identifiants s2n_*( (surface) ; parse de
CMakeLists.txt (find_package, SEARCH_LIBCRYPTO, S2N_INTERN_LIBCRYPTO) et
README.md/docs/BUILD.md (plateformes, providers, compilateurs). Aucun build
crypto lancé, analyse statique seule.
Licence du document : MPL 2.0 — note de travail, exploratoire (non engageante).
Alimente docs/specs/layer-2/air-tls.md (contingence maison, ADR-042 §Contingence).
Audit FACE-LIBC — completeness critic pré-sceau couche-1-v1.0 (roadmap §5.1)
Rôle du document. Livrable de l’étape §5.1 de la roadmap couche 1 (
docs/roadmap-couche1-libc-pal-fr.md). Il rejoue le périmètre libc v1 (ADR-046) contre l’inventaire réel des crates couche 1 et statue, famille par famille, si la brique couche 1 (API Rust) existe pour porter la face C correspondante. Ce n’est pas l’implémentation de la libc (qui vit au-dessus du sceau) : c’est le critère de complétude qui autorise le sceaucouche-1-v1.0(§5.4).Méthode. S’appuie sur les 41 audits
docs/notes/audits/audit-musl-*.md(périmètre de fonctions attendu par famille) et leRAPPORT-CONSOLIDE-libc-fr.md. L’inventaire couche 1 a été établi par lecture directe despub fn/struct/enumdes cratescrates/. Tous les symboles cités sont réels (vérifiés par grep, pas devinés).
0. Rappel du cadre décisionnel (ce qui compte pour le verdict)
Trois clauses d’ADR-046 gouvernent l’analyse :
- D2 — layering strict. La libc ne dépend QUE de la couche 1, jamais de la
couche 0 (
air-sys-*). Conséquence contraignante : « c’est à la couche 1 d’exposer tout ce dont la libc a besoin, y compris des passthroughs fidèles au kernel ». La couche 1 offre deux tiers : un tier kernel-faithful (fidèle, EINTR remonté, sans politique) et des helpers ergonomiques. La libc se lie au tier fidèle. - D4 —
errnothread-local. Fourni en bordure (shim POSIX), jamais globale interne. - D7 — périmètre. ~25-30 familles ; cible pilote OpenSSH ~15 familles / 250-350 fn ; différés v1+ : aio, SysV IPC, mq, ldso, wordexp/glob, regex.
Grille de lecture pour « bloquant vs additif ». Le modèle de re-sceau de la couche 1
(roadmap §5.3/§5.4, précédent couche 0 ADR-051)
est le descellement additif v1.x : les manques futurs s’ajoutent sans refonte des
jalons scellés. Donc :
- BLOQUANT pour le sceau = une brique fondatrice absente dont l’ajout obligerait à refondre une API déjà scellée (rupture de signature / de contrat figé).
- ADDITIF = le manque se comble par un nouveau
pub fn/pub usedans une crate existante (ou une crate neuve), sans toucher aux signatures scellées. La face C se construit au-dessus.
⚠️ Constat structurant, répété tout au long de cet audit. La couche 0 (
air-sys-syscall) possède déjà la surface kernel complète dont la libc a besoin (openat2/read/write/lseek/dup_fd,signalfd_create/block_signals/kill/tgkill, familleterminalcomplète,ppoll,mmap/mprotect/munmap…). Mais la couche 1 n’en re-expose aujourd’hui qu’un sous-ensemble ergonomique curé. Le tier kernel-faithful que D2 impose à la couche 1 est donc, pour plusieurs familles fondatrices, pas encore construit. C’est exactement le travail annoncé par ADR-046 D2 (« la couche 1 doit grandir ») et séquencé par la roadmap en §5.3 (combler les manques additifs) avant §5.4 (sceller). Aucun de ces manques n’est bloquant au sens refonte — mais leur volume est substantiel et fondateur pour la cible OpenSSH.
1. Inventaire couche 1 (rappel synthétique des briques réelles)
| Crate | Rôle libc-face | Symboles-clés réels |
|---|---|---|
air-base-core | errno, chemins octets, temps, encodages, log, id | AirError/AirErrorKind/AirResult, AirPath, AirInstant/AirSystemTime/AirDuration, sleep/sleep_until, encoding::{base64,hex}, AirLog, AirUuid/AirId128 |
air-base-lib | i18n Unicode (icu4x), calendaire | AirString (norm./casing-locale/segmentation/collation), AirLocale, AirDateTime/AirCalendar |
air-libm | math no_std | pub use libm::* (surface fdlibm complète) |
air-env | environ octets (lecture) | get, vars/Vars, set_environ (pas de set/unset/secure_get) |
air-stdio | handles bruts 0/1/2 | stdin/stdout/stderr, StandardStream::{read,write,is_terminal,lock}, reset_after_fork |
air-terminal | tty + modèle/codec | tty::{isatty,ttyname}, AirTerminal/AnsiCodec/TerminalCodec, modèle TermIn/TermOut, Winsize (re-export). Pas de tcgetattr/tcsetattr/PTY publics |
air-filesystem | fs haut-niveau + watch | canonicalize/resolve_within/exists, write_atomic/read_to_bytes, read_dir/glob, copy/copy_tree, AirTempDir, AirFileSystemWatcher (inotify). Pas de handle open/read/write/lseek générique |
air-socket | TCP/UDP/Unix + DNS | AirTcpSocket/AirTcpListener/AirUdpSocket/AirUnix*, AirNameResolver/AirDnsSource (RFC1035), AirIpAddress::parse, send_fd/recv_fd |
air-process | clone3/exec/wait/pidfd | AirCommand/AirProcess, wait_timeout/wait_until/try_wait, signal/kill (enfant), air_pipe, drop_privileges, exit_process. Re-export PollFd/PollEvents |
air-thread | mutex/rwlock/sem/chan + spawn | AirMutex/AirRwLock/AirSemaphore, air_channel, atomic::*, AirThreadBuilder/join, runtime_primitives::{current_tid,create_thread,futex_wait_word} |
air-runtime | TCB/TLS/errno/fork natif | AirRuntime::{errno,set_errno,current_tid}, thread::spawn/JoinHandle, ThreadControlBlock, ThreadLocalStorage, fork::AtForkHandlers, start::bootstrap |
air-memory | arènes/pool/slab + mapping | AirArena/AirObjectPool/AirSlab, AirMemoryTracker, raw_mapping::{map_anonymous,unmap,RawMapping} |
air-alloc | allocateur global | GlobalAllocator (impl GlobalAlloc), Arena::{allocate,deallocate,reallocate,allocate_zeroed}, reset_after_fork |
air-crypto | CSPRNG + hash/AEAD/KDF/sig | AirRandom::{fill,u64,generate_key}, AirHash/AirHmac/AirAead/AirHkdf, AirArgon2::{hash_password,verify_password}, AirSigningKey/AirEcdh* |
air-config | backends /etc (pwd/grp/shadow…) | PasswdBackend (emit/import passwd/group/shadow), AccountsDomain, accounts_config::Reader, BackendRegistry |
2. Tableau famille → brique(s) couche 1 → statut → bloquant/additif
Légende : ✅ brique présente · ◐ partielle (manque précisé) · ❌ manquante · ⏭ différée v1+ (ADR-046 D7). Colonne « B/A » : B = bloquant sceau · A = additif · — = n/a.
2.1 Cœur userland + cible OpenSSH (~15 familles)
| # | Famille (audit musl) | Brique(s) couche 1 réelle(s) | Statut | B/A |
|---|---|---|---|---|
| 1 | errno (errno) | air-base-core::AirError/AirErrorKind (round-trip errno()), shim thread-local air-runtime::AirRuntime::{errno,set_errno} + ThreadControlBlock::errno (D4) | ✅ | — |
| 2 | string / mem (string) | air-base-core::AirPath (octets), encoding; air-base-lib::AirString (UTF-8). memcpy/strlen/strcmp/explicit_bzero = calcul pur sur core/alloc (aucune brique syscall requise) | ✅ | — |
| 3 | ctype (ctype) | air-base-lib::AirString::to_*case_in_locale + propriétés icu4x ; ASCII isalpha/isdigit = pur sur core | ✅ | — |
| 4 | multibyte / wchar (multibyte) | air-base-lib::AirString (UTF-8 garanti), segmentation grapheme/word/sentence (icu4x), NormalizationForm. La machine d’état C mbstate_t/mbrtowc se construit au-dessus (ADR-053) | ◐ (state-machine C = face-C) | A |
| 5 | locale / i18n (locale) | air-base-lib::AirLocale (BCP 47), collation compare_in_locale, casing locale, normalisation — socle icu4x présent (ADR-053). setlocale/localeconv POSIX = face-C | ✅ | — |
| 6 | time (time) | air-base-core::{AirInstant,AirSystemTime,AirDuration}, sleep/sleep_until (nanosleep, EINTR retry couche 1) ; air-base-lib::AirDateTime/AirCalendar (calcul calendaire icu4x). strftime/mktime/gmtime = formatage face-C au-dessus du calcul présent | ✅ (horloge/sleep/calcul) ; ◐ formatage | A |
| 7 | math (libm) (math/complex/fenv) | air-libm = pub use libm::* (surface fdlibm complète, no_std). Prérequis ADR-053 satisfait | ✅ (base réelle) ; ⏭ complex/fenv avancés | A |
| 8 | malloc / stdlib (malloc,stdlib) | air-alloc::GlobalAllocator (impl GlobalAlloc) + Arena::{allocate,deallocate,reallocate,allocate_zeroed} (malloc/free/calloc/realloc). atoi/strtol/qsort/bsearch/abs = pur sur core | ✅ | — |
| 9 | stdio (brut) (stdio) | air-stdio : handles fd 0/1/2, read/write (1 syscall, EINTR remonté), lock/StreamGuard, is_terminal, reset_after_fork. FILE*/vfprintf/scanf/bufferisation = face-C au-dessus (normal, D7) | ✅ (brut requis) | — |
| 10 | env (env) | air-env::{get,vars,set_environ} (octets). Manque : setenv/putenv/unsetenv/clearenv (mutation différée, ADR-047) et secure_getenv (OpenSSH utilise setenv) | ◐ | A |
| 11 | I/O fichier — unistd cœur (unistd,fcntl) | Couche 0 complète (openat2/openat/read/write/pread/pwrite/lseek/close/dup_fd). Couche 1 : air-filesystem n’expose que des ops ergonomiques fichier-entier (write_atomic/read_to_bytes/copy) ; aucun handle open/read/write/lseek/fstat/dup/fcntl générique. air-process::air_pipe/AirPipeReader/Writer + air-socket couvrent read/write sur pipes/sockets uniquement | ◐ (tier passthrough fichier absent en couche 1) | A (fondateur, à combler §5.3) |
| 12 | stat (stat) | Couche 0 statx (utilisé en interne par copy). Couche 1 : pas de stat/fstat/lstat public retournant les métadonnées ; seuls exists/AirFileType (via read_dir) affleurent | ◐ | A |
| 13 | dirent (dirent) | air-filesystem::read_dir (getdents64 → Vec<AirDirEntry> bornes ADR-032). DIR* streaming + seekdir/telldir/rewinddir = face-C au-dessus | ✅ (getdents64 couvert) | — |
| 14 | poll / select (select) | Couche 0 ppoll. Couche 1 : seulement air-process::AirProcess::wait_until(&mut [PollFd]) (couplé au pidfd de l’enfant), + re-export des types PollFd/PollEvents. Aucun multiplexeur poll()/select() générique sur un ensemble de fd arbitraire (boucle principale OpenSSH) | ◐ (types oui, multiplexeur générique absent) | A (fondateur, à combler §5.3) |
| 15 | termios / PTY (termios) | Couche 0 air-sys-syscall::terminal complète (tcgetattr/tcsetattr/tcdrain/tcflush/tcflow/tcsendbreak/get_winsize/set_winsize/open_pty_master/pty_number/pty_unlock/pty_peer/tcgetpgrp/tcsetpgrp/session_id/set_ctty/clear_ctty). Couche 1 air-terminal : seulement isatty/ttyname + modèle/codec ; le tier termios/PTY/job-control n’est PAS re-exposé (usage interne pour isatty, et sous #[cfg(test)]) | ◐ (passthrough termios/PTY absent en couche 1) | A (fondateur OpenSSH, à combler §5.3) |
| 16 | mman (mman) | Couche 0 mmap/mprotect/munmap. Couche 1 : air-memory::{map_anonymous,unmap,RawMapping} (anonyme seul) ; air-alloc::MmapBacking (interne). Manque : mmap fichier-adossé, mprotect, madvise, shm_open, mlock | ◐ | A |
| 17 | réseau (network) | air-socket : AirTcpSocket/AirTcpListener/AirUdpSocket/AirUnix* (cycle complet socket/bind/connect/listen/accept/send/recv), résolveur AirNameResolver/AirDnsSource (RFC1035, /etc/hosts, resolv.conf) = équivalent getaddrinfo, AirIpAddress::parse (inet_pton), send_fd/recv_fd (SCM_RIGHTS). Manque additif : getsockopt/setsockopt génériques (seuls set_nodelay/set_keepalive exposés) | ✅ | A (options socket) |
| 18 | process / exec (process,exit) | air-process : AirCommand::spawn (clone3+execve, enfant AS-safe), AirProcess::{wait_timeout,wait_until,try_wait,wait_forever} (waitid), pidfd, drop_privileges (setresuid/gid/setgroups/capset/seccomp/Landlock), air_pipe, exit_process (exit_group) ; fork natif via air-runtime::fork::AtForkHandlers+thread::spawn. posix_spawn-classe couvert | ✅ | — |
| 19 | signal (signal) | Couche 0 complète (signalfd_create/block_signals/unblock_signals/set_signal_mask/current_signal_mask/wait_for_signal/kill/tgkill/rt_sigqueueinfo). Couche 1 : seulement air-process::AirProcess::{signal,kill} (→ pidfd_send_signal sur l’enfant possédé). Manque en couche 1 : création signalfd, masquage (sigprocmask/pthread_sigmask), kill(pid) arbitraire, raise/sigsuspend/sigpending. ADR-020 pose la primauté signalfd, mais la brique n’est pas exposée en couche 1 (l’appelant « passe un signalfd pré-construit » — construit en couche 0) | ◐ (envoi-enfant seul ; face signal générale absente en couche 1) | A (fondateur OpenSSH, à combler §5.3) |
| 20 | thread / pthread (thread,sched) | air-thread : AirMutex/AirRwLock/AirSemaphore (futex, sans poison), air_channel, atomic::*, AirThreadBuilder/join, runtime_primitives::{current_tid,create_thread,futex_wait_word,set_thread_pointer} ; air-runtime : thread::spawn/JoinHandle (clone3+CLONE_SETTLS), ThreadControlBlock/ThreadLocalStorage (TLS/TCB), errno TLS. Manque additif : condvar publique (futex interne), pthread_key/TSD ergonomique, sched_yield/sched_setaffinity public (affinité en set via AirThreadBuilder::cpu_affinity) | ✅ (primitives + spawn/join + TLS) | A (condvar/TSD/sched) |
2.2 Familles « longue traîne » (dans/adjacentes au périmètre v1)
| Famille (audit musl) | Brique(s) couche 1 | Statut | B/A |
|---|---|---|---|
crypt (crypt) | air-crypto::AirArgon2 (Argon2id, salt CSPRNG interne, vérif constant-time) = réponse Air au hachage mot de passe. crypt() DES/MD5 legacy = compat face-C optionnelle | ✅ (hachage moderne) | A (compat legacy) |
prng (prng) | air-crypto::AirRandom::{fill,u64} (getrandom kernel, fail-closed) = getentropy/arc4random. rand/random/rand48 déterministes = pur sur core | ✅ | — |
temp (temp) | air-filesystem::AirTempDir (mkdtemp, O_EXCL 0700). mkstemp/mkostemp → **nécessite le handle fichier générique O_EXCL | 0600** (cf. famille 11) | ◐ |
misc (misc) | realpath ≈ air-filesystem::canonicalize ✅ ; syslog ≈ air-base-core::AirLog (journald, transport différent — assumé) ✅ ; getopt/basename/dirname = pur ; openpty/getpass → dépendent du tier termios/PTY (cf. #15) ; wordexp NON porté (RCE, RAPPORT-CONSOLIDE §H) | ◐ | A |
search (search) | qsort/bsearch/lsearch/tsearch/hsearch = pur sur core/alloc (état non-global chez Air) | ✅ (aucune brique) | — |
conf (conf) | sysconf/pathconf/confstr non exposés ; constantes + rares syscalls (nb CPU via sched_getaffinity). Non-critique | ❌ (mineur) | A |
stdlib divers (stdlib) | parsing/tri/abs/div = pur ; getenv/setenv cf. #10 ; exit/abort via air-process::exit_process + panic/abort Rust | ✅ | A (setenv) |
legacy (legacy) | getpass/utmp/ftw… — majoritairement évités/différés (RAPPORT-CONSOLIDE §G6). N/A pour le sceau | ⏭ | — |
linux (linux) | syscalls Linux divers (prctl, getrandom, sysinfo, epoll, sendfile…) — couche 0 les a ; couche 1 sélective (getrandom via air-crypto ✅). Reste additif | ◐ | A |
setjmp / longjmp (setjmp) | ABSENT de tout le code (crates/ : zéro setjmp/longjmp/jmp_buf). Îlot asm incompressible identifié par ADR-047 P6 / RAPPORT-CONSOLIDE §D. À confiner en surface FFI unsafe le moment venu (OpenSSH n’en a guère besoin) | ❌ | A (îlot asm, FFI-only) |
2.3 Familles différées v1+ (ADR-046 D7 — hors périmètre du sceau libc-v1)
| Famille (audit musl) | Justification du report | Statut |
|---|---|---|
aio (aio) | Réimplémentée sur io_uring (roadmap §4-K air-runtime async) ; consommateur = couche 2+, incluse dans la surface scellée mais pas d’audit face-toit ici | ⏭ |
SysV IPC (ipc) | Explicitement hors v1 (ADR-046 D7). Couche 0 a ipc.rs, mais la famille libc shmget/semop/msgget est différée | ⏭ |
mq (mq) | POSIX message queues — différé D7 | ⏭ |
ldso (ldso) | Éditeur de liens dynamique — hors libc statique initiale ; air-runtime::reloc ne couvre que le static-PIE (R_*_RELATIVE). ld.so complet = post-v1 | ⏭ |
| wordexp / glob | wordexp jamais porté (fork+exec /bin/sh = RCE). glob() libc différé (mais air-filesystem::glob existe pour l’usage interne Air) | ⏭ |
regex (regex) | Différé post-sceau (roadmap §4-I) : crate additive neuve, son ajout ne descelle rien. Ne bloque que regcomp/find_regex (non-critiques) | ⏭ |
complex / fenv (complex,fenv) | Axe FP avancé, adjacent à libm ; base air-libm suffit à icu4x. Sentinelle FE_DFL_ENV=(-1) bannie ADR-021 | ⏭ |
3. Points d’attention spécifiques (tranchés)
- stdio.
air-stdiocouvre bien le brut requis (fd 0/1/2, read/write 1-syscall EINTR-fidèle, verrou par flux,is_terminal,reset_after_fork). LeFILE*bufferisé,vfprintf(moteur de format, sans%npar défaut — RAPPORT-CONSOLIDE §B6) etscanfrestent légitimement face-C au-dessus (D7). Verdict : brique suffisante. - pwd / grp / shadow.
air-config::PasswdBackendest la brique : il émet et importepasswd/group/shadow(hash enshadowseul, mode 0600 dès création viaopenat2, buffersZeroizing, élision du secret dans les erreurs) et exposeaccounts_config::Reader(name/uid/gid/ gecos/home/shell/password_hash). C’est la source de vérité contre laquellegetpwnam/getpwuid/getgrnam/getgrgid/getspnamse câblent en face-C. Nuance : l’API est orientée projection d’artefact ↔ /etc, pas lookup ponctuel indexé ; les itérateurs/lookupsgetpw*_rowned se construisent au-dessus (additif). Verdict : brique présente (◐ sur l’ergonomie lookup, additif). - string / ctype / multibyte.
air-base-core(octets/encodages) +air-base-lib(icu4x : casing-locale, normalisation, collation, segmentation) fournissent le socle ADR-053. Le ctype ASCII et la machinembstate_tsont du calcul pur/face-C au-dessus du socle. Verdict : brique présente. - setjmp / longjmp. Absent (grep
crates/= 0). C’est l’unique résidu asm réellement incompressible (ADR-047 P6, RAPPORT-CONSOLIDE §D) : inexprimable en Rust safe (saute par-dessus lesDrop). À confiner en surface FFI C-ABI unsafe le moment venu. Additif, non-bloquant (nouvelle unité asm/FFI, aucune API scellée touchée ; OpenSSH n’en dépend guère). - regex. Différé post-sceau (ADR-046 / roadmap §4-I). Crate additive : ne descelle rien.
4. Gaps BLOQUANTS pour le sceau
Aucun gap bloquant au sens refonte. Justification (à charge, un completeness critic doit prouver l’absence de bloquant, pas la postuler) :
- Toutes les briques kernel nécessaires existent déjà en couche 0 (
air-sys-syscall) — fichier, signal, termios/PTY, poll, mman — et tous les types sont enair-sys-types. Aucune brique fondatrice n’est à inventer ; il s’agit de re-exposer un tier couche-1 fidèle. - Chaque manque identifié en §2 se comble par ajout (
pub fn/pub use/module neuf, voire crate neuve) sans modifier une signature couche-1 déjà figée → descellement additifv1.x(modèle roadmap §5.3/§5.4, précédent ADR-051). Aucun ne force une rupture de contrat scellé. - Le seul « trou » structurellement irréductible (setjmp/longjmp) est un îlot asm FFI-only, par nature hors de l’API Rust scellée.
Réserve honnête (ne pas la lire comme un bloquant, mais ne pas la sous-estimer). Le fait que rien ne soit bloquant-refonte ne signifie pas que la face libc v1 est portable en l’état. Un tier passthrough substantiel et fondateur pour OpenSSH reste à construire en couche 1 (§5.3) avant que la libc puisse être écrite : I/O fichier générique (#11), poll/select générique (#14), termios/PTY (#15), signal générique (#19), plus mman (#16) et
setenv(#10). Ce sont les manques listés ci-dessous — additifs, mais prioritaires.
5. Gaps ADDITIFS post-sceau (attendus : la face C n’est pas encore écrite)
Classés par priorité pour la cible OpenSSH. Tous relèvent du §5.3 « combler les manques additifs » et aucun ne descelle l’API figée.
P0 — fondateurs pour OpenSSH, à exposer en couche 1 avant d’écrire la libc :
- I/O fichier générique (#11). Exposer en couche 1 un tier fidèle
open/openat/read/write/pread/pwrite/lseek/close/dup/fcntl(F_GETFL/F_SETFL/F_SETFD)/ftruncate(la couche 0 a tout :openat2/read/write/lseek/dup_fd). Sans lui,open(2)/read(2)/write(2)de la libc sont inexprimables (D2 interdit le saut vers couche 0). - poll/select générique (#14). Exposer un multiplexeur
ppollsur fd arbitraires (la couche 0 appoll; seulair-process::wait_untill’affleure, couplé au pidfd). Boucle principale OpenSSH. - termios / PTY / job-control (#15). Re-exposer en
air-terminalle tiertcgetattr/tcsetattr/tc*/get_winsize/set_winsize/open_pty_master/pty_*/tcgetpgrp/tcsetpgrp/session_id/set_ctty(tout en couche 0).cfmakeraw(Result, pasvoid— RAPPORT-CONSOLIDE §B2),openpty,getpassen dépendent. - Signal générique (#19). Exposer en couche 1 la création
signalfd(primauté ADR-020), le masquage (sigprocmask/pthread_sigmask),kill(pid)arbitraire,raise/sigsuspend. Ne PAS forcerSA_RESTART(EINTR remonté, RAPPORT-CONSOLIDE §H).
P1 — fondateurs mais moins critiques au démarrage :
- mman (#16). mmap fichier-adossé,
mprotect,madvise,shm_open(..+O_NOFOLLOW, RAPPORT-CONSOLIDE §F4),mlock. Couche 0 a mmap/mprotect/munmap ; couche 1 n’a que l’anonyme. - env mutation (#10).
setenv/putenv/unsetenv/clearenv(+ gouvernance ADR-047) etsecure_getenv(gatingAT_SECURE, RAPPORT-CONSOLIDE §F9). - stat/fstat/lstat public (#12) ; mkstemp/mkostemp (dépend de #11, O_EXCL|0600).
P2 — ergonomie / complétude :
- thread (#20) : condvar publique,
pthread_key/TSD,sched_yield/sched_setaffinitypublic. - réseau (#17) :
getsockopt/setsockoptgénériques. - stdlib/time : moteur
vfprintf/strftime(face-C au-dessus des briques présentes). - setjmp/longjmp : îlot asm FFI-only (ADR-047 P6) — à créer si un userland C l’exige.
- conf/linux divers :
sysconf/pathconfréels (pas de faux succès, RAPPORT-CONSOLIDE §H11).
Rappel de durcissement (RAPPORT-CONSOLIDE, à porter dans la face C, pas des gaps de brique) :
malloc zero-on-free absent d’air-alloc (choix de durcissement, additif) ; neutraliser %n
par défaut ; entropie unique CSPRNG (déjà air-crypto::AirRandom, fail-closed) ; zeroize secrets
(déjà Zeroizing/ZeroizeOnDrop en air-config/air-crypto).
6. VERDICT de sceau-readiness FACE-LIBC
Le verdict se lit à deux niveaux, tous deux nécessaires à une réponse honnête :
(a) Au sens de la question posée — « rien de FONDATEUR ne manque, les manques sont additifs » : OUI.
Les fondations de la couche 1 sont présentes et saines : errno + shim thread-local (D4),
allocateur global, primitives thread futex + TLS/TCB, temps + sleep, environ (lecture), clone3/exec/
wait/pidfd/drop_privileges, réseau complet + résolveur RFC1035, socle i18n icu4x + libm, crypto
CSPRNG/hachage, backends passwd/group/shadow. Aucun manque n’exige de refondre une API
scellée : tous sont additifs (re-exposition d’un tier couche-1 fidèle par-dessus une couche 0
déjà complète, ou crate neuve). Le modèle de descellement additif v1.x (ADR-051, roadmap
§5.3/§5.4) absorbe tout manque résiduel découvert après le sceau. Le sceau couche-1-v1.0 est
donc SÛR : le sceller ne fige aucune erreur de conception irréversible.
(b) Au sens fonctionnel — « la face libc v1 (OpenSSH) est-elle portable sur la couche 1 en l’état » : NON, pas encore.
Un tier passthrough kernel-faithful fondateur reste à exposer en couche 1 (travail §5.3
explicitement prévu avant §5.4) : I/O fichier générique, poll/select générique, termios/PTY,
face signal générique (P0), puis mman et setenv (P1). Ce sont exactement les « manques additifs
remontés par 5.1 » que la roadmap ordonne de combler avant de sceller.
Conclusion opérationnelle. OUI au sceau sous le régime additif — à la condition explicite
d’exécuter d’abord §5.3 sur les 6 chantiers P0/P1 ci-dessus (aucun n’est un bloquant-refonte ;
tous sont des ajouts de pub/crates au-dessus d’une couche 0 déjà dotée). Sceller sans §5.3
resterait techniquement sûr (rien d’irréversible) mais produirait une couche 1 incapable de
porter la face libc v1 sans un descellement additif immédiat — ce que la séquence §5.3→§5.4 vise
précisément à éviter. Recommandation : §5.3 (P0+P1) puis §5.4.
Complétude critique — checklist du critique. Périmètre v1 rejoué famille par famille (✅/◐/❌/⏭) ;
symboles réels vérifiés par grep ; couche 0 confirmée comme socle des passthroughs manquants ;
setjmp confirmé absent ; wordexp confirmé exclu ; distinction bloquant-refonte / additif tranchée
et justifiée (pas postulée). Points d’attention du prompt (stdio, pwd/grp, string/ctype, setjmp,
regex) tous tranchés en §3.
Audit face-PAL — clôture pré-sceau couche-1-v1.0 (roadmap §5.2)
Statut : note d’audit (completeness critic), NON engageante. Préalable au sceau
couche-1-v1.0(roadmap §5.2). Lecture seule du code — aucune modification. Symboles réels, grepés (crates/), pas devinés. Rédigé le 2026-07-02.Objet. Le PAL Rust = le backend
std::sysqu’une toolchain*-linux-air(ADR-049/050/052) doit implémenter. Cet audit statue, primitivestd::syspar primitive, si la brique couche 1 (API Rust) existe pour porter la primitive. Le PAL est un toit au-dessus de la couche 1 (ADR-052 : seule la couche 1 consomme la couche 0) : il compose les briques couche 1 ; il ne re-syscall pas.Grille de statut. ✅ Brique présente · ◐ Partielle (manque précisé) · ❌ Manquante. Pour ◐/❌ : BLOQUANT (fondateur absent — non comblable sans refonte d’une API qu’on s’apprête à sceller) vs ADDITIF post-sceau (comblable en
v1.xpar descellement additif, sans toucher l’API scellée — modèle ADR-051).
1. Tableau — primitive std::sys → brique(s) couche 1 → statut
| # | Primitive std::sys | Brique(s) couche 1 (symboles réels) | Statut | Bloquant / Additif |
|---|---|---|---|---|
| 1 | thread — spawn/join | air_runtime::thread::spawn(entry: extern "C" fn(usize), arg: usize) -> Result<JoinHandle, Errno> + JoinHandle::{join, tid} (thread.rs:136/85) ; primitive : air_thread::runtime_primitives::create_thread(...) (runtime_primitives.rs:147) | ◐ | Additif — le spawn natif bas-niveau (pile mmap + TLS/TCB + clone3 + join futex) est présent et prouvé on-target. L’ergonomique boxé (FnOnce()->T) manque sur la cible → §2.B.2 ci-dessous. |
| 1b | thread — yield / sleep / name / id | yield : couche 0 air_sys_syscall::process::sched_yield() (process.rs:3494) ; sleep : air_base_core::time::{sleep, sleep_until} (time.rs:331/359) ; id : air_thread::runtime_primitives::current_tid() (:264), air_runtime::AirRuntime::current_tid() ; name (PR_SET_NAME) : cible absent (seul le wrapper hôte std::thread) | ◐ | Additif — sleep/yield/id ✅ ; yield_now = passthrough trivial sched_yield (couche 0) à surfacer ; nommage de thread sur cible = wrapper prctl(PR_SET_NAME) à ajouter. |
| 2 | sync — Mutex / RwLock | air_thread::{AirMutex, AirRwLock} (+ guards) sur futex(2) maison (sync.rs:135/341), sans std::sync, sans poisoning, modélisés loom (ADR-058) | ✅ | — (fondateur présent) |
| 2b | sync — Condvar | (aucun type Condvar/AirCondvar) — briques futex : air_thread::runtime_primitives::{futex_wait_word, futex_wake_one, reset_futex_word} (:219/238/248) | ❌ | Additif — pas de brique Condvar, mais les primitives futex fondatrices sont exposées ⇒ constructible au-dessus sans toucher l’API scellée. |
| 2c | sync — Once / lazy-init | (aucun type Once/AirOnce) — briques : air_thread::atomic::AtomicU32 + futex ci-dessus | ❌ | Additif — Once = AtomicU32 + futex ; fondations présentes. |
| 3 | thread_local (#[thread_local], TLS/TCB) | air_runtime::thread_control_block::ThreadControlBlock (errno, canari, DTV, tid ; :69) ; air_runtime::thread_local_storage::{ThreadLocalStorage, init_spawned_thread_tls} (:460/626) ; errno vrai #[thread_local] sur cible (thread_control_block.rs:289) — Variant I/II, ADR-049 D2/D3, prouvé on-target | ◐ | Additif — brique TLS/TCB fondatrice présente. Manque : destructeurs thread_local! (Drop fin de thread, ADR-049 D9) — champ DTV présent, pas de register_dtor. Mineur (panic=abort). |
| 4 | time — Instant / SystemTime | air_base_core::time::{AirInstant, AirSystemTime, AirDuration} (:49/76/55) — CLOCK_MONOTONIC/CLOCK_REALTIME, UNIX_EPOCH, duration_since -> AirResult (recul d’horloge honnête) | ✅ | — |
| 5 | fs — File / metadata / dir | air_filesystem::AirFileSystem = façade path-level : canonicalize/exists/write_atomic/read_to_bytes/copy/read_dir/glob (paths.rs, atomic.rs, search.rs) + AirTempDir + watcher inotify. Syscalls fichier bruts : couche 0 air_sys_syscall::fs::{openat2, read, write, pread/pwrite, lseek, statx, getdents64, mkdirat, unlinkat, renameat2, symlinkat, readlinkat, ftruncate, fsync} (fs.rs) | ◐ | Additif — pas d’objet File couche 1 (open/read/write/seek/metadata). Le socle couche 0 est scellé et complet ; il manque le handle AirFile dans air-filesystem (brique existante) → ajout additif, aucune refonte. (Voir Gaps additifs — c’est le plus substantiel.) |
| 6 | net — TCP / UDP / lookup | air_socket::{AirTcpSocket, AirTcpListener, AirUdpSocket, AirUnixStream/Listener/Datagram} (connect/bind/listen/accept/read/write/send_to/recv_from/shutdown/as_fd) + AirNameResolver::{resolve, resolve_socket} (résolveur DNS maison, hosts/resolv.conf) | ✅ | — |
| 7 | process — Command / Child / exit | air_process::{AirCommand (builder + spawn), AirProcess (wait_*/try_wait/signal/kill/pidfd/take_std*), AirExitStatus, AirStdio} + air_pipe + exit_process(code) -> ! (clone3/pidfd/exec internes) | ✅ | — |
| 8 | env — vars | air_env::{get, vars, Vars} (octets, lecture seule ; set_environ au démarrage) | ◐ | Additif — lecture ✅, mais set/unset (mutation setenv) absents. std::env::set_var en aura besoin → ajout additif à air-env. |
| 15 | args (argv) | air_runtime::args::{count, get, args, Args} (octets ; :27/46/66) | ✅ | — |
| 9 | random (seed HashMap / getrandom) | Couche 0 air_sys_syscall::system::getrandom surfacé par air_crypto::AirRandom::fill(&mut [u8]) -> AirResult<()> (random.rs:60) et air_base_core::id (usage privé UUIDv7). CSPRNG kernel, jamais PRNG userspace. | ✅ | — (le PAL peut lier AirRandom::fill ou directement couche-0 getrandom pour éviter de tirer les deps RustCrypto) |
| 10 | stdio — Stdin/out/err | air_stdio::{stdin, stdout, stderr} -> StandardStream : read/write bruts (fd 0/1/2), is_terminal, lock() -> StreamGuard, reset_after_fork (atfork). no_std, verrou par flux modélisé loom | ✅ | — |
| 11 | path — séparateurs / composants | air_base_core::path::{AirPath, ComponentIterator} : séparateur / (POSIX), components/parent/join/file_name/extension/is_absolute/normalize_lexically/to_c_string (path.rs) | ✅ | — |
| 12 | alloc (GlobalAlloc) | air_alloc::GlobalAllocator : unsafe impl core::alloc::GlobalAlloc — alloc/alloc_zeroed/dealloc/realloc (global.rs:75), const fn new, backing mmap réel (MmapBacking), OOM = null déterministe (jamais de hang). #[global_allocator] static câblé dans rt/air-rt. | ✅ | — |
| 13 | os_str (OsStr/OsString octets) | Substrat présent : AirPath (octets possédés) + conventions &[u8]/Vec<u8> (env/args/AirDirEntry). Pas de paire newtype OsStr/OsString dédiée (AirPath = owned-only, emprunts en &[u8] brut) | ◐ | Additif — le PAL os_str Unix EST un wrapper de [u8]/Vec<u8> : substrat trivialement présent ; newtype AirOsStr/AirOsString = ajout additif si l’on veut une paire nommée. |
| 14 | backtrace / unwind | panic = abort partout (ADR-045/ADR-049 D9) ⇒ pas d’unwind cross-thread ; sortie via air_process::exit_process / couche 0 exit_group(status) -> ! (process.rs:688) | ✅ | — (surface std::sys backtrace/unwind minimale par construction) |
2. Points durs tranchés
2.1 — sync : Condvar & Once (grep)
Confirmé par grep (air-thread/src/, arbre entier) : aucun Condvar/AirCondvar,
aucun Once/AirOnce. La surface sync exportée est exactement
AirMutex/AirRwLock/AirSemaphore (+ guards) et air_channel (MPSC).
Statut : ❌ absents, mais ADDITIFS. Les deux se construisent au-dessus des
primitives futex fondatrices déjà publiques (futex_wait_word/futex_wake_one/
reset_futex_word + AtomicU32). Les ajouter = nouveaux types dans air-thread
(brique existante), sans nouvelle surface couche 0 ni refonte d’API scellée →
descellement additif v1.x (ADR-051). Non bloquant.
2.2 — TLS (#[thread_local] / TCB) pour la face PAL
Statut : ◐ — brique fondatrice présente. air-runtime fournit le TCB
(ThreadControlBlock : errno, canari %fs:0x28/tpidr_el0, DTV, tid) et le TLS
ELF (ThreadLocalStorage, Variant I/II, init_spawned_thread_tls,
install_main), avec un vrai errno #[thread_local] sur *-linux-air
(ADR-049 D3), prouvé on-target (selftest rt/, 2 arches). C’est la brique
que std::sys::thread_local réclame. Manque additif : l’exécution des
destructeurs thread_local! en fin de thread (ADR-049 D9) — le champ DTV
existe mais aucun register_dtor n’est câblé. Impact mineur (panic=abort,
threads courts). Non bloquant.
2.3 — random : quelle brique ?
La seed HashMap / getrandom de std::sys se lie à air_crypto::AirRandom::fill
(random.rs:60), lui-même sur la couche 0 air_sys_syscall::system::getrandom.
Recommandation : pour le PAL, lier directement la couche-0 getrandom
(comme le fait déjà air_base_core::id) plutôt que de tirer toute la fermeture
RustCrypto d’air-crypto — cela évite aussi le point dur cpufeatures→libc
d’aarch64 (§2.5). ✅ présent.
2.4 — path / os_str : AirPath couvre-t-il la face PAL ?
path : ✅. AirPath couvre exactement la logique std::sys::path Unix :
séparateur /, itération de composants (ComponentIterator → &[u8]), parent,
join, is_absolute, file_name, extension, normalize_lexically,
to_c_string (FFI syscall). os_str : ◐ additif — AirPath est owned-only
(pas de paire empruntée &OsStr), et il n’existe pas de newtype OsStr/OsString
générique ; sur Unix, std::sys::os_str n’est qu’un wrapper de [u8]/Vec<u8>,
substrat déjà présent partout. Newtype dédié = additif.
2.5 — fermeture on-target (rappel + caveat aarch64)
§2.A a prouvé la fermeture no_std icu pour x86_64/aarch64-unknown-linux-air
(release, build-std=core,alloc, LSTM inclus). C’est une propriété de fermeture
de lien, distincte de la complétude d’API objet de cet audit. Caveat
(§2.E.2) : sur aarch64, la dép cpufeatures d’air-crypto lie libc
(getauxval) sans profil env=air → chemin air_auxval(AT_HWCAP) libc-free à
fournir. C’est une limitation de fermeture (chantier dédié), pas un manque
d’API PAL : le PAL n’a pas besoin d’air-crypto pour son random (il lie
couche-0 getrandom).
3. Gaps BLOQUANTS (fondateur absent)
Aucun.
Toutes les primitives std::sys disposent de leur brique fondatrice en couche
1 (ou de son socle couche-0 scellé + point d’ancrage couche-1) :
- l’allocateur global (
GlobalAlloccomplet, backing mmap réel) — présent ; - le spawn natif avec bootstrap TCB/TLS (la partie unsound-si-fausse, qui fige la disposition TLS qu’on scelle) — présent et prouvé on-target ;
- les verrous
Mutex/RwLocksur futex maison + les primitives futex exposées — présents ; - time, net, process, stdio, path, args, random, exit — présents ;
- le socle fs (syscalls fichier couche 0) — scellé et complet.
Rien de ce qui reste ne peut casser une API scellée : ce sont des ajouts au-dessus.
4. Gaps ADDITIFS post-sceau (comblables en v1.x, sans refonte)
Classés par importance décroissante. Aucun ne modifie une API scellée ; chacun s’ajoute à une brique existante (ou au toit PAL) au-dessus de fondations figées.
fs— handleAirFilecouche 1 (le plus substantiel).air-filesystemn’expose pas de type fichier (open/read/write/seek/metadata) ; seulement des ops path-level. Le socle couche-0 (openat2/read/write/lseek/statx/getdents64…) est scellé et complet. → ajouter un objetAirFile(RAIIOwnedFd) +metadata/DirEntrydansair-filesystem. Additif (brique existante, socle figé, cohérent avec la façon dontair-socket/air-processexposent déjà leurs handles I/O).thread— spawn ergonomique boxé (FnOnce()->T+ résultat au join) sur la cible. Voir §5 (recommandation dédiée). Additif.sync—CondvaretOnce— au-dessus des primitives futex publiques (§2.1). Additif.env— mutation (set/unset,std::env::set_var).air-envest lecture-seule aujourd’hui. Additif.os_str— newtypeAirOsStr/AirOsString(paire owned/emprunté octets). Substrat[u8]/Vec<u8>déjà présent. Additif.thread_local— destructeursthread_local!(ADR-049 D9) — DTV présent,register_dtorà câbler. Additif, mineur (panic=abort).thread— divers cible :yield_now(passthrough couche-0sched_yieldà surfacer en couche 1), nommage de thread sur cible (prctl(PR_SET_NAME)). Additifs, triviaux.
5. Recommandation argumentée — spawn ergonomique natif (§2.B.2)
Question ouverte §2.B.2. L’API std::thread::spawn a-t-elle sa brique
ergonomique sur la cible ? Le spawn natif bas-niveau existe
(air_runtime::thread::spawn(entry: extern "C" fn(usize), arg: usize) +
air_thread::runtime_primitives::create_thread), mais l’ergonomique boxé
(FnOnce()->T via air-alloc + trampoline extern "C" + résultat récupéré au
join) n’est pas câblé sur *-linux-air. Sur l’hôte, AirThreadBuilder::spawn
existe mais enrobe std::thread et est gardé #[cfg(not(target_env = "air"))]
— donc absent de la cible, qui est précisément celle du PAL.
Verdict : ADDITIF (non bloquant pour le sceau)
Argumentaire :
-
La partie FONDATRICE est déjà là. Le difficile — et le dangereux-si-faux — du spawn Air-natif, c’est le bootstrap TCB/TLS : allouer pile + image TLS,
clone3 + CLONE_SETTLS, faire résoudreerrno/#[thread_local]dans le bloc du thread, joindre parCHILD_CLEARTID/futex. C’est exactement ce qui fige la disposition TLS que le sceau grave — et c’est présent, testé on-target (air_runtime::thread::spawn+create_thread, selftestrt/). Si cette pièce manquait, ce serait bloquant (impossible à ajouter plus tard sans toucher la disposition scellée). Elle ne manque pas. -
Ce qui manque est de la pure composition, au-dessus de deux fondations déjà présentes et à sceller : (a) le spawn natif ci-dessus, (b) l’allocateur global
air_alloc::GlobalAllocator(✅). L’ergonomique = boxer la closure (Box<dyn FnOnce()>viaair-alloc), un trampolineextern "C" fn(usize)qui reconstitue leBoxet l’appelle, et une case tas où déposerT, lue aujoin. Zéro nouvelle surface couche 0, zéro modification d’API scellée. Avecpanic=abort(ADR-049 D9), la « capture de panique au join » est sans objet (un thread qui panique abort le process) : lejoinn’a qu’à rendreT. -
Le modèle
stdlui-même sépare ces deux étages :std::sysfournit leThread::new(stack, main: Box<dyn FnOnce()>)bas-niveau, etstd::threadpose au-dessus le plombage du résultat/handle. Notre découpe est donc alignée sur l’amont : couche 1 = le natif ; le boxage/résultat s’ajoute proprement au-dessus.
Où le poser (décision de couche)
- PAS dans
air-thread. Cycle : l’ergonomique a besoin du spawn natif, qui vit dansair-runtime, lequel dépend déjà d’air-thread(runtime_primitives::create_thread) →air-thread → air-runtimebouclerait. (C’est la contrainte notée en §2.B.2 de la roadmap.) - Recommandé : dans
air-runtime(nouveau point d’entrée, p. ex.air_runtime::thread::spawn_boxed<F, T>(f: F) -> Result<JoinHandle<T>, Errno>), qui : (i) possède déjà le spawn natif et laJoinHandle(cohésion) ; (ii) peut dépendre d’air-allocsans cycle — la dépendanceair-runtime → air-allocexiste déjà (air_runtime::forkappelleair_alloc::reset_after_fork). C’est l’emplacement que la roadmap §2.B.2 anticipe (« le spawn Air-natif viendra d’air-runtime+ allocateur réel »). - Variante acceptable : laisser le plombage
T-au-join dans le toit PAL (std::sys::threadde*-linux-air) et n’exiger de couche 1 que l’acceptation d’une closure boxée (Box<dyn FnOnce()>) — calqué surstd::sys::pal::unix. Dans les deux cas : additif, aucune API scellée touchée.
Conséquence pour le sceau : sceller maintenant le spawn natif + le
GlobalAlloc ; l’ergonomique se pose après en couche additive v1.x. Le sceau
n’a pas à attendre spawn_boxed.
6. VERDICT de sceau-readiness face-PAL
OUI — la couche 1 est brique-complète pour porter le PAL
std::sys.
Justification. Toutes les primitives std::sys fondatrices disposent de
leur brique couche 1, réelle et (pour le code target-only) prouvée on-target :
allocateur global (GlobalAlloc complet), spawn natif avec bootstrap
TCB/TLS (la pièce qui fige la disposition scellée), verrous Mutex/RwLock
sur futex maison + primitives futex exposées, temps monotone/mur + sleep,
réseau (TCP/UDP/Unix + DNS), process (Command/Child/exit), stdio brut (fd 0/1/2 +
isatty + verrou + atfork), chemins octets, argv/env (lecture), random (CSPRNG
kernel), et exit/panic=abort pour la face backtrace/unwind minimale.
Aucun gap BLOQUANT (§3). Les manques recensés (§4) — handle fs::File couche 1,
spawn ergonomique boxé (§5), Condvar/Once, mutation env, newtype os_str,
destructeurs TLS, yield_now/nommage cible — sont tous ADDITIFS : ils se
construisent au-dessus de fondations figées (socle couche-0 scellé + briques
couche-1 fondatrices), sans refonte d’aucune API que le sceau grave. Ils
relèvent du comblement additif §5.3 et/ou du descellement additif v1.x
(modèle ADR-051), pas d’un pré-requis au sceau.
Réserve de fermeture (non-API). Sur aarch64, la fermeture de lien reste
imparfaite tant que le chemin air_auxval(AT_HWCAP) libc-free n’est pas fourni
(§2.5, §2.E.2) — mais c’est une propriété de lien, pas de complétude d’API PAL,
et le random du PAL n’y est pas exposé (lien couche-0 getrandom direct).
7. Synthèse (mémo)
| Face PAL | Verdict |
|---|---|
| Primitives fondatrices | toutes présentes (alloc, spawn natif+TCB/TLS, Mutex/RwLock+futex, time, net, process, stdio, path, args, env-lecture, random, exit) |
| Gaps bloquants | 0 |
| Gaps additifs | fs::File (handle couche 1) · spawn ergonomique boxé · Condvar/Once · env mutation · os_str newtype · dtors TLS · yield/nommage cible |
| §2.B.2 (spawn ergo) | ADDITIF, à poser dans air-runtime (spawn_boxed, dépend d’air-alloc — pas de cycle) ; le natif+TCB/TLS, déjà scellable, suffit au sceau |
| Sceau-readiness face-PAL | OUI |
Note d’architecture — prérequis d’air-sshd : graphe de dépendances et ordre de bataille
Statut : note de travail (non normative). Cadre les prérequis techniques d’air-sshd
(ADR-074) et leur ordonnancement. Les décisions
structurantes qui en découleront passeront par des ADR/RFC dédiés. S’appuie sur la
doctrine de configuration binaire (ADR-073)
et le socle réseau couche 1 (ADR-069).
1. Idée directrice
air-sshd n’est pas qu’un démon : c’est le premier « bon citoyen » de la couche
service d’Air. À ce titre, il est aussi une fonction de forçage pour la couche
supérieure — le runtime asynchrone, le modèle de service, la session/login — exactement
comme « compiler OpenSSH » a été la fonction de forçage de la couche réseau. La plupart
des prérequis ci-dessous sont donc des facilités partagées que tout service Air
réutilisera, pas des briques jetables propres à ssh.
2. Décision d’outillage : configuration via air-config (capnp), pas serde
Air dispose déjà de air-config / air-config-schema / air-config-compile, bâtis sur
Cap’n Proto (capnp 0.26, dépendance unique embedded-io, no_std-friendly) +
zeroize. C’est exactement le mécanisme de schéma binaire versionné visé par ADR-073.
air-sshd lit donc sa configuration via air-config. On n’introduit pas serde :
serde seul est propre (serde_core no_std, sans dépendance runtime ; serde_derive
proc-macro hôte au build), mais il n’est qu’un framework — il faudrait lui adjoindre un
format binaire (postcard/bincode), ce qui dupliquerait air-config et ajouterait des
dépendances (règle des 80 %, ADR-024). Verdict : capnp/air-config.
3. Fondations déjà en place (couche 0/1)
Réutilisées telles quelles par air-sshd :
| Brique | Rôle pour air-sshd |
|---|---|
| io_uring (couche 0, Temps 1 / 2b réseau) | soumission/complétion d’I/O asynchrone |
| air-socket + air-handle + air-poll (ADR-069) | accept/connect/read/write, registre de fd |
| air-runtime | runtime process/thread (_start, TCB/TLS) — pas un exécuteur async |
| air-crypto | X25519, Ed25519, ChaCha20-Poly1305, AES-GCM, SHA-2, HMAC (profil SSH moderne complet) |
| air-config (capnp) | configuration binaire (ADR-073) |
| air-process | clone3, pidfd, drop_to_user, credentials — base du privsep et du largage de privilèges |
| air-terminal / termios | allocation et discipline de PTY |
| air-signal / signalfd | réception des signaux (à brancher sur le réacteur) |
| air-account | comptes (passwd/group) — base de la politique de login |
| SCM_RIGHTS (sendmsg/recvmsg, #259) | passage de fd moniteur↔enfant (privsep) |
| AirLog | journalisation |
4. Les prérequis manquants
4.1 air-async — le runtime asynchrone partagé (pièce maîtresse)
« La runloop » recouvre en réalité quatre choses, aujourd’hui inexistantes (io_uring
Temps 1 a laissé l’async en différé ; air-runtime n’est pas un scheduler de futures) :
- Réacteur : complétions io_uring → réveil de la bonne tâche (
Waker). - Exécuteur : ordonnancement et poll des
Future,spawn. - Feuilles d’I/O async : accept/read/write renvoyant des
Futureau-dessus d’air-socket. - Timers : login grace, keepalive, intervalle de rekey.
C’est le plus gros manque et une facilité partagée → à sortir en composant à
part entière (air-async). Décision de conception à trancher tôt (ADR) : async/await
(exécuteur + intégration Waker) vs machine à états explicite. Test : sur l’hôte, avec
loom (concurrence déterministe) et fuzzing des feuilles d’I/O.
4.2 Modèle de service : supervision + provisionnement du socket
En amont de l’IPC : qui lance/surveille/redémarre un service Air, et d’où vient son socket d’écoute (activation par socket ? passage de fd ?). Il manque un gestionnaire de services Air (équivalent init/superviseur) et son contrat de cycle de vie.
4.3 IPC Air
Le bus d’IPC auquel un service se connecte — cité mais non développé. Prérequis pour le « bon citoyen », mais pas pour un premier prototype autonome d’air-sshd.
4.4 Modèle session/login + politique d’autorisation
Après l’auth : concept unifié de session (largage uid/gid via drop_to_user, PTY,
environnement, cwd, setrlimit, comptabilité type utmp/wtmp) et surtout la politique
qui décide qu’un utilisateur a le droit de se connecter (équivalent Air de PAM/nsswitch,
adossé à air-account). Les briques existent, l’orchestration non.
4.5 Mécanisme d’autorisation administrateur (config CLI)
La ressaisie du mot de passe administrateur d’ADR-073 (dérivation, stockage, vérification du secret) — laissé ouvert par ADR-073, à spécifier en RFC. Nécessaire au CLI de conf, pas au démarrage du démon (qui lit un binaire déjà produit).
5. air-sshd proprement dit (le composant cible)
Décisions internes à prendre tôt, même si ce ne sont pas des prérequis externes :
- Privsep : écouteur qui largue les privilèges + enfant non privilégié par connexion + moniteur privilégié (signature clé d’hôte, décision d’auth, alloc pty) qui repasse résultats/fd via SCM_RIGHTS. Architecture de sécurité centrale.
- Stockage de la clé d’hôte Ed25519 (identité du démon) : où, permissions, rotation — adossé à ADR-073.
- Parseur de paquets = entrée non fiable : source n°1 de CVE SSH → schema-first, fuzzé, zéro panic sur entrée malformée (comme sockaddr).
- Audit d’auth + anti-force-brute : événements d’auth (IP, empreinte de clé) async-safe, cohérents avec la doctrine binaire.
- Signaux : SIGTERM (drain), SIGHUP (recharge conf binaire), SIGCHLD (récolte enfants) — signalfd branché sur le réacteur.
6. Graphe de dépendances
io_uring (couche 0) air-runtime
| |
+-----------+--------------+
v
┌───────────┐
│ air-async │ (réacteur + exécuteur + timers) ← PIÈCE MAÎTRESSE
└───────────┘
|
air-crypto air-socket/air-handle | air-config(capnp) air-process air-terminal
| | | | | |
+-------+-------+----------+-------+------------+--------+--------+------+-------+
v
┌──────────────┐
│ air-sshd │
│ transport → │ KEX curve25519, paquets ChaCha20-Poly1305 (async)
│ userauth → │ publickey Ed25519 ── politique login (air-account)
│ connection │ canaux, pty, exec ── session/login, drop_to_user
│ (privsep) │ moniteur↔enfant via SCM_RIGHTS
└──────────────┘
|
supervision + socket ──────+────── IPC Air (déploiement « service »)
(gestionnaire de services) (bon citoyen)
Hors chemin critique du premier prototype (mais requis en production) : gestionnaire de services, IPC, mécanisme d’autorisation admin du CLI de conf.
7. Ordre de bataille
Chaque étape testée sur l’hôte d’abord (cf. §8), avec barrière + fuzz/loom là où c’est pertinent.
air-async— réacteur + exécuteur + timers + feuilles d’I/O async. ADR de conception, puis implémentation, loom, fuzz. Débloque tout le reste.- Schéma de conf
air-sshd(viaair-config/capnp) — clés d’hôte, port, utilisateurs autorisés. Indépendant du mécanisme d’autorisation admin (qui viendra s’y brancher). - Couche transport SSH-2 sur
air-async— identification,KEXINIT(moderne seul), KEXcurve25519-sha256, dérivation, paquets chiffrés. Parseur fuzzé. Jalon : un clientsshréel complète le KEX et obtient un canal transport chiffré. ssh-userauth(publickey Ed25519) + politique de login (surair-account).- Privsep — moniteur privilégié + enfant non privilégié, échange via SCM_RIGHTS. Peut s’introduire autour des étapes 3-4.
ssh-connection— canaux,pty-req/shell/exec, session/login (drop_to_user, PTY, environnement). Premierssh user@airinteractif.- Supervision + provisionnement socket + IPC — intégration comme vrai service Air.
- RFC autorisation admin + stockage clé d’hôte — sécurisation du CLI de conf.
8. Le point qui de-risque : host-first, le PAL n’est pas un verrou pour démarrer
Les crates couche 1 (air-socket, air-runtime, air-crypto, air-config) compilent
et tournent déjà sur l’hôte linux-gnu — c’est ainsi qu’on les teste. La logique
d’air-sshd (runtime async, transport, KEX, userauth, canaux) est du Rust pur,
prototypable et testable intégralement sur l’hôte avant livraison du PAL. Le
PAL/linux-air conditionne le binaire natif de production, pas le développement. On peut
donc mener air-async et air-sshd en parallèle du PAL, au lieu de sérialiser
« PAL d’abord ».
9. Résumé
L’oubli central : « la runloop » = un vrai runtime async partagé (air-async :
réacteur + exécuteur + timers), pièce maîtresse à bâtir avant tout. Viennent ensuite le
privsep, la session/login, la supervision + socket + IPC, le stockage de
clé d’hôte et l’audit. La conf passe par air-config (capnp), pas serde. Et le
PAL n’est pas un verrou pour commencer : host-first.
Rapport de design — air-async (runloop io_uring de la couche 2) — INCRÉMENT 0
Statut : rapport de conception + reconnaissance + squelette prouvé. Non
engageant au sens ADR (une décision structurante — l’introduction du réacteur
couche 1 air-uring et le découpage réacteur/exécuteur — sera gravée par un
ADR/RFC avant l’incrément 1). Branche feat/air-async-design.
Amont normatif : ADR-038
(sync-first / async opt-in, io_uring seul moteur, pas de tokio),
ADR-091 (motif réseau sans-IO :
air-async = le pilote I/O async), ADR-022
(architecture io_uring), ADR-023 (runtime
async Air). Complète architecture-prerequis-air-sshd-fr.md
(qui identifie air-async comme prérequis manquant de la couche 2).
0. TL;DR — la recommandation
-
Layering — option (a), et c’est la SEULE cohérente. Scinder le « runloop » en deux crates :
air-uring— le réacteur, en couche 1 (rempart safe au-dessus de l’unsafeio_uring de la couche 0, exactement commeair-pollexposeppoll) ;air-async— l’exécuteur (futures,block_on/spawn, timers), en couche 2 (surface applicative async, consommée par le framework réseau).
C’est le seul découpage qui honore à la fois ADR-038 (qui place le runtime async en couche 2) et le gate
cargo xtask check-layers(une couche 2 ne peut pas toucher la couche 0 io_uring). Preuve :check-layersVERT (§4). -
Modèle d’exécution (aligné ADR-038) : mono-thread d’abord (un réacteur par thread — l’
IoUringcouche 0 estSend + !Sync), buffers possédés (déjà garanti par la couche 0 — rien à réinventer), un CQE → un réveil deWaker, timers =IORING_OP_TIMEOUT(io_uring seul, pas detimerfd). -
Squelette prouvé (§3) :
air-uring::Reactor+air-async::block_on+ futureTimeout; 3 tests verts exécutant de vraies opérationsIORING_OP_TIMEOUTsur l’hôte gnu.check-layers/clippy -D/fmtpropres. -
On s’arrête au squelette. L’introduction d’
air-uring(nouveau crate couche 1) et le découpage réacteur/exécuteur sont une grosse décision : conformément à la consigne, on livre le design + la preuve minimale et on rapporte — le runtime complet (spawn, roue de timers, annulation, I/O réseau/fichier) n’est pas bâti. Questions ouvertes en §6.
1. §A.1 — Reconnaissance : la surface io_uring de la couche 0
air-sys-syscall::io_uring (couche 0, module scellé couche-0-v1) est riche et
directement consommable pour bâtir un réacteur. Tout ce qui suit est safe
(l’unsafe — asm des 3 syscalls, protocole d’anneau, mmaps — est confiné
dans la crate couche 0).
Cycle de vie du ring. IoUring::new(entries: NonZeroU32) /
IoUringBuilder (CQ size, max_inflight, SQPOLL, attach_wq, restrictions
sandbox). IoUring est Send + !Sync (ADR-022 D6 : un réacteur par
thread). Téardown sound par Drop quiescent + shutdown() (annule + draine,
ou fuite contrôlée — jamais d’UAF). Pour le multi-thread : LockedIoUring,
RingPool (thread-per-core, io-wq partagé), SqpollIoUring.
Soumission. Pas de builder SQE typé exposé, mais une méthode safe typée
par opération, chacune rendant un SubmissionToken : submit_timeout,
submit_poll_add, submit_read/submit_write (+ readv/writev),
submit_accept/submit_connect/submit_send/submit_receive (+ zero-copy,
sendmsg/recvmsg), submit_nop, submit_cancel, submit_close, submit_openat2,
submit_statx, xattr, splice/tee… Multishot : submit_*_multishot
(accept/recv/read/poll/timeout) rendant un MultishotToken. Options par-op via
with(SubmitOptions) (drain/force_async/skip_cqe_on_success). Chaînes
liées via LinkedChainBuilder (then/with_link_timeout). Les submit_*
mettent en file ; rien ne part avant submit()/submit_and_wait().
Attente / moisson des complétions. submit(&mut) -> u32 (soumet, sans
attente) ; submit_and_wait(want) -> u32 (soumet + GETEVENTS,
min_complete = want, bloquant) ; wait_completion() -> Completion
(bloquant, ≥1) ; wait_completion_timeout(Duration) ; try_completion() -> Option<Completion> (non bloquant, sans syscall) ; completions() -> CompletionIter. Completion expose token(), raw_result() -> i32, flags(),
has_more() (multishot CQE_F_MORE), is_notif(), buffer_id(), et des
extracteurs typés qui redéplacent le payload possédé (into_result,
completed, into_buffer_result -> (Vec<u8>, usize), accepted_fd,
into_poll_result…). Convention : res < 0 ⇒ -errno.
Corrélation submit↔complete. SubmissionToken { slot, generation } — opaque
(champs pub(crate)), Copy + Eq. Encodé en interne dans le user_data
kernel ; la génération filtre les CQE périmés (protection ABA). Point
clé : le token ne porte pas de Waker ; c’est au réacteur de mapper
SubmissionToken → Waker.
Embarquement dans une boucle hôte. register_eventfd(BorrowedFd) /
register_eventfd_async / unregister_eventfd (IORING_REGISTER_EVENTFD) : une
boucle externe (epoll/GLib) attend ce seul fd pour savoir qu’Air a du travail
prêt — l’interop sans implémenter epoll qu’exige ADR-038 §5.
Timers. submit_timeout(TimeoutSpec, TimeoutFlags) (IORING_OP_TIMEOUT,
res == -ETIME à l’expiration), submit_timeout_remove/_update,
submit_timeout_multishot(interval) (périodique). TimeoutSpec::after(Duration)
/ after_completions(count). Tout ce qu’il faut pour des timers sans
timerfd.
Ownership des buffers — DÉJÀ RÉSOLU en couche 0. C’est le résultat de recon
le plus structurant. La contrainte io_uring « le buffer doit survivre à la
complétion » (le modèle owned-buffer de tokio-uring/glommio) est déjà
implémentée : submit_read/write/send/receive prennent un Vec<u8>
par valeur, garé dans le slab S1 (OwnedOp) jusqu’à la complétion, et
restitué par into_buffer_result. Buffers enregistrés (RegisteredBuffers),
fournis ring-mapped (ProvidedBufferRing, RAII ProvidedBuffer), et zero-copy
(NOTIF différée) sont aussi présents. Conséquence : ni air-uring ni
air-async n’ont à réinventer la discipline d’ownership — ils enfilent des
buffers possédés à travers les futures.
2. §A.2 — La question de layering (tranchée)
Le fait qui contraint tout
cargo xtask check-layers (xtask/src/check_layers.rs) impose, pour une crate de
couche N dépendant d’un air-* de couche M : M ∈ {N-1, N} (pas de
dépendance montante, pas de saut de couche). Seul air-sys-types est
transverse (exempté) ; air-sys-syscall (io_uring), lui, est couche 0
ordinaire, non exempté. Les arêtes [target.'cfg(…)'.dependencies] ne sont
qu’advisory (ADR-087), mais on ne veut pas d’un réacteur cible-only.
Donc :
- un réacteur qui consomme io_uring (couche 0) doit être couche 1 (1−1=0 ✓) ;
air-asyncen couche 2 ne peut pas dépendre d’air-sys-syscall(2→0 = saut de couche = échec dur sur le graphe hôte).
Or ADR-038 place explicitement le runtime async en couche 2 (« le runloop /
runtime asynchrone (air-event, couche 2) »). Tension apparente : le runtime est
couche 2, mais il lui faut un accès io_uring couche 0 interdit à la couche 2.
Les options
- (a) Réacteur couche 1 (
air-uring) + exécuteur couche 2 (air-async). Le réacteur (soumission + attente CQE + réveilWaker) est le rempart safe couche 1 sur io_uring couche 0 ; l’exécuteur (futures,block_on/spawn, timers) est la surface applicative couche 2, consommée par le framework réseau.check-layers:air-uring(1)→air-sys-syscall(0)✓ ;air-async(2)→air-uring(1)✓. - (b) Tout (réacteur + exécuteur) en couche 1. Un seul crate couche 1.
check-layerspasse aussi (1→0 ✓). Mais contredit ADR-038 (runtime en couche 2) et brouille la doctrine : la couche 1 est le rempart fidèle et mince sur les syscalls (air-poll,air-socket) — y loger une politique d’ordonnancement de tâches (spawn, réveils, roue de timers, annulation) mêle deux natures et alourdit le tier fondateur. - (c) Autre découpage (réacteur dans
air-runtime, etc.). Rejeté :air-runtimeest lecrt0/TLS/threads (« pas un scheduler de futures ») ; y greffer un réacteur le dénaturerait.
Recommandation : (a)
Option (a) est la seule qui satisfait simultanément :
| Contrainte | (a) | (b) | (c) |
|---|---|---|---|
check-layers VERT (graphe hôte) | ✅ | ✅ | ✅/❌ |
| ADR-038 : runtime async en couche 2 | ✅ | ❌ | ❌ |
| Doctrine : couche 1 = rempart safe/mince sur couche 0 | ✅ | ❌ | ❌ |
air-runtime reste le crt0, pas un scheduler | ✅ | ✅ | ❌ |
| Cœurs de protocole (couche 2) consomment l’API async (2→2) | ✅ | ✅ | ✅ |
Le découpage est naturel : air-uring fait la même chose pour io_uring que
air-poll pour ppoll (exposer safement un mécanisme d’attente couche 0).
air-async est le pilote I/O async d’ADR-091 que les cœurs sans-IO
consomment. La frontière est nette : air-uring possède tous les types
io_uring et ne rend à la couche 2 que des types couche 1 / core (jetons
opaques OpToken, résultats i32/Errno, buffers Vec<u8>). air-async ne
nomme jamais air_sys_syscall — check-layers vert et abstraction honnête.
Nuance ADR-038. ADR-038 nomme le runtime
air-event(couche 2) ; ADR-091 et la présente reconnaissance l’appellentair-async. Divergence de nommage à réconcilier par l’ADR (§6, Q1). On adopteair-async(mission + ADR-091, plus récent). La substance d’ADR-038 (runtime en couche 2) est respectée : l’exécuteurair-asyncest en couche 2 ; seul le réacteur descend en couche 1, ce qu’ADR-038 n’interdit pas (il ne dit pas où vit la mécanique io_uring, seulement où vit le runtime).
3. §A.3 / §B — Modèle d’exécution + squelette prouvé
Aligné ADR-038 et prouvé par le squelette (crates/air-uring, crates/air-async).
- Mono-thread d’abord.
IoUringestSend + !Sync⇒ un réacteur par thread. Le squelette est mono-thread ; le thread-per-core (viaRingPool/LockedIoUringcouche 0 + vol de tâches) est un incrément ultérieur. Le réacteur est partagé avec les futures parRc<RefCell<Reactor>>(ReactorHandle) — les emprunts ne se chevauchent jamais dans le temps (lepollrend la main avant chaqueturn). - Buffers possédés. Les futures I/O prendront/rendront des
Vec<u8>possédés, directement portés par le slab S1 de la couche 0 — modèle owned-buffer detokio-uring, gratuit ici. (Le squelette exerceIORING_OP_TIMEOUT, sans buffer ; la futureRead/Recvsuivra le même patron avecinto_buffer_result.) - Un CQE → un réveil.
ReactortientSubmissionToken → (Waker, Option<res>).Reactor::turn():submit_and_wait(1)(soumet la file + bloque), puis draine la CQ non bloquant (try_completion), range chaqueressous son jeton et réveille leWakerenregistré.block_onre-poll sur réveil. - Timers =
IORING_OP_TIMEOUT. ADR-038 §3 : io_uring seul moteur (readiness et timeouts), pas detimerfd, pas d’epoll. La futureTimeoutarme unsubmit_timeout(TimeoutSpec::after(d)); l’expiration (res == -ETIME) est le succès nominal. Roue de timers /submit_timeout_multishot= incrément ultérieur. - Annulation.
submit_cancel/submit_timeout_removeexistent ; la sûreté d’annulation au drop d’une future (garder le buffer vivant jusqu’au CQE d’annulation) est une question ouverte dure (§6, Q3) — non traitée dans le squelette.
Anatomie visée (le pilote I/O du motif sans-IO, ADR-091)
air-async est le pilote I/O async (l’étage « mince » d’ADR-091) : il câble un
cœur sans-IO (parse/état, pur, couche 2) à io_uring. Les futures d’air-async
(Read/Write/Accept/Connect/Timeout) alimentent en octets un cœur qui
n’a aucune notion de socket ni d’horloge réelle. Neutralité sync/async
(ADR-091 §6) : le même cœur se pilote en synchrone (air-socket) ou en
async (air-async) — le choix vit dans le pilote, pas dans le cœur.
Squelette livré et preuve (hôte gnu, io_uring réel)
crates/air-uring (couche 1) — Reactor : new, arm_timeout(dur, &Waker),
set_waker, take_result, turn, tracked. no_std + alloc, zéro
unsafe exposé, zéro dépendance externe.
crates/air-async (couche 2) — reactor(entries), block_on(&handle, future),
future Timeout. std (pilote I/O). Zéro dépendance externe ; ne nomme aucun
type couche 0 io_uring (seul Errno, transverse).
$ cargo test -p air-uring
test tests::timeout_op_completes_and_wakes ... ok # réacteur : IORING_OP_TIMEOUT 50 ms → CQE → Waker réveillé → res=-ETIME
$ cargo test -p air-async
test tests::block_on_timeout_completes ... ok # exécuteur : block_on(Timeout 50 ms) via réacteur io_uring
test tests::block_on_two_sequential_timeouts ... ok # 2 timeouts séquentiels (corrélation multi-jetons)
Chemin prouvé de bout en bout : exécuteur (L2) → réacteur (L1) → io_uring (L0) →
CQE → réveil du Waker → re-poll → Ready.
4. §B — check-layers VERT (preuve du layering)
$ cargo xtask check-layers
▌ check-layers — WARN
couches conformes (1 advisory cible-only)
crates air-* scannées = 52 couche 0 = 2 couche 1 = 25 couche 2 = 25
⚠ air-libc-capi (layer 2) -> air-sys-syscall (layer 0) : … [cible-only … ADR-087 : advisory]
✓ graphe de couches conforme (règles a/b/c) — 52 couche(s) déclarée(s), 1 advisory cible-only
Les deux nouvelles arêtes — air-uring(1)→air-sys-syscall(0) et
air-async(2)→air-uring(1) — sont conformes (passent en silence). L’unique
advisory (air-libc-capi, cible-only) préexiste et n’est pas lié à ce
travail. cargo fmt --check et cargo clippy -p air-uring -p air-async --all-targets -- -D warnings sont propres.
5. Ce qui N’EST PAS fait (périmètre du squelette)
Volontairement hors incrément 0 (bâtir le runtime complet contredirait la
consigne « s’arrêter au design sur grosse décision ») : exécuteur multi-tâches
(spawn, file de tâches prêtes) ; roue de timers / timeout_multishot ; futures
I/O réseau/fichier (Read/Write/Accept/Connect, buffers possédés) ; sûreté
d’annulation au drop ; multishot (accept/recv) + provided buffers ; éveil
inter-thread (eventfd + msg_ring pour nudger un submit_and_wait bloqué) ;
thread-per-core (RingPool) ; intégration as_fd() d’air-socket et
register_eventfd pour boucles hôtes ; API C-ABI (toit couche 2).
6. Questions ouvertes (pour l’ADR / l’incrément 1)
- Q1 — Nommage.
air-event(ADR-038) vsair-async(ADR-091/mission). Trancher et amender l’ADR. Et : le réacteur couche 1 s’appelle-t-ilair-uring,air-reactor,air-ring? (proposé :air-uring, par mimétisme d’air-poll). - Q2 — Un ADR dédié ? Graver le découpage réacteur(L1)/exécuteur(L2) et la frontière « la couche 1 possède tous les types io_uring » dans un ADR (amende/complète ADR-038 et ADR-023).
- Q3 — Sûreté d’annulation. Quand une future I/O est droppée avant sa
complétion, le buffer possédé (dans le slab S1) doit rester vivant jusqu’au CQE
d’annulation. Politique :
submit_cancelau drop + rétention du slot ? S’appuyer sur le téardown quiescent de la couche 0 ? À concevoir (c’est le piège io_uring). - Q4 — Corrélation token→tâche. Le squelette balaie un
Veclinéairement. La production doit indexer par le slot du jeton (O(1)). Faut-il qu’air-uringexpose une table indexée, ou que la couche 0 rende le slot public ? (RFC couche 0 scellée si besoin). - Q5 — Éveil inter-thread & mono-vs-multi-thread. Modèle de réveil quand un
Wakerest déclenché depuis un autre thread (channel) alors que le réacteur est bloqué danssubmit_and_wait: eventfd enregistré +msg_ring/NOP ? Et quand bascule-t-on vers thread-per-core (RingPool) ? - Q6 — Intégration boucle hôte. Exposer
register_eventfd+ le seamas_fd()d’air-socketpour l’embarquement (ADR-038 §5) : à quel niveau d’API ? - Q7 — Back-pressure de soumission.
EBUSY(SQ/slab pleins) : le réacteur bufferise-t-il les ops en excès, ou la future retourne-t-ellePendingjusqu’à libération d’un slot ? Politique explicite à définir (ADR-091 §8 : back-pressure explicite).
INCRÉMENT 1 — sûreté d’annulation + exécuteur multi-tâches + 1ʳᵉ future réseau
Statut : implémenté et prouvé sur carbon (x86_64, host-first, ring io_uring
réel sur la cible gnu). Branche feat/air-async-inc1. Répond aux questions
ouvertes Q3 (sûreté d’annulation) et Q4 (corrélation token→tâche), et livre
la première I/O réseau async d’Air.
I1.1 — Sûreté d’annulation io_uring (la priorité — ADR-028)
Le piège et pourquoi Air y échappe par construction
io_uring est completion-based : le kernel garde un pointeur vers le buffer d’une
op jusqu’au CQE. En Rust async, une Future peut être droppée à tout
moment (select!, timeout, annulation). Libérer le buffer pendant que l’op est
en vol ⇒ le kernel écrit dans de la mémoire libérée ⇒ use-after-free piloté par
le noyau (le défaut classique de tokio-uring/glommio).
Air neutralise ce piège en deux étages :
-
Couche 0 (ADR-028 S1/S2) — l’ownership du buffer. Les
submit_*prennent un buffer possédé (Vec<u8>) et le déplacent dans le slot du slab ; il n’en ressort qu’au CQE (try_completionle rend dans uneCompletion). Conséquence décisive : tant qu’une op est en vol, rien au-dessus de la couche 0 ne possède le buffer. Aucune future de couche 1/2 ne peut donc le libérer — le dropper est structurellement impossible, pas seulement « évité ». La destruction du ring lui-même est quiescente (S2 : annule + draine avant de rendre la mémoire). -
Couche 1 (
air-uring, cet incrément) — le mécanisme d’orphelin. L’étage 0 garantit la sûreté mémoire ; il reste un problème de liveness / non-fuite : une op qui ne se complète jamais seule (recvsur socket muet,acceptsans connexion) laissée par une future droppée occuperait son slot pour toujours (fuite →EBUSY), et son buffer resterait indéfiniment détenu. Le réacteur résout cela sans jamais libérer prématurément :- la future d’I/O, dans son
Drop(si son op est encore en vol), appelleReactor::forget(token)— elle ne libère rien ; forgetmarque le slot de suivi orphelin (plus deWaker) et soumet unIORING_OP_ASYNC_CANCELciblant le jeton, puis laisse l’op en vol ;- au CQE correspondant (complétion nominale ou
-ECANCELED),Reactor::turnréconcilie l’orphelin : il libère le slot et laisse tomber laCompletion— c’est là, et seulement là, que le buffer possédé (côté couche 0) est relâché.
Si l’op était déjà complétée au moment du
forget(CQE arrivé, résultat non encore récupéré), le slot est simplement libéré et laCompletiondroppée : l’op ayant déjà quitté le kernel, relâcher le buffer est sûr, sans annulation. - la future d’I/O, dans son
Invariant net : aucune libération de buffer ne précède le CQE, jamais — que
la future vive, soit droppée en vol, ou que le ring soit détruit. Le forget
best-effort (si la SQ/le slab sont pleins, l’annulation échoue silencieusement)
reste sûr : l’orphelin demeure tracé et sera drainé au pire par le téardown
quiescent du ring (S2). On ne panique jamais dans un Drop.
Table des orphelins & anti-ABA
Le réacteur tient un slab de slots de suivi couche 1 (Vec<Slot> + free-list).
Chaque slot porte : le SubmissionToken couche 0 (clé de corrélation CQE), le
Waker, un drapeau orphaned, et la Completion rangée. À chaque libération, la
génération du slot est incrémentée ; l’OpToken opaque rendu à la couche 2
embarque (token, slot, génération). Un jeton périmé (slot réutilisé) est
rejeté par vérification de génération — un forget/take tardif ne peut jamais
être confondu avec l’op courante (anti-ABA), en plus de l’anti-ABA de la couche 0.
Preuve de soundness (drastique)
-
- Test drop-en-vol (
air-uring,drop_in_flight_defers_buffer_release_until_cqe) - on arme un
recvsur une paire de sockets muette (ne complète jamais seule), on le pousse réellement en vol (flush,kernel_in_flight ≥ 1⇒ buffer vivant dans le slab couche 0), on oublie l’op (comme unDropde future) ; on prouve que l’orphelin reste suivi (buffer non relâché), qu’une annulation est soumise, puis qu’auturnl’orphelin est réconcilié et le buffer relâché exactement au CQE (tracked == 0,kernel_in_flight == 0).
- Test drop-en-vol (
- Stress déterministe (
cancellation_stress_no_leak) : 200 cycles arm-recv-en-vol →forget→turn; le slab couche 1 se recycle intégralement (zéro fuite de slot), le kernel revient quiescent. - Anti-ABA (
stale_token_is_rejected) : un jeton d’op récupérée n’agit jamais sur l’op ultérieure qui réutilise le slot. - Couche 2 (
dropped_accept_future_is_forgotten) : unacceptmis en course avec un timeout (select-like) est droppé en vol quand le timeout gagne ; il est annulé et libéré — pas de fuite jusqu’à la couche applicative. - Valgrind memcheck (Miri ne supporte pas les syscalls io_uring) : les binaires
de test d’
air-uringet d’air-asyncpassent sousvalgrind --leak-check=full --error-exitcode=99—ERROR SUMMARY: 0 errors,definitely lost: 0 bytes. Zéro UAF, zéro fuite définitive sur les scénarios drop-en-vol répétés et le chemin réseau complet. (Le seul « possibly lost » observé provient de l’infrastructure de threads du harnais de test std, pas du réacteur. Sous valgrind, lancer les tests en--test-threads=1: la borne temporelle haute du test de concurrence est sensible au ralentissement ~20× de l’instrumentation.)
I1.2 — Exécuteur multi-tâches (air-async, couche 2)
Runtime:spawn(future) -> JoinHandle<T>+block_on(root). Boucle d’exécution : polle la racine et les tâchesspawnées dont le drapeau de réveil est levé, puis park sur le réacteur (turn=submit_and_wait+ drain) quand tout estPending. Un CQE réveille leWakerde la tâche concernée (⇒ drapeau levé) ; le tour suivant la re-polle.block_on(fonction libre) reste la variante mono-future.Wakerpar tâche :Arc<TaskWaker>(drapeau atomique).Send + Sync(exigé parstd::task::Wake) bien qu’on reste mono-thread ; le CQE d’une op réveille toujours sa tâche d’origine, jamais une autre. LesDropde futures s’exécutent hors de tout emprunt du réacteur (la tâche est sortie de la table avant son poll), doncforgetauDropn’entre jamais en collisionRefCell.JoinHandle<T>:await-able ; la glue despawncapture la sortie et réveille le joignant.- Corrélation token→tâche (Q4) : récupération / oubli / re-
Wakeren O(1) par indexation directe du slot (l’OpTokenporte l’index). La réconciliation CQE→slot dansturnreste un balayage borné (par la concurrence de pointe) : un O(1) strict à ce point exigerait d’exposer le slot duSubmissionTokendepuis la couche 0 scellée — dette explicitement assumée (voir « Suites »), jamais contournée par un raccourci. - Timers :
IORING_OP_TIMEOUTper-op conservé (suffisant et prouvé) ; la roue de timers reste un raffinement ultérieur (non requis fonctionnellement ici).
I1.3 — Première future réseau réelle (§3)
air-uring expose arm_accept / arm_connect / arm_socket / arm_receive /
arm_send (rempart safe, aucun type io_uring fuité). air-async en tire des
futures ergonomiques — TcpListener::accept, TcpStream::connect,
TcpStream::read / write — chacune cancel-safe (leur Drop appelle
forget). Buffers restitués intacts par transfert d’ownership (couche 0).
Preuve — echo TCP async en loopback (tcp_echo_async_loopback) : un TcpListener
async accept, lit une ligne, la réécrit ; un client connecte (socket + connect
async), envoie, relit. Serveur et client vivent dans deux tâches spawnées
concurrentes : l’accept du serveur ne peut aboutir que si le connect du client
progresse en parallèle — ce qui exige l’exécuteur multi-tâches. Assertion :
l’écho reçu est bit-pour-bit identique à l’émis (charge utile incluant de l’UTF-8
multi-octets). Prouve donc exécuteur multi-tâches + I/O réseau async de bout en
bout. spawn_runs_tasks_concurrently prouve en outre le recouvrement temporel (deux
timeouts de 60 ms ⇒ ~60 ms, pas ~120 ms).
I1.4 — Layering & qualité
cargo xtask check-layersVERT :air-uring= 1,air-async= 2, aucun saut (le seul advisory est préexistant,air-libc-capi, cible-only ADR-087).air-asyncne nomme aucun type io_uring (vérifié : seules des mentions en commentaire/opcode subsistent).cargo fmt --check,cargo clippy -D warnings(dontarithmetic_side_effects,cast_possible_truncation),cargo test -p air-uring -p air-async: verts.air-uringreste#![no_std]+alloc(build non-test OK) ; zérounsafeexposé (tout l’unsafeio_uring reste confiné en couche 0).
I1.5 — Ce qui reste (suites)
- Q4 (résiduel) — O(1) strict de la réconciliation
turn: exposer le slot duSubmissionToken(couche 0 scellée → BDFL/RFC), ou une table indexée côté couche 0. - Q5 — éveil inter-thread (eventfd enregistré +
msg_ring) et bascule thread-per-core (RingPool). - Q7 — politique de back-pressure explicite sur
EBUSY(SQ/slab pleins) : aujourd’hui l’arm_*remonte l’Errno; à raffiner enPending/ré-essai. - Roue de timers ; multishot
accept/recv(Temps 3d couche 0) ;read/writefichiers async ; intégration boucle hôte (register_eventfd).
INCRÉMENT 2 — robustesse : éveil inter-thread, roue de timers, back-pressure, I/O fichier
Statut : implémenté et prouvé sur carbon (x86_64, host-first, ring io_uring
réel sur la cible gnu). Branche feat/air-async-inc2. Durcit le runtime vers un
usage réel avant l’IPC, en répondant aux questions ouvertes Q5 (éveil
inter-thread) et Q7 (back-pressure), et en ajoutant timers mutualisés et I/O
fichier. La sûreté d’annulation de l’incrément 1 reste prouvée (tests
drop-en-vol conservés + valgrind vert).
I2.1 — Éveil inter-thread (§1, le plus structurant)
Le problème
Un Waker de l’incrément 1 ne levait qu’un drapeau atomique : la boucle de
l’exécuteur re-pollait la tâche au tour suivant. Cela suffit tant que le réveil
survient sur le thread du réacteur. Mais un Waker déclenché depuis un
autre thread OS (un canal alimenté par un thread de calcul, un JoinHandle
attendu d’ailleurs) trouve le réacteur endormi dans le kernel
(submit_and_wait) : lever un drapeau en mémoire ne le réveille pas → deadlock.
Mécanisme retenu — eventfd en op de lecture permanente
Options envisagées (mission) : (a) un eventfd que le réacteur tient en op et
qu’un réveil externe écrit ; (b) IORING_OP_MSG_RING depuis un ring
émetteur. On retient (a), l’eventfd, car il ne suppose pas que le thread
émetteur possède lui-même un ring (un simple write(8 octets) suffit), là où
msg_ring exigerait un IoUring par thread réveilleur. msg_ring reste le bon
choix pour un futur thread-per-core (ring↔ring) ; il n’est pas nécessaire ici.
Concrètement, en couche 1 (air-uring) :
- le
Reactorcrée uneventfd(compteur,CLOEXEC|NONBLOCK) et tient dessus une lecture permanente (IORING_OP_READde 8 octets, ré-armée à chaque CQE). Tant que cette lecture est en vol, toute écriture sur l’eventfd complète la lecture ⇒submit_and_waitrevient ; Reactor::wake_handle()rend unWakeHandleSend + Syncclonable (face « écriture », unArc<EventFd>).WakeHandle::wake()écrit1— best-effort, jamais de panique (idéal dans un chemin de réveil). L’eventfd est le seul type couche 0 exposé (transverse) ; aucun type io_uring ne fuite ;- la lecture d’eventfd est interne : elle n’occupe pas de slot de suivi
couche 1 (reconnue par son jeton dans le drainage, ré-armée, jetée), et
kernel_in_flight()l’exclut (métrique « ops utilisateur ») — les tests de soundness inc 1 restent donc exacts (kernel_in_flight == 0).
En couche 2 (air-async), un Parker partagé par tous les TaskWaker
porte : le WakeHandle, un drapeau parked et l’ThreadId propriétaire.
TaskWaker::signal lève le drapeau de tâche puis, si et seulement si le réveil
vient d’un autre thread et que l’exécuteur est parké, nudge l’eventfd.
Le test « autre thread » élimine tout write superflu sur le chemin mono-thread
chaud (le réacteur étant !Send, son thread est fixe) ; le test « parké » évite de
nudger quand la boucle reprendra la main d’elle-même.
Anti-perte de réveil (handshake SeqCst)
Course redoutée : l’exécuteur décide de parquer pendant qu’un autre thread réveille.
drive annonce parked = true puis re-vérifie la disponibilité (drapeaux)
avant de bloquer ; le Waker, symétriquement, publie la disponibilité puis
lit parked. Les quatre accès (parked store/load, ready store/load) sont en
SeqCst : leur ordre total garantit qu’au moins l’un des deux voit l’autre
(argument de type Dekker). Aucun réveil perdu, aucun busy-loop (le park est
un vrai submit_and_wait bloquant).
Preuve
air-uring : wake_handle_unparks_turn_from_another_thread — sans op utilisateur,
turn bloquerait indéfiniment ; un autre thread écrit l’eventfd ⇒ turn
revient. air-async : cross_thread_wakeup_unparks_executor (canal) et
cross_thread_wakeup_via_join_handle (JoinHandle). La terminaison de ces tests
EST la preuve — sans le mécanisme, ils deadlockent.
I2.2 — Roue de timers (§2)
L’incrément 1 armait un IORING_OP_TIMEOUT par sleep (N sleeps ⇒ N ops
kernel). L’incrément 2 introduit une roue de timers (tas binaire par échéance)
en couche 2 (politique d’ordonnancement, crates/air-async/src/time.rs) servie
par un unique timeout kernel :
sleep(Duration),interval(Duration)(échéances absolues, sans dérive),timeout(future, Duration)(combinateur) ;- l’exécuteur, à chaque park, fait feu tous les timers échus (
deadline ≤ now) puis (ré)arme l’unique timeout kernel sur l’échéance la plus proche (annulant le précédent viaforget, cancel-safe) ; - anti-obsolescence par génération de slot (un timer annulé libère son slot ; les entrées de tas périmées sont filtrées à la volée — même doctrine que l’anti-ABA du réacteur) ;
timeout(future, dur)réutilise la sûreté d’annulation inc 1 : à l’échéance, il rendElapsedet droppe la future enveloppée ⇒ sonDropappelleforget⇒ l’op sous-jacente est annulée, buffer relâché au CQE.
Preuve : sleeps_are_precise_and_share_one_timer (10 sleep de 50 ms
concurrents ⇒ ~50 ms, pas 500 ms : un seul timer kernel les sert),
interval_ticks_periodically, et timeout_cancels_slow_op (un recv sur socket
muet est annulé à l’échéance : Elapsed, op libérée au CQE, zéro fuite).
I2.3 — Back-pressure de soumission (§3)
Piège couche 0 : submit_receive/submit_read/… déplacent le buffer dans
le slab avant de détecter le plein ; sur EBUSY (SQ/slab plein) le buffer
serait perdu. On ne laisse donc jamais un arm_* porteur de buffer partir
vers un EBUSY :
Reactor::ensure_capacity(waker)(couche 1) garantit une place avant que la future ne confie son buffer. Sinon il pompe (flush SQ + drain non bloquant des CQE prêts, ce qui libère des slots), et si c’est encore plein il enregistre le waker et rendfalse;- la future d’I/O, sur
false, restePendingen conservant son buffer ; - le prochain
turn(qui bloque pour ≥ 1 CQE) libère un slot puis réveille en bloc les futures différées, qui re-tentent. Le slab est construit avecmax_inflight = entries(capacité connue) et le gate utilisateur réserve un slot pour la lecture d’eventfd (capacity − 1).
Jamais d’EBUSY remonté, jamais de perte d’op, jamais de panique.
Preuve : submission_backpressure_no_loss — anneau de 8 entrées, 64 ops
concurrentes (slab largement débordé) ⇒ toutes complètent (tracked == 0,
kernel_in_flight == 0).
I2.4 — I/O fichier async (§4)
air-uring expose arm_read / arm_write (offset optionnel) / arm_fsync
(rempart safe, aucun type io_uring fuité). air-async en tire un type [File]
(read_at / write_at / fsync), cancel-safe et soumis à la back-pressure
comme le réseau. Deuxième pilote I/O du motif sans-IO (ADR-091) après le réseau.
Preuve : file_ops_read_write_fsync (couche 1) et file_async_roundtrip
(couche 2) — écrit, fsync, relit, aller-retour exact (UTF-8 multi-octets
inclus).
I2.5 — Layering, soundness & qualité
cargo xtask check-layersVERT :air-uring= 1,air-async= 2, aucun saut (seul advisory préexistant :air-libc-capi, cible-only ADR-087).air-uringreste#![no_std]+alloc(build non-test OK) ; zérounsafeexposé (tout l’unsafeio_uring reste confiné en couche 0).- Sûreté d’annulation inc 1 conservée : les tests drop-en-vol restent verts ;
kernel_in_flight()exclut désormais la lecture d’eventfd (métrique « ops utilisateur ») pour rester exacte. - valgrind (
--leak-check=full --error-exitcode=99,--test-threads=1) sur les binaires de test d’air-uringet d’air-async:definitely lost: 0,ERROR SUMMARY: 0 errors, zéro invalid read/write.cargo fmt --check,cargo clippy -D warnings,cargo test: verts (7 testsair-uring, 12 testsair-async).
I2.6 — Ce qui reste (suites, pour l’incrément suivant)
- Q4 (résiduel) — O(1) strict de la réconciliation
turn(exposer le slot duSubmissionToken, couche 0 scellée → BDFL/RFC). msg_ring& thread-per-core — bascule multi-ring (RingPool), éveil ring↔ring parIORING_OP_MSG_RING, vol de tâches.- Multishot
accept/recv+ provided buffers (débit) ;register_eventfdpour l’embarquement dans une boucle hôte externe (ADR-038 §5). - Micro-optim de la roue : timeout kernel persistant ré-armé seulement quand l’échéance la plus proche change (aujourd’hui : ré-arm par park, cancel-safe mais quelques ops d’annulation en trop sous interruption fréquente).
- Anti-thundering-herd de la back-pressure : ne réveiller qu’autant de futures différées que de slots réellement libérés (aujourd’hui : réveil en bloc, borné).
INCRÉMENT 3 — thread-per-core + multishot + buffers fournis
Statut : implémenté et prouvé sur carbon (x86_64, host-first, ring io_uring
réel sur la cible gnu). Branche feat/air-async-inc3. Monte le runtime en
débit et parallélisme : plusieurs réacteurs (un par cœur), éveil inter-réacteur
msg_ring, opérations multishot (accept/recv) et buffers fournis. La
sûreté d’annulation des inc 1/2 est conservée (tests drop-en-vol + valgrind verts)
et étendue au multishot (le point délicat). Répond aux suites Q5 (bascule
thread-per-core), msg_ring, multishot + provided buffers et
embarquement hôte.
I3.1 — Thread-per-core (ReactorPool, §1)
L’inc 2 avait un réacteur par thread mais mono-réacteur. L’inc 3 en fait tourner
N (un par cœur), au-dessus du RingPool de la couche 0 (partage du pool
io-wq par ATTACH_WQ — le nombre de threads workers kernel reste borné quel
que soit N, crucial sur Pi 4 — et enregistrement des ring fds pour le routage
msg_ring).
PooledRing— enveloppeSend, réacteur bâti sur son thread. UnReactorrange desCompletioncouche 0 dans ses slots, orCompletionest!Send(elle porte des buffers via pointeurs bruts) : leReactorest donc!Send. L’IoUringnu, lui, estSend(unsafe implcouche 0). On distribue donc l’anneau vide (ReactorPool::into_rings → Vec<PooledRing>,PooledRingenveloppant l’IoUringde façon opaque — aucun type io_uring ne fuit) et l’on bâtit le réacteur sur son propre thread (Reactor::from_pooled). La couche 1 reste ainsi sans le moindreunsafe(pur assemblage safe) : le seulunsafe impl Sendvit en couche 0, sur l’anneau nu.- Piège cross-thread :
REGISTER_RING_FDSest task-local. LeRingPoolenregistre les ring fds sur le thread créateur ; unio_uring_enter(REGISTERED_RING)depuis un thread worker échouerait enEINVAL.from_pooledré-enregistre le ring fd sur son thread (idempotent côté façade), retombant sur l’enter par FD ordinaire en cas d’échec. (Sans ce détail,turndeadlockerait/échouerait sur chaque worker.)
Éveil inter-réacteur — IORING_OP_MSG_RING (ring↔ring)
L’inc 2 réveillait un réacteur parké par eventfd (un thread quelconque écrit un fd). Le thread-per-core appelle un mécanisme ring-natif : un réacteur pousse le réveil vers un pair par son propre anneau, sans syscall séparé.
Reactor::wake_peer(peer)poste unIORING_OP_MSG_RING(MSG_DATA) vers le ring du pair puis flush. Le CQE injecté fait revenir lesubmit_and_waitparké du pair. Leuser_dataposé est une sentinelle hors-borne (slot = u32::MAX, bit 63 = 0) : côté pair, le slab couche 0 le filtre comme périmé (drainé, jeté) — seul l’éveil compte, pas le contenu. Le CQE de complétion de l’opmsg_ringcôté émetteur n’est pas suivi et est jeté au drainage (aucune fuite : son slot de slab se libère à la complétion).RingPeer(Send + Copy) désigne un pair ; obtenue avant distribution (ReactorPool::peer).
Couche 2 — Cluster + spawn_on
Un Cluster de N Runtime (un par thread). Chaque worker possède une Inbox
(Send + Sync, Mutex<Vec<future>>). spawn_on(from, k, fut) dépose fut
(qui doit être Send) dans l’inbox de k, puis from.wake_peer(peer_k). La boucle
d’exécution de k draine l’inbox en tête de chaque tour (et après chaque
éveil) : la future reçue devient une tâche armée, pollée immédiatement.
Anti-perte de réveil. Le dépôt (verrou inbox) précède l’éveil msg_ring.
Le worker cible draine l’inbox à chaque tour et est tiré de son park par le CQE
msg_ring. Même si l’éveil arrive alors que k n’est pas encore parké, le CQE
attend dans sa CQ : le prochain submit_and_wait le consomme sans blocage (le kernel
vérifie la CQ avant de dormir). Le handshake SeqCst de l’inc 2 (annonce parked
puis re-vérification) reste en vigueur pour la voie eventfd intra-thread.
Preuves. wake_peer_unparks_peer_reactor_via_msg_ring (couche 1 : B parké dans
turn sur son thread, réveillé par le msg_ring de A) ; cluster_spawn_on_unblocks_peer_task_via_msg_ring
(couche 2 : tâche postée+éveillée depuis A débloque la racine de B, résultat exact
42). La terminaison de ces tests EST la preuve — sans le msg_ring, ils
deadlockeraient. (Le chemin est un vrai io_uring multi-ring : non modélisable en
loom ; on suit la doctrine inc 2 — stress déterministe + terminaison = preuve.)
I3.2 — Multishot + buffers fournis (§2, le point délicat)
Modèle de slot
Le réacteur distingue désormais, dans chaque slot de suivi, une op mono-coup
(chemin prouvé de l’inc 1, intact) d’un flux multishot (OpKind::{OneShot, AcceptStream, RecvStream}). Corrélation : une op mono-coup par son SubmissionToken
(Completion::token), un flux par son MultishotToken (Completion::multishot_token)
— la couche 0 scellée n’expose pas de conversion MultishotToken → SubmissionToken,
mais fournit cancel_multishot et multishot_token, suffisants ici (pas de
descellement requis).
acceptmultishot : une soumission → un flux de connexions (un CQE par connexion). Couche 2 :TcpListener::incoming()→Incoming::accept().await.recvmultishot + buffers fournis : le kernel choisit un buffer d’un anneau fourni par op ; le CQE indique quel buffer. Couche 2 :Runtime::buffer_group(count, buf_size)→TcpStream::recv_multishot(&group)→RecvStream::recv().await.
Ownership du pool & extension de la sûreté d’annulation (le raisonnement)
L’anneau de buffers fournis est possédé par air-uring (couche 1). La contrainte
d’io_uring — le buffer doit survivre à la complétion — se combine ici avec un
modèle emprunt→copie→rendu :
- À chaque CQE de
recvmultishot,extract_providedobtient la garde RAIIProvidedBuffer(couche 0) qui emprunte le buffer au groupe, en copie les octets dans unVecpossédé, puis laisse tomber la garde — ce qui rend le buffer au pool immédiatement. Le rendu se fait donc exactement une fois par CQE, au drainage, quoi qu’il advienne du consommateur couche 2. - Pourquoi la copie ? Un buffer fourni n’est pas un
Vecséparément possédé : le pool est un unique bloccount × buf_sizering-mappé. On ne peut donc pas déplacer un buffer hors du pool ; on le copie puis on le rend. C’est le coût inhérent au modèle ring-mapped — assumé (le gain de débit du multishot — pas de SQE ni de gestion de buffer par recv — subsiste). - Annulation sound. Annuler un
recvmultishot (forget→cancel_multishot, annulation synchrone par jeton) est sûr par construction :- les buffers des CQE déjà drainés ont déjà été copiés et rendus (étape 1) ;
- les CQE encore en vol qui arrivent après l’annulation (orphelins) passent
eux aussi par
extract_provided: copie + rendu du buffer, puis les octets sont jetés (le flux est orphelin) ; - les fragments non lus en file sont des
Vecpossédés : simplement jetés, sans toucher au pool (leur buffer a déjà été rendu) ; - le CQE terminal (
-ECANCELED, sansCQE_F_MORE) libère le slot couche 1. Conséquence : jamais de double-rendu, jamais de fuite, jamais d’UAF — le pool revient cohérent (available == count) après toute annulation.
Le forget unifie les deux natures : mono-coup → submit_cancel asynchrone ;
flux → cancel_multishot synchrone ; un flux déjà terminal est libéré
immédiatement (aucun CQE terminal ne viendrait sinon → fuite évitée).
Preuves. Couche 1 : accept_multishot_streams_connections_from_one_submission
(3 connexions d’UNE soumission + annulation propre) ;
recv_multishot_provided_buffers_roundtrip_and_clean_cancel (aller-retour exact +
available == count avant/après annulation) ;
recv_multishot_cancel_with_unread_fragments_is_sound (annulation avec fragments
non lus : pool cohérent, zéro double-rendu/fuite). Couche 2 :
incoming_accepts_many_from_one_submission, recv_multishot_stream_reassembles_and_pool_stays_coherent.
valgrind (--test-threads=1, --leak-check=full) sur les binaires de test des
deux crates : definitely lost: 0, ERROR SUMMARY: 0 errors, zéro invalid
read/write — confirmant l’absence d’UAF et de fuite sur tous les chemins multishot,
provided-buffer et thread-per-core.
I3.3 — Embarquement dans une boucle hôte (§3)
Reactor::enable_host_readiness() crée un eventfd et l’enregistre
(IORING_REGISTER_EVENTFD) : le kernel l’écrit à chaque CQE posté.
Reactor::readiness_fd() rend le fd à poller par un runtime hôte externe
(epoll/GLib/tokio) : quand il devient lisible, le réacteur air-async a du travail
prêt → l’hôte fait un turn non bloquant. L’hôte n’implémente pas io_uring
(ADR-038 §5). Preuve : host_readiness_fd_becomes_readable_on_completion — un
timeout armé + poussé en vol ; la lecture du fd de disponibilité ne revient qu’à la
complétion (~50 ms) ⇒ le fd devient lisible quand une op complète.
I3.4 — Layering, soundness & qualité
cargo xtask check-layersVERT :air-uring= 1,air-async= 2, aucun saut (seul advisory préexistant :air-libc-capi, cible-only ADR-087).air-asyncne nomme aucun type io_uring (RingPeer/PooledRing/BufferGroupId/StreamPollsont des types couche 1 opaques ; le rempart cache toute la surface io_uring).air-uringreste#![no_std]+alloc, zérounsafeexposé (tout l’unsafeio_uring reste confiné en couche 0).cargo fmt --check,cargo clippy -D warnings,cargo test: verts (12 testsair-uring, 15 testsair-async).- Sûreté d’annulation inc 1/2 conservée : les tests drop-en-vol restent verts ;
la métrique
kernel_in_flightreste exacte (exclut l’eventfd d’éveil interne).
I3.5 — Ce qui reste (suites)
- Q4 (résiduel) — O(1) strict de la réconciliation
turn(exposer le slot duSubmissionToken, couche 0 scellée → BDFL/RFC). - Zéro-copie des buffers fournis — le modèle actuel copie hors du pool
(contrainte ring-mapped, cf. I3.2). Une livraison zéro-copie exigerait de prêter la
garde
ProvidedBufferjusqu’à la couche 2 (borrow traversant unawait) ou un pool deVecséparément possédés — à concevoir (mesure d’abord, Principe 5). - Vol de tâches (work-stealing) inter-réacteur au-delà du
spawn_ondirigé actuel ; épinglage cœur explicite (sched_setaffinity) si exposé en couche 1. - Mode incrémental des buffers fournis (
IOU_PBUF_RING_INC, un buffer sert plusieurs complétions) — non requis pour la preuve, gain mémoire ultérieur. -ENOBUFS(pénurie de buffers) est aujourd’hui terminal côté flux (surfacé enErr) ; un réapprovisionnement + resoumission automatique est une suite.
Rapport de design — AirCom (IPC natif d’Air), incrément 0
Statut : note de travail exploratoire (non engageante). Cadre la conception d’AirCom — l’IPC natif d’Air (ADR-001, décision immuable) — avant de l’implémenter, sur le modèle du travail réseau (
reseau-architecture-crates-fr.md) qui a précédé ADR-091. Livre : une reconnaissance de l’existant couche 0/1/2, un design sans-IO, un squelette buildé + prouvé, et les questions ouvertes pour l’ADR/spec. AirCom mérite une spec dédiée (ADR-001 §« Statut futur » l’annonce) ; ce rapport en est le matériau d’entrée.Décisions structurantes émergentes → à trancher par le BDFL (voir §7) : le wire format (recommandation : Cap’n Proto, déjà tranché ADR-040), le modèle de capability (recommandation : fd-vers-connexion
SCM_RIGHTS), le découpage/nommage des crates, et quatre lacunes de primitives couche 1 qui conditionnent l’IPC complet. Conformément à la consigne de mission, on s’arrête au design pour ces points — on ne bâtit pas l’IPC complet.
Auteur : Thierry DELHAISE. Date : 2026-07-12.
1. Résumé exécutif (recommandations)
| Sujet | Recommandation | Base | Confiance |
|---|---|---|---|
| Wire format | Cap’n Proto (schema-first, zero-copy, versioning ABI). Codec placeholder au squelette tant que le schéma n’est pas figé. | ADR-040 (déjà tranché), ADR-001, exception 80 % mutualisée | Haute — déjà décidé |
| Control plane | Unix SOCK_SEQPACKET (frontières préservées + FD passing). Piloté par air-async (io_uring). | macro-architecture §B, ADR-001 | Haute |
| Data plane | memfd_create + mmap SHARED + F_ADD_SEALS, fd partagé via SCM_RIGHTS. Zero-copy. | ADR-001, macro-arch | Haute |
| Capability | Un fd vers une connexion AirCom = la capability. Unforgeable par sémantique noyau ; pas de token/crypto. Révocation = fermeture fd ; délégation = re-SCM_RIGHTS. | ADR-010, macro-arch §B, ADR-001 §117 | Haute |
| Découpage | Deux crates : air-com-proto (cœur sans-IO, no_std+alloc, fuzzable) + air-com (pilote air-async). | ADR-091 §2.1/§2.3 ; renforcé par air-async = crate std | Moyenne (nommage à trancher) |
| Anatomie | Motif sans-IO 9 composants (ADR-091), absences marquées. | ADR-091 (normatif) | Haute |
Quatre lacunes de primitives couche 1 bloquent l’IPC complet (pas le squelette). Aucune ne requiert le descellement de la couche 0 (les primitives kernel existent déjà, scellées) — ce sont des façades/ops additives couche 1/2, mais elles conditionnent les incréments suivants. Détail §6. On s’arrête au design ici.
2. §A — Reconnaissance de l’existant
2.1 Transport — ce qui existe
air-socket (couche 1) — sockets BSD synchrones, no_std, zéro-C.
- Unix domain sockets :
AirUnixStream(SOCK_STREAM),AirUnixListener,AirUnixDatagram(SOCK_DGRAM) —crates/air-socket/src/unix.rs:42/47/52. AdressesAirUnixAddress::from_pathetfrom_abstract(namespace abstrait) —address.rs:236/253. - FD passing
SCM_RIGHTS:AirUnixStream::send_fd(BorrowedFd)/recv_fd() -> OwnedFd—unix.rs:100/119(viasendmsg/recvmsg,CMSG_CLOEXEC). Surface généraleengine::{send_message, receive_message}(engine.rs:189/202) +socket_pair(domain, kind, proto)(engine.rs:312). - Précurseur sans-IO : le client DNS — codec pur
crate::dns(build_query/parse_response, fuzzé) séparé du piloteresolver.rs. C’est le patron à reproduire (ADR-091 §… le nomme explicitement). - Modèle d’ownership des fds : le fd est possédé par le registre
air-handle(engine.rs:302/318) ;AirSocketn’expose queas_fd()(BorrowedFd) etinto_raw_fd()(RawFd, le registre garde l’ownership).
air-async (couche 2) + air-uring (couche 1) — l’exécuteur io_uring que
consomme AirCom (son 1er gros consommateur).
- Exécuteur :
Runtime::{new, spawn, block_on, handle}+block_onlibre (crates/air-async/src/lib.rs:441/551/576/419). Mono-thread,!Send. - I/O socket :
TcpListener/TcpStream— noms « Tcp » mais agnostiques au fd (from_owned(OwnedFd),accept/read/write=IORING_OP_ACCEPT/RECV/SENDsur unBorrowedFd). AF_UNIX fonctionne aujourd’hui viafrom_owned(les tests d’air-asyncle prouvent :tests.rswrappe une paireUnixStream). - Multishot + provided buffers :
incoming()(accept multishot),recv_multishot(&BufferGroup)(net.rs:70/168). - Timers : roue de timers (
sleep/interval/timeout), fichiers (read_at/write_at/fsync). - Ops io_uring exposées par
air-uring(arm_*) :timeout, socket, connect, accept, receive, send, read, write, fsync, accept_multishot, receive_multishot(crates/air-uring/src/lib.rs:643..930).
Data plane (memfd/mmap) — couche 0 (air-sys-syscall::mem, ::fs) :
memfd_create(&CStr, MemfdFlags)(mem.rs:1051, flagsCLOEXEC|ALLOW_SEALING),ftruncate(fs.rs:1980),mmap_file(fd, len, off, prot, MapFlags::SHARED)(mem.rs:274),munmap(mem.rs:695),add_seals/get_seals(F_ADD_SEALS,fs.rs:2486/2504),MmapRegion::from_file(région partagéeArc, io_uring-compatible, avecfutex_wordintra-région —mem.rs:510/601).
Wire format — Cap’n Proto déjà dans le workspace :
capnp = "=0.26.0" (pin strict, pur Rust, zéro dep transitive, zéro C),
consommé par air-config-schema / air-config-compile. Codegen commité
(regenerate.sh + capnpc hors du chemin de build critique, ADR-025). Décidé par
ADR-040, explicitement mutualisé avec AirCom.
Capabilities — la doctrine (aucun code encore) :
- ADR-010 : entitlements déclaratifs signés, deny-by-default, 4 familles (dont
AirCom) ;
air-launchd(couche 5) applique au lancement et distribue les capabilities AirCom initiales par FD passing. « Pas d’autorité ambiante ». - macro-architecture §B (le plus précis) : « Une capability AirCom est un handle
opaque non-falsifiable. Techniquement : FD vers une connexion AirCom, plus
métadonnées de type » ; control plane =
SEQPACKET; délégation = re-SCM_RIGHTS(modèle seL4 / Mach ports) ; révocation = fermeture fd ; capability implicite universelle =air-registry.
2.2 Transport — ce qui manque (lacunes couche 1)
Voir §6 pour le détail et l’impact. En bref :
- Aucune façade couche 1 sur
memfd/mmap(les primitives sont couche 0, scellées ;air-comcouche 2 ne peut pas les toucher —check-layers). - Aucun FD passing asynchrone :
air-uringn’a pas d’arm_recvmsg/arm_sendmsg(le passage de fdSCM_RIGHTSsur io_uring n’existe pas). Seul le chemin synchroneair-socketexiste. - Aucun wrapper
SEQPACKETdansair-socket(couche 0 a bienSocketType::SeqPacket = 5, mais L1 ne l’enveloppe pas). - Aucun pont
OwnedFdentreair-socket(fds possédés par le registre) etair-async(from_owned(OwnedFd)) — modèles d’ownership incompatibles.
3. §A (suite) — décisions de reconnaissance
3.1 Wire format : Cap’n Proto (recommandé — déjà tranché)
Cap’n Proto satisfait toutes les exigences AirCom d’ADR-001 : schema-first,
versioning à règles testables en CI (ADR-012), zero-copy natif (mmap du data
plane), FD passing de 1ʳᵉ classe (tables de capabilities capnp). La dépendance est
déjà auditée et mutualisée (règle 80 %, docs/EXCEPTIONS.md) — aucune
nouvelle exception pour AirCom. Le workflow codegen commité (regenerate.sh) est
prouvé sur air-config-schema et réutilisable tel quel.
Le cœur (Framer/Codec) reste sans-IO : capnp est un runtime de (dé)sérialisation octets↔message pur, sans I/O — il vit dans le cœur
air-com-proto, fuzzable. Le squelette utilise un codec placeholder trivial (tag +u64) pour prouver la frontière Codec↔Framer↔StateMachine sans figer prématurément le schéma AirCom (le.capnpviendra avec la spec).
3.2 Modèle de capability : fd-vers-connexion, SCM_RIGHTS (recommandé)
Aligné mot pour mot sur macro-architecture §B et ADR-001 §117 :
- Une capability = un fd vers une connexion AirCom (
SEQPACKET), + métadonnées de type. Unforgeabilité par le noyau : on ne fabrique pas un fd qu’on n’a pas reçu. Pas de token, pas de nonce, pas de crypto, pas dememfd/sealing pour la capability elle-même (le sealing sert au data plane). - Distribution initiale par
air-launchd(couche 5) viaSCM_RIGHTSselon les entitlements signés. Délégation p2p par re-SCM_RIGHTSà travers AirCom lui-même. Révocation par fermeture du fd côté serveur. - Renfort : la sandbox io_uring couche 0 (
RestrictionSet::from_entitlements,io_uring/sandbox.rs:125) est le « double verrou » — la couche 5 traduira les entitlements signés enRestrictionSet. (Non implémenté ; point d’intégration.)
Conséquence pour le cœur sans-IO : le cœur ne détient aucun fd. Le Session Context (#8) porte les métadonnées de capability (le quoi) ; le pilote détient les fds (le comment). Le squelette matérialise cette frontière (
SessionContext::grant_capability, placeholder).
4. §B — Design sans-IO d’AirCom (le cœur du rapport)
4.1 Deux étages (ADR-091 §2.1)
┌──────────────────────── air-com-proto (couche 2) ────────────────────────┐
octets ───▶ │ Framer → Codec → StateMachine → (Session Context) │ ───▶ messages typés
(feed) │ CŒUR SANS-IO — pur, no_std+alloc, FUZZABLE, zéro socket/async/horloge │ + octets (poll_transmit)
└───────────────────────────────────────────────────────────────────────────┘
▲ API (pas d'I/O)
┌──────────────────────────────────┴──────── air-com (couche 2) ────────────┐
│ PILOTE I/O — la SEULE partie qui touche un socket : câble le cœur à │
│ air-async (io_uring). Control plane (SEQPACKET) + data plane (memfd). │
└───────────────────────────────────────────────────────────────────────────┘
▲
air-async (io_uring) / air-socket
4.2 Anatomie canonique à 9 composants appliquée à AirCom
| # | Composant | Rôle dans AirCom | Squelette | Cible (spec) |
|---|---|---|---|---|
| 1 | Framer | Délimiter les messages control-plane | ✅ préfixe de longueur u32 (transport flux) | Trame = 1 recvmsg sur SEQPACKET (le noyau délimite ⇒ Framer trivial) |
| 2 | Codec | (dé)sérialiser méthode/args ↔ message typé | ✅ placeholder Ping/Pong (tag+u64) | Cap’n Proto (schema-first, versioning ADR-012) |
| 3 | StateMachine | États connexion + requête/réponse | ✅ discipline requête→réponse | Multi-états : ouverture/handshake, requêtes concurrentes, fermeture, erreurs protocolaires |
| 4 | Handshaker | Établissement + négociation | ⛔ marqué absent | Négociation de version de schéma + jeu d’extensions (la capability, elle, est déjà établie via SCM_RIGHTS) |
| 5 | Flow Controller | Back-pressure explicite (reactive streams) | ⛔ marqué absent | Crédits/fenêtres pour le streaming data-plane (ADR-001 §back-pressure) |
| 6 | Multiplexer | Plusieurs requêtes/canaux sur un fd | ⛔ marqué absent | Ids de requête, isolation inter-canaux (façon canaux SSH / streams h2) |
| 7 | Timer Manager | Échéances (timeout requête, keepalive) | ⛔ marqué absent (dans le cœur) | Horloge injectée dans le cœur ; roue air-async dans le pilote |
| 8 | Session Context | État/params/capabilities par connexion | ✅ role/id + compteur de capabilities | Métadonnées de capabilities négociées, secrets zeroïsés au drop, zéro fuite inter-sessions |
| 9 | Extension hooks | Points d’extension (entitlements, négos) | ⛔ marqué absent | Registre de hooks à interface étroite (entitlements ADR-010, extensions de schéma) |
Les 5 absences sont marquées explicitement (marqueurs unité
channel::{Handshaker, FlowController, Multiplexer, TimerManager, ExtensionHooks}
- commentaires
// NO …) — « on retrouve toujours les mêmes cases » (ADR-091 §2 règle 3).
4.3 Control plane vs data plane (le découpage clé d’AirCom)
- Control plane — petits messages de signalisation/appel de méthode. Transport
SEQPACKET(frontières préservées, FD passing). C’est le domaine du cœur sans-IO (Framer/Codec/State) — la surface hostile fuzzée. - Data plane — charges volumineuses (images, frames, buffers GPU). Transport
memfd+mmapSHARED(scellé), dont le fd est passé sur le control plane parSCM_RIGHTS: zero-copy inter-processus. Le cœur ne voit que la référence (id/offset/len) ; le pilote fait lemmap. Back-pressure explicite (Flow Controller, #5) pour le streaming.
Cette séparation est la raison d’être du refus de D-Bus (ADR-001 §« zero-copy et back-pressure ») : le control plane reste léger, le data plane ne copie jamais via un broker.
4.4 Découpage en crates (recommandé)
Deux crates (choix retenu, cf. §7 pour le nommage) :
air-com-proto(couche 2,#![no_std]+alloc) — le cœur sans-IO. Deps :air-base-coreseulement (+capnpà terme). Fuzzable en isolation (fuzz/: une cible par parseur — Framer, Codec, Handshaker).air-com(couche 2,std) — le pilote surair-async. Deps :air-com-proto,air-async,air-sys-types.
Pourquoi deux crates plutôt qu’un seul proto/+io/ (les deux sont permis par
ADR-091 §2.3) : air-async est un crate std (Rc/RefCell/thread-locals) ; un
crate unique forcerait le cœur à hériter de std et empêcherait le no_std
visé par ADR-091, et ferait entrer air-async dans le build de fuzz du cœur. La
séparation en crates garantit un cœur no_std réellement isolé (fuzz sans
socket, sans exécuteur). C’est aussi le précédent quinn-proto/quinn.
5. §C — Squelette minimal + preuve (livré, host gnu)
Crates créés ([package.metadata.air] layer = 2 pour les deux) :
crates/air-com-proto/ — CŒUR SANS-IO (no_std + alloc)
src/framer.rs (1) préfixe de longueur, borné MAX_FRAME_LEN, fuzz-ready
src/codec.rs (2) Ping/Pong placeholder (cible : Cap'n Proto)
src/state.rs (3) machine requête/réponse (transitions illégales rejetées)
src/session.rs (8) Session Context (role/id/capabilities)
src/channel.rs assemblage feed/poll_transmit + marqueurs d'absence 4/5/6/7/9
crates/air-com/ — PILOTE I/O (std, sur air-async)
src/io.rs serve_pong / request_pong / unix_stream_pair (+ tests)
examples/ping_pong.rs — PREUVE exécutable
Preuve — aller-retour Ping → Pong entre deux services in-process sur une
paire de sockets Unix pilotée par air-async (io_uring) :
$ cargo run -p air-com --example ping_pong
AirCom incrément 0 — Ping(0x0a1cc0de12345678) → Pong OK (1 requête servie)
$ echo $?
0
Portes vertes (host x86_64-unknown-linux-gnu, carbon) :
cargo fmt --check✅ ·cargo clippy -- -D warnings✅ (lintsdenyarithmétique/cast respectés :checked_*/try_from, zéroas).cargo test✅ — 19 tests (18 cœur : framer/codec/state/session/channel ; 1 pilote : round-trip de bout en bout) + 3 tests pilote additionnels (2 allers- retours, pair fermé, mapping errno).cargo xtask check-layersVERT ✅ —air-com/air-com-protone sautent pas en couche 0 (arêtes 2→2 et 2→1 conformesM ∈ {N-1,N}). Le seul advisory restant est l’escape hatch cible-only préexistant d’air-libc-capi(ADR-087).cargo machete✅ (zéro dep inutilisée). Zéro dépendance externe.- Couverture ✅ — cœur
air-com-proto94–100 %/fichier (session.rs/state.rs= 100 %) ; piloteair-com/io.rs= 93 % régions / 97 % lignes / 100 % fonctions (> 90 %, cible couche 2). Reste non couvert : les branches d’échec transport rares (writepartiel à 0, échecsocketpair).
Un seul bloc unsafe dans tout le livrable : la conversion de fd bootstrap
std OwnedFd → air OwnedFd (io.rs, // SAFETY: présent, idiome copié des
tests d’air-async). Le cœur air-com-proto n’expose aucune fn unsafe.
Choix de squelette assumés (et pourquoi) :
- Transport =
SOCK_STREAM(pasSEQPACKET) :air-socketn’a pas de wrapperSEQPACKET(lacune §6.3) ; on re-délimite au Framer. - Bootstrap de la paire via
std::os::unix(pasair-socket) : pas de pontOwnedFdpropre depuisair-socket(lacune §6.4).air-asyncpilote ensuite toute l’I/O — c’est bien « piloté parair-async». - Pas de FD passing dans la preuve : le passage de fd asynchrone n’existe pas
(lacune §6.2) et le data plane
memfdn’a pas de façade L1 (lacune §6.1). Les démontrer proprement (dans les règles de couche) exige de combler ces lacunes d’abord — d’où l’arrêt au design.
6. Lacunes de primitives couche 1 (STOP-and-report)
Aucune de ces lacunes ne requiert le descellement d’une couche 0 scellée : les
primitives kernel (memfd/mmap/seal/sendmsg/recvmsg/SEQPACKET) existent
toutes en couche 0 et y sont scellées/testées. Ce sont des façades/ops
additives couche 1 (et une future arête couche 1→0 dans air-uring). Mais elles
conditionnent l’IPC complet — d’où le report avant de bâtir.
6.1 Façade couche 1 de mémoire partagée (memfd + mmap + seals) — manquante
Les primitives sont couche 0 (air-sys-syscall::mem/::fs). air-com
(couche 2) ne peut pas les appeler (check-layers interdit 2→0). air-memory
(couche 1) n’expose qu’un raw_mapping anonyme (piles de threads) ; son backing
mmap/MmapRegion est explicitement différé (backing.rs, « heap en v1 »).
→ Requis : un module/crate couche 1 « shm » packageant memfd_create → ftruncate → mmap SHARED → add_seals → poignée typée » (candidat : étendre air-memory, ou un air-shmdédié). **Bloque le data plane zero-copy.** Envisager aussi une varianteAirHandleKind::Memfd/Shmdansair-handle`.
6.2 FD passing asynchrone (recvmsg/sendmsg io_uring) — manquant
air-uring expose arm_{socket,connect,accept,receive,send,read,write,fsync,…}
mais pas arm_recvmsg/arm_sendmsg (donc pas de SCM_RIGHTS sur io_uring), et
aucun escape-hatch SQE brut. Le passage de capability/fd async est donc
impossible sans ajout. → Requis : arm_sendmsg/arm_recvmsg (+ take_*) dans
air-uring (couche 1, arête 1→0 conforme) + futures SendMsg/RecvMsg dans
air-async (couche 2). Le chemin synchrone existe (air-socket::AirUnixStream:: {send_fd,recv_fd}) et peut servir de secours pour le bootstrap de air-launchd,
mais l’IPC en régime établi veut l’async. Bloque les capabilities async +
data-plane fd async.
6.3 Wrapper SEQPACKET dans air-socket — manquant
La couche 0 a SocketType::SeqPacket = 5 mais air-socket n’enveloppe que
SOCK_STREAM/SOCK_DGRAM (unix.rs). Le control plane AirCom veut SEQPACKET
(frontières préservées ⇒ Framer trivial, FD passing). → Requis : un
AirUnixSeqpacket (ou paramétrer les wrappers Unix par SocketType). Additif
couche 1. Contournement squelette : SOCK_STREAM + Framer à préfixe.
6.4 Pont OwnedFd air-socket ↔ air-async — manquant
air-socket possède ses fds via le registre air-handle (into_raw_fd garde
l’ownership) ; air-async::TcpStream::from_owned veut un OwnedFd. Il n’y a pas
de conversion propre (risque de double-close). → Requis : air-socket exposant
un into_owned_fd() (via air_handle::AirHandle::into_owned_fd, qui existe), ou
air-async acceptant une source de fd du registre. Additif couche 1.
Contournement squelette : bootstrap via std::os::unix (idiome des tests
air-async).
Note de gouvernance :
air-socket(couche 1, campagne ADR-069) est vraisemblablement scellé — 6.3 et 6.4 le touchent et pourraient donc exiger un descellement couche 1 (décision BDFL), contrairement à 6.1/6.2 qui sont des ajouts sur des crates couche 1/2 en cours de construction (air-memorypartiel ;air-uring/air-asyncincréments actifs). À arbitrer.
6.5. Lacunes couche 1 comblées (mise en œuvre — branche feat/aircom-couche1-gaps)
Statut : fait. Les quatre lacunes du §6 sont comblées, host-first, à rigueur couche 1 (100 % de couverture lignes sur le neuf, aucune fonction
unsafeexposée,check-layersvert). Aucun descellement couche 0 (les primitives kernel existaient déjà, scellées). Le seul descellement est couche 1 additif surair-socket(§6.3/6.4) — zéro changement sémantique des API STREAM/DGRAM existantes ; à re-scellercouche-1-v2.1après revue BDFL.
| # | Lacune | Solution (crate, couche) | API ajoutée | Preuve |
|---|---|---|---|---|
| 1 | Façade shm | air-shm (nouvelle crate, couche 1) | AirShmSegment (create/from_owned_fd/add_seals/seals/as_fd/into_owned_fd/map_readonly/map_writable) + AirShmMap/AirShmMapMut (vues possédées &[u8]/&mut [u8]) | 9 tests, 100 % lignes ; write→seal→read + adoption de fd |
| 2 | SCM_RIGHTS async | air-uring (couche 1) + air-async (couche 2) | Reactor::{arm_sendmsg,arm_recvmsg,take_sendmsg,take_recvmsg} ; TcpStream::{send_message,recv_message} (futures SendMsg/RecvMsg) | envoi/réception d’un fd async en loopback socketpair (couche 1) + aller-retour in-process (couche 2) ; orphelin recvmsg annulé et libéré au CQE |
| 3 | SEQPACKET | air-socket (couche 1, descellement additif) | AirUnixSeqpacket / AirUnixSeqpacketListener (connect/pair/bind/accept/send/recv/send_fd/recv_fd) | 11 tests : frontières préservées, troncature, FD passing, accept |
| 4 | Pont OwnedFd | air-socket (couche 1, descellement additif) | AirSocket::{into_owned_fd,from_owned_fd} (+ sur AirUnixStream/AirUnixSeqpacket) | aller-retour registre→sortie→ré-adoption sans double-close |
Choix de conception clés :
- Lacune 1 → crate dédiée
air-shm, pasair-memory.air-memoryest un crate d’allocateurs (arène/pool/slab) + comptabilité ; un segmentmemfdpartageable (un fd, pas un allocateur) est un concern distinct.air-shm: zéro dépendance externe,no_std+alloc,air-base-core/air-sys-syscall/air-sys-types. - Lacune 4 — NON, ce n’était pas un vrai conflit d’ownership. Le diagnostic
redouté (« registre
air-handlevs ownershipair-asyncincompatibles ») ne se matérialise pas : le primitifair_handle::AirHandle::into_owned_fdexiste déjà (ADR-069, pensé pour l’interop) — il retire l’entrée du registre et transfère l’ownership seul auOwnedFd(pas de double-close), et le type de fd est identique des deux côtés (air_sys_types::fd::OwnedFd). Le pont est donc un simple move :AirSocket::into_owned_fd → TcpStream::from_owned. Contrepartie assumée et documentée : le fd cédé devient invisible àair_handle::live_count/kind_ofet auclose()de la façade libc — c’est le rôle d’un escape-hatch d’interop. Pas de STOP nécessaire. - Lacune 2 — couche 0 déjà prête.
air_sys_syscallexposait déjàsubmit_send_message/submit_receive_message(SCM_RIGHTS,OwnedOp::SendMsg/RecvMsg, modèle orphelin S1) ; l’ajout couche 1 est une paire de façades mincesarm_*/take_*, arête 1→0 conforme — zéro descellement couche 0.
Preuve d’intégration réunissant les 4 lacunes : air-com::capability
(capability_and_shm_dataplane_roundtrip) — un aller-retour AirCom entre deux
services in-process passe un fd (capability) par SCM_RIGHTS async sur un
control plane SEQPACKET (bridgé OwnedFd) portant un segment shm scellé
zero-copy ; le pair mmap le segment via le fd reçu et lit la charge utile
(assert d’égalité). Seul le fd a traversé le socket — la charge n’est jamais copiée.
Barrière : check-layers vert (55 crates, air-shm=1 ; seul advisory =
l’escape-hatch cible-only préexistant d’air-libc-capi, ADR-087) ; fmt/clippy -D/tests verts ; machete vert. La couverture root/sudo de la barrière
complète est laissée à l’arbitrage CI (la couverture lignes per-crate du neuf
est à 100 % en local).
7. Questions ouvertes pour l’ADR / la spec AirCom
- Nommage des crates — à trancher (ADR-029). La macro-architecture §B nomme
air-aircom(transport+runtime),air-aircom-schema(compilateur),air-aircom-codegen,air-registry. La mission et ce squelette ont utiliséair-com(pilote) +air-com-proto(cœur). Divergence à réconcilier : garderair-aircom(cohérence macro-arch) ou acterair-com/air-com-proto(+air-com-schema) ? La spec AirCom doit figer le jeu de crates (proto/pilote/schema/codegen/registry) et leurs couches. - Codec Cap’n Proto : figer le premier schéma AirCom (
.capnp) et la politique de versioning (champs réservés,InterfaceV2coexistant — ADR-012). Réutiliser le workflowregenerate.sh/codegen commité d’air-config-schema. - Modèle d’objet (ADR-002) : AirCom transporte des invocations sur des
AirObject. Articuler la StateMachine (#3) + Multiplexer (#6) avec le modèle d’objet C-ABI et lecx.capability::<T>()de macro-arch §B. air-registry(couche 5) : format du handshake de découverte, capability implicite universelle, one-fd-par-service vs canal bootstrap multiplexé (macro-arch laisse ouvert).- Transport control plane : confirmer
SEQPACKET(vsSTREAM+framing) et trancher la lacune §6.3. - Data plane : conventions de
memfd(taille, sealing obligatoire ?F_SEAL_WRITEpour un partage read-only ?), et l’API L1 shm (§6.1). - Sync vs async (ADR-038) : AirCom expose-t-il une API synchrone de 1ʳᵉ
classe (via
air-socket) en plus de l’async (air-async) ? Le cœur sans-IO le permet « gratuitement ». - Régime de fuzz (ADR-091 §5) : cibles
fuzz/par parseur (Framer, Codec capnp, futur Handshaker) ; model-based testing de la StateMachine ; virtual clock pour le Timer.
8. Conclusion
AirCom s’inscrit proprement sur l’existant : air-async pilote AF_UNIX
aujourd’hui, Cap’n Proto est déjà auditée et mutualisée, le modèle de capability
(fd + SCM_RIGHTS) est cohérent ADR-010/ADR-001, et le motif sans-IO ADR-091
s’applique naturellement (control plane fuzzable, data plane zero-copy à part). Le
squelette prouve la boucle cœur↔pilote↔io_uring de bout en bout (Ping→Pong,
exit 0, check-layers vert). Quatre lacunes de primitives couche 1 (façade shm,
FD passing async, SEQPACKET, pont OwnedFd) conditionnent l’IPC complet et
appellent des décisions d’arbitrage/descellement — on s’arrête donc au design,
avec une spec AirCom à ouvrir (ADR-001 l’annonce) pour figer nommage, schéma,
registre et transport.
Licence du document : MPL 2.0 — note de travail, exploratoire (non engageante).
Roadmap — clôture du PAL (std::sys::pal::air) : les 3 chantiers
Statut : note de roadmap (opérationnelle). Fige le plan de clôture du PAL décidé par
ADR-075. S’appuie sur l’audit
face-PAL (audit-face-pal-cloture-fr.md), le journal rt/DECISIONS.md, et
ADR-049/050/052/056. Non normative : les évolutions structurantes restent des ADR.
✅ CLÔTURE 2026-07-10 — CHANTIER B/C FAIT,
hello-stdTOURNE 2 ARCHES (M5 atteint). Le lien dehello-stdcontrelibairpasse de 12 → 0 symboles indéfinis ; il s’exécute on-target —hello from std on air+ exit 0 sur x86_64-air (carbon) ET aarch64-air (raspi). Un programme Ruststdstatic-PIE lié intégralement sans glibc. Pari ADR-076 tenu de bout en bout. Re-sceauxcouche-0-v1.12(ADR-087raw_syscall) +couche-1-v1.9(ADR-086 introspection thread). Statut vivant :etat-avancement.md. Prochaine phase = OpenSSH /air-sshd(ADR-074). Dette ouverte : retirer l’escape hatchsyscall/raw_syscall(ADR-087) avec l’équipe Rust. (Le détail ci-dessous — chantiers A/B/C — est conservé comme historique du plan ; il est désormais réalisé.)
⚠️ MISE À JOUR 2026-07-08 — chantier A FAIT, chantier B pivoté (option A). (Superseded par le bandeau de clôture ci-dessus : le chantier B/C est terminé —
hello-stdtourne on-target 2 arches, jalon M5 atteint le 2026-07-10. Le texte ci-dessous est conservé comme trace du pivot.)
- Chantier A COMPLET : les 6 briques additives couche 1 sont mergées (
AirCondvar,AirOnce, os_str, mutation d’envair_env::set/unset,yield_now/set_thread_name, destructeursthread_local). Un bug de fondation aarch64 (TCB recouvrant la zone TLS) a été traqué au gdb et corrigé, validé 2 arches.- Chantier B pivoté vers l’OPTION A (ADR-076) : le portage a montré que
std(cible unix/linux) EXIGE une libc — on abandonne le palaircustom ;stdréutilise le palunix+ la libc d’Air (libair) fournit les symboles (zéro glibc). La cible porteenv=musl+vendor=air.build-std=stdcompile entièrement pour Air, sans patch libc/std. Cible de production = compléterlibairpour lierstd→hello-stdon-target 2 arches (§5 chantier C).
1. Point de départ (dé-risqué)
Fait et prouvé on-target 2 arches (selftest rt/, exit 42) : _start + self-reloc
static-PIE, TLS Variant I/II + errno #[thread_local] + TCB, spawn clone3/CLONE_SETTLS
- join futex, allocateur réel
air-allocbranché, layering ADR-052 (air-rt = shim mince, runtime en couche 1). Migration#![no_std]: 10/10 crates que le PAL compose.
Verdict audit face-PAL : zéro gap fondateur. Chaque primitive std::sys a sa brique
couche 1. Reste : quelques briques additives + écrire le backend + bootstrapper
std.
2. Table de correspondance std::sys → brique couche 1
Primitive std::sys | Brique couche 1 | État |
|---|---|---|
alloc (GlobalAlloc) | air_alloc::GlobalAllocator | ✅ branché on-target |
| thread spawn/join | air_runtime::thread (spawn/JoinHandle) | ✅ (ergonomie boxée à câbler) |
| thread yield/sleep/id | couche-0 sched_yield · air_base_core::time::sleep · current_tid | ◐ yield/name à surfacer |
| thread_local (TLS/TCB) | air_runtime::{thread_control_block, thread_local_storage} | ◐ dtors à ajouter |
| sync Mutex/RwLock | air_thread::{AirMutex, AirRwLock} (futex, loom) | ✅ |
| sync Condvar / Once | primitives futex air_thread (publiques) | ❌ additif |
| time Instant/SystemTime | air_base_core::time::{AirInstant, AirSystemTime} | ✅ |
| fs File/metadata/dir | air_filesystem::AirFile + AirFileMetadata + read_dir | ✅ (sceau v1.0, #214) |
| net TCP/UDP/lookup | air_socket::* + AirNameResolver | ✅ |
| process Command/Child | air_process::{AirCommand, AirProcess} + air_pipe | ✅ |
| env vars | air_env::{get, vars} | ◐ set/unset additif |
| args (argv) | air_runtime::args | ✅ |
| stdio | air_stdio::{stdin, stdout, stderr} | ✅ |
| path | air_base_core::path::AirPath | ✅ |
| os_str | substrat [u8]/Vec<u8> (AirPath owned) | ◐ newtype additif |
| random (getrandom) | couche-0 getrandom (lien direct — pas air-crypto) | ✅ |
| backtrace/unwind | panic=abort → surface minimale | ✅ |
3. Chantier A — briques additives couche 1 (host-testables, sous barrière 100 %)
Chacune = descellement additif (ADR-051), aucune refonte d’API scellée :
- A1
air_thread::AirCondvar(surfutex_wait_word/futex_wake_one). - A2
air_thread::AirOnce(AtomicU32+ futex) — pourstd::sync::Once/lazy. - A3 destructeurs
thread_local!:register_dtor+ exécution en fin de thread (champ DTV présent dans le TCB). Mineur (panic=abort, threads courts) mais requis parstd::sys::thread_local. - A4 mutation d’env :
air_env::{set, unset}(pourstd::env::set_var/remove_var) — gouverner l’__environmutable (audit musl ADR-047). - A5 newtype
os_str:AirOsStr/AirOsString(paire empruntée/possédée sur[u8]). - A6
thread::yield_now(passthrough couche-0sched_yield) + nommage (prctl(PR_SET_NAME)) sur cible.
4. Chantier B — le backend std::sys::pal::air (phase 3)
Un rust-src patché (ADR-075) : ajouter sys/pal/air qui compose les briques
ci-dessus (jamais de re-syscall). Portage module par module, chacun validé par un bout
de std qui l’exerce. Ordre naturel (dépendances croissantes) :
- alloc + process::exit/abort (déjà amorcés) → un
stdqui démarre/quitte. - time, env, args →
std::env,SystemTime,Instant. - thread + thread_local + sync (mutex/rwlock/condvar/once) →
std::thread,std::sync. - fs (
File/metadata/read_dir) →std::fs. - stdio (
Stdin/out/err,is_terminal) →println!/std::io. - process (
Command/Child) →std::process. - net (TCP/UDP + lookup) →
std::net. - path / os_str →
std::path,OsStr. - random (seed HashMap via couche-0
getrandom), pipe, backtrace minimal.
5. Chantier C — bootstrap std + lane CI nightly (phase 4)
- C1 faire compiler
-Z build-std=stdpour*-linux-air(2 arches). - C2 lier et exécuter un « hello std » : ouvre un fichier (
AirFile), un socket, un thread, écrit viaprintln!— prouvé exit propre sur x86_64 (carbon) + aarch64 (raspi). - C3 lane CI nightly dédiée qui rejoue C1+C2 → referme le carve-out de
rt/(aujourd’hui hors barrière). - C4 (après démo) : enregistrer les cibles Tier-3 en amont (upstream rustc).
6. Résidus sécurité à refermer au passage
- canari de pile depuis
auxv AT_RANDOM(matérialiser%fs:0x28/tpidr_el0). mprotectRELRO après self-relocation (.data.rel.ro/.gotinscriptibles sinon).- placement d’
errnoà offset C-ABI fixe dans le TCB (utile à la future libc, pas au PAL Rust).
7. Ordre de bataille
- ADR-075 (fait) + cette note.
- Chantier A (A1→A6) — briques additives, host-testées, sous 100 %. Faible risque,
parallélisable, valeur immédiate (elles servent aussi le userland
no_std). - Chantier B — pal module par module (§4), chaque étape adossée à un fragment
std. - Chantier C — bootstrap
std+ « hello std » 2 arches + lane nightly. - Résidus sécurité (§6) au fil de l’eau.
8. Rappel : host-first
Les briques du chantier A et la logique testable se valident sur l’hôte (les crates
couche 1 tournent déjà en gnu). Seuls le backend pal (chantier B, construit via
build-std) et le bootstrap std (chantier C) exigent la cible + le nightly — validés
par selftest/hello-std on-target (carbon x86, raspi aarch64), comme le programme rt/
actuel.
Note de travail — cible Rust *-linux-air (chaîne d’outils)
Statut : note exploratoire, NON engageante (
docs/notes/, cf. INDEX). Graduera en ADR le jour où l’on décide de produire la chaîne. Énoncée par le BDFL le 2026-06-27.
Idée
Produire une cible Rust *-linux-air : un target dont le std et l’édition de liens
lient par défaut la libc Air → tout exécutable construit est « natif » (zéro glibc/musl)
par construction, sans dépendre de l’analyse DT_NEEDED à l’exec. La signature reste
l’ancre de confiance ; la cible garantit la conformité à la source.
Précédents : Redox (*-unknown-redox + relibc). Modèle technique : eyra/c-scape
(std → libc Rust via origin/rustix, aucune libc C).
1. QUAND le produire
- Prérequis fonctionnel : la libc Air couvre la surface
std(threads/pthread, fichiers, process, env,malloc). État actuel : T1+T2 (identifiants + AirLog) → loin. - Poule/œuf : la cible compile un userland natif ; un userland natif justifie la cible.
- → Quand : libc Air ≈ complète côté
stdet userland à compiler. Pas avant.
2. QUOI (la chaîne)
- target spec
*-linux-air(triple + JSON ou upstream) : linker, libs par défaut, std backend ; stdbâti sur la libc Air (gros morceau — modèle eyra) ;- CRT/startup (init runtime Air) + config linker liant
libair-cpar défaut (jamais glibc/musl) ; build-stdoustdprébuilt pour la cible ;- pipeline build + signature (provenance : produit les binaires signés natifs).
3. PRÉREQUIS OS pour CONSTRUIRE (2 phases)
- Bootstrap (cross-build) : host Ubuntu + rustc stable + linker (lld/ld). Prérequis modestes ; la libc Air (Rust + syscalls bruts) se compile sur tout Linux.
- Cible runtime : Air = noyau Linux 6.x (couche 0 : io_uring/Landlock/clone3). C’est l’ABI visée, pas le host de build.
- Self-host (à terme) : la chaîne tourne sur Air ; prérequis = libc Air couvre rustc/cargo.
→ Construire ne demande d’abord qu’un Linux de dev standard ; les vraies exigences sont le runtime cible (6.x) puis le self-host.
4. Toolchain C *-linux-air (clang) — AUSSI nécessaire
On ne peut pas exclure le C (cohabitation). Or rustc ne compile PAS le C ; cargo non
plus (il délègue via le crate cc à un compilateur C externe clang/gcc). → il FAUT
un compilateur C pour *-linux-air. Choix clang (cross-target par --target+sysroot,
là où gcc exige un cross-build par cible ; + backend LLVM partagé avec rustc, LTO Rust↔C).
Le point clé : UN seul sysroot, DEUX front-ends. rustc-*-linux-air et clang-*-linux-air
consomment le même sysroot Air = headers (libc Air + air_base.h + headers C standard
fournis par la libc Air) + libair-c + CRT/linker (lie libc Air par défaut, jamais glibc/musl).
Le gros du travail = libc + sysroot (commun) ; la part clang est bon marché (pointer clang
sur le sysroot). Posture : clang amont NON forké (dép d’outillage ADR-024, comme
cbindgen/Doxygen/capnp) — on possède le sysroot, pas le compilateur. (Air bannit le C dans
ses propres crates — check-c-surface ; clang sert au userland C tiers porté.)
5. C++ *-linux-air — ajout SÉPARABLE (pas un nouveau compilateur)
clang compile déjà C et C++ (clang++ = clang en mode C++) → aucun compilateur C++
à construire. Le C++ exige seulement, au-dessus de la libc C, des runtimes : libc++
(std C++), libc++abi (ABI : exceptions/RTTI/new/delete), libunwind (déroulement de
pile pour les exceptions) — projets LLVM amont, à porter sur le sysroot Air (new/
delete → allocateur Air). Donc C-clang n’entraîne PAS C++ : le support C++ est un ajout
optionnel/différable (poser les 3 runtimes dans le sysroot quand on veut le userland C++).
Choix LLVM-cohérent (libc++ plutôt que libstdc++ GCC). Nuance : exceptions C++ = unwinding
(libunwind) vs Air Rust panic=abort no-unwind → pas de conflit (le programme C++ porté,
probablement confiné, embarque SON runtime d’exceptions ; n’affecte pas le code Rust d’Air).
Lien : vision libc [[air-libc-rust-vision]] (exécution par provenance, modèle eyra).
Note d’étude — une std Rust sur Air safe, sans libc C (PAL sur la couche 1)
Statut : note d’étude, NON normative. Cadre le nouvel objectif premier — une toolchain Rust dont la
stdrepose uniquement sur Air, par un backendstd::sysbindant la couche 1 Rust safe, sans lib C ni bindings C. Elle instruit une décision qui amendera ADR-076 (« option A :stdsur la libc Clibair_c») ; les décisions structurantes qui en découleront passeront par un ADR dédié puis, le cas échéant, un RFC amont à la Rust Teams libs. Aucun code n’est engagé par cette note.Source disséquée (réelle, pas de mémoire) : le
rust-srcdu nightly piné du treert/—nightly-2026-06-13,library/std/src/sys/.
1. Pourquoi ce pivot
M5 a prouvé que std compile et tourne sur Air, mais par le chemin d’ADR-076 :
std réutilise le PAL unix et lie une libc C (libair_c, crates air-libc-*). Or la
frontière C (extern "C" / #[no_mangle] / FFI / errno in-band) est unsafe par nature
et a même forcé l’escape hatch raw_syscall (ADR-087,
futex/gettid appelés en C brut). On a donc retiré de l’unsafe au nom de la doctrine
(« l’unsafe vit en couche 0 uniquement », macro-architecture §1) pour le réintroduire
par une libc C sous std. Incohérent.
Cible de l’étude : un backend std::sys::pal::air + des modules sys/<domaine>/air.rs
qui bindent la couche 1 Rust safe (les Managers de domaine, ADR-077), zéro FFI.
libair_c survit — elle reste la libc pour les vrais développeurs C (porter des outils
Unix, doctrine « Linux-conforme, pas POSIX ») — mais std ne passe plus par elle.
2. Comment std::sys est structuré aujourd’hui (et pourquoi ça nous arrange)
Le std moderne a vidé le PAL (chantier amont « FIXME(117276) », visible dans
sys/mod.rs). Deux niveaux :
- Sous-systèmes transverses
sys/<domaine>/mod.rs(fs,net,thread,thread_local,time,process,alloc,stdio,random,sync,os_str,args,env,fd,pipe,exit…). Chacun contient uncfg_select!qui route vers un fichier par OS (fs/unix.rs,fs/hermit.rs…) viause <os> as imp;, en enveloppantimpd’une grosse couche générique réutilisable (read_dir, copy, each_addr…). - PAL résiduel
sys/pal/<os>/— ne garde plus que l’amorçage runtime (init,cleanup,abort_internal), le modulefutex,time(souvent réexporté),stack_overflow,weak(dlsym).sys/pal/mod.rsdispatche parcfg_select!.
Le précédent qui valide le pivot : des backends std d’OS Rust sans libc existent déjà
en amont — hermit (unikernel Rust), xous (micro-noyau Rust), motor, uefi, zkvm.
Le PAL de hermit fait ~171 lignes (2 fichiers) contre ~2000 pour pal/unix. Le
mécanisme est donc conçu pour accueillir un backend non-libc.
3. Le patron à copier — et ce qui distingue Air
Structurellement, Air suit le patron hermit : PAL ultra-mince + un fichier
sys/<domaine>/air.rs par sous-système, bindant l’interface de l’OS.
Mais tous les backends existants bindent une frontière unsafe — c’est là qu’Air innove :
| Backend | Interface bindée | Nature | unsafe |
|---|---|---|---|
unix | libc (glibc/musl) | C-ABI externe | partout à la frontière |
hermit | hermit_abi (extern "C", #[link_name="sys_*"]) | C-ABI (libc-en-Rust) | 25 (socket), 17 (fs), 13 (thread)… |
xous | asm!("ecall") inline | asm brut | tout appel noyau |
motor | moto-rt : façade Rust au-dessus d’une vDSO extern "C" | C-ABI cachée | à la frontière |
uefi | r-efi (extern "efiapi") | C-ABI firmware | à la frontière |
air (cible) | Managers couche 1 Rust safe (AirFile, AirTcpSocket, AirThreadBuilder…) → Result typés | API Rust safe, lossless | confiné en couche 0 |
Le modèle le plus proche est motor/moto-rt (une façade Rust fn futex_wait(&AtomicU32) -> bool
au-dessus d’un mécanisme brut). Air pousse plus loin : la façade elle-même est safe et
sans C-ABI intermédiaire — les sys/*/air.rs appellent la couche 1, dont l’unsafe est
déjà confiné tout en bas dans air-sys-syscall (couche 0). Air serait le premier backend
std safe de bout en bout. C’est précisément l’angle qui justifie un RFC (§7).
⚠️ Invariant de layering (règle absolue). Le PAL est un toit : il s’assoit sur la couche 1, quoi qu’il arrive, et JAMAIS sur la couche 0. La couche 0 n’est pas stable (elle suit le kernel — doctrine de stabilité) : aucun toit ne s’y accroche. Donc tout ce dont le PAL a besoin doit être exposé par le Manager couche 1 adéquat (ADR-077), lequel s’appuie en interne sur la couche 0. Le jeu de cette étude est exactement cela : identifier ce qui manque au PAL et le placer dans le bon Manager couche 1 — pas de raccourci vers la couche 0, jamais.
4. Le contrat exact d’un backend air
4.1 Bras cfg_select! à ajouter
Un bras target_os = "air" (ou target_env = "air") — voir la décision §6.a — à insérer
avant le bras unix dans le PAL et chaque sous-système migré :
| Fichier | Action | Piège |
|---|---|---|
sys/pal/mod.rs | target_os="air" => mod air avant unix => | sinon target_family="unix" capte Air → pal/unix → libc |
sys/alloc/mod.rs | mod air (GlobalAlloc) | — |
sys/args/mod.rs | mod air (ou réutilise common) | — |
sys/env/mod.rs | mod air | — |
sys/fd/mod.rs | mod air (FileDesc(OwnedFd)) | brique de fs/net/pipe/stdio/process |
sys/fs/mod.rs | mod air | le plus gros mais le plus « traduisible » |
sys/io/mod.rs + sys/io/error/* | mod air (is_terminal, decode_error_kind) | — |
sys/net/connection/{mod,socket/mod,hostname/mod}.rs | mod air | le plus couplé libc — réécriture complète |
sys/paths/mod.rs | mod air (getcwd, current_exe, temp_dir…) | — |
sys/pipe/mod.rs | mod air | — |
sys/process/mod.rs | mod air | le plus dur (fork/exec) |
sys/random/mod.rs | mod air (obligatoire) | dispatch par target_os → sinon _ => {} vide, ne compile pas |
sys/stdio/mod.rs | mod air | facile une fois fd |
sys/sync/{mutex,condvar,rwlock,thread_parking}/mod.rs | ajouter target_os="air" aux bras futex existants (PAS un module) | dispatch par target_os → sinon tombe sur pthread = libc |
sys/thread/mod.rs | mod air (Thread) | id() renvoie aujourd’hui libc::pthread_t → id Air |
sys/thread_local/mod.rs | TLS statique (native) + guard::enable, ou clés os | voir §6.c |
sys/exit.rs | bras air → exit via AirTaskManager (couche 1 ; 2 endroits : exit et unique_thread_exit) | sinon _ => intrinsics::abort() = abort au lieu d’exit (bug silencieux) |
sys/personality/mod.rs | bras gcc (déjà target_family="unix") → symboles _Unwind_* | dépendance de lien, pas un module à écrire |
Aucune action (générique, convient déjà) : sys/os_str (→ bytes, OsStr = [u8]),
les couches génériques de fs/process/net/connection/sync/once/backtrace, cmath
(→ symboles libm externes, fournis par air-libm).
4.2 Le PAL air mince (gabarit = pal/unsupported/common.rs + pal/hermit)
pub unsafe fn init(argc, argv, sigpipe)— appelleargs::init, protection stack-overflow, SIGPIPE.pub unsafe fn cleanup()pub fn abort_internal() -> !pub fn unsupported<T>() / unsupported_err()pub mod futex:type Futex = Atomic<u32>(+SmallFutex),futex_wait(&Atomic<u32>, u32, Option<Duration>) -> bool,futex_wake(&Atomic<u32>) -> bool,futex_wake_all(&Atomic<u32>). Binde la couche 1 (le futex exposé parAirTaskManager—air-threada déjàraw_futex), jamais la couche 0.pub mod time(souvent#[path="../unix/time.rs"], ou unair.rs).- point d’entrée runtime (déjà couvert par
air-rt-std/air-std-entrydu harnais M5).
Le meilleur ratio du projet : ces 3 fonctions futex suffisent — Mutex, Condvar,
RwLock, Once, Parker de std sont alors gratuits (impls génériques sync/*/futex.rs,
100 % Rust safe).
5. Cartographie couche 1 → sous-systèmes (la couche 1 couvre déjà tout)
La couche 1 expose partout des APIs AirResult<T> safe, sur couche 0 uniquement, jamais
de libc C. Correspondance :
Sous-système std | Brique couche 1 | État |
|---|---|---|
fs | air-filesystem (AirFile, AirFileManager, AirFileMetadata) | couvre |
fd | air-handle (AirHandle, OwnedFd) + air-filesystem | couvre |
net | air-socket (AirTcpSocket/Listener, AirUdpSocket, AirSocket, AirNameResolver) + air-handle | couvre |
thread | air-thread (AirThreadBuilder, yield_now, set_thread_name) + air-runtime (JoinHandle, current_tid) | couvre |
sync / parking | air-thread (raw_futex : futex_wait/futex_wake) | couvre (3 wrappers → toute la synchro) |
thread_local | air-runtime (TCB/TLS ELF natif #[thread_local], run_thread_local_destructors) | couvre (voir §6.c pour les clés dynamiques) |
time | air-base-core (AirInstant, AirSystemTime) | couvre |
process | air-process (AirCommand, AirProcess, spawn_process, credentials) + air-runtime/fork | couvre |
os_str / path | air-base-core (AirOsStr/String, AirPath) | couvre |
args / env | air-runtime/args + air-env (get/vars/set/unset, environ_pointer) | couvre |
alloc (GlobalAlloc) | air-alloc (GlobalAllocator, reset_after_fork) | couvre |
stdio | air-stdio (stdin/stdout/stderr, is_terminal) | couvre |
random | air-crypto (AirRandom::fill, sur getrandom couche 0) | couvre |
| signaux (SIGPIPE/SIGCHLD, auto-réveil) | air-signal (install_handler, AirSignalFd) + air-poll | couvre |
Manques (mineurs) à combler côté couche 1, additifs v1.x :
current_exe(/proc/self/exe) — non trouvé ; probable vrai manque pourstd::env::current_exe.DirEntrytypé —read_directory_entriesrend un buffer brut ; le PAL peut parsergetdents64lui-même, ou l’on ajoute unReadDir/DirEntryergonomique dansair-filesystem.park/unpark/park_timeouthelper dansair-thread(les primitives futex suffisent, mais éviter de dupliquer).- Options socket dédiées (
SO_RCVTIMEO/SO_LINGER/IP_TTL/peek) — faisables via l’APIset_socket_option_intgénérique ou à ajouter. - Clés TLS dynamiques — seul
#[thread_local]natif est fourni (suffisant pour lestdmoderne, voie « native ») ; une API de clés ne serait requise que par la voieos.
Placement Manager (ADR-077) — décision de l’étude. Le PAL binde des Managers, pas des fonctions libres éparses. Le tableau ci-dessus liste les crates ; il faut arrêter, pour chaque besoin du PAL, le Manager qui l’expose. Cas notables :
AirTaskManager(fidèle au kernel), PASAirProcessManagerniAirThreadManager(décision). « Kernel = bible » : le kernel ne connaît que latask(struct task_struct, l’unité ordonnançable) ; ce que POSIX appelle process est un thread group (tasks partageant untgid; le leader apid == tgid), et un thread est une task membre.forket création de thread sont le mêmeclone3(seuls les flagsCLONE_THREAD/CLONE_VM/… changent). Le couplage fork → thread unique est intrinsèque (aprèsclone, réinitialiser TID, TLS/TCB, handlersatfork,errno_location,air-alloc::reset_after_fork,air-stdio::reset_after_fork— déjà porté parair-runtime/fork.rs+process_context). ⇒AirTaskManagermodéliseAirTask+AirThreadGroup(tgid), médie surair-process+air-thread+ les concerns tâche/fork/TLS d’air-runtime, et produit côté userland les notions POSIX : process = thread group (getpid→ tgid), thread = task (gettid→ pid de task). Il expose : spawn/join,getpid/gettid,yield,park/unpark,set_name. (Le Manager est une surface de médiation — aucune fusion de crates requise.) Faux ami à éviter : lestruct task_groupdu kernel = regroupement d’ordonnancement CPU / cgroups (CONFIG_CGROUP_SCHED), sans rapport avec le thread group ; il relèverait d’AirSystemManager.futex— à placer consciemment. Ce n’est pas purement « thread » : c’est une primitive de synchronisation sur un mot mémoire, utilisable inter-process (mémoire partagée). Dans le PAL il sert de parking de task (il backMutex/Condvar/Parker). Reco : l’exposer viaAirTaskManageraux côtés depark/unpark(c’est son usage PAL), en documentant sa nature inter-process. (air-thread::raw_futexfournit déjàfutex_wait/futex_wake.)- Les manques (1)-(5) ci-dessus sont à ajouter dans le Manager de domaine adéquat (additifs
couche-1
v1.x) :current_exe/DirEntry→AirFileManager; options socket →AirNetworkManager;park/unpark→AirTaskManager.
Règle du chantier : pour chaque item du contrat PAL (§4), la question n’est jamais « quel syscall » mais « quel Manager couche 1 l’expose, et que faut-il y ajouter ». Zéro accroche couche 0 depuis le PAL.
6. Décisions de conception à trancher (pour l’ADR)
a. La cible : target_os="air" (à la hermit), PAS env=musl+target_family="unix".
ADR-076 a délibérément fait ressembler Air
à unix (env=musl+vendor=air) pour réutiliser le PAL unix + une libc. Le pivot
inverse ce choix : pour un PAL custom, Air doit ne pas être capté par les bras
target_family="unix" (sinon il retombe sur pal/unix→libc). hermit est target_os="hermit",
hors famille unix. ⇒ Air devrait être target_os="air" (cible JSON custom, comme en
phase 1), quitte à ajouter des bras air là où hermit a les siens. (Alternative discutée :
rester unix-family et insérer un bras air avant unix partout — plus fragile, plus de
patches, risque de capture accidentelle par un bras unix oublié.)
b. Allocateur : air-alloc derrière GlobalAlloc — et la dette unsafe. Le hook
GlobalAlloc est unsafe (le trait), mais zéro FFI : air-alloc est un allocateur Rust
possédé. C’est la dette GlobalAlloc-safe (macro-architecture §8) — à instruire dans ce
chantier (peut-elle devenir safe ? sujet potentiel d’RFC, §7).
c. TLS : voie « native » (statique ELF), pas les clés os. air-runtime supporte déjà
#[thread_local] natif ; __tls_get_addr n’est pas requis (TLS statique, acquis M5). Il
reste à fournir le hook de fin de thread (guard::enable) qui déclenche les destructeurs.
Évite de réimplémenter pthread_key_*.
d. Résorber raw_syscall (ADR-087). raw_syscall n’existait que parce que le PAL unix
appelait syscall/futex/gettid en C brut (via la libc). Avec le PAL custom, le PAL
binde la couche 1 (jamais la couche 0) : c’est le Manager couche 1 adéquat qui expose
futex et gettid (en s’appuyant en interne sur la couche 0). ⇒ l’escape hatch
raw_syscall disparaît — le pivot résorbe une dette au lieu d’en créer. futex et
gettid sont exposés par AirTaskManager (task + thread group, fidèle au kernel —
voir §5, note « placement Manager »).
e. id() du thread : un id Air, pas libc::pthread_t. sys/thread renvoie aujourd’hui un
pthread_t ; Air renvoie un Tid/u64. (Attention : bug connu du spawn natif aarch64,
cf. suivi — à traiter dans ce chantier.)
7. La frontière de l’RFC — ce qu’on fait seuls vs ce qu’on propose à Rust
Ce qu’on peut faire seuls, tout de suite (downstream) : exactement comme M5 — cible JSON
custom + -Z build-std + rust-src patché (bras air ajoutés). C’est éprouvé. Cela
n’exige aucun RFC pour prototyper et prouver un std safe sur Air.
Ce qui mérite un RFC amont (pour ne pas forker std::sys éternellement) : l’angle n’est
pas « rendre le mécanisme non-libc possible » (hermit le prouve déjà), mais :
- L’upstreaming Tier-3 de
*-linux-air(cible + mainteneur = projet Air) — planifié (ADR-050) : c’est le véhicule qui rend les brasairlégitimes en amont plutôt que patchés localement. - Un point d’extension propre pour qu’un « OS userland Rust safe » branche son backend
sans dupliquer les bras
cfg_select!de chaque sous-système — la question honnête à poser à la Teams libs : le contrat PAL actuel (pensé pour des frontières C-ABI) peut-il accueillir de première classe un backend safe dont l’unsafeest chez l’OS (couche 0) et non dansstd::sys? Y a-t-il un intérêt à formaliser un trait de backend partagé ? GlobalAllocsafe (§6.b) — la question la plus « recherche » : un hook d’allocateur global qui n’oblige pas àunsafe impl.
Honnêteté de cadrage : le gros de la valeur RFC est (1) — le reste ((2),(3)) est une conversation à ouvrir avec la Teams libs, pas un préalable au prototype. On avance downstream, on prouve, puis on propose.
8. Prochaines étapes (proposition)
- Cette note — revue BDFL (nous y sommes).
- ADR de direction — amende ADR-076 :
PAL custom safe sur couche 1,
target_os="air",libair_cdécouplée destd, résorption deraw_syscall. Tranche §6.a–e. - Reprofilage de la cible
*-linux-air(JSON :target_os="air", plusenv=musl) + les brasairdans unrust-srcpatché (fork de travail hors-arbrert/, carve-out ADR-030). - Implémentation par sous-système, du plus facile au plus dur, chacun bindant sa brique
couche 1 :
exit/random/time/os_str/args/env→fd/stdio→futex+sync→fs→thread+thread_local→process→net. Manques couche 1 (§5) comblés en additifsv1.xau fil de l’eau. - Jalon :
hello-stdre-tourne sur le PAL safe (2 arches), sanslibair_cliée — la preuve que le userland Rust est safe de bout en bout (unsafe confiné couche 0). - RFC amont (Tier-3 + conversation backend safe), une fois la preuve en main.
9. Conclusion
Le pivot est techniquement dégagé : (a) std::sys moderne accueille les backends non-libc
(hermit le prouve, PAL ~171 lignes) ; (b) la couche 1 d’Air couvre déjà tous les
sous-systèmes en Rust safe, à quelques additifs mineurs près ; (c) le contrat est borné et
connu (les bras à ajouter, les ~15 modules air.rs, le module futex qui débloque toute la
synchro) ; (d) le pivot résorbe raw_syscall au lieu d’en créer, et confine enfin l’unsafe
là où la doctrine l’exige — la couche 0. La singularité d’Air — un backend std safe, non
une n-ième frontière C-ABI — est ce qui, à terme, mérite d’être porté à la Rust Teams libs.
Note d’étude — un trait de backend std::sys : proposition d’RFC à la Rust Teams libs
Statut : note d’étude, NON normative — matière à discussion pré-RFC. Explore un mécanisme à proposer en amont à l’équipe Rust libs : formaliser la frontière per-OS de
std::sys(imp) en un trait de backend OS-neutre, sur lequelstds’appuie pour sa couche hors-plateforme, et qu’un provider (Air, mais aussi tout futur OS userland Rust) implémente. Prolonge la noteetude-std-pal-air-safe-fr.mdet la décision ADR-088. Rien n’engage encore le code ; l’RFC amont ne serait déposé qu’après la preuve downstream (jalonhello-stdsur PAL safe).Ancrage empirique : la frontière du trait n’est pas théorique — c’est exactement la liste des « murs durs » (A1–A4) + le Tier B relevés par la reconnaissance de l’incrément 0 du chantier PAL (voir
rt/DECISIONS.md, ADR-088). L’implémentation réelle du backendairest la preuve d’existence qui sous-tend cette proposition.
1. Le problème, vu des deux côtés
Côté équipe libs. std::sys a été profondément réusiné (chantier amont « FIXME(117276) »)
pour séparer une couche générique réutilisable (read_dir, copy, each_addr, les impls
sync/*/futex.rs…) d’un noyau per-OS imp sélectionné par cfg_select!. Cette frontière
imp existe déjà et est curée — mais le contrat qu’elle impose est implicite : il n’est
défini que par « est-ce que std compile pour cet OS ? ». Ajouter/mainteneur un OS = éditer le
cfg_select! de ~15 fichiers sys/<domaine>/mod.rs, sans filet de type.
Côté provider (Air). Pour brancher un backend, il faut forker rust-src et insérer un
bras target_os="air" dans chacun de ces ~15 dispatchs — puis rebaser ce fork à chaque nightly.
C’est précisément la dette que le chantier PAL subit aujourd’hui (ADR-088, §Réversibilité).
Thèse de la note. La frontière imp — déjà tracée par l’équipe — gagnerait à être
cristallisée en trait. Pas un rewrite : une formalisation de l’existant, qui (a) documente
et fait vérifier par le compilateur le contrat, (b) réduit le point d’ajout d’un backend de
15 dispatchs à 1 sélection, et (c) ouvre la voie à un backend hors-arbre.
2. Ce qu’un trait apporte — et ce qu’il n’apporte PAS
Il n’apporte PAS de polymorphisme à l’exécution, et c’est voulu : un binaire = un OS = un
backend, figé à la compilation. Le trait n’est pas un dyn runtime ; c’est un contrat
statique. Sa valeur est triple :
- Contrat explicite, checké.
impl Platform for Airéchoue à la compilation si une opération manque ou a la mauvaise signature — au lieu d’une erreurcfg_select!obscure à l’autre bout de l’arbre. - Point d’extension sanctionné. Un backend = une impl, pas 15 patchs de dispatch.
- (Horizon) backend hors-arbre. Le provider implémente le trait dans sa propre crate
(pour Air : les Managers couche 1), sans forker
std.
3. Décision de conception n°1 — un trait OS-neutre, PAS unix-*
Tentation naturelle (et à écarter) : nommer les traits UnixThread, UnixProcess, ou
LinuxThread… unix n’est pas un OS, c’est une famille qui présuppose *libc + errno in-band
- sémantique POSIX*. Air est Linux (le kernel est la bible) mais hors unix-family (pas de
libc, safe) — un trait
UnixThreadtraînerait l’hypothèse libc et réintroduirait le problème même qu’Air résout.
⇒ Traits neutres : PlatformThread, PlatformFs… dont unix est un implémenteur
parmi d’autres (windows, hermit, air…). « unix » redevient une impl concrète, pas un contrat.
Nuance offrable à l’équipe : une crate d’aide
unix-commonpeut fournir desimplpartagées du trait neutre pour les backends libc — au-dessus du contrat, jamais en le définissant. La factorisation unix reste possible sans contaminer l’abstraction.
4. Décision de conception n°2 — plusieurs petits traits, agrégés par un supertrait
Un Platform monolithique serait ingérable et empêcherait la réutilisation. Mieux : un trait
par sous-système (ce sont exactement les frontières que la reconnaissance a listées), composés
par un supertrait à types associés :
#![allow(unused)]
fn main() {
// std::sys::platform (esquisse)
pub trait Platform: 'static {
const NAME: &'static str;
// Types concrets fournis par le backend (portés par des sous-traits) :
type Fs: PlatformFs;
type Net: PlatformNet;
type Thread: PlatformThread;
type Sync: PlatformSync; // le futex : débloque Mutex/Condvar/RwLock/Once/Parker
type Alloc: PlatformAlloc;
type Process: PlatformProcess;
type Stdio: PlatformStdio;
// Opérations transverses :
unsafe fn init(argc: isize, argv: *const *const u8, sigpipe: u8);
unsafe fn cleanup();
fn abort_internal() -> !;
fn exit(code: i32) -> !; // évite le repli abort()
fn getrandom(buf: &mut [u8]) -> io::Result<()>; // A3
fn decode_error_kind(errno: RawOsError) -> io::ErrorKind; // A2
fn error_string(errno: RawOsError) -> String; // A2
}
/// La primitive qui, seule, débloque TOUTE la synchro générique de std.
pub trait PlatformSync {
type Futex; // typiquement AtomicU32
fn futex_wait(f: &Self::Futex, expected: u32, timeout: Option<Duration>) -> bool;
fn futex_wake(f: &Self::Futex) -> bool;
fn futex_wake_all(f: &Self::Futex);
// Mutex / Condvar / RwLock / Once / Parker restent GÉNÉRIQUES au-dessus (sync/*/futex.rs).
}
pub trait PlatformAlloc { // A1
fn alloc(layout: Layout) -> *mut u8;
unsafe fn dealloc(ptr: *mut u8, layout: Layout);
unsafe fn realloc(ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8;
fn alloc_zeroed(layout: Layout) -> *mut u8;
}
pub trait PlatformFs {
type File;
type ReadDir;
type DirEntry;
fn open(path: &Path, opts: &OpenOptions) -> io::Result<Self::File>;
fn read(f: &Self::File, buf: &mut [u8]) -> io::Result<usize>;
fn write(f: &Self::File, buf: &[u8]) -> io::Result<usize>;
fn read_dir(path: &Path) -> io::Result<Self::ReadDir>;
// … (la couche générique read_to_end/copy/canonicalize reste au-dessus)
}
}
Composabilité — le gain décisif. Un backend qui ne fournit pas un sous-système réutilise
l’impl Unsupported existante, typée : type Process = unsupported::Process;. C’est le
mécanisme unsupported d’aujourd’hui, mais vérifié — un unikernel sans processus reste
légal, et le compilateur le sait. Symétriquement, la couche générique de std ne bouge pas :
on ne trace le trait qu’à la frontière imp déjà curée. C’est une cristallisation d’une
frontière existante, pas une révolution — l’argument-clé d’acceptabilité.
5. Cartographie sous-système → trait → (côté Air) Manager couche 1
Empruntée à la reconnaissance de l’incrément 0 — chaque bras imp de std a son trait candidat
et, pour Air, son Manager couche 1 fournisseur (preuve que le contrat est réalisable safe) :
Sous-système std::sys | Trait candidat | Impl Air (couche 1 safe) |
|---|---|---|
alloc | PlatformAlloc | air-alloc::GlobalAllocator |
io/error | (méthodes de Platform) | air-base-core (Errno↔ErrorKind) |
random | Platform::getrandom | air-crypto::AirRandom::fill |
thread_local::guard | PlatformThread::tls_guard | air-runtime (TLS ELF natif) |
exit | Platform::exit | AirTaskManager |
sync/* (futex) | PlatformSync | AirTaskManager / air-thread::raw_futex |
fd/fs | PlatformFs | air-handle + AirFileManager |
stdio | PlatformStdio | air-stdio |
net | PlatformNet | AirNetworkManager (air-socket + air-handle) |
thread | PlatformThread | AirTaskManager (air-thread + air-runtime) |
process | PlatformProcess | AirTaskManager (air-process + fork) |
args/env | PlatformEnv | air-runtime/args + air-env |
time | PlatformTime | air-base-core (AirInstant/AirSystemTime) |
6. Les deux vrais obstacles (honnêteté d’ingénierie)
a. Le churn des sites d’appel. std référence des types concrets (sys::fs::File), pas
<P as Platform>::Fs::File. Threader des types associés dans tout std = gros diff, et l’équipe
libs sort épuisée de 117276. Mitigation : conserver les façades type-alias actuelles,
résolues par une seule sélection au lieu de 15 :
#![allow(unused)]
fn main() {
cfg_select! {
target_os = "air" => { use air_backend::Air as SelectedSys; }
target_family = "unix" => { use self::unix::Unix as SelectedSys; }
target_os = "windows" => { use self::windows::Win as SelectedSys; }
// …
}
pub type Sys = SelectedSys; // LE point de sélection unique
pub type File = <Sys as Platform>::Fs::File; // façades côté std inchangées
}
⇒ zéro churn aux appelants ; on remplace N dispatchs éparpillés par 1. Introductible sous-système par sous-système, incrémentalement — pas un big-bang.
b. La sélection : in-tree vs hors-arbre. Le trait seul ne dit pas quel type est Sys. Deux
niveaux, découplables :
- In-tree (modeste, fort taux d’acceptation) : le
cfg_select!choisitSys. Un provider patche alors une seule ligne au lieu de 15 fichiers. Gain déjà majeur, sans nouvelle machinerie. - Hors-arbre (l’ambition, la vraie contribution) : un slot de backend résolu par cargo —
oùcargo +nightly build -Z build-std -Z build-std-platform=air_std_backendair_std_backendest une crate normale exportantimpl Platform for Air. Plus aucun forkrust-src. Dépend de la maturation de-Z build-std(encore unstable) ⇒ à proposer comme horizon, pas comme préalable.
7. L’angle différenciant d’Air : le premier backend safe — et il nettoie std
Tous les backends existants bindent une frontière unsafe : unix→libc C-ABI, hermit→
hermit_abi (extern "C"), xous→asm!("ecall"), uefi→r-efi. Air serait le premier
dont l’unsafe vit entièrement chez le provider, tout en bas (couche 0, la barrière kernel),
le reste du backend étant du Rust safe.
Conséquence concrète pour le trait : ses signatures peuvent être safe — io::Result<T>
plutôt qu’errno in-band, pas de *mut exposé là où un &[u8]/OwnedFd suffit. Ce n’est pas
qu’esthétique : un contrat exprimé en signatures safe est plus propre pour l’équipe libs
elle-même (le type dit ce qui échoue). L’argument de tête de l’RFC n’est donc pas « rendez le
non-libc possible » (hermit le prouve déjà) mais : « un point d’extension qui, en prime, permet
des backends sans unsafe à la frontière — Air comme preuve d’existence. » Mais un membre du
libs team répondra, à juste titre : « qu’est-ce qui me garantit que ce backend est safe ? » —
c’est l’objet du §7bis.
7bis. Comment le compilateur vérifie la promesse safe (et où est la limite)
Question du sceptique libs : « une plateforme promet un backend safe — qu’est-ce qui me garantit que c’est vrai, et le compilateur peut-il le vérifier ? » Réponse rigoureuse.
a. « Safe backend » recouvre TROIS claims distincts
- Signatures safe — les méthodes du trait sont des
fn(pasunsafe fn), échangeant des types safe (&[u8],io::Result<T>,OwnedFd), jamais*mut/errno in-band. → trivialement vérifiable : c’est la définition du trait. - Corps sans
unsafe— l’impl du provider est du Rust safe. → la vraie question, la dure. - Respect des invariants dont
stddépend (fd valide, pointeur d’allocconforme aulayout…). → sémantique, indécidable (Rice) : aucun compilateur ne la prouve gratis.
b. Deux impossibilités à assumer (sinon l’RFC ment)
- « Zéro
unsafedans tout le graphe » est impossible. Au fond, quelqu’un parle au kernel (syscall = asm/FFI) : la frontière kernel estunsafepar nature. La promesse crédible n’est pas « pas d’unsafe » mais « unsafe confiné, minimal, déclaré ; tout ce qui est au-dessus est vérifié safe » (doctrine Air :unsafeen couche 0 uniquement). - Le compilateur ne prouvera jamais la correction sémantique de la frontière unsafe. Un allocateur qui ment reste du code qui compile.
c. Ce que le compilateur GARANTIT — la forme (et c’est beaucoup)
c.1 — unsafe trait + unsafe impl = énumération FORCÉE des points de confiance. Les
sous-traits dont std dépend pour sa propre soundness (alloc en tête) sont des unsafe trait.
On ne peut pas écrire un impl safe d’un unsafe trait : le compilateur exige le mot-clé.
#![allow(unused)]
fn main() {
pub unsafe trait PlatformAlloc { // std : "je dépends de ta correction pour MA soundness"
fn alloc(layout: Layout) -> *mut u8;
// …
}
unsafe impl PlatformAlloc for Air { // <- token OBLIGATOIRE, greppable : une assertion visible
// SAFETY: délègue à air-alloc (couche 1) → mmap couche 0 ; layout respecté ; cf. tests …
}
}
⇒ le libs team obtient la liste exhaustive, vérifiée compilateur, de chaque endroit où le
backend assure un invariant unsafe. count(unsafe impl) + count(unsafe fn) = la surface
de confiance. Rien d’autre ne peut cacher d’unsafe compromettant std.
c.2 — signatures safe ⇒ un backend buggé produit des erreurs de logique, pas de l’UB. Un
getrandom(&mut [u8]) -> io::Result<()> safe : un backend qui rend des octets non-aléatoires est
insécure mais pas UB — il ne corrompt pas la mémoire de std. Un open rendant un OwnedFd
bidon : type RAII valide, les I/O rendent Err, pas d’UB. Le danger n’existe que là où std
s’appuie sur un invariant unsafe : alloc, TLS, from_raw_fd, spawn/join (soundness de
thread::scope), init. Ces cas — et eux seuls — restent unsafe trait/unsafe fn. La
conception du trait doit donc minimiser et expliciter cette poignée : les marqueurs unsafe
du trait documentent exactement où repose la promesse de sûreté de la cible.
c.3 — #![deny(unsafe_code)] + îlot #[allow] localisé ⇒ le compilateur prouve « aucun unsafe
hors frontière déclarée ». Le lint unsafe_code attrape blocs unsafe, unsafe fn,
unsafe impl/unsafe trait, et même #[no_mangle]/#[export_name] (donc la FFI).
#![allow(unused)]
#![deny(unsafe_code)] // deny (pas forbid) : surchargeable localement
fn main() {
mod boundary {
#![allow(unsafe_code)] // LE seul îlot autorisé — auditable d'un grep
unsafe impl PlatformAlloc for Air { /* SAFETY: … */ }
unsafe impl PlatformTls for Air { /* SAFETY: … */ }
// … la poignée irréductible, chacune justifiée, chacune déléguant à la couche 0
}
// tout le reste de l'adaptateur : compilé-vérifié 100 % safe
}
(forbid(unsafe_code) interdirait aussi l’îlot — réservé aux crates à zéro unsafe. Pour la
frontière localisée : deny + allow ciblé.)
d. La réponse au sceptique : pas une preuve, une réduction
Le compilateur transforme une confiance non-bornée (« ce backend entier est-il safe ? ») en un
audit borné (« ces N assertions unsafe — déclarées, localisées, justifiées — sont-elles
correctes ? »), et vérifie mécaniquement tout le reste. C’est exactement le contrat que
std offre déjà à ses utilisateurs. L’angle Air : rendre N minimal et le pousser en couche 0.
Le libs team peut alors exiger et mesurer, pas croire :
- surface unsafe quantifiée :
grep 'unsafe impl'= la liste ;cargo-geiger= le compte du graphe ; - CI du mainteneur Tier-3 imposant
deny(unsafe_code)horsmod boundary; - dossier de preuve de la frontière : Miri sur la logique pure, Kani/Prusti (vérif formelle) sur les blocs unsafe, fuzz + 100 % coverage + property tests (déjà la doctrine Air) comme évidence de correction.
e. Le vrai gap — et la contribution d’RFC honnête
Aujourd’hui std/rustc ne peuvent pas imposer deny(unsafe_code) à une crate de backend
arbitraire (les lints sont un choix de l’auteur de crate), ni prouver « l’unsafe de mon graphe ne
vient que de la crate X (couche 0) ». C’est un manque réel du langage (conversation « unsafe
dans le graphe / capabilities de crate », sans mécanisme abouti). ⇒ la demande différenciante à
porter au libs team n’est pas « rendez le non-libc possible » mais : « donnez un moyen qu’une
cible déclare — et que le compilateur/cargo vérifie — que sa surface unsafe est confinée à une
frontière allowlistée et minimale. » Air serait la preuve d’existence : un backend dont
l’unsafe est N assertions en mod boundary, toutes déléguant à une couche 0 auditée.
Synthèse §7bis : le compilateur ne prouve pas la sûreté — il garantit la forme (safe partout sauf N points déclarés
unsafe) et réduit la confiance à un audit fini. Air s’engage à minimiser N, à le confiner en couche 0, et à l’outiller (Miri/Kani/fuzz). Le mécanisme de langage manquant (confinement d’unsafe vérifié) est la contribution d’RFC.
8. Précédents à citer (crédibilité)
- FIXME(117276) — la frontière générique/
impexiste et est curée : l’RFC la nomme, ne l’invente pas. #[global_allocator]/#[panic_handler]— précédents d’implémentation fournie de l’extérieur questd/coreconsomment.hermit_abi—stdtolère déjà un backend externe (via C-ABI) : la version trait Rust safe en est l’évolution naturelle, pas une rupture.- WG
build-std/ std-aware cargo — le véhicule de l’étage 2 (backend hors-arbre). restricted_std— précédent destdpartiel/gaté pour cibles non-standard.
9. Forme d’RFC recommandée — deux étages découplés
- Étage 1 — « Formaliser la frontière
impen traitPlatformOS-neutre ». Landable seul, faible risque : réduit la charge de maintenance amont, self-documenté, vérifié par le compilateur, sélection unique. Bénéfice immédiat pour l’équipe et les providers in-tree. Introduction incrémentale (un sous-système à la fois), façades préservées. - Étage 2 — « Backend
std::syshors-arbre via-Z build-std-platform». L’horizon qui débloque Air et tout futur OS userland Rust, couplé à la stabilisation debuild-std.
Séquencement côté Air : on prouve d’abord downstream (fork rust-src patché, jalon
hello-std sur PAL safe 2 arches — ADR-088), puis on dépose l’RFC avec l’implémentation air
en preuve d’existence. L’upstreaming Tier-3 de *-linux-air (ADR-050) est le véhicule qui
rend les bras air légitimes en amont.
10. Questions ouvertes (à instruire avant l’RFC)
- Types associés vs types génériques :
Platformà types associés (un backend figé par cible) suffit-il, ou faut-il paramétrer certains sous-systèmes ? (Réponse pressentie : types associés — pas de polymorphisme runtime voulu.) - Consts/statics : certaines frontières exposent des constantes (
MIN_ALIGN, tables d’erreurs). Les consts associées couvrent-elles tout, ou reste-t-il des items non exprimables en trait ? - Lang items entremêlés (alloc error handler, panic) : quels bras restent hors du trait parce que liés à des lang items, et faut-il les traiter à part ?
unsafedu trait : quelles méthodes restentunsafepar nécessité (init, dealloc) vs celles qu’on peut rendre safe ? Cartographier le minimum irréductible.- Granularité exacte : combien de sous-traits ? (Trop = cérémonie ; trop peu = perte de la
composabilité
unsupported.) La liste §5 est le point de départ. - Stabilité / semver : le trait est-il
#[unstable]interne à jamais (contrat de compilation), ou expose-t-on une surface stable aux providers Tier-3 ?
11. Conclusion
L’intuition est juste : std gagnerait un trait de backend, à trois conditions non
négociables — (1) neutre, jamais unix-* (sinon on rebranche l’hypothèse libc qu’Air casse) ;
(2) vendu comme cristallisation de la frontière imp existante, incrémentale et à façades
préservées (pas un rewrite) ; (3) porté par l’argument différenciant du backend safe — Air
en preuve d’existence, l’unsafe confiné chez le provider (couche 0), et un contrat plus propre
en prime pour l’équipe libs. L’étage 1 (trait in-tree) est déposable seul ; l’étage 2 (backend
hors-arbre) suit la maturation de build-std. On avance downstream, on prouve, puis on propose.
Couche 0 d’Air — introduction (développeurs)
Document de présentation, pour entrer vite dans la couche 0 et savoir où trouver la
référence. Les ADR (docs/adrs/) sont des documents internes au projet ; ce
document-ci s’adresse aux développeurs qui consomment ou étudient la couche 0.
Ce qu’est la couche 0
La couche 0 est le socle syscall d’Air : une API Rust typée et sûre au-dessus des appels système Linux, sans libc. Deux crates :
air-sys-types— les types purs (aucun syscall) : identifiants typés (Pid,Tid,Uid/Gid,WatchDescriptor…),Errno, drapeaux/bitflags, types miroirs#[repr(C)]des structures kernel.air-sys-syscall— les wrappers qui appellent le kernel viacore::arch::asm!et rendent desResult<T, Errno>.
Ce que tu y trouves :
- 11 familles de syscalls :
process,fs,mem,signal,time,net,ipc,security,system,device(uevent/evdev),ebpf(bpf()+perf_event_open) ; - le module
io_uringcomplet (12 « Temps » : cœur, fichiers, réseau, async,uring_cmd, registration, buffers fournis, opérations liées, multishot, multi-thread, confinement, accès brut) — la fondation des I/O asynchrones d’Air ; - des extensions :
fs::inotify, affinité CPU (sched_set/getaffinity),mem::MmapRegion, l’extension privsep deprocess(setgroups/setres*id).
Pourquoi cette architecture
- Pas de libc, pas de
rustix. La couche 0 parle au kernel directement enasm!. Objectif : maîtrise totale du comportement, aucune dépendance C, et un capno_std-compatible. (ADR-004 : Linux tier-1.) - Sûreté par le typage.
Option<T>au lieu des sentinelles kernel (-1/0),Result<T, Errno>au lieu d’errno global,OwnedFd/BorrowedFd(RAII, pas de fuite de FD), newtypes par identifiant. (Conventions gravées : ADR-021.) - io_uring comme socle async. Le runtime asynchrone d’Air (ADR-023) se bâtit sur io_uring (ADR-022/028), pas sur epoll ni Tokio. Modèle d’ownership « slab S1 » sûr, prouvé (Miri/loom).
- Variantes modernes préférées :
clone3(pasclone),pidfd_*(pas le PID nu),waitid(paswait/waitpid),signalfd(ADR-020), les register-ops*2d’io_uring… - Rigueur de couche fondatrice : 100 % de couverture lignes + branches (hors
exceptions documentées), property-testing, fuzzing sur tout parseur de données
externes, Miri/loom sur l’
unsafe/le concurrent. (Principe 1.)
Pourquoi un noyau 6.12 minimum
6.12 est un noyau LTS (support long, cohérent avec l’horizon de durabilité d’Air),
et c’est la version où la surface io_uring dont Air dépend est disponible (jusqu’aux
opérations et register-ops récents), aux côtés de Landlock, seccomp-bpf, eBPF, etc.
Cibler une seule version stable évite l’enfer des #ifdef de compatibilité et
garde la couche 0 simple et auditable. Les numéros de syscalls et dispositions sont
vérifiés par architecture (x86_64 et aarch64) sur l’uapi 6.12.
Ce que tu n’y trouveras pas (et pourquoi)
- Aucune libc, aucun
rustix— par conception (cf. ci-dessus). - Les syscalls legacy délibérément écartés, listés et justifiés dans
UNSUPPORTED.md: les variantes anciennes supplantées par une variante moderne (clone,wait/waitpid, PID nus…), et les mécanismes io_uring obsolètes (PROVIDE_BUFFERS/REMOVE_BUFFERSop-based, register-ops v1 non taguées…). - Différés par décision (présents nulle part, et c’est voulu) : epoll (le
besoin de readiness est couvert par le
poll/multishot d’io_uring ; seuls des typesEpoll*spéculatifs existent), fanotify (primitif privilégié, pour un service de sécurité ultérieur), futex synchrone (viendra avec l’implémentation futex maison d’air-thread).
UNSUPPORTED.md est la liste de référence de ces choix.
Dépendances externes
Aucune en production. La couche 0 est du Rust pur + asm! — pas de C, pas de
libc, pas de rustix. Les seules dépendances sont test-only (property-testing,
loom, cargo-fuzz), isolées et soumises à deny.toml/cargo audit (carve-out
ADR-030). C’est inhabituel et assumé : la fondation ne dépend de rien au-dessus
du kernel.
Pour le développeur C/C++
La couche 0 n’expose pas d’ABI C, et c’est volontaire : toute sa valeur vit dans
le système de types Rust (RAII, lifetimes, Option/Result, ownership io_uring)
— qui ne traverse pas une frontière C. Si tu es en C/C++ :
- syscalls bruts → utilise ta libc (glibc/musl) ; io_uring en C → liburing ;
- fonctionnalités Air → la C ABI des couches 1+ (
air-base-lib, puisair-runtime/air-aircom), là où Air est polyglotte par conception (ADR-027) ; - asseoir des outils C sur la stack Air est l’objet d’une piste
libc-compat(exploration,docs/notes/), pas de la couche 0.
Référence — pour aller plus loin
- Specs par famille :
docs/specs/layer-0/family-*.md. - Module io_uring : inventaire maître
io-uring-0-inventaire.md+ un document par Temps (io-uring-1-core.md…io-uring-4-raw.md). - Décisions : ADR-021 (conventions couche 0), ADR-019 (erreurs), ADR-020 (signaux), ADR-022/028 (io_uring), ADR-029 (nommage), ADR-024/030/034 (dépendances), ADR-031/035 (couverture & taxonomie d’exceptions).
- Listes de référence :
UNSUPPORTED.md(legacy écartés),COVERAGE-EXCEPTIONS.md(exceptions de couverture justifiées). - API : la référence rustdoc générée (
/api/).
Licence du document : MPL 2.0.
Spec couche 0 — Crate air-sys-types
Spécification technique — Version 0.1 (partielle, à compléter)
⚠️ Statut du document
Cette spec couvre les types fondamentaux de la crate
air-sys-types, identifiés au fil des spécifications des familles de syscalls. Les types sont regroupés par famille d’origine. Le total estimé est d’environ ~187 types publics dans la crate complète.Ce document liste les types et leurs signatures. Une spec complète détaillerait pour chaque type ses méthodes, invariants, conversions, et tests. Travail à reprendre : produire la spec exhaustive type par type, en parallèle de l’implémentation effective.
Vue d’ensemble de la crate
La crate air-sys-types est la fondation absolue du stack Air : tous les autres modules dépendent d’elle. Elle expose :
- Les newtypes fondamentaux :
Errno,Pid,Tid, etc. - Les wrappers RAII pour les ressources kernel :
OwnedFd,BorrowedFd,Mapping, etc. - Les bitflags pour les flags kernel :
OpenFlags,CloneFlags, etc. - Les enums typés pour les sémantiques kernel :
Signal,Capability, etc. - Les structures de données mirroring kernel :
StatxResult,SignalFdInfo, etc.
Principes de la crate.
-
Pas d’allocation par défaut. Les types fondamentaux (Errno, Pid, etc.) sont des newtypes sur des types primitifs, sans allocation.
-
#[repr(transparent)]ou#[repr(C)]selon les besoins. Les types qui doivent avoir un layout kernel-compatible utilisent#[repr(C)]. Les newtypes simples utilisent#[repr(transparent)]. -
Conversions explicites. Les conversions entre types (par exemple,
Pid::as_raw() -> i32) sont explicites via méthodes nommées, pas viaFrom/Intoimplicites. -
Validation à la construction. Les types qui ont des invariants (par exemple,
Pidqui estNonZeroI32) valident à la construction et exposent des méthodestry_newqui retournentOptionouResult. -
Documentation extensive. Chaque type a un docstring qui explique son rôle, ses invariants, et son lien avec le kernel.
Types fondamentaux (universels)
Errno
#![allow(unused)]
fn main() {
#[repr(transparent)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Errno(NonZeroI32);
impl Errno {
// ~140 constantes correspondant aux codes errno standards
pub const EPERM: Self;
pub const ENOENT: Self;
pub const ESRCH: Self;
pub const EINTR: Self;
pub const EIO: Self;
// ... etc.
pub const fn as_raw(self) -> i32;
pub const fn name(self) -> &'static str;
pub const fn description(self) -> &'static str;
}
impl core::fmt::Display for Errno { /* ... */ }
impl core::error::Error for Errno {}
}
Cf. ADR-019 pour le contexte.
Jeu d’errno piloté par les besoins (extension additive du sceau). Le socle n’expose que les constantes effectivement référencées — par le code couche 0 ou par un contrat couche 1 landé qui doit mapper l’errno par nom. Le prompt
074a ainsi ajoutéEXDEV/EPIPE/ELOOP/ECONNRESET/ETIMEDOUT(+ aliasEWOULDBLOCK ≡ EAGAIN), pilotés par les contratsair-base-lib(§1 mapping) etair-filesystem(# Errors). Valeursasm-generic, identiques x86_64/aarch64. La listename()/description()exhaustive (~140) viendra avec la PR dédiée ADR-019.
File descriptors
#![allow(unused)]
fn main() {
pub struct OwnedFd { /* ... */ }
pub struct BorrowedFd<'fd> { /* ... */ }
pub type RawFd = i32;
impl OwnedFd {
pub fn as_fd(&self) -> BorrowedFd<'_>;
pub fn into_raw_fd(self) -> RawFd;
pub unsafe fn from_raw_fd(fd: RawFd) -> Self;
}
impl<'fd> BorrowedFd<'fd> {
pub fn as_raw_fd(&self) -> RawFd;
pub unsafe fn borrow_raw(fd: RawFd) -> Self;
}
impl Drop for OwnedFd {
fn drop(&mut self) { /* appelle close(fd), ignore erreur */ }
}
}
Discipline : tout FD géré par Air passe par OwnedFd/BorrowedFd. Pas de RawFd à nu sauf dans les APIs raw.
DirFd
#![allow(unused)]
fn main() {
pub enum DirFd<'fd> {
Cwd,
Fd(BorrowedFd<'fd>),
}
}
Base pour les opérations *at (cf. ADR-021 convention 1).
Types de la famille process
#![allow(unused)]
fn main() {
pub struct Pid(NonZeroI32);
pub struct Tid(NonZeroI32);
pub struct PidFd { /* OwnedFd interne */ }
pub struct PidFdReceiver { /* ... */ }
pub struct TidReceiver { /* ... */ }
pub enum WaitTarget<'fd> {
AnyChild,
Pid(Pid),
ProcessGroup(Pid),
AnyProcessGroup,
PidFd(BorrowedFd<'fd>),
}
pub struct WaitOptions(i32); // bitflags
pub struct WaitStatus {
pub pid: Pid,
pub uid: u32,
pub event: WaitEvent,
}
pub enum WaitEvent {
Exited { code: i32 },
Killed { signal: Signal, core_dumped: bool },
Stopped { signal: Signal },
Continued,
Trapped { signal: Signal },
}
pub struct CloneFlags(u64); // bitflags
pub struct CloneArgs { /* ... */ }
pub enum CloneResult { /* ... */ }
pub struct Signal(NonZeroI32);
pub struct SignalMask { /* ... */ }
pub enum Capability { /* énum des capabilities Linux */ }
pub struct CapabilityMask(u64);
pub struct CapabilitySet { /* ... */ }
pub enum CapabilityTarget { /* ... */ }
pub enum Resource { /* énum des limites Linux */ }
pub struct Rlimit { /* ... */ }
pub enum RlimitValue { Finite(u64), Infinity }
pub enum DumpableMode { /* ... */ }
pub struct ExecveatFlags(i32); // bitflags
pub struct PidFdOpenFlags(u32); // bitflags
}
~20 types pour cette famille.
Types de la famille fs
#![allow(unused)]
fn main() {
pub struct OpenFlags(u64); // bitflags
pub type Mode = u32;
pub struct OpenHow { /* ... */ }
pub struct ResolveFlags(u64); // bitflags
pub struct StatxResult { /* ... */ }
pub struct StatxMask(u32); // bitflags
pub struct StatxFlags(u32); // bitflags
pub struct StatxTimestamp { /* ... */ }
pub struct AccessMode(u32); // bitflags
pub struct AccessFlags(i32); // bitflags
pub struct RenameFlags(u32); // bitflags
pub struct FallocateMode(i32); // bitflags
pub enum SeekWhence { Set, Current, End, Data, Hole }
pub struct DirEntry { /* ... */ } // name: Vec<u8> (octets) — ADR-048
pub enum DirEntryType { /* ... */ }
pub struct DirEntryIter<'buf> { /* ... */ }
pub enum UtimeValue { Now, Omit, Time(StatxTimestamp) }
pub struct FdFlags(i32); // bitflags
pub struct StatusFlags(i32); // bitflags
pub struct FileLock { /* ... */ }
pub struct Seals(u32); // bitflags
pub struct FileHandle { /* ... */ }
pub struct NameToHandleFlags(i32); // bitflags
pub struct IoSlice<'data> { /* ... */ }
pub struct IoSliceMut<'data> { /* ... */ }
}
~25 types pour cette famille.
Types de la famille mem
#![allow(unused)]
fn main() {
pub struct Mapping {
addr: NonNull<u8>,
length: usize,
}
pub struct MappingPointer { /* ... */ }
pub struct ProtectionFlags(i32); // bitflags
pub struct MapFlags(i32); // bitflags
pub struct MremapFlags(i32); // bitflags
pub enum MadviseAdvice { /* ~25 variants */ }
pub struct MlockFlags(u32); // bitflags
pub struct MlockallFlags(i32); // bitflags
pub struct MemfdFlags(u32); // bitflags
pub struct MsyncFlags(i32); // bitflags
pub struct RemoteIoSlice { /* ... */ }
}
~10 types pour cette famille.
Types du module io_uring
Placement des types (ADR-022 / ADR-028). Ce document spécifie la crate
air-sys-types. Pourio_uring, seuls les types pure-données (sans comportement, ni FD/mmap, ni durée de vie liée à un anneau) y vivent ; les types stateful / couplés (anneau, builders, jetons, complétions, miroirs#[repr(C)], gardes RAII) vivent dansair-sys-syscall::io_uringpar encapsulation. La règle se tranche Temps par Temps, au fil de l’implémentation.Note (nommage, ADR-029). Noms de façade explicites, sans abréviation. Les anciens noms abrégés (
IoUringOp,RawSqe,RawCqe,RawOp,FdPool,RegisteredBuffer,ProvidedBuffers) sont remplacés ; les miroirs kernel gardent les noms hérités courts.
#![allow(unused)]
fn main() {
// PLACEMENT (ADR-022/028). Seuls `SetupFlags`, `CompletionFlags` et
// `IoUringOpcode` vivent dans `air-sys-types` (types purs, livrés au Temps 1).
// TOUS les autres types ci-dessous vivent dans `air-sys-syscall::io_uring`
// (stateful / couplés — encapsulation) ; listés ici pour mémoire, spécifiés dans
// les specs `io-uring-*.md`. Les Temps ultérieurs placeront leurs types selon la
// même règle (purs ici, couplés dans air-sys-syscall).
pub struct IoUring { /* ... */ }
pub struct IoUringBuilder { /* ... */ }
pub struct IoUringParams { /* ... */ }
pub struct IoUringCapabilities { /* ... */ }
pub struct SetupFlags(u32); // bitflags
pub struct Completion { /* ... */ }
pub struct CompletionFlags(u32); // bitflags
pub struct CompletionIter<'ring> { /* ... */ }
pub struct SubmitOptions { /* ... */ }
pub struct SubmissionToken(/* opaque : index de slot + génération */);
pub struct MultishotToken(/* opaque */);
pub enum IoUringOpcode { /* énum des opérations */ }
pub enum CancelTarget { /* token / fd / op / any */ }
// Ressources fixes (Temps 3a)
pub struct FixedFdTable { /* ... */ }
pub struct FixedSlot<'t> { /* ... */ }
pub enum FixedSlotTarget { /* Index(u32) | Alloc */ }
pub struct RegisteredBuffers { /* ... */ }
pub struct RegisteredBufferSlice<'b> { /* ... */ }
pub struct Personality(/* id */);
pub struct WorkQueueWorkerLimits { /* ... */ }
pub struct NapiConfig { /* ... */ }
pub enum ClockSource { /* Monotonic | Boottime | Realtime */ }
// Buffers fournis ring-mapped (Temps 3b)
pub struct ProvidedBufferRing { /* ... */ }
pub struct ProvidedBuffer<'r> { /* guard RAII */ }
pub struct ProvidedBufferRingOptions { /* ... */ }
pub struct ProvidedBufferRingStatus { /* ... */ }
// Linked / multishot / réseau zero-copy
pub struct LinkedChainBuilder<'ring> { /* ... */ }
pub struct ChainTokens { /* ... */ }
pub struct ZeroCopyFlags(u32); // bitflags
// Multi-thread (Temps 3e)
pub struct LockedIoUring { /* ... */ }
pub struct RingPool { /* ... */ }
pub struct RingHandle { /* ... */ }
pub struct SqpollIoUring { /* ... */ }
// Confinement (Temps 3f)
pub struct RestrictionSet { /* ... */ }
pub enum Restriction { /* ... */ }
pub enum RegisterOp { /* ... */ }
pub struct SqeFlagSet { /* ... */ }
// Accès brut niveau 1 (Temps 4) — type verbeux, champs = noms kernel
#[repr(C)]
pub struct RawSubmissionQueueEntry { /* 64 octets ; champs user_data, res… */ }
#[repr(C)]
pub struct RawCompletionQueueEntry { /* 16 ou 32 octets */ }
pub struct RawOpcode(pub u8);
pub const RAW_USER_DATA_TAG: u64 = 1 << 63;
pub struct TimeoutFlags(u32); // bitflags
pub struct PollEvents(u32); // bitflags
pub enum EpollOp { /* ... */ }
pub struct EpollEvent { /* ... */ }
}
Dans air-sys-types : 3 types (Temps 1 — SetupFlags, CompletionFlags,
IoUringOpcode). Les autres vivent dans air-sys-syscall::io_uring ou ne sont
pas encore implémentés.
Types de la famille signal
#![allow(unused)]
fn main() {
pub struct SignalFdInfo { /* ... */ }
pub struct SignalFdFlags(i32); // bitflags
pub struct SignalQueueInfo { /* ... */ }
pub enum SignalValue { Integer(i32), Pointer(u64) }
// Sous-module synchronous_handler
pub enum FatalSignal { Segv, Bus, Fpe, Ill }
pub struct SignalInfo { /* ... */ }
pub struct PreviousHandler { /* opaque */ }
}
~6 types pour cette famille.
Types de la famille time
#![allow(unused)]
fn main() {
pub enum Clock {
Realtime, Monotonic, ProcessCpuTime, ThreadCpuTime,
MonotonicRaw, RealtimeCoarse, MonotonicCoarse,
Boottime, RealtimeAlarm, BoottimeAlarm, Tai,
}
pub struct Instant {
clock: Clock,
seconds: i64,
nanoseconds: u32,
}
pub struct Timespec { /* interne */ }
pub struct TimerFd { /* OwnedFd interne */ }
pub struct TimerFdSpecification {
pub initial: Duration,
pub interval: Duration,
}
pub struct TimerFdFlags(i32); // bitflags
pub struct TimerSetFlags(i32); // bitflags
pub enum SleepDeadline {
Relative(Duration),
AbsoluteInstant(Instant),
}
pub enum SleepError {
Interrupted { remaining: Duration },
Other(Errno),
}
}
~9 types pour cette famille.
Types de la famille net
#![allow(unused)]
fn main() {
pub enum SocketDomain { /* ... */ }
pub enum SocketType { /* ... */ }
pub enum SocketAddr {
Unix(UnixSocketAddr),
Inet(InetSocketAddr),
Inet6(Inet6SocketAddr),
// ...
}
pub struct UnixSocketAddr { /* ... */ }
pub struct InetSocketAddr { /* ... */ }
pub struct Inet6SocketAddr { /* ... */ }
pub struct MessageFlags(i32); // bitflags
pub struct AcceptFlags(i32); // bitflags
pub struct AcceptResult { /* ... */ }
pub struct SendMessageRequest { /* ... */ }
pub struct ReceiveMessageRequest { /* ... */ }
pub struct SendMessageResult { /* ... */ }
pub struct ReceiveMessageResult { /* ... */ }
pub enum ShutdownMode { Read, Write, Both }
pub enum AncillarySent { /* ... */ }
pub enum AncillaryReceived { /* ... */ }
pub enum SocketOptionLevel { /* ... */ }
pub enum LingerOption { Disabled, Enabled(Duration) }
pub struct UnixCredentials { /* ... */ }
}
~20 types pour cette famille.
Types de la famille ipc
#![allow(unused)]
fn main() {
pub struct EventFd { /* OwnedFd interne */ }
pub struct EventFdFlags(i32); // bitflags
pub struct PipeFlags(i32); // bitflags
pub struct SpliceFlags(u32); // bitflags
}
~4 types pour cette famille.
Types de la famille security
#![allow(unused)]
fn main() {
pub struct SeccompFilter { /* ... */ }
pub struct SeccompRule { /* ... */ }
pub enum SeccompAction { /* ... */ }
pub enum SeccompMode { /* ... */ }
pub struct SeccompFilterFlags(u32); // bitflags
pub struct SyscallNumber(i32);
pub struct SyscallArgumentCondition { /* ... */ }
pub enum ConditionOp { /* ... */ }
pub struct LandlockRuleset { /* ... */ }
pub struct LandlockAccessFs(u64); // bitflags
}
~10 types pour cette famille.
Types de la famille system
#![allow(unused)]
fn main() {
pub struct UtsName {
pub sysname: CString,
pub nodename: CString,
pub release: CString,
pub version: CString,
pub machine: CString,
pub domainname: CString,
}
pub struct SystemInfo { /* ... */ }
pub struct GetrandomFlags(u32); // bitflags
}
3 types pour cette famille.
Récapitulatif global
| Famille | Nombre approximatif de types |
|---|---|
| Fondamentaux | 5 (Errno, OwnedFd, BorrowedFd, RawFd, DirFd) |
process | 20 |
fs | 25 |
mem | 10 |
io_uring (dans air-sys-types) | 3 (Temps 1) |
signal | 6 |
time | 9 |
net | 20 |
ipc | 4 |
security | 10 |
system | 3 |
Total estimé : ~140-160 types publics.
La spec finale aurait peut-être un peu plus de types selon les besoins qui émergent à l’implémentation. L’estimation initiale de ~187 types reste raisonnable.
Travail à reprendre
TODO : produire une spec exhaustive de
air-sys-types:Pour chaque type, documenter :
- Représentation interne (
#[repr(...)]).- Constructeurs (
new,try_new, etc.) et invariants.- Méthodes principales (
as_raw,from_raw, conversions).- Implémentations de traits (
Debug,Display,Error,Clone,Copy, etc.).- Tests à prévoir (constructions, invariants, conversions).
- Lien avec les types kernel équivalents.
Cette spec exhaustive est probablement à produire en parallèle de l’implémentation effective de la crate, plutôt qu’en amont, parce que des ajustements émergeront naturellement.
Licence du document : MPL 2.0
Statut : Spec partielle de la crate air-sys-types. À compléter en parallèle de l’implémentation.
Spec couche 0 — Famille process
Spécification technique — Version 1.0
Vue d’ensemble de la famille
Le module air-sys-syscall::process expose les primitives kernel de gestion des processus, threads, et processus enfants. C’est l’une des familles fondamentales de la couche 0 : tout ce qui concerne le cycle de vie d’un processus (création, attente, terminaison), l’identité (PIDs, TIDs), les capabilities, et les rlimits.
Périmètre de la famille.
Dix-huit syscalls wrappés, organisés en sous-catégories :
- Identité :
getpid,gettid. - Création de processus :
clone3,execve,execveat. - Attente et terminaison :
waitid,exit_group,pidfd_open,pidfd_send_signal,pidfd_getfd. - Groupes et sessions :
setpgid,getpgid,setsid,getsid. - Contrôle de processus :
prctl(opérations exposées individuellement). - Limites de ressources :
getrlimit,setrlimit,prlimit. - Capabilities :
capget,capset.
Caractéristiques transverses de la famille.
-
Préférence pour les variantes modernes.
clone3plutôt queclone,pidfd_openplutôt que d’utiliser les PIDs nus pour les opérations post-fork. Les syscalls legacy sont listés dansUNSUPPORTED.mdavec justification. -
Newtypes systématiques pour les identifiants.
Pid,Tid,PidFdsont des newtypes typés. Pas dei32brut pour un PID. -
Sémantique d’ownership claire.
PidFdest RAII : ferme automatiquement le FD à la destruction. Les FDs reçus par les opérations sont toujours retournés commeOwnedFd. -
Validation amont systématique. Conformément au Principe d’ingénierie 4, toute fonction publique valide ses paramètres au début et retourne
ResultouOptionen cas de violation.
Sous-section 1 : Identité
getpid
Signature.
#![allow(unused)]
fn main() {
pub fn getpid() -> Pid;
}
Syscall sous-jacent. getpid (x86_64 n°39, ARM64 n°172). Page man getpid(2).
Comportement.
Retourne le PID du processus appelant. Ne peut pas échouer. POSIX garantit qu’un PID retourné est strictement positif.
Type de retour.
Pid est un newtype sur NonZeroI32. La fonction est totale (pas de Result).
Performance. Négligeable (~30-50 ns via vDSO sur les kernels récents).
Tests.
- Test nominal :
getpid()retourne un Pid valide. - Test cohérence : deux appels successifs retournent la même valeur.
gettid
Signature.
#![allow(unused)]
fn main() {
pub fn gettid() -> Tid;
}
Syscall sous-jacent. gettid (x86_64 n°186, ARM64 n°178). Page man gettid(2).
Comportement.
Retourne le TID du thread appelant. Sur Linux, chaque thread a un TID distinct ; le thread principal d’un processus a un TID égal au PID du processus.
Type de retour.
Tid est un newtype distinct de Pid pour empêcher les confusions par type.
Performance. ~30-50 ns.
Tests.
- Test thread principal :
gettid()égalegetpid()pour le thread principal. - Test multi-thread : deux threads d’un même processus ont des TIDs distincts.
Sous-section 2 : Création de processus
clone3
Signature.
#![allow(unused)]
fn main() {
pub unsafe fn clone3(args: &CloneArgs) -> Result<CloneResult, Errno>;
pub enum CloneResult {
Parent { child_pid: Pid, child_pidfd: Option<PidFd> },
Child,
}
#[derive(Debug, Default)]
pub struct CloneArgs {
pub flags: CloneFlags,
pub pidfd: Option<PidFdReceiver>,
pub child_tid: Option<TidReceiver>,
pub parent_tid: Option<TidReceiver>,
pub exit_signal: Option<Signal>,
pub stack: Option<StackSpecification>,
pub tls: Option<u64>,
pub set_tid: Option<&[Pid]>,
pub cgroup: Option<RawFd>,
}
bitflags! {
pub struct CloneFlags: u64 {
const VM = 0x00000100;
const FS = 0x00000200;
const FILES = 0x00000400;
const SIGHAND = 0x00000800;
const PIDFD = 0x00001000;
const PTRACE = 0x00002000;
const VFORK = 0x00004000;
const PARENT = 0x00008000;
const THREAD = 0x00010000;
const NEWNS = 0x00020000;
const SYSVSEM = 0x00040000;
const SETTLS = 0x00080000;
const PARENT_SETTID = 0x00100000;
const CHILD_CLEARTID = 0x00200000;
const DETACHED = 0x00400000;
const UNTRACED = 0x00800000;
const CHILD_SETTID = 0x01000000;
const NEWCGROUP = 0x02000000;
const NEWUTS = 0x04000000;
const NEWIPC = 0x08000000;
const NEWUSER = 0x10000000;
const NEWPID = 0x20000000;
const NEWNET = 0x40000000;
const IO = 0x80000000;
const CLEAR_SIGHAND = 0x100000000;
const INTO_CGROUP = 0x200000000;
const NEWTIME = 0x400000000;
}
}
pub struct StackSpecification {
pub addr: *mut u8,
pub size: usize,
}
}
Syscall sous-jacent. clone3 (x86_64 n°435, ARM64 n°435). Page man clone(2). Disponible depuis Linux 5.3 (septembre 2019).
Préconditions (Safety).
L’API est unsafe parce que clone3 peut créer un thread partageant la mémoire avec le parent (CLONE_VM), ce qui demande une gestion attentive de l’aliasing mémoire que Rust ne peut pas vérifier.
La documentation # Safety exige :
-
Pour les usages “fork classique” (création d’un processus enfant indépendant), pas de précondition particulière. Le wrapper Air pourra fournir un helper safe
fork_processqui appelle clone3 avec les flags appropriés. -
Pour les usages avec
CLONE_VM, l’appelant doit garantir que la mémoire partagée est manipulée de manière thread-safe par les deux côtés. -
Pour les usages avec
CLONE_NEWUSER,CLONE_NEWNS, etc. (namespaces), l’appelant doit avoir les capabilities nécessaires.
Comportement.
clone3 est l’opération moderne unifiée pour créer processus, threads, et toute combinaison entre les deux. Le comportement précis dépend des flags :
- Sans flags spéciaux : équivalent à
fork(). Crée un processus enfant indépendant. - Avec
CLONE_VM | CLONE_THREAD | CLONE_SIGHAND: crée un thread dans le processus courant. - Avec
CLONE_NEW*: crée le processus dans de nouveaux namespaces. - Avec
CLONE_PIDFD: retourne aussi un pidfd pour le nouveau processus, évitant les races sur le PID recyclé.
Le wrapper Air retourne un CloneResult qui distingue clairement le côté parent du côté enfant. Pour le parent, le PID de l’enfant est retourné, plus optionnellement un PidFd si CLONE_PIDFD était demandé.
Pattern recommandé.
#![allow(unused)]
fn main() {
let mut args = CloneArgs::default();
args.flags = CloneFlags::PIDFD;
args.exit_signal = Some(Signal::SIGCHLD);
// SAFETY: pas de mémoire partagée, fork classique avec pidfd
let result = unsafe { clone3(&args)? };
match result {
CloneResult::Parent { child_pid, child_pidfd } => {
let pidfd = child_pidfd.expect("requested PIDFD");
// Attendre l'enfant via waitid sur le pidfd
}
CloneResult::Child => {
// Code enfant
// Probablement execve ici
unreachable!("execve should not return");
}
}
}
Erreurs.
EAGAIN: limites système atteintes (nombre maximum de processus).EINVAL: combinaison de flags invalide.ENOMEM: mémoire kernel insuffisante.EPERM: capabilities insuffisantes pour les flags demandés.EUSERS: trop de namespaces utilisateurs.
Performance.
clone3 avec fork classique : ~50-100 µs. Le coût principal est la duplication des tables de pages (copy-on-write).
clone3 pour création de thread : ~20-50 µs.
clone3 avec namespaces : variable, ~100-500 µs selon les namespaces demandés.
Portabilité.
Strictement Linux. Pas d’équivalent direct sur les autres Unix (qui ont fork/vfork/pthread_create séparés).
Tests.
- Test fork classique : créer un processus enfant qui exit immédiatement, waitid, vérifier code de sortie.
- Test pidfd : créer avec CLONE_PIDFD, vérifier que le pidfd est valide.
- Test exec dans enfant : fork puis execve, vérifier que le binaire externe est exécuté.
- Test namespaces : créer dans nouveau PID namespace, vérifier l’isolation.
- Test capabilities : tenter sans privilèges, vérifier EPERM.
execve
Signature.
#![allow(unused)]
fn main() {
pub fn execve(
path: &CStr,
argv: &[&CStr],
envp: &[&CStr],
) -> Result<Infallible, Errno>;
}
Syscall sous-jacent. execve (x86_64 n°59, ARM64 n°221). Page man execve(2).
Préconditions.
argv et envp sont des tableaux de chaînes C-compatibles. Le wrapper convertit en *const *const c_char en interne.
Comportement.
Remplace l’image mémoire du processus courant par celle du binaire à path. En cas de succès, ne retourne jamais (le processus exécute désormais le nouveau binaire). En cas d’échec, retourne avec une Errno.
Type de retour.
Result<Infallible, Errno> : Infallible est l’idiome Rust pour “n’arrive jamais en succès”. L’utilisateur peut faire let _: Infallible = execve(...)?; puis avoir un unreachable!().
Erreurs.
E2BIG: argv/envp trop grands.EACCES: permissions insuffisantes pour exécuterpath.ENOENT:pathn’existe pas.ENOEXEC:pathn’est pas un exécutable valide.ENOMEM: mémoire insuffisante.ETXTBSY: le binaire est ouvert en écriture par un autre processus.
Performance.
Variable selon la taille du binaire et la complexité du loader. Typiquement ~1-5 ms pour un binaire simple, plus pour des binaires liés dynamiquement avec beaucoup de bibliothèques.
Tests.
- Test nominal : fork puis execve d’un binaire simple (
/bin/true), waitid. - Test ENOENT : execve sur chemin inexistant.
- Test EACCES : execve sur fichier non exécutable.
execveat
Signature.
#![allow(unused)]
fn main() {
pub fn execveat(
dirfd: DirFd,
path: &CStr,
argv: &[&CStr],
envp: &[&CStr],
flags: ExecveatFlags,
) -> Result<Infallible, Errno>;
bitflags! {
pub struct ExecveatFlags: i32 {
const EMPTY_PATH = 0x1000;
const SYMLINK_NOFOLLOW = 0x100;
}
}
}
Syscall sous-jacent. execveat (x86_64 n°322, ARM64 n°281). Disponible depuis Linux 3.19.
Comportement.
Variante de execve qui prend un FD de répertoire (pour résolution de path relative) ou un FD direct vers le binaire (avec EMPTY_PATH).
Cas d’usage principal : exécuter un binaire référencé par un FD ouvert précédemment, sans race sur le chemin filesystem.
#![allow(unused)]
fn main() {
let bin_fd = openat(DirFd::Cwd, c"/usr/bin/myapp", OpenFlags::PATH | OpenFlags::CLOEXEC, Mode::empty())?;
// Plus tard, après vérifications éventuelles :
execveat(DirFd::Fd(bin_fd.as_fd()), c"", &argv, &envp, ExecveatFlags::EMPTY_PATH)?;
}
Tests.
- Test EMPTY_PATH : openat sur binaire, execveat avec FD.
- Test path relatif : execveat avec dirfd + path.
Sous-section 3 : Attente et terminaison
waitid
Signature.
#![allow(unused)]
fn main() {
pub fn waitid(
target: WaitTarget,
options: WaitOptions,
) -> Result<Option<WaitStatus>, Errno>;
pub enum WaitTarget {
AnyChild,
Pid(Pid),
ProcessGroup(Pid),
AnyProcessGroup,
PidFd(BorrowedFd<'_>),
}
bitflags! {
pub struct WaitOptions: i32 {
const EXITED = 4;
const STOPPED = 2;
const CONTINUED = 8;
const NOHANG = 1;
const NOWAIT = 0x01000000;
}
}
pub struct WaitStatus {
pub pid: Pid,
pub uid: u32,
pub event: WaitEvent,
}
pub enum WaitEvent {
Exited { code: i32 },
Killed { signal: Signal, core_dumped: bool },
Stopped { signal: Signal },
Continued,
Trapped { signal: Signal },
}
}
Syscall sous-jacent. waitid (x86_64 n°247, ARM64 n°95). Page man waitid(2).
Préconditions.
WaitOptions doit contenir au moins un de EXITED, STOPPED, ou CONTINUED (sinon waitid ne sait pas quels événements rapporter).
Comportement.
Attend qu’un événement survienne sur le processus ou groupe ciblé. Plus puissant que wait ou waitpid parce qu’il peut attendre sur un pidfd directement (évitant les races) et discriminer les types d’événements.
Le wrapper retourne Result<Option<WaitStatus>, Errno>. Ok(None) indique que NOHANG était demandé et qu’aucun événement n’était disponible.
Pattern avec pidfd.
#![allow(unused)]
fn main() {
let mut args = CloneArgs::default();
args.flags = CloneFlags::PIDFD;
// SAFETY: fork classique avec pidfd
let result = unsafe { clone3(&args)? };
if let CloneResult::Parent { child_pidfd: Some(pidfd), .. } = result {
let status = waitid(WaitTarget::PidFd(pidfd.as_fd()), WaitOptions::EXITED)?;
match status {
Some(WaitStatus { event: WaitEvent::Exited { code }, .. }) => {
println!("Child exited with code {}", code);
}
_ => { /* autres cas */ }
}
}
}
Erreurs.
ECHILD: pas d’enfant à attendre.EINTR: interrompu par un signal (conformément à la convention 2 d’ADR-021, remonté à l’appelant).EINVAL: options invalides.
Performance.
Variable. Avec NOHANG, retour immédiat ~1 µs. Sans NOHANG, bloquant jusqu’à événement.
exit_group
Signature.
#![allow(unused)]
fn main() {
pub fn exit_group(status: i32) -> !;
}
Syscall sous-jacent. exit_group (x86_64 n°231, ARM64 n°94). Page man exit_group(2).
Comportement.
Termine tous les threads du processus appelant avec le code de sortie spécifié. Ne retourne jamais.
Type de retour.
! (never type) : la fonction ne retourne jamais.
Performance. Négligeable côté appelant (le processus disparaît).
pidfd_open
Signature.
#![allow(unused)]
fn main() {
pub fn pidfd_open(pid: Pid, flags: PidFdOpenFlags) -> Result<PidFd, Errno>;
bitflags! {
pub struct PidFdOpenFlags: u32 {
const NONBLOCK = 0x800;
}
}
pub struct PidFd { /* possède OwnedFd interne */ }
impl PidFd {
pub fn as_fd(&self) -> BorrowedFd<'_>;
pub fn into_fd(self) -> OwnedFd;
}
}
Syscall sous-jacent. pidfd_open (x86_64 n°434, ARM64 n°434). Disponible depuis Linux 5.3.
Comportement.
Ouvre un FD qui référence un processus par PID. Le FD reste valide même si le PID est recyclé après mort du processus original (le FD ne se “transfère” pas à un nouveau processus).
Le pidfd peut ensuite être utilisé pour :
waitid(attendre la fin du processus).pidfd_send_signal(envoyer un signal sans race sur le PID).pidfd_getfd(récupérer un FD du processus cible).- Surveillance via poll/epoll (le FD devient lisible quand le processus se termine).
Erreurs.
EINVAL: flags invalides.ESRCH: processus inexistant.ENOMEM: mémoire kernel insuffisante.
Performance. ~5-10 µs.
Tests.
- Test nominal : fork enfant, pidfd_open sur son PID, waitid sur le pidfd.
- Test ESRCH : pidfd_open sur PID inexistant.
- Test post-mortem : créer pidfd, tuer processus, vérifier que le pidfd reste valide jusqu’à waitid.
pidfd_send_signal
Signature.
#![allow(unused)]
fn main() {
pub fn pidfd_send_signal(
pidfd: BorrowedFd<'_>,
signal: Signal,
info: Option<&SignalInfo>,
) -> Result<(), Errno>;
}
Syscall sous-jacent. pidfd_send_signal (x86_64 n°424, ARM64 n°424). Disponible depuis Linux 5.1.
Comportement.
Envoie un signal au processus référencé par le pidfd. Pas de race sur le PID recyclé : si le processus original est mort, le signal n’est pas délivré à un éventuel nouveau processus avec le même PID.
À préférer à kill(pid, sig) quand un pidfd est disponible.
Erreurs.
EBADF: pidfd invalide.EINVAL: signal invalide.EPERM: permissions insuffisantes.ESRCH: le processus référencé n’existe plus.
pidfd_getfd
Signature.
#![allow(unused)]
fn main() {
pub fn pidfd_getfd(
pidfd: BorrowedFd<'_>,
target_fd: RawFd,
flags: u32,
) -> Result<OwnedFd, Errno>;
}
Syscall sous-jacent. pidfd_getfd (x86_64 n°438, ARM64 n°438). Disponible depuis Linux 5.6.
Comportement.
Récupère un duplicata d’un FD ouvert dans un autre processus. Nécessite CAP_SYS_PTRACE ou être le même utilisateur que le processus cible.
Cas d’usage spécialisé : debuggers, outils de surveillance, transfert de ressources entre processus coopérant.
Sous-section 4 : Groupes et sessions
setpgid, getpgid, setsid, getsid
Signatures.
#![allow(unused)]
fn main() {
pub fn setpgid(pid: Option<Pid>, pgid: Option<Pid>) -> Result<(), Errno>;
pub fn getpgid(pid: Option<Pid>) -> Result<Pid, Errno>;
pub fn setsid() -> Result<Pid, Errno>;
pub fn getsid(pid: Option<Pid>) -> Result<Pid, Errno>;
}
Syscalls sous-jacents. setpgid (x86_64 n°109, ARM64 n°154), getpgid (n°121, n°155), setsid (n°112, n°157), getsid (n°124, n°156).
Préconditions.
Option<Pid> permet de signifier “le processus courant” via None (au lieu de la sentinelle 0 du kernel). Cohérent avec la convention 1 de l’ADR-021.
Comportement.
Gestion des process groups et sessions Unix. Usage rare dans les applications modernes mais nécessaire pour les shells, les démons, et certaines applications terminal.
Performance. Négligeable (~1-2 µs).
Sous-section 5 : prctl (opérations exposées individuellement)
prctl est un syscall multiplexé : 60+ opérations distinctes selon le premier argument. Conformément à la convention 3 de l’ADR-021, Air ne wrappe pas prctl génériquement mais expose chaque opération comme fonction dédiée typée.
Opérations exposées
#![allow(unused)]
fn main() {
// PR_SET_PDEATHSIG / PR_GET_PDEATHSIG
pub fn set_parent_death_signal(signal: Option<Signal>) -> Result<(), Errno>;
pub fn get_parent_death_signal() -> Result<Option<Signal>, Errno>;
// PR_SET_NO_NEW_PRIVS / PR_GET_NO_NEW_PRIVS
pub fn set_no_new_privs() -> Result<(), Errno>;
pub fn get_no_new_privs() -> Result<bool, Errno>;
// PR_SET_NAME / PR_GET_NAME (nom du thread, max 16 octets)
pub fn set_thread_name(name: &CStr) -> Result<(), Errno>;
pub fn get_thread_name() -> Result<CString, Errno>;
// PR_SET_DUMPABLE / PR_GET_DUMPABLE
pub fn set_dumpable(dumpable: DumpableMode) -> Result<(), Errno>;
pub fn get_dumpable() -> Result<DumpableMode, Errno>;
pub enum DumpableMode {
NotDumpable,
Dumpable,
SuidDumpable,
}
// PR_SET_KEEPCAPS / PR_GET_KEEPCAPS
pub fn set_keep_caps(keep: bool) -> Result<(), Errno>;
pub fn get_keep_caps() -> Result<bool, Errno>;
// PR_SET_TIMERSLACK
pub fn set_timer_slack(slack_ns: u64) -> Result<(), Errno>;
// PR_CAP_AMBIENT (avec sous-opérations)
pub fn cap_ambient_raise(cap: Capability) -> Result<(), Errno>;
pub fn cap_ambient_lower(cap: Capability) -> Result<(), Errno>;
pub fn cap_ambient_clear_all() -> Result<(), Errno>;
pub fn cap_ambient_is_set(cap: Capability) -> Result<bool, Errno>;
// PR_SET_SECCOMP_MODE_FILTER (alternative au syscall seccomp direct)
// Non exposé ici, préférer seccomp_load_filter dans la famille security.
}
Cette liste couvre les opérations prctl les plus utilisées. Les opérations rares (PR_TASK_PERF_EVENTS, PR_GET_TID_ADDRESS, etc.) sont listées dans UNSUPPORTED.md et peuvent être ajoutées au cas par cas si Air en a besoin.
Sous-section 6 : Limites de ressources
getrlimit, setrlimit, prlimit
Signatures.
#![allow(unused)]
fn main() {
pub fn getrlimit(resource: Resource) -> Result<Rlimit, Errno>;
pub fn setrlimit(resource: Resource, limit: Rlimit) -> Result<(), Errno>;
pub fn prlimit(
pid: Option<Pid>,
resource: Resource,
new_limit: Option<Rlimit>,
) -> Result<Rlimit, Errno>;
pub enum Resource {
Cpu,
FileSize,
Data,
Stack,
Core,
Rss,
NProc,
NoFile,
MemLock,
As,
Locks,
SigPending,
MsgQueue,
Nice,
RtPrio,
RtTime,
}
pub struct Rlimit {
pub soft: RlimitValue,
pub hard: RlimitValue,
}
pub enum RlimitValue {
Finite(u64),
Infinity,
}
}
Syscalls sous-jacents. getrlimit (x86_64 n°97, ARM64 n°163), setrlimit (n°160, n°164), prlimit64 (n°302, n°261).
Comportement.
Lit et modifie les limites de ressources d’un processus. prlimit est plus puissant : permet d’opérer sur un autre processus (avec privilèges appropriés) et fait les deux opérations (lecture ancienne valeur + écriture nouvelle) en un seul appel atomique.
Recommandation Air. Préférer prlimit partout. getrlimit et setrlimit sont exposés pour compatibilité mais sont essentiellement des cas particuliers de prlimit.
Erreurs.
EFAULT: ne devrait pas arriver via l’API safe.EINVAL: ressource invalide ou limite invalide.EPERM: tentative de relever la hard limit sans privilèges.ESRCH: processus inexistant (pour prlimit).
Sous-section 7 : Capabilities
capget, capset
Signatures.
#![allow(unused)]
fn main() {
pub fn capget(target: CapabilityTarget) -> Result<CapabilitySet, Errno>;
pub fn capset(target: CapabilityTarget, set: &CapabilitySet) -> Result<(), Errno>;
pub enum CapabilityTarget {
CurrentThread,
Thread(Tid),
Process(Pid),
}
pub struct CapabilitySet {
pub effective: CapabilityMask,
pub permitted: CapabilityMask,
pub inheritable: CapabilityMask,
}
#[derive(Debug, Clone, Copy)]
pub struct CapabilityMask(u64);
#[derive(Debug, Clone, Copy)]
pub enum Capability {
Chown,
DacOverride,
DacReadSearch,
Fowner,
Fsetid,
Kill,
Setgid,
Setuid,
SysAdmin,
SysBoot,
SysChroot,
SysModule,
SysNice,
SysPtrace,
SysRawio,
SysResource,
SysTime,
NetAdmin,
NetBindService,
NetRaw,
// ... autres capabilities Linux
}
}
Syscalls sous-jacents. capget (x86_64 n°125, ARM64 n°90), capset (n°126, n°91).
Comportement.
Gestion des capabilities Linux : permissions fines remplaçant le modèle binaire root/non-root.
Trois ensembles par thread :
- Effective : capabilities actuellement utilisables.
- Permitted : capabilities que le thread peut activer.
- Inheritable : capabilities transmises à travers
execve.
Performance. ~1-2 µs.
Tests.
- Test capget courant : lire les capabilities, vérifier cohérence.
- Test capset drop : retirer une capability, vérifier qu’elle disparaît.
- Test EPERM : tenter d’élever une capability non permitted, vérifier l’erreur.
Sous-section 8 : exec / redirection (extension couche 1)
Extension additive pilotée par le contrat de la couche 1 (air-process) :
au-delà de la création de processus (clone3) et de l’exec
(execve/execveat, sous-section 2), lancer un programme exige de
marshaller ses arguments, de rediriger ses flux standard (dup3), de
fixer son répertoire de travail (fchdir/chdir) et de terminer
proprement l’enfant si l’exec échoue (exit_group, sous-section 3, promu
en primitive publique).
Marshalling argv / envp — CStrArray
execve/execveat attendent des tableaux char *const argv[] terminés par
un pointeur NULL. Le type CStrArray<'a> matérialise cette frontière de
marshalling à partir d’une tranche de CStr Rust sûrs :
#![allow(unused)]
fn main() {
pub struct CStrArray<'a> { /* Vec<*const c_char> NULL-terminé, emprunte 'a */ }
impl<'a> CStrArray<'a> {
pub fn new(items: &'a [&'a CStr]) -> Self; // tranche vide → { NULL }
pub fn len(&self) -> usize; // hors terminateur NULL
pub fn is_empty(&self) -> bool;
}
}
C’est la frontière fuzzée (fuzz/fuzz_targets/fuzz_exec_argv.rs, via
process::fuzz_api::marshal_exec_args) :
- rejet propre des NUL embarqués : un argument C ne peut pas contenir
d’octet NUL ; l’invariant est garanti en amont, au point où l’appelant
construit ses
&CStr(CStr::from_bytes_with_nul/CString::newrejettent un NUL embarqué) — pas de revalidation ici (« parse, don’t validate ») ; - totalité : construction sans panic pour toute liste (vide, longue) ;
- terminaison
NULLsystématique, ordre préservé.
L’allocation du tableau de pointeurs est l’exception ADR-021 §4 (buffer dynamique intrinsèque : taille = nombre d’arguments, inconnu à la compilation). Les chaînes elles-mêmes ne sont pas copiées.
dup3
Signature.
#![allow(unused)]
fn main() {
pub fn dup3(old: BorrowedFd<'_>, new: RawFd, flags: Dup3Flags) -> Result<OwnedFd, Errno>;
bitflags! { pub struct Dup3Flags: i32 { const CLOEXEC = 0o2_000_000; } }
}
Syscall sous-jacent. dup3 (x86_64 n°292, ARM64 n°24). Page man dup3(2).
Comportement. Variante moderne de dup2 (préférée, ADR-021) : duplique
old vers le numéro new en fermant atomiquement new s’il était ouvert, et
exige des drapeaux explicites (O_CLOEXEC est le seul valide). Cas d’usage
couche 1 : rediriger les flux standard d’un enfant (dup3(pipe, STDOUT_FILENO, …)) juste avant l’exec. À la différence de dup2, échoue avec EINVAL
si old == new (pas de no-op silencieux). Le new retourné est possédé
(OwnedFd, fermé au Drop).
Erreurs. EINVAL (flags invalides ou old == new), EBADF (new hors
plage RLIMIT_NOFILE), EBUSY (course rare), EINTR (remonté tel quel,
convention 2 ADR-021).
Tests. Redirection réussie sur un fd haut non réutilisable (résultat
possédé) ; old == new → EINVAL.
fchdir, chdir
Signatures.
#![allow(unused)]
fn main() {
pub fn fchdir(dir: BorrowedFd<'_>) -> Result<(), Errno>;
pub fn chdir(path: &CStr) -> Result<(), Errno>;
}
Syscalls sous-jacents. fchdir (x86_64 n°81, ARM64 n°50),
chdir (x86_64 n°80, ARM64 n°49).
Comportement. Fixent le répertoire de travail courant du processus. Cas
d’usage couche 1 : current_dir d’un enfant juste avant l’exec. Préférer
fchdir quand un fd de répertoire est disponible (pas de race sur le chemin).
Erreurs. fchdir : EBADF, ENOTDIR (le fd n’est pas un répertoire),
EACCES. chdir : ENOENT, ENOTDIR, EACCES, ENAMETOOLONG.
Tests. Erreurs en process (fchdir sur /dev/null → ENOTDIR ;
chdir sur chemin inexistant → ENOENT) ; succès dans un enfant forké (le
cwd est un état process-global : l’isolation par fork évite de perturber les
tests fs concurrents), vérifiés via /proc/self/cwd.
Récapitulatif de la famille process
Fonctions exposées :
| Catégorie | Fonctions principales |
|---|---|
| Identité | getpid, gettid |
| Création | clone3, execve, execveat |
| Attente | waitid, exit_group |
| Redirection / cwd | dup3, fchdir, chdir |
| Pidfd | pidfd_open, pidfd_send_signal, pidfd_getfd |
| Groupes | setpgid, getpgid, setsid, getsid |
| prctl | ~12 opérations individuelles |
| Limites | getrlimit, setrlimit, prlimit |
| Capabilities | capget, capset |
Total : 21 syscalls wrappés, plus les opérations prctl individuelles. (Le
marshalling argv/envp est exposé via le type CStrArray.)
Syscalls non-wrappés (listés dans UNSUPPORTED.md) :
fork,vfork: remplacés parclone3.clone(sans le 3) : remplacé parclone3.wait,wait3,wait4,waitpid: remplacés parwaitid.kill: exposé dans la famillesignal, pas dansprocess.tkill: deprecated, remplacé partgkill(dans famille signal)._exit: remplacé parexit_group.dup,dup2: remplacés pardup3(drapeaux explicites, refus du no-opold == new).- Opérations
prctlrares non listées ci-dessus.
Types ajoutés à air-sys-types
Pid,TidPidFd,PidFdReceiver,TidReceiverWaitTarget,WaitOptions,WaitStatus,WaitEventCloneFlags,CloneArgs,CloneResultSignal,SignalMask(utilisés aussi en famille signal)Capability,CapabilityMask,CapabilitySet,CapabilityTargetResource,Rlimit,RlimitValueDumpableModeExecveatFlags,Dup3Flags,PidFdOpenFlagsCStrArray(marshallingargv/envp, exposé parair-sys-syscall::process)
Soit ~20 types pour cette famille.
Décisions de fond émergées dans la famille process
1. Pid et Tid comme newtypes distincts.
Empêche par typage la confusion entre PID de processus et TID de thread. Application directe du Principe 7.
2. Option<Pid> pour “processus courant”.
Au lieu de la sentinelle kernel 0 qui signifie “le processus appelant”, Air utilise None. Plus clair, plus sûr, application de la convention 1 de l’ADR-021.
3. PidFd RAII avec ownership clair.
Le pidfd est encapsulé dans un type qui ferme automatiquement le FD à la destruction. Pas de fuite possible.
4. clone3 unsafe mais avec helper safe à venir.
L’API de bas niveau reste unsafe parce que CLONE_VM peut violer les invariants Rust. Un helper safe fork_process couvrant le cas commun (fork classique sans VM partagée) sera exposé en couche 1.
5. prctl exposé opération par opération.
Refus du multiplexé générique. Chaque opération prctl est une fonction dédiée typée. Coût en volume de code compensé par la clarté.
Licence du document : MPL 2.0
Statut : Spécification technique du module air-sys-syscall::process (couche 0).
Sous-section 7 : runtime & libc (ADR-051, re-sceau couche-0-v1.7)
Six syscalls ajoutés pour le runtime Air et l’objectif libc (cf.
ADR-051). Consommateurs :
les objets couche 1 — AirRuntime/ThreadControlBlock/ThreadLocalStorage et
air-process — jamais le runtime en direct (seule la couche 1 consomme la couche 0,
ADR-052). Le 7ᵉ syscall du lot,
arch_prctl, est x86_64-only et vit dans la famille arch
(family-arch.md).
getppid
#![allow(unused)]
fn main() {
pub fn getppid() -> Option<Pid>;
}
Syscall sous-jacent. getppid (x86_64 n°110, ARM64 n°173). getppid(2).
Retourne None quand le kernel rend 0 (parent non visible dans le PID
namespace) — la sentinelle 0 est re-présentée en Option (ADR-021 §1).
set_tid_address
#![allow(unused)]
fn main() {
pub unsafe fn set_tid_address(clear_child_tid: Option<&AtomicU32>) -> Tid;
}
Syscall sous-jacent. set_tid_address (x86_64 n°218, ARM64 n°96). Enregistre
clear_child_tid (mot mis à 0 + FUTEX_WAKE à la mort du thread — primitif de
join) et rend le TID appelant. unsafe : la mémoire pointée doit rester valide
jusqu’à la fin du thread (même contrat que CloneArgs::child_tid).
sched_yield
#![allow(unused)]
fn main() {
pub fn sched_yield();
}
Syscall sous-jacent. sched_yield (x86_64 n°24, ARM64 n°124). Cède le CPU.
Fonction totale (réussit toujours sous Linux).
umask
#![allow(unused)]
fn main() {
pub fn umask(new_mask: Mode) -> Mode;
}
Syscall sous-jacent. umask (x86_64 n°95, ARM64 n°166). Fixe le masque de
création de fichiers, rend l’ancien. Fonction totale.
getcwd
#![allow(unused)]
fn main() {
pub fn getcwd(buf: &mut [u8]) -> Result<&[u8], Errno>;
}
Syscall sous-jacent. getcwd (x86_64 n°79, ARM64 n°17). Écrit le chemin
absolu du répertoire courant dans buf (octets, jamais supposé UTF-8 — Principe 3 ;
buffer fourni, zéro alloc — ADR-021 §4) et rend la tranche sans le NUL final.
ERANGE si buf trop petit.
getrusage
#![allow(unused)]
fn main() {
pub fn getrusage(who: RusageWho) -> Result<Rusage, Errno>;
}
Syscall sous-jacent. getrusage (x86_64 n°98, ARM64 n°165). Statistiques de
ressources pour RusageWho::{SelfProcess, Children, Thread}. Rusage est
#[repr(C)] fidèle au kernel (2 Timeval + 14 long, 144 octets). Le bras
d’erreur est inatteignable via l’API typée (exception STRUCTURAL, cf.
COVERAGE-EXCEPTIONS.md).
Famille arch — syscalls spécifiques à l’architecture
Couche 0 — crate air-sys-syscall, module arch. Ajoutée par ADR-051 (re-sceau couche-0-v1.7).
Vue d’ensemble
Cette famille expose les syscalls dont la sémantique dépend de l’architecture.
À ce jour : arch_prctl(2), présent uniquement sur x86_64, pour programmer la
base du registre de segment %fs (registre TLS du thread).
L’objet ThreadLocalStorage (couche 1) — brique du runtime AirRuntime
(ADR-049, placement corrigé par
ADR-052) — s’en sert pour ancrer le
TCB du thread principal au registre TLS. (C’est cet objet couche 1 qui appelle
set_fs, pas le runtime en direct : seule la couche 1 consomme la couche 0 — ADR-052.)
Sur aarch64, le registre TLS (tpidr_el0) est inscriptible directement en EL0 par
une instruction msr (asm encapsulé dans l’objet ThreadLocalStorage couche 1,
pas un syscall) — d’où l’absence d’équivalent arch côté aarch64. Les threads
spawnés (toutes arches) reçoivent leur registre TLS du kernel via CLONE_SETTLS
(clone_thread, famille process).
set_fs / get_fs (x86_64 uniquement)
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")]
pub unsafe fn set_fs(base: usize) -> Result<(), Errno>;
#[cfg(target_arch = "x86_64")]
pub fn get_fs() -> Result<usize, Errno>;
}
Syscall sous-jacent. arch_prctl (x86_64 n°158) avec ARCH_SET_FS (0x1002)
ou ARCH_GET_FS (0x1003). Pas d’équivalent aarch64 (cf. ci-dessus). Conforme à
ADR-021 §3 : fonctions dédiées par opération, pas de wrapper arch_prctl(op, …)
multiplexé.
Sémantique. set_fs fixe la base de %fs ; après l’appel, tout accès
thread_local du thread résout via cette base. get_fs lit la base courante.
Sûreté. set_fs est unsafe : une base erronée corrompt tout l’état
par-thread (le contrat exige une image TLS valide). get_fs est sûr (lecture).
Erreurs. EINVAL/EPERM si la base est rejetée (p. ex. adresse non
canonique) — le kernel ne modifie alors pas %fs.
Tests. get_fs non nul (TLS libc présent) ; set_fs(get_fs()) (réécriture
no-op de la base courante → chemin succès sans casser le TLS) ; set_fs(non canonique) → erreur sans changer %fs. Le bras d’erreur de get_fs est
inatteignable par construction (buffer de pile valide) — exception STRUCTURAL,
cf. COVERAGE-EXCEPTIONS.md.
Spec couche 0 — Famille fs
Spécification technique — Version 1.0
Vue d’ensemble de la famille
Le module air-sys-syscall::fs expose les primitives kernel de gestion du filesystem : ouverture et fermeture de fichiers, lecture et écriture, métadonnées, opérations sur les répertoires, manipulations de chemins, opérations spéciales (truncate, fsync, fallocate, copy_file_range, splice, etc.).
C’est la plus grande famille de la couche 0 par volume, avec environ 35 syscalls wrappés.
Périmètre de la famille.
Catégories d’opérations :
- Ouverture et fermeture :
openat2,openat,close. - I/O synchrone :
read,write,pread,pwrite,readv,writev,preadv,pwritev. - Positionnement :
lseek. - Métadonnées :
statx,faccessat. - Répertoires :
mkdirat,getdents64,unlinkat,renameat2,linkat,symlinkat,readlinkat,mknodat. - Permissions :
fchmodat,fchownat,utimensat. - Opérations spéciales :
ftruncate,truncate,fsync,fdatasync,sync_file_range,fallocate,copy_file_range,splice,tee,vmsplice,flock,statfs,fstatfs. - fcntl (opérations exposées individuellement, cf. ADR-021 convention 3).
- Handles persistants :
name_to_handle_at,open_by_handle_at.
Caractéristiques transverses de la famille.
-
Variantes
*atpréférées systématiquement.openat2plutôt queopen,mkdiratplutôt quemkdir,unlinkatplutôt queunlink, etc. Les variantes*atprennent un FD de répertoire comme base, ce qui élimine les races sur les chemins. -
statxpréféré àfstat/fstatat/lstat/stat.statxest l’API moderne unifiée qui couvre tous les cas et permet de sélectionner les champs à récupérer. -
openat2préféré àopenat.openat2(Linux 5.6+) permet une résolution de chemin plus stricte via le champresolvedeOpenHow. Le wrapper Air détecte au runtime siopenat2est disponible et tombe suropenaten fallback. -
CLOEXEC par défaut universel. Tout FD ouvert via le wrapper Air a
O_CLOEXECactivé par défaut. Pour les rares cas où on veut désactiver CLOEXEC, l’option doit être passée explicitement. -
Type
DirFdpour les*at. Au lieu d’utiliserAT_FDCWD(sentinelle kernel-100), Air expose un typeDirFd:
#![allow(unused)]
fn main() {
pub enum DirFd<'fd> {
Cwd,
Fd(BorrowedFd<'fd>),
}
}
Cohérent avec la convention 1 de l’ADR-021.
- Pas de support des chemins relatifs sans
*at. Les fonctions wrapper exigent une base explicite (DirFd::Cwdou un FD), pas de magie sur le cwd implicite.
Types transverses utilisés.
OwnedFd,BorrowedFd: ownership des FDs.DirFd: base pour les opérations*at.OpenFlags,Mode: flags d’ouverture et permissions.OpenHow: structure complète pouropenat2.StatxResult,StatxMask: résultats et masques destatx.Errno: erreurs.
Sous-section 1 : Ouverture et fermeture
openat2
Signature.
#![allow(unused)]
fn main() {
pub fn openat2(
dirfd: DirFd<'_>,
path: &CStr,
how: OpenHow,
) -> Result<OwnedFd, Errno>;
#[derive(Debug, Clone, Copy, Default)]
pub struct OpenHow {
pub flags: OpenFlags,
pub mode: Mode,
pub resolve: ResolveFlags,
}
bitflags! {
pub struct OpenFlags: u64 {
const RDONLY = 0;
const WRONLY = 1;
const RDWR = 2;
const CREAT = 0o100;
const EXCL = 0o200;
const NOCTTY = 0o400;
const TRUNC = 0o1000;
const APPEND = 0o2000;
const NONBLOCK = 0o4000;
const DSYNC = 0o10000;
const ASYNC = 0o20000;
const DIRECT = 0o40000;
const LARGEFILE = 0o100000;
const DIRECTORY = 0o200000;
const NOFOLLOW = 0o400000;
const NOATIME = 0o1000000;
const CLOEXEC = 0o2000000;
const PATH = 0o10000000;
const TMPFILE = 0o20200000;
}
}
bitflags! {
pub struct ResolveFlags: u64 {
const NO_XDEV = 0x01;
const NO_MAGICLINKS = 0x02;
const NO_SYMLINKS = 0x04;
const BENEATH = 0x08;
const IN_ROOT = 0x10;
const CACHED = 0x20;
}
}
pub type Mode = u32;
}
Syscall sous-jacent. openat2 (x86_64 n°437, ARM64 n°437). Disponible depuis Linux 5.6 (mars 2020).
Préconditions.
path est une chaîne C-compatible (terminée par NUL). Pour les ouvertures à FD direct (sans path relatif), passer c"".
Le wrapper Air ajoute systématiquement OpenFlags::CLOEXEC aux flags, sauf si l’appelant a explicitement utilisé un mode raw qui désactive cette discipline.
Comportement.
Ouvre un fichier (ou un répertoire avec DIRECTORY, ou un FD path-only avec PATH). La résolution du chemin est contrôlée par resolve :
ResolveFlags::BENEATH: refuse de résoudre au-delà dudirfd. Empêche les attaques par “../../”.ResolveFlags::NO_SYMLINKS: refuse de suivre les liens symboliques.ResolveFlags::NO_MAGICLINKS: refuse les liens magiques (/proc/self/fd/X).ResolveFlags::IN_ROOT: traitedirfdcomme une nouvelle racine.
Ces flags permettent une discipline stricte de résolution, utile pour les contextes sandboxés.
Pattern recommandé.
#![allow(unused)]
fn main() {
let how = OpenHow {
flags: OpenFlags::RDONLY,
mode: 0,
resolve: ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS,
};
let fd = openat2(DirFd::Cwd, c"./config.toml", how)?;
}
Erreurs.
EACCES: permissions insuffisantes.EEXIST: fichier existe etEXCLétait demandé.EISDIR: tentative d’ouvrir un répertoire en écriture sansDIRECTORY.ENOENT: chemin inexistant.ENOTDIR: un composant du chemin n’est pas un répertoire.EXDEV: violation deNO_XDEV.EAGAIN: violation deBENEATHouNO_SYMLINKS(résolution refusée).
Performance. ~5-20 µs selon la complexité du path.
Tests.
- Test ouverture en lecture : fichier existant, vérifier FD valide et CLOEXEC.
- Test ENOENT : chemin inexistant.
- Test BENEATH : tentative de remonter avec
../, vérifier EAGAIN.
openat (fallback)
Signature.
#![allow(unused)]
fn main() {
pub fn openat(
dirfd: DirFd<'_>,
path: &CStr,
flags: OpenFlags,
mode: Mode,
) -> Result<OwnedFd, Errno>;
}
Comportement.
Version sans resolve. Pour les kernels qui ne supportent pas openat2 (< 5.6). Le wrapper Air utilise openat automatiquement si openat2 retourne ENOSYS.
close
Signature.
#![allow(unused)]
fn main() {
pub fn close(fd: OwnedFd) -> Result<(), Errno>;
}
Comportement.
Ferme un FD. Le wrapper consomme l’OwnedFd (prend l’ownership), garantissant qu’il ne peut pas être réutilisé après close.
Note. Le Drop automatique d’OwnedFd appelle close en silence (ignorant les erreurs). La fonction explicite close permet de récupérer une erreur éventuelle (rare mais possible : EIO, EINTR sur certains systèmes).
Sous-section 2 : I/O synchrone
read, write
Signatures.
#![allow(unused)]
fn main() {
pub fn read(fd: BorrowedFd<'_>, buf: &mut [u8]) -> Result<usize, Errno>;
pub fn write(fd: BorrowedFd<'_>, buf: &[u8]) -> Result<usize, Errno>;
}
Syscalls sous-jacents. read (x86_64 n°0, ARM64 n°63), write (x86_64 n°1, ARM64 n°64).
Comportement.
Lecture/écriture séquentielle au FD. Retourne le nombre d’octets effectivement lus/écrits, qui peut être inférieur à buf.len() (read partiel pour les sockets, pipes, fichiers en fin).
Erreurs courantes.
EAGAIN: FD non-bloquant et pas de données disponibles (ou pipe plein).EINTR: interrompu par signal (cf. ADR-021 convention 2).EIO: erreur I/O bas niveau.EBADF: FD invalide.
pread, pwrite
Signatures.
#![allow(unused)]
fn main() {
pub fn pread(fd: BorrowedFd<'_>, buf: &mut [u8], offset: u64) -> Result<usize, Errno>;
pub fn pwrite(fd: BorrowedFd<'_>, buf: &[u8], offset: u64) -> Result<usize, Errno>;
}
Comportement.
Variantes positionnées : lisent/écrivent à un offset spécifique sans modifier la position courante du FD. Sûres pour usage concurrent depuis plusieurs threads.
readv, writev, preadv, pwritev
Signatures.
#![allow(unused)]
fn main() {
pub fn readv(fd: BorrowedFd<'_>, iov: &mut [IoSliceMut<'_>]) -> Result<usize, Errno>;
pub fn writev(fd: BorrowedFd<'_>, iov: &[IoSlice<'_>]) -> Result<usize, Errno>;
pub fn preadv(fd: BorrowedFd<'_>, iov: &mut [IoSliceMut<'_>], offset: u64) -> Result<usize, Errno>;
pub fn pwritev(fd: BorrowedFd<'_>, iov: &[IoSlice<'_>], offset: u64) -> Result<usize, Errno>;
}
Comportement.
Variantes scatter-gather : lecture/écriture vers/depuis plusieurs buffers en un seul syscall. Utile pour les protocoles avec header + payload.
Sous-section 3 : Positionnement
lseek
Signature.
#![allow(unused)]
fn main() {
pub fn lseek(fd: BorrowedFd<'_>, offset: i64, whence: SeekWhence) -> Result<u64, Errno>;
pub enum SeekWhence {
Set,
Current,
End,
Data, // Linux-specific: next data after offset
Hole, // Linux-specific: next hole after offset
}
}
Comportement.
Modifie la position courante d’un FD seekable. Retourne la nouvelle position absolue.
Sous-section 4 : Métadonnées
statx
Signature.
#![allow(unused)]
fn main() {
pub fn statx(
dirfd: DirFd<'_>,
path: &CStr,
flags: StatxFlags,
mask: StatxMask,
) -> Result<StatxResult, Errno>;
bitflags! {
pub struct StatxFlags: u32 {
const EMPTY_PATH = 0x1000;
const NO_AUTOMOUNT = 0x800;
const SYMLINK_NOFOLLOW = 0x100;
}
}
bitflags! {
pub struct StatxMask: u32 {
const TYPE = 0x0001;
const MODE = 0x0002;
const NLINK = 0x0004;
const UID = 0x0008;
const GID = 0x0010;
const ATIME = 0x0020;
const MTIME = 0x0040;
const CTIME = 0x0080;
const INO = 0x0100;
const SIZE = 0x0200;
const BLOCKS = 0x0400;
const BTIME = 0x0800;
const MNT_ID = 0x1000;
const ALL = 0xfff;
}
}
pub struct StatxResult {
pub mask: StatxMask,
pub blksize: u32,
pub attributes: u64,
pub nlink: u32,
pub uid: u32,
pub gid: u32,
pub mode: u16,
pub ino: u64,
pub size: u64,
pub blocks: u64,
pub atime: StatxTimestamp,
pub btime: StatxTimestamp,
pub ctime: StatxTimestamp,
pub mtime: StatxTimestamp,
pub rdev_major: u32,
pub rdev_minor: u32,
pub dev_major: u32,
pub dev_minor: u32,
pub mount_id: u64,
}
pub struct StatxTimestamp {
pub seconds: i64,
pub nanoseconds: u32,
}
}
Syscall sous-jacent. statx (x86_64 n°332, ARM64 n°291). Disponible depuis Linux 4.11.
Comportement.
Lit les métadonnées d’un fichier. mask sélectionne les champs souhaités (perf : le kernel ne lit que ce qui est demandé). Le champ mask du résultat indique les champs effectivement remplis (peut être un sous-ensemble du mask demandé si certaines informations ne sont pas disponibles).
Pattern.
#![allow(unused)]
fn main() {
let result = statx(
DirFd::Cwd,
c"./config.toml",
StatxFlags::empty(),
StatxMask::SIZE | StatxMask::MTIME,
)?;
if result.mask.contains(StatxMask::SIZE) {
println!("Size: {}", result.size);
}
}
faccessat
Signature.
#![allow(unused)]
fn main() {
pub fn faccessat(
dirfd: DirFd<'_>,
path: &CStr,
mode: AccessMode,
flags: AccessFlags,
) -> Result<(), Errno>;
bitflags! {
pub struct AccessMode: u32 {
const F_OK = 0;
const R_OK = 4;
const W_OK = 2;
const X_OK = 1;
}
}
bitflags! {
pub struct AccessFlags: i32 {
const EACCESS = 0x200;
const SYMLINK_NOFOLLOW = 0x100;
}
}
}
Comportement.
Teste les permissions d’accès à un fichier sans l’ouvrir. Sujet à des races (le résultat peut être obsolète au moment où on agit dessus) : préférer ouvrir directement et gérer l’erreur.
Routage faccessat / faccessat2 (honorer les flags).
Le syscall noyau faccessat historique ne prend que 3 arguments
(dirfd, path, mode) : il ignore silencieusement tout flag
comportemental passé par-dessus. Honorer AT_SYMLINK_NOFOLLOW (0x100) /
AT_EACCESS (0x200) exige le syscall faccessat2 (numéro 439 sur x86_64
et aarch64, Linux 5.8+, 4 arguments). Comme la glibc, le wrapper route donc
selon la valeur de flags, sans changer sa signature publique :
flags | Syscall noyau | Numéro (x86_64 / aarch64) |
|---|---|---|
empty() | faccessat (3-arg) | 269 / 48 |
| non vide | faccessat2 (4-arg) | 439 / 439 |
Le chemin flags-vide conserve le syscall 3-arg, universellement disponible.
Le chemin flags-non-vide emprunte faccessat2, le seul qui applique
réellement les flags ; sur un noyau < 5.8 dépourvu de faccessat2, un appel
avec flags retourne ENOSYS (jamais un résultat où le flag aurait été ignoré
en silence — la garantie « on n’implémente pas un flag qu’on ignore »).
Sous-section 5 : Répertoires
mkdirat
#![allow(unused)]
fn main() {
pub fn mkdirat(dirfd: DirFd<'_>, path: &CStr, mode: Mode) -> Result<(), Errno>;
}
getdents64
#![allow(unused)]
fn main() {
pub fn getdents64(fd: BorrowedFd<'_>, buf: &mut [u8]) -> Result<usize, Errno>;
pub struct DirEntry {
pub inode: u64,
pub offset: i64,
pub file_type: DirEntryType,
pub name: Vec<u8>, // octets bruts (sans NUL) — cf. note std-free ci-dessous
}
pub enum DirEntryType {
Unknown,
Fifo,
Character,
Directory,
Block,
Regular,
Symlink,
Socket,
}
pub struct DirEntryIter<'buf> { /* ... */ }
impl<'buf> Iterator for DirEntryIter<'buf> {
type Item = DirEntry<'buf>;
// ...
}
pub fn parse_dirents<'buf>(buf: &'buf [u8], length: usize) -> DirEntryIter<'buf>;
}
Comportement.
Lit les entrées d’un répertoire ouvert avec DIRECTORY. Le format binaire kernel est complexe ; le wrapper fournit un itérateur typé.
Note
std-free (ADR-048, re-sceau couche-0-v1.6).DirEntry.nameest une copie propriétaire d’octets bruts (alloc::vec::Vec<u8>), sans leNULterminateur. Le design initial (&'buf CStremprunté) supposaitcore::ffi::CStret un emprunt sur le buffer ; l’implémentation a retenu une copie possédée pour découpler l’entrée du buffer de lecture (réutilisable entre appelsgetdents64). Le type octets (et nonOsString/String) honore la doctrine « chemins = octets » : sur Unix un nom de fichier est une suite d’octets opaque, potentiellement non-UTF-8 (Principe 3, zéro présomption d’encodage).parse_direntsrendimpl Iterator<Item = DirEntry>(entrées possédées, pas de paramètre de durée de vie surDirEntry).
unlinkat, renameat2, linkat, symlinkat, readlinkat, mknodat
Signatures classiques, suivant la convention *at. Détails standards.
renameat2
#![allow(unused)]
fn main() {
pub fn renameat2(
old_dirfd: DirFd<'_>,
old_path: &CStr,
new_dirfd: DirFd<'_>,
new_path: &CStr,
flags: RenameFlags,
) -> Result<(), Errno>;
bitflags! {
pub struct RenameFlags: u32 {
const NOREPLACE = 1;
const EXCHANGE = 2;
const WHITEOUT = 4;
}
}
}
RenameFlags::EXCHANGE est particulièrement utile pour les updates atomiques (échanger deux fichiers atomiquement).
Sous-section 6 : Permissions
fchmodat, fchownat, utimensat
Signatures classiques. utimensat peut prendre UTIME_NOW ou UTIME_OMIT comme valeurs spéciales pour les timestamps, exprimées via un enum typé :
#![allow(unused)]
fn main() {
pub enum UtimeValue {
Now,
Omit,
Time(StatxTimestamp),
}
}
Sous-section 7 : Opérations spéciales
ftruncate, truncate, fsync, fdatasync
Signatures standards.
fallocate
#![allow(unused)]
fn main() {
pub fn fallocate(
fd: BorrowedFd<'_>,
mode: FallocateMode,
offset: i64,
length: i64,
) -> Result<(), Errno>;
bitflags! {
pub struct FallocateMode: i32 {
const KEEP_SIZE = 0x01;
const PUNCH_HOLE = 0x02;
const COLLAPSE_RANGE = 0x08;
const ZERO_RANGE = 0x10;
const INSERT_RANGE = 0x20;
const UNSHARE_RANGE = 0x40;
}
}
}
copy_file_range
#![allow(unused)]
fn main() {
pub fn copy_file_range(
fd_in: BorrowedFd<'_>,
off_in: Option<&mut u64>,
fd_out: BorrowedFd<'_>,
off_out: Option<&mut u64>,
length: usize,
flags: u32,
) -> Result<usize, Errno>;
}
Copie zero-copy entre fichiers (sur le même filesystem si possible).
splice, tee, vmsplice
Couvert dans la famille ipc mais référencé ici parce que ce sont aussi des opérations FS.
flock, statfs, fstatfs
Signatures standards.
Sous-section 8 : fcntl (opérations individuelles)
Comme prctl, fcntl est un syscall multiplexé. Air l’expose opération par opération conformément à la convention 3 de l’ADR-021 :
#![allow(unused)]
fn main() {
// F_DUPFD, F_DUPFD_CLOEXEC
pub fn dup_fd(fd: BorrowedFd<'_>, min_fd: RawFd) -> Result<OwnedFd, Errno>;
// F_GETFD, F_SETFD
pub fn get_fd_flags(fd: BorrowedFd<'_>) -> Result<FdFlags, Errno>;
pub fn set_fd_flags(fd: BorrowedFd<'_>, flags: FdFlags) -> Result<(), Errno>;
// F_GETFL, F_SETFL
pub fn get_status_flags(fd: BorrowedFd<'_>) -> Result<StatusFlags, Errno>;
pub fn set_status_flags(fd: BorrowedFd<'_>, flags: StatusFlags) -> Result<(), Errno>;
// F_SETPIPE_SZ, F_GETPIPE_SZ
pub fn set_pipe_size(fd: BorrowedFd<'_>, size: usize) -> Result<usize, Errno>;
pub fn get_pipe_size(fd: BorrowedFd<'_>) -> Result<usize, Errno>;
// F_SETLK, F_SETLKW, F_GETLK
pub fn try_lock(fd: BorrowedFd<'_>, lock: &FileLock) -> Result<bool, Errno>;
pub fn lock(fd: BorrowedFd<'_>, lock: &FileLock) -> Result<(), Errno>;
// F_ADD_SEALS, F_GET_SEALS (pour memfd)
pub fn add_seals(fd: BorrowedFd<'_>, seals: Seals) -> Result<(), Errno>;
pub fn get_seals(fd: BorrowedFd<'_>) -> Result<Seals, Errno>;
// ... autres opérations selon besoins Air
}
Sous-section 9 : Handles persistants
name_to_handle_at, open_by_handle_at
#![allow(unused)]
fn main() {
pub fn name_to_handle_at(
dirfd: DirFd<'_>,
path: &CStr,
flags: NameToHandleFlags,
) -> Result<FileHandle, Errno>;
pub fn open_by_handle_at(
mount_fd: BorrowedFd<'_>,
handle: &FileHandle,
flags: OpenFlags,
) -> Result<OwnedFd, Errno>;
}
Permet de référencer un fichier par un handle opaque persistant (même après changement de chemin). Cas d’usage avancé.
Récapitulatif de la famille fs
Fonctions exposées : ~35 syscalls principaux + opérations fcntl individuelles.
| Catégorie | Fonctions principales |
|---|---|
| Ouverture | openat2, openat, close |
| I/O sync | read, write, pread, pwrite, readv, writev, preadv, pwritev |
| Position | lseek |
| Métadonnées | statx, faccessat |
| Répertoires | mkdirat, getdents64, unlinkat, renameat2, linkat, symlinkat, readlinkat, mknodat |
| Permissions | fchmodat, fchownat, utimensat |
| Spéciales | ftruncate, truncate, fsync, fdatasync, fallocate, copy_file_range, flock, statfs, fstatfs |
| fcntl | ~10 opérations individuelles |
| Handles | name_to_handle_at, open_by_handle_at |
Syscalls non-wrappés (listés dans UNSUPPORTED.md) :
open(sansat) : remplacé paropenat2/openat.stat,fstat,lstat,fstatat: remplacés parstatx.mkdir(sansat) : remplacé parmkdirat.unlink,rmdir(sansat) : remplacés parunlinkat.rename,renameat: remplacés parrenameat2.link,symlink,readlink,mknod,access(sansat) : remplacés par variantesat.chmod,chown,utimes,lchown: remplacés par variantesat.getdents(sans 64) : deprecated, remplacé pargetdents64.chdir,fchdir: Air n’expose pas la manipulation du cwd implicite (cf. discussion convention).
Types ajoutés à air-sys-types
DirFdOpenFlags,Mode,OpenHow,ResolveFlagsStatxResult,StatxMask,StatxFlags,StatxTimestampAccessMode,AccessFlagsRenameFlags,FallocateModeSeekWhenceDirEntry,DirEntryType,DirEntryIterUtimeValueFdFlags,StatusFlags,FileLock,SealsFileHandleNameToHandleFlagsIoSlice,IoSliceMut
Soit ~25 types pour cette famille, portant le total de air-sys-types à ~45 types après les familles process et fs.
Décisions de fond émergées dans la famille fs
1. Variantes *at exclusives.
Pas de wrapper pour les variantes non-at. Toute opération filesystem prend une base explicite (DirFd::Cwd ou FD). Cohérent avec la sécurité et la convention 1 de l’ADR-021.
2. statx comme unique fonction de métadonnées.
stat, fstat, lstat, fstatat ne sont pas wrappés. statx les couvre tous, plus performant grâce au mask.
3. openat2 préféré, fallback openat transparent.
Le wrapper bascule automatiquement si openat2 retourne ENOSYS. Pas de friction côté appelant.
4. CLOEXEC universel.
Tout FD ouvert via Air a CLOEXEC. Pour les rares exceptions (héritage volontaire vers un fork), une API raw existe.
5. Pas de manipulation du cwd.
chdir/fchdir non exposés. Le cwd est une variable globale du processus, source de bugs en code concurrent. Air préfère que les chemins relatifs partent d’un DirFd explicite.
Licence du document : MPL 2.0
Statut : Spécification technique du module air-sys-syscall::fs (couche 0).
Spec couche 0 — Famille mem
Spécification technique — Version 1.0
Vue d’ensemble de la famille
Le module air-sys-syscall::mem expose les primitives kernel de gestion mémoire : mappings (mmap/munmap), protection (mprotect), conseils kernel (madvise), verrouillage en mémoire (mlock), memfd, et accès mémoire inter-processus (process_vm_readv/process_vm_writev).
C’est une famille moyenne (13 syscalls) mais critique : la gestion mémoire est au cœur de la performance et de la sécurité, particulièrement pour des composants comme le data plane de AirCom (qui utilise memfd + mmap pour le zero-copy).
Périmètre de la famille.
Catégories d’opérations :
- Mappings :
mmap,munmap,mremap. - Protection :
mprotect. - Conseils kernel :
madvise. - Verrouillage :
mlock,mlock2,munlock,mlockall,munlockall. - memfd :
memfd_create,memfd_secret. - Synchronisation :
msync. - Accès inter-processus :
process_vm_readv,process_vm_writev.
Caractéristiques transverses de la famille.
-
Type
MappingRAII. Les mappings retournés parmmapsont encapsulés dans un typeMappingqui appellemunmapà la destruction. Pas de fuite de mémoire mappée possible. -
Distinction entre mappings anonymes et fichiers. L’API typée distingue les deux cas via des fonctions séparées (
mmap_anonymous,mmap_file). -
Page size dynamique sur ARM64. Les Raspberry Pi et autres ARM64 peuvent avoir des page sizes différentes (4 KB, 16 KB, 64 KB selon kernel). Le wrapper Air détecte la page size à l’initialisation via
sysconf(_SC_PAGESIZE)et l’expose comme constante runtime (pasconst). -
Alignement vérifié. Les opérations qui demandent un alignement sur page size sont vérifiées en amont. Pas d’
EINVALopaque kernel. -
memfd au cœur de AirCom.
memfd_createest la primitive sur laquelle est construit le data plane de l’IPC AirCom (ADR-001). Spec détaillée.
Sous-section 1 : Mappings
mmap (typé)
Plutôt qu’un wrapper unique sur mmap (qui est multiplexé selon les flags), Air expose des fonctions typées par cas d’usage.
mmap_anonymous
Signature.
#![allow(unused)]
fn main() {
pub fn mmap_anonymous(
length: usize,
prot: ProtectionFlags,
flags: MapFlags,
) -> Result<Mapping, Errno>;
pub struct Mapping {
addr: NonNull<u8>,
length: usize,
// Drop appelle munmap automatiquement
}
impl Mapping {
pub fn as_ptr(&self) -> *const u8;
pub fn as_mut_ptr(&mut self) -> *mut u8;
pub fn len(&self) -> usize;
pub fn as_slice(&self) -> &[u8];
pub unsafe fn as_mut_slice(&mut self) -> &mut [u8];
}
bitflags! {
pub struct ProtectionFlags: i32 {
const NONE = 0;
const READ = 1;
const WRITE = 2;
const EXEC = 4;
}
}
bitflags! {
pub struct MapFlags: i32 {
const SHARED = 0x01;
const PRIVATE = 0x02;
const FIXED = 0x10;
const ANONYMOUS = 0x20;
const GROWSDOWN = 0x0100;
const DENYWRITE = 0x0800;
const EXECUTABLE = 0x1000;
const LOCKED = 0x2000;
const NORESERVE = 0x4000;
const POPULATE = 0x8000;
const NONBLOCK = 0x10000;
const STACK = 0x20000;
const HUGETLB = 0x40000;
const SYNC = 0x80000;
const FIXED_NOREPLACE = 0x100000;
}
}
}
Syscall sous-jacent. mmap (x86_64 n°9, ARM64 n°222).
Comportement.
Crée un mapping anonyme (sans fichier sous-jacent). Le wrapper ajoute MAP_ANONYMOUS | MAP_PRIVATE au flags par défaut.
len est arrondi à la page size supérieure par le kernel. Le wrapper Air ne fait pas l’arrondi explicitement (l’appelant peut le faire s’il veut connaître la taille effective).
Pattern.
#![allow(unused)]
fn main() {
let mapping = mmap_anonymous(
4096,
ProtectionFlags::READ | ProtectionFlags::WRITE,
MapFlags::PRIVATE, // ANONYMOUS implicite
)?;
// Utilisation du mapping
// Le munmap est automatique au drop
}
Erreurs.
EINVAL: taille invalide ou flags invalides.ENOMEM: pas de mémoire disponible.EACCES: permissions insuffisantes.
Performance. Très variable. ~10-100 µs selon la taille et la mémoire système.
mmap_file
Signature.
#![allow(unused)]
fn main() {
pub fn mmap_file(
fd: BorrowedFd<'_>,
length: usize,
offset: u64,
prot: ProtectionFlags,
flags: MapFlags,
) -> Result<Mapping, Errno>;
}
Comportement.
Crée un mapping basé sur un fichier. offset doit être aligné sur page size.
Cas d’usage typique.
#![allow(unused)]
fn main() {
let fd = openat2(DirFd::Cwd, c"./data.bin", OpenHow::read_only())?;
let stat = statx(DirFd::Fd(fd.as_fd()), c"", StatxFlags::EMPTY_PATH, StatxMask::SIZE)?;
let mapping = mmap_file(
fd.as_fd(),
stat.size as usize,
0,
ProtectionFlags::READ,
MapFlags::PRIVATE,
)?;
let data: &[u8] = mapping.as_slice();
}
mmap_fixed
Signature.
#![allow(unused)]
fn main() {
pub unsafe fn mmap_fixed(
addr: NonNull<u8>,
length: usize,
prot: ProtectionFlags,
flags: MapFlags,
fd: Option<BorrowedFd<'_>>,
offset: u64,
) -> Result<MappingPointer, Errno>;
}
Comportement.
Mappe à une adresse spécifique. unsafe parce que peut écraser des mappings existants si MAP_FIXED_NOREPLACE n’est pas utilisé.
Cas d’usage rare et avancé : implémentation de runtime, allocator custom, JIT.
munmap
Signature.
#![allow(unused)]
fn main() {
pub fn munmap(mapping: Mapping) -> Result<(), Errno>;
}
Comportement.
Démappe explicitement. Consomme le Mapping. Le Drop automatique appelle aussi munmap (en ignorant l’erreur) ; la fonction explicite permet de récupérer une erreur éventuelle.
mremap
Signature.
#![allow(unused)]
fn main() {
pub fn mremap(
mapping: Mapping,
new_length: usize,
flags: MremapFlags,
) -> Result<Mapping, Errno>;
bitflags! {
pub struct MremapFlags: i32 {
const MAYMOVE = 1;
const FIXED = 2;
const DONTUNMAP = 4;
}
}
}
Comportement.
Redimensionne un mapping. Avec MAYMOVE, le kernel peut déplacer le mapping en mémoire si le redimensionnement sur place est impossible.
Sous-section 2 : Protection
mprotect
Signature.
#![allow(unused)]
fn main() {
pub fn mprotect(
addr: NonNull<u8>,
length: usize,
prot: ProtectionFlags,
) -> Result<(), Errno>;
}
Comportement.
Change les permissions d’une plage mémoire mappée. Pattern typique : allouer en READ+WRITE, initialiser, puis passer en READ uniquement pour les données constantes.
Erreurs.
EINVAL: plage invalide ou pas alignée.EACCES: tentative de gagner une permission interdite (typiquement EXEC sur du mapped depuis un FS no-exec).ENOMEM: ressources kernel insuffisantes.
Sous-section 3 : Conseils kernel
madvise
Signature.
#![allow(unused)]
fn main() {
pub fn madvise(
addr: NonNull<u8>,
length: usize,
advice: MadviseAdvice,
) -> Result<(), Errno>;
#[derive(Debug, Clone, Copy)]
pub enum MadviseAdvice {
Normal,
Random,
Sequential,
WillNeed,
DontNeed,
Free,
Remove,
DontFork,
DoFork,
Mergeable,
Unmergeable,
HugePage,
NoHugePage,
DontDump,
DoDump,
WipeOnFork,
KeepOnFork,
Cold,
PageOut,
PopulateRead,
PopulateWrite,
DontNeedLocked,
Collapse,
}
}
Comportement.
Informe le kernel sur le pattern d’accès attendu à une plage mémoire. Le kernel peut alors optimiser (préfetch, swapping, transparent huge pages, etc.).
Cas courants :
Sequential+WillNeedpour des reads séquentiels intensifs.Randompour des accès aléatoires (évite le préfetch contre-productif).DontNeedpour libérer des pages dont on n’aura plus besoin sans démapper.
Sous-section 4 : Verrouillage
mlock, mlock2, munlock, mlockall, munlockall
#![allow(unused)]
fn main() {
pub fn mlock(addr: NonNull<u8>, length: usize) -> Result<(), Errno>;
pub fn mlock2(addr: NonNull<u8>, length: usize, flags: MlockFlags) -> Result<(), Errno>;
pub fn munlock(addr: NonNull<u8>, length: usize) -> Result<(), Errno>;
pub fn mlockall(flags: MlockallFlags) -> Result<(), Errno>;
pub fn munlockall() -> Result<(), Errno>;
bitflags! {
pub struct MlockFlags: u32 {
const ONFAULT = 1;
}
}
bitflags! {
pub struct MlockallFlags: i32 {
const CURRENT = 1;
const FUTURE = 2;
const ONFAULT = 4;
}
}
}
Comportement.
Verrouille des pages mémoire pour empêcher leur swap. Cas d’usage : sécurité (données sensibles qui ne doivent pas finir sur disque swap), temps réel (latences prévisibles).
Limites : RLIMIT_MEMLOCK borne ce qu’un processus non-privilégié peut verrouiller.
Sous-section 5 : memfd
memfd_create
Signature.
#![allow(unused)]
fn main() {
pub fn memfd_create(name: &CStr, flags: MemfdFlags) -> Result<OwnedFd, Errno>;
bitflags! {
pub struct MemfdFlags: u32 {
const CLOEXEC = 1;
const ALLOW_SEALING = 2;
const HUGETLB = 4;
const NOEXEC_SEAL = 8;
const EXEC = 0x10;
}
}
}
Syscall sous-jacent. memfd_create (x86_64 n°319, ARM64 n°279). Disponible depuis Linux 3.17.
Comportement.
Crée un FD vers une zone mémoire anonyme (un “memory file”). Le FD peut être :
- Étendu avec
ftruncate. - Mmapé pour partage entre processus.
- Passé entre processus via SCM_RIGHTS sur un Unix socket.
- Scellé avec
fcntl(F_ADD_SEALS)pour interdire certaines opérations.
C’est la primitive centrale pour la mémoire partagée moderne sur Linux. Au cœur du data plane de AirCom (ADR-001).
Pattern : partage de données entre processus.
#![allow(unused)]
fn main() {
// Processus A : créer un memfd, le remplir, le partager
let fd = memfd_create(c"shared-data", MemfdFlags::CLOEXEC | MemfdFlags::ALLOW_SEALING)?;
ftruncate(fd.as_fd(), 4096)?;
let mapping = mmap_file(fd.as_fd(), 4096, 0, ProtectionFlags::READ | ProtectionFlags::WRITE, MapFlags::SHARED)?;
// ... remplir le mapping ...
// Sceller pour interdire modifications ultérieures
add_seals(fd.as_fd(), Seals::SEAL_WRITE | Seals::SEAL_SHRINK | Seals::SEAL_GROW)?;
// Envoyer le FD via Unix socket
sendmsg(socket, &[fd.as_fd()], ...)?;
// Processus B : recevoir le FD, mmap en lecture
let received_fd: OwnedFd = recvmsg(socket, ...)?;
let mapping = mmap_file(received_fd.as_fd(), 4096, 0, ProtectionFlags::READ, MapFlags::SHARED)?;
// Lire les données partagées
}
memfd_secret
Signature.
#![allow(unused)]
fn main() {
pub fn memfd_secret(flags: u32) -> Result<OwnedFd, Errno>;
}
Syscall sous-jacent. memfd_secret (x86_64 n°447, ARM64 n°447). Disponible depuis Linux 5.14.
Comportement.
Variante de memfd_create qui crée une zone mémoire non accessible au kernel après le mapping. Utilisé pour les secrets cryptographiques de haute valeur.
Sous-section 6 : Synchronisation
msync
#![allow(unused)]
fn main() {
pub fn msync(
addr: NonNull<u8>,
length: usize,
flags: MsyncFlags,
) -> Result<(), Errno>;
bitflags! {
pub struct MsyncFlags: i32 {
const ASYNC = 1;
const SYNC = 4;
const INVALIDATE = 2;
}
}
}
Comportement.
Force la synchronisation des modifications d’un mapping vers le fichier sous-jacent. Pour les mappings MAP_SHARED sur fichier.
Sous-section 7 : Accès inter-processus
process_vm_readv, process_vm_writev
#![allow(unused)]
fn main() {
pub fn process_vm_readv(
pid: Pid,
local_iov: &mut [IoSliceMut<'_>],
remote_iov: &[RemoteIoSlice],
flags: u32,
) -> Result<usize, Errno>;
pub fn process_vm_writev(
pid: Pid,
local_iov: &[IoSlice<'_>],
remote_iov: &[RemoteIoSlice],
flags: u32,
) -> Result<usize, Errno>;
pub struct RemoteIoSlice {
pub addr: u64,
pub len: usize,
}
}
Comportement.
Permet de lire/écrire la mémoire d’un autre processus sans ptrace. Demande des permissions (typiquement CAP_SYS_PTRACE ou même utilisateur que la cible).
Cas d’usage spécialisé : debuggers, outils de surveillance, transfert de buffer entre processus coopérant sans copie.
Récapitulatif de la famille mem
Fonctions exposées :
| Catégorie | Fonctions principales |
|---|---|
| Mappings | mmap_anonymous, mmap_file, mmap_fixed, munmap, mremap |
| Protection | mprotect |
| Conseils | madvise |
| Verrouillage | mlock, mlock2, munlock, mlockall, munlockall |
| memfd | memfd_create, memfd_secret |
| Sync | msync |
| Inter-process | process_vm_readv, process_vm_writev |
Total : 13 syscalls wrappés.
Syscalls non-wrappés (listés dans UNSUPPORTED.md) :
brk,sbrk: gestion historique du heap, gérée par l’allocateur, pas exposée.pkey_alloc,pkey_free,pkey_mprotect: Memory Protection Keys, marginal en pratique.mbind,migrate_pages,move_pages: NUMA-specific, à évaluer ultérieurement si Air a besoin de support NUMA fin.set_mempolicy,get_mempolicy: idem NUMA.
Répartition des types entre les deux crates
Types dans air-sys-types (purs, sans syscall)
MappingPointer— pointeur + longueur d’un mapping, sans sémantique de propriété (ne libère rien au drop). C’est le type de retour demmap_fixed, où l’appelant gère lui-même le cycle de vie.ProtectionFlags,MapFlags,MremapFlagsMadviseAdviceMlockFlags,MlockallFlagsMemfdFlagsMsyncFlagsRemoteIoSlice
Type RAII dans air-sys-syscall::mem (et non air-sys-types)
Mapping— voir la décision de fond n° 2 ci-dessous.
Décision d’architecture (Q1, validée le 2026-05-31). La rédaction initiale de cette spec plaçait
Mappingdansair-sys-types. C’est incorrect : leDropdeMappingdoit appelermunmap, qui est un syscall. Orair-sys-typesne contient que des types purs et n’appelle jamais de syscall (seulair-sys-syscallle fait, viacore::arch::asm!). Faire vivreMappingdansair-sys-typesforcerait cette crate à embarquer de l’asm!, brisant la séparation de responsabilités entre les deux crates de la couche 0. En conséquence,Mapping(type RAII dont leDropappelle un syscall) est défini dansair-sys-syscall::mem, aux côtés des fonctions qui le produisent (mmap_anonymous,mmap_file,mremap). Seuls les types sans syscall (MappingPointeret lesbitflags/enums ci-dessus) restent dansair-sys-types. La même règle s’applique àLandlockRulesetcôté famillesecurity(cf.family-security.md).
Décisions de fond émergées dans la famille mem
1. mmap éclaté en fonctions typées.
Plutôt qu’un wrapper unique qui prendrait tous les flags et arguments, séparation en mmap_anonymous, mmap_file, mmap_fixed. Plus clair pour le développeur, moins d’erreurs (oublier MAP_ANONYMOUS quand on n’a pas de FD, par exemple).
2. Mapping RAII strict — et défini dans air-sys-syscall::mem.
Un Mapping ne peut pas être copié, seulement déplacé. Le munmap automatique au
drop évite les fuites. Pour les cas où on veut un mapping « long-lived » qui survit
à plusieurs scopes, on déplace l’ownership explicitement.
Parce que son Drop appelle le syscall munmap, Mapping réside dans
air-sys-syscall::mem, pas dans air-sys-types (cf. la section « Répartition
des types » ci-dessus). Règle générale de la couche 0 : un type RAII dont la
destruction déclenche un syscall vit dans la crate des wrappers (air-sys-syscall),
jamais dans la crate des types purs (air-sys-types). Le type MappingPointer
(pointeur nu, sans propriété, retourné par mmap_fixed) reste en revanche dans
air-sys-types puisqu’il ne libère rien.
3. Page size détectée au runtime.
Sur Raspberry Pi 4, la page size peut être 4 KB ou 16 KB selon la configuration kernel. Air détecte à l’init et expose comme static. Pas de constante de compilation.
4. memfd_create central pour AirCom.
Conformément à l’ADR-001, le data plane de AirCom utilise memfd + mmap shared. La spec memfd ici est délibérément détaillée parce qu’elle conditionne la performance d’IPC.
5. Pas d’allocateur Air en couche 0.
Air n’expose pas d’allocateur custom au niveau syscall. Le système global allocator (souvent jemalloc ou mimalloc) est utilisé par défaut. L’allocateur peut être configuré au niveau de l’application, hors du périmètre couche 0.
Licence du document : MPL 2.0
Statut : Spécification technique du module air-sys-syscall::mem (couche 0).
Spec couche 0 — Famille signal
Spécification technique — Version 1.0
Vue d’ensemble de la famille
Le module air-sys-syscall::signal expose les primitives de gestion des signaux Unix. Conformément à l’ADR-020, l’approche d’Air est :
-
signalfd par défaut pour tous les signaux différables. Le mécanisme transforme les signaux en événements lisibles sur un FD, ce qui s’intègre naturellement avec io_uring et avec l’event loop unifiée.
-
sigaction restreint dans un sous-module
synchronous_handlerpour les quatre signaux synchrones non-déférables (SIGSEGV,SIGBUS,SIGFPE,SIGILL).
Cette discipline élimine structurellement une classe entière de bugs (handlers signaux non-async-signal-safe), tout en préservant la capacité à gérer les cas où sigaction est inévitable.
Caractéristiques transverses de la famille.
-
Pas de handler générique exposé. L’API ne permet pas d’installer un handler sigaction sur un signal arbitraire. La voie est barrée par construction.
-
Tous les FDs créés sont CLOEXEC par défaut. Cohérent avec la discipline universelle de la couche 0.
-
Gestion thread-safe. Les opérations sur masques de signaux sont thread-safe (chaque thread a son propre masque). Les opérations sur handlers sont par processus.
-
Intégration io_uring naturelle. Un FD signalfd peut être lu via io_uring, ce qui permet d’attendre les signaux dans le même mécanisme que les autres événements asynchrones.
Types transverses utilisés.
Signal: représentation typée d’un signal.SignalMask: ensemble de signaux comme bitmask.SignalFd: FD signalfd.SignalFdInfo: structure retournée par la lecture d’un signalfd.FatalSignal: enum restreint des 4 signaux synchrones acceptables pour sigaction.SignalAction: type pour les handlers (utilisé uniquement dans le sous-module synchronous_handler).
Sous-section 1 : signalfd et masques de signaux
signalfd_create
Signature.
#![allow(unused)]
fn main() {
pub fn signalfd_create(
mask: &SignalMask,
flags: SignalFdFlags,
) -> Result<SignalFd, Errno>;
pub struct SignalFd { /* opaque, possède OwnedFd interne */ }
impl SignalFd {
pub fn as_fd(&self) -> BorrowedFd<'_>;
pub fn into_fd(self) -> OwnedFd;
pub fn read(&self) -> Result<SignalFdInfo, Errno>;
pub fn update_mask(&mut self, mask: &SignalMask) -> Result<(), Errno>;
}
bitflags! {
pub struct SignalFdFlags: i32 {
const NONBLOCK = 0x800;
const CLOEXEC = 0x80000;
}
}
#[repr(C)]
pub struct SignalFdInfo {
pub signal: Signal,
pub errno: i32,
pub code: i32,
pub pid: Pid,
pub uid: u32,
pub fd: i32,
pub timer_id: u32,
pub band: u32,
pub overrun: u32,
pub trap_no: u32,
pub status: i32,
pub int: i32,
pub ptr: u64,
pub utime: u64,
pub stime: u64,
pub addr: u64,
}
}
Syscall sous-jacent. signalfd4 (x86_64 n°289, ARM64 n°74). Page man signalfd(2). Disponible depuis Linux 2.6.27.
Préconditions.
mask indique les signaux que ce signalfd doit attraper. Les signaux non listés dans le mask continuent à être délivrés selon leur disposition par défaut.
SignalFdFlags::CLOEXEC est toujours activé par défaut par le wrapper.
Comportement.
Crée un FD qui transforme les signaux du mask en événements lisibles. Quand un des signaux listés est délivré au thread, au lieu d’invoquer un handler, il devient “lisible” sur le FD signalfd.
Important : le mask seul ne suffit pas.
Créer un signalfd pour un signal S ne suffit pas à empêcher la délivrance normale de S (handler ou comportement par défaut). Il faut aussi bloquer S dans le masque de signaux du thread via block_signals. Le pattern correct :
#![allow(unused)]
fn main() {
let mask = SignalMask::from_signals(&[Signal::SIGTERM, Signal::SIGINT]);
block_signals(&mask)?;
let sfd = signalfd_create(&mask, SignalFdFlags::empty())?;
// maintenant SIGTERM et SIGINT arriveront uniquement sur sfd
}
Air fournit un helper qui combine les deux étapes :
#![allow(unused)]
fn main() {
pub fn signalfd_create_blocking(
mask: &SignalMask,
flags: SignalFdFlags,
) -> Result<SignalFd, Errno>;
}
Erreurs.
EINVAL: mask invalide ou flags invalides.EMFILE,ENFILE: limites de FDs atteintes.ENODEV,ENOMEM: ressources kernel insuffisantes.
Performance.
Création : ~5-10 µs. Lecture via read() : ~1-2 µs si un signal est disponible immédiatement.
block_signals et unblock_signals
Signature.
#![allow(unused)]
fn main() {
pub fn block_signals(mask: &SignalMask) -> Result<SignalMask, Errno>;
pub fn unblock_signals(mask: &SignalMask) -> Result<SignalMask, Errno>;
pub fn set_signal_mask(mask: &SignalMask) -> Result<SignalMask, Errno>;
pub fn current_signal_mask() -> Result<SignalMask, Errno>;
}
Syscall sous-jacent. rt_sigprocmask (x86_64 n°14, ARM64 n°135).
Comportement.
block_signals ajoute les signaux du mask au mask courant du thread. Retourne l’ancien mask (avant modification).
unblock_signals retire les signaux du mask.
set_signal_mask remplace entièrement le mask.
current_signal_mask lit le mask actuel sans modification.
Cas particuliers.
SIGKILL et SIGSTOP ne peuvent pas être bloqués. Tentative de les inclure dans le mask est silencieusement ignorée par le kernel.
Le mask est hérité à travers fork. execve réinitialise le mask.
Performance. ~100-200 ns. Très rapide.
Helper wait_for_signal
Signature.
#![allow(unused)]
fn main() {
pub fn wait_for_signal(
mask: &SignalMask,
timeout: Option<Duration>,
) -> Result<SignalFdInfo, Errno>;
}
Comportement.
Helper qui crée un signalfd temporaire, attend l’arrivée d’un signal du mask, et retourne l’info. Pratique pour les patterns simples.
Sous-section 2 : Envoi de signaux
kill et tgkill
#![allow(unused)]
fn main() {
pub fn kill(pid: Pid, signal: Option<Signal>) -> Result<(), Errno>;
pub fn tgkill(tgid: Pid, tid: Tid, signal: Option<Signal>) -> Result<(), Errno>;
}
Syscalls sous-jacents. kill (x86_64 n°62, ARM64 n°129), tgkill (x86_64 n°234, ARM64 n°131).
Préconditions.
Option<Signal> permet le pattern “signal 0” (test d’existence sans envoi effectif) via None. Cohérent avec la convention 1 de l’ADR-021.
Comportement.
kill(pid, sig) envoie un signal au processus identifié par pid.
tgkill(tgid, tid, sig) envoie un signal à un thread spécifique d’un processus.
Recommandation Air.
Pour les processus enfants, préférer pidfd_send_signal (famille process) sur kill : pas de race sur le PID recyclé.
Erreurs.
EINVAL: signal invalide.EPERM: permissions insuffisantes.ESRCH: processus inexistant.
Performance. ~1-2 µs.
rt_sigqueueinfo
#![allow(unused)]
fn main() {
pub fn rt_sigqueueinfo(
pid: Pid,
signal: Signal,
info: SignalQueueInfo,
) -> Result<(), Errno>;
pub struct SignalQueueInfo {
pub code: i32,
pub value: SignalValue,
}
pub enum SignalValue {
Integer(i32),
Pointer(u64),
}
}
Envoi de signal avec une “valeur” attachée. Cas d’usage rare.
Sous-section 3 : sigaction restreint
Conformément à l’ADR-020, le sous-module air-sys-syscall::signal::synchronous_handler expose sigaction uniquement pour les 4 signaux synchrones fatals.
Type FatalSignal
#![allow(unused)]
fn main() {
pub mod synchronous_handler {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FatalSignal {
Segv,
Bus,
Fpe,
Ill,
}
impl FatalSignal {
pub const fn as_signal(self) -> Signal;
}
}
}
L’enum est restreint à ces 4 signaux. Pas moyen de passer un autre signal à cette API.
install_fatal_handler
#![allow(unused)]
fn main() {
pub mod synchronous_handler {
pub unsafe fn install_fatal_handler(
signal: FatalSignal,
handler: FatalHandler,
) -> Result<PreviousHandler, Errno>;
pub unsafe fn restore_handler(
signal: FatalSignal,
previous: PreviousHandler,
) -> Result<(), Errno>;
pub type FatalHandler = unsafe extern "C" fn(
signum: c_int,
info: *mut SignalInfo,
context: *mut c_void,
);
pub struct PreviousHandler {
// Opaque
}
#[repr(C)]
pub struct SignalInfo {
pub signal: c_int,
pub errno: c_int,
pub code: c_int,
pub addr: u64,
// ...
}
}
}
Préconditions (Safety).
L’API est unsafe. La documentation # Safety exige que le handler soit async-signal-safe.
Cas d’usage légitimes.
- Crash reporter : capture l’état du processus au moment d’un crash, écrit un mini-dump, puis termine.
- Stack guards : détection de stack overflow via signal handler sur une stack alternative.
Tests délicats.
Les tests s’exécutent dans des sous-processus isolés via harnais subprocess.
Récapitulatif de la famille signal
Fonctions exposées :
| Catégorie | Fonctions principales |
|---|---|
| signalfd | signalfd_create, signalfd_create_blocking, SignalFd::read, SignalFd::update_mask |
| Masques | block_signals, unblock_signals, set_signal_mask, current_signal_mask |
| Helpers | wait_for_signal |
| Envoi | kill, tgkill, rt_sigqueueinfo |
| Handlers (restreint) | install_fatal_handler, restore_handler |
Total : ~12 fonctions publiques principales.
Syscalls non-wrappés (listés dans UNSUPPORTED.md) :
signal(BSD-style, deprecated).tkill(deprecated, remplacé par tgkill).pause,alarm: legacy, remplacés par signalfd + timerfd.sigsuspend,rt_sigsuspend: pattern obsolète, remplacé par signalfd.sigaltstack: à ajouter plus tard si stack guards nécessaires.
Types ajoutés à air-sys-types
SignalFdInfoSignalFdFlagsSignalQueueInfo,SignalValueFatalSignal(sous-module synchronous_handler)SignalInfo(sous-module synchronous_handler)PreviousHandler(sous-module synchronous_handler)
Soit ~6 types supplémentaires.
Décisions de fond émergées dans la famille signal
1. signalfd_create_blocking comme helper canonique.
Le pattern “créer signalfd + bloquer signaux” est tellement universel qu’un helper combiné simplifie l’usage et évite le piège de l’oubli.
2. kill via Option<Signal> pour le signal 0.
Application de la convention 1 de l’ADR-021.
3. install_fatal_handler retourne PreviousHandler opaque.
Cache les détails kernel de la struct sigaction.
4. Pas de wrapper pour sigaltstack.
Cas spécialisé, à ajouter plus tard sans casser l’API.
Licence du document : MPL 2.0
Statut : Spécification technique du module air-sys-syscall::signal (couche 0).
Spec couche 0 — Famille time
Spécification technique — Version 1.0
Vue d’ensemble de la famille
Le module air-sys-syscall::time expose les primitives de gestion du temps : horloges système, timers FD, sleeps précis. C’est une petite famille (~8 syscalls) mais essentielle pour tous les patterns qui ont besoin de mesurer ou d’attendre des durées.
Caractéristiques transverses de la famille.
-
Multiples horloges Linux. Le kernel expose plusieurs horloges aux sémantiques distinctes :
CLOCK_REALTIME(heure murale, ajustable),CLOCK_MONOTONIC(monotone depuis le boot),CLOCK_BOOTTIME(inclut le suspend), et d’autres. Le wrapper Air les expose comme un enum typé pour empêcher la confusion entre horloges incompatibles. -
Timerfd intégré io_uring. Comme signalfd, timerfd est un FD lisible quand le timer expire. Cela s’intègre naturellement avec io_uring.
-
Précision nanoseconde. Les structures
Timespecdu kernel expriment le temps avec une résolution nanoseconde. -
Gestion EINTR.
clock_nanosleepet les lectures bloquantes sur timerfd peuvent retournerEINTR. Conformément à la convention 2 de l’ADR-021, remonté à l’appelant. -
Représentations duales. Air expose à la fois
Duration(standard Rust) pour les durées etInstant(custom Air) pour les points dans le temps.
Sous-section 1 : Horloges et instants
Type Clock
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(i32)]
pub enum Clock {
Realtime = 0,
Monotonic = 1,
ProcessCpuTime = 2,
ThreadCpuTime = 3,
MonotonicRaw = 4,
RealtimeCoarse = 5,
MonotonicCoarse = 6,
Boottime = 7,
RealtimeAlarm = 8,
BoottimeAlarm = 9,
Tai = 11,
}
impl Clock {
pub const fn as_raw(self) -> i32;
pub fn try_from_raw(value: i32) -> Option<Self>;
pub fn name(self) -> &'static str;
}
}
Sémantiques des horloges.
Realtime: heure murale UTC. Ajustable par NTP. Discontinue.Monotonic: compteur depuis un point arbitraire. Strictement croissant. Pour mesurer des durées.ProcessCpuTime: temps CPU consommé par le processus.ThreadCpuTime: temps CPU consommé par le thread.MonotonicRaw: comme Monotonic mais non affecté par NTP slewing.RealtimeCoarse,MonotonicCoarse: versions à résolution réduite, plus rapides.Boottime: comme Monotonic mais inclut le suspend.RealtimeAlarm,BoottimeAlarm: peuvent réveiller le système depuis suspend.Tai: International Atomic Time.
Recommandations d’usage.
- Mesurer une durée :
Monotonic. - Timer ou timeout :
MonotonicouBoottime(si suspend-aware). - Timestamp utilisateur :
Realtime. - Réveil de la machine :
BoottimeAlarm(avec privilèges).
clock_gettime
#![allow(unused)]
fn main() {
pub fn clock_gettime(clock: Clock) -> Result<Instant, Errno>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Instant {
clock: Clock,
seconds: i64,
nanoseconds: u32,
}
impl Instant {
pub fn clock(&self) -> Clock;
pub fn seconds(&self) -> i64;
pub fn nanoseconds(&self) -> u32;
pub fn as_duration_since_epoch(&self) -> Duration;
pub fn checked_duration_since(&self, earlier: Instant) -> Option<Duration>;
pub fn saturating_duration_since(&self, earlier: Instant) -> Duration;
}
}
Comportement.
Lit le temps courant pour l’horloge spécifiée. Retourne un Instant qui inclut l’horloge utilisée.
Pas d’Instant arithmétique cross-clock.
Le type empêche par construction les opérations entre Instant d’horloges différentes.
Performance. ~30-100 ns via vDSO pour les horloges principales.
clock_settime et clock_getres
#![allow(unused)]
fn main() {
pub fn clock_settime(clock: Clock, instant: Instant) -> Result<(), Errno>;
pub fn clock_getres(clock: Clock) -> Result<Duration, Errno>;
}
clock_settime demande CAP_SYS_TIME. Usage rare.
clock_getres retourne la résolution de l’horloge.
Sous-section 2 : Sleep
clock_nanosleep
#![allow(unused)]
fn main() {
pub fn clock_nanosleep(
clock: Clock,
deadline: SleepDeadline,
) -> Result<(), SleepError>;
pub enum SleepDeadline {
Relative(Duration),
AbsoluteInstant(Instant),
}
pub enum SleepError {
Interrupted { remaining: Duration },
Other(Errno),
}
}
Comportement.
Bloque le thread jusqu’à l’instant spécifié (mode absolu) ou pendant la durée spécifiée (mode relatif). Le mode absolu est généralement préféré pour éviter les dérives.
Gestion EINTR particulière.
En cas d’EINTR avec mode relatif, le kernel retourne le temps restant à dormir. Le wrapper Air le récupère et le passe via SleepError::Interrupted { remaining }.
Sous-section 3 : Timerfd
timerfd_create
#![allow(unused)]
fn main() {
pub fn timerfd_create(
clock: Clock,
flags: TimerFdFlags,
) -> Result<TimerFd, Errno>;
pub struct TimerFd { /* possède OwnedFd interne */ }
impl TimerFd {
pub fn as_fd(&self) -> BorrowedFd<'_>;
pub fn into_fd(self) -> OwnedFd;
pub fn arm(&self, spec: &TimerFdSpecification, flags: TimerSetFlags) -> Result<TimerFdSpecification, Errno>;
pub fn disarm(&self) -> Result<TimerFdSpecification, Errno>;
pub fn current(&self) -> Result<TimerFdSpecification, Errno>;
pub fn read(&self) -> Result<u64, Errno>;
}
bitflags! {
pub struct TimerFdFlags: i32 {
const NONBLOCK = 0x800;
const CLOEXEC = 0x80000;
}
}
bitflags! {
pub struct TimerSetFlags: i32 {
const ABSTIME = 1;
const CANCEL_ON_SET = 2;
}
}
#[derive(Debug, Clone, Copy)]
pub struct TimerFdSpecification {
pub initial: Duration,
pub interval: Duration,
}
}
Syscall sous-jacent. timerfd_create (x86_64 n°283, ARM64 n°85).
Comportement.
Crée un FD timerfd. L’application l’arme via arm() avec une spécification.
À chaque expiration, le FD devient lisible. read() retourne le nombre d’expirations survenues.
Modes de spec.
- One-shot :
initial = duration,interval = ZERO. - Periodic :
initial = duration,interval = period. - Disarmed :
initial = ZERO,interval = ZERO.
ABSTIME et CANCEL_ON_SET.
ABSTIME : initial est un instant absolu.
CANCEL_ON_SET : si l’horloge Realtime est ajustée, le timer est annulé.
Pattern d’usage.
#![allow(unused)]
fn main() {
let tfd = timerfd_create(Clock::Monotonic, TimerFdFlags::empty())?;
let spec = TimerFdSpecification {
initial: Duration::from_secs(5),
interval: Duration::ZERO,
};
tfd.arm(&spec, TimerSetFlags::empty())?;
// Attendre l'expiration
let expirations = tfd.read()?;
// Ou via io_uring
ring.submit_read(tfd.as_fd(), buf, 0)?;
}
Récapitulatif de la famille time
Fonctions exposées :
| Catégorie | Fonctions principales |
|---|---|
| Horloges | clock_gettime, clock_settime, clock_getres |
| Sleep | clock_nanosleep |
| Timerfd | timerfd_create, TimerFd::arm, TimerFd::disarm, TimerFd::current, TimerFd::read |
Total : ~8 fonctions publiques principales.
Syscalls non-wrappés (listés dans UNSUPPORTED.md) :
getitimer,setitimer(legacy)times(couvert par clock_gettime CpuTime)time,gettimeofday(legacy)adjtimex,clock_adjtime(phase 0 reportée)nanosleep(remplacé par clock_nanosleep)
Types ajoutés à air-sys-types
ClockInstantTimespec(interne)TimerFd,TimerFdSpecification,TimerFdFlags,TimerSetFlagsSleepDeadline,SleepError
Soit ~9 types supplémentaires.
Décisions de fond émergées dans la famille time
1. Instant typé par horloge.
Empêche par construction les bugs de mélange d’horloges.
2. SleepError distinct de Errno.
Type d’erreur dédié pour exposer l’information “remaining” pour le cas EINTR.
3. Pas d’API std::time::Instant directe.
Le type Instant Air est distinct de std::time::Instant. Conversion fournie mais pas implicite.
4. TimerFd::read() retourne le nombre d’expirations.
Important pour les patterns temps réel.
Licence du document : MPL 2.0
Statut : Spécification technique du module air-sys-syscall::time (couche 0).
Spec couche 0 — Famille net
Spécification technique — Version 1.0
Vue d’ensemble de la famille
Le module air-sys-syscall::net expose les primitives socket synchrones. Conformément à la Décision 2 de l’ADR-022, les opérations existent en parallèle de leurs équivalents io_uring, avec types et conventions partagés.
Périmètre de la famille.
Trois catégories d’opérations :
-
Setup des sockets :
socket,bind,listen,getsockopt,setsockopt. Pas d’équivalent io_uring sur Linux 5.15. -
Opérations connectées synchrones :
connect,accept4,send,recv,sendmsg,recvmsg,shutdown. Toutes ont leur équivalent io_uring exposé au Temps 2b. -
Opérations utilitaires :
getsockname,getpeername,socketpair.
Caractéristiques transverses de la famille.
-
CLOEXEC universel. Tous les FDs sockets créés ont CLOEXEC par défaut.
-
NOSIGNAL par défaut sur send. Les sends synchrones désactivent SIGPIPE par défaut.
-
Types partagés avec io_uring.
SocketAddr,MessageFlags,AcceptFlags, etc. sont les mêmes types qu’au Temps 2b. -
Validation des familles. Les fonctions valident la cohérence entre la famille du socket et l’adresse utilisée.
Sous-section 1 : Setup des sockets
socket
#![allow(unused)]
fn main() {
pub fn socket(
domain: SocketDomain,
ty: SocketType,
protocol: i32,
) -> Result<OwnedFd, Errno>;
}
Le wrapper Air utilise SOCK_CLOEXEC systématiquement.
bind
#![allow(unused)]
fn main() {
pub fn bind(sock: BorrowedFd<'_>, address: &SocketAddr) -> Result<(), Errno>;
}
Pour les Unix sockets path-based, le pattern correct est de tenter unlinkat avant bind, en ignorant ENOENT. Un helper Air en couche 1 (bind_unix_socket_replacing) facilitera ce pattern.
listen
#![allow(unused)]
fn main() {
pub fn listen(sock: BorrowedFd<'_>, backlog: u32) -> Result<(), Errno>;
}
getsockopt et setsockopt
API typée par option (cf. convention 3 de l’ADR-021). Plutôt qu’un wrapper générique, Air expose une fonction par option courante :
#![allow(unused)]
fn main() {
pub fn get_so_keepalive(sock: BorrowedFd<'_>) -> Result<bool, Errno>;
pub fn set_so_keepalive(sock: BorrowedFd<'_>, enable: bool) -> Result<(), Errno>;
pub fn get_so_reuseaddr(sock: BorrowedFd<'_>) -> Result<bool, Errno>;
pub fn set_so_reuseaddr(sock: BorrowedFd<'_>, enable: bool) -> Result<(), Errno>;
pub fn get_so_reuseport(sock: BorrowedFd<'_>) -> Result<bool, Errno>;
pub fn set_so_reuseport(sock: BorrowedFd<'_>, enable: bool) -> Result<(), Errno>;
pub fn get_tcp_nodelay(sock: BorrowedFd<'_>) -> Result<bool, Errno>;
pub fn set_tcp_nodelay(sock: BorrowedFd<'_>, enable: bool) -> Result<(), Errno>;
pub fn get_so_rcvbuf(sock: BorrowedFd<'_>) -> Result<u32, Errno>;
pub fn set_so_rcvbuf(sock: BorrowedFd<'_>, size: u32) -> Result<(), Errno>;
pub fn get_so_sndbuf(sock: BorrowedFd<'_>) -> Result<u32, Errno>;
pub fn set_so_sndbuf(sock: BorrowedFd<'_>, size: u32) -> Result<(), Errno>;
pub fn get_so_error(sock: BorrowedFd<'_>) -> Result<Option<Errno>, Errno>;
pub fn get_so_peercred(sock: BorrowedFd<'_>) -> Result<UnixCredentials, Errno>;
pub fn get_so_linger(sock: BorrowedFd<'_>) -> Result<LingerOption, Errno>;
pub fn set_so_linger(sock: BorrowedFd<'_>, linger: LingerOption) -> Result<(), Errno>;
pub enum LingerOption {
Disabled,
Enabled(Duration),
}
pub fn set_so_bindtodevice(sock: BorrowedFd<'_>, ifname: &CStr) -> Result<(), Errno>;
pub fn get_so_bindtodevice(sock: BorrowedFd<'_>) -> Result<CString, Errno>;
// TCP-specific
pub fn set_tcp_keepidle(sock: BorrowedFd<'_>, seconds: u32) -> Result<(), Errno>;
pub fn set_tcp_keepintvl(sock: BorrowedFd<'_>, seconds: u32) -> Result<(), Errno>;
pub fn set_tcp_keepcnt(sock: BorrowedFd<'_>, count: u32) -> Result<(), Errno>;
// IP-specific
pub fn set_ip_ttl(sock: BorrowedFd<'_>, ttl: u8) -> Result<(), Errno>;
pub fn set_ipv6_v6only(sock: BorrowedFd<'_>, v6only: bool) -> Result<(), Errno>;
// Options à STRUCTURE — descellement additif `couche-0-v1.13` (arbitrage BDFL).
// Délais (`SO_RCVTIMEO`/`SO_SNDTIMEO`, `struct timeval` 16 o sur LP64) :
// `None` ↔ timeval nul = « pas de délai » (sémantique kernel).
pub fn set_receive_timeout(sock: BorrowedFd<'_>, timeout: Option<Timeval>) -> Result<(), Errno>;
pub fn receive_timeout(sock: BorrowedFd<'_>) -> Result<Option<Timeval>, Errno>;
pub fn set_send_timeout(sock: BorrowedFd<'_>, timeout: Option<Timeval>) -> Result<(), Errno>;
pub fn send_timeout(sock: BorrowedFd<'_>) -> Result<Option<Timeval>, Errno>;
// Appartenance multicast (`IP_ADD/DROP_MEMBERSHIP` `ip_mreq` 8 o ;
// `IPV6_ADD/DROP_MEMBERSHIP` `ipv6_mreq` 20 o). Types ABI `#[repr(C)]` fidèles.
pub fn join_multicast_v4(sock: BorrowedFd<'_>, membership: &IpMembershipV4) -> Result<(), Errno>;
pub fn leave_multicast_v4(sock: BorrowedFd<'_>, membership: &IpMembershipV4) -> Result<(), Errno>;
pub fn join_multicast_v6(sock: BorrowedFd<'_>, membership: &IpMembershipV6) -> Result<(), Errno>;
pub fn leave_multicast_v6(sock: BorrowedFd<'_>, membership: &IpMembershipV6) -> Result<(), Errno>;
// struct ip_mreq (8 o) — deux in_addr (ordre réseau) ; struct ipv6_mreq (20 o).
pub struct IpMembershipV4 { pub multiaddr: [u8; 4], pub interface: [u8; 4] }
pub struct IpMembershipV6 { pub multiaddr: [u8; 16], pub interface: u32 /* ifindex */ }
}
Descellement
couche-0-v1.13(options à structure). ADR-021 conv. 3 impose des fonctions dédiées typées (jamais unsetsockopt(level, optname, buf)générique) : ces wrappers construisent eux-mêmes letimeval/ip_mreq/ipv6_mreq(aucun parsing d’octets externes ⇒ pas de fuzz d’entrée). Le seul décodage est celui dutimevalrendu pargetsockopt, isolé dans une fonction puredecode_timeoutet property-testé (None ⟺ {0,0}). Layout ABI verrouillé par assertions const (size_of). Écarte lesetsockopt_bytesgénérique envisagé par ADR-080 (v1.12, jamais implémenté).
Si un développeur a besoin d’une option non couverte, deux voies : RFC pour ajouter la fonction wrapper, ou API raw dans air-sys-syscall::net::raw.
Sous-section 2 : Opérations synchrones
connect
#![allow(unused)]
fn main() {
pub fn connect(sock: BorrowedFd<'_>, address: &SocketAddr) -> Result<(), Errno>;
}
Pour les sockets non-blocking, retourne EINPROGRESS immédiatement.
accept4
#![allow(unused)]
fn main() {
pub fn accept4(
listener: BorrowedFd<'_>,
flags: AcceptFlags,
) -> Result<AcceptResult, Errno>;
}
AcceptFlags::CLOEXEC activé par défaut. accept4 préféré à accept parce qu’il permet de passer CLOEXEC atomiquement.
send, recv, sendmsg, recvmsg, shutdown
#![allow(unused)]
fn main() {
pub fn send(
sock: BorrowedFd<'_>,
buf: &[u8],
flags: MessageFlags,
) -> Result<usize, Errno>;
pub fn recv(
sock: BorrowedFd<'_>,
buf: &mut [u8],
flags: MessageFlags,
) -> Result<usize, Errno>;
pub fn sendmsg(
sock: BorrowedFd<'_>,
request: &SendMessageRequest,
) -> Result<SendMessageResult, Errno>;
pub fn recvmsg(
sock: BorrowedFd<'_>,
request: &mut ReceiveMessageRequest,
) -> Result<ReceiveMessageResult, Errno>;
pub fn shutdown(
sock: BorrowedFd<'_>,
mode: ShutdownMode,
) -> Result<(), Errno>;
}
Différence sémantique avec io_uring.
Les versions synchrones prennent des références (&[u8], &mut [u8]) plutôt que des valeurs possédées. Pas besoin de transfert d’ownership puisque l’opération est synchrone.
NOSIGNAL par défaut. Comme en io_uring, MessageFlags::NOSIGNAL est activé par défaut dans le wrapper send synchrone.
FD passing via sendmsg/recvmsg. Identique au Temps 2b : FDs reçus auto-encapsulés en OwnedFd, ancillaires typés.
Sous-section 3 : Opérations utilitaires
getsockname et getpeername
#![allow(unused)]
fn main() {
pub fn getsockname(sock: BorrowedFd<'_>) -> Result<SocketAddr, Errno>;
pub fn getpeername(sock: BorrowedFd<'_>) -> Result<SocketAddr, Errno>;
}
socketpair
#![allow(unused)]
fn main() {
pub fn socketpair(
domain: SocketDomain,
ty: SocketType,
protocol: i32,
) -> Result<(OwnedFd, OwnedFd), Errno>;
}
Seuls les Unix sockets supportent socketpair sur Linux. CLOEXEC activé par défaut sur les deux FDs.
Récapitulatif de la famille net
Fonctions exposées :
| Catégorie | Fonctions principales |
|---|---|
| Setup | socket, bind, listen |
| Options | getsockopt/setsockopt typés (~20 options exposées) |
| Connexion sync | connect, accept4 |
| I/O sync | send, recv, sendmsg, recvmsg, shutdown |
| Utilitaires | getsockname, getpeername, socketpair |
Total : ~30 fonctions publiques principales.
Syscalls non-wrappés (listés dans UNSUPPORTED.md) :
accept(sans le 4) : remplacé par accept4.sendto,recvfrom: couverts via sendmsg/recvmsg.sendmmsg,recvmmsg: opérations batched, à évaluer pour ajout futur.
Types ajoutés à air-sys-types
Principalement réutilisation des types déjà introduits au Temps 2b (SocketAddr, MessageFlags, etc.). Quelques ajouts :
SocketOptionLevelLingerOption- Constantes diverses pour les options
Soit ~3 types supplémentaires.
Décisions de fond émergées dans la famille net
1. API typée par option pour setsockopt/getsockopt.
Application stricte de la convention 3 de l’ADR-021. Le coût en volume de code est compensé par la sûreté et la documentation par option.
2. Pas de wrapper accept non-accept4.
accept4 est strictement supérieur.
3. NOSIGNAL par défaut.
Cohérent avec io_uring. SIGPIPE est presque toujours indésirable.
4. socketpair retourne un tuple.
Choix idiomatique Rust.
Licence du document : MPL 2.0
Statut : Spécification technique du module air-sys-syscall::net (couche 0).
Spec couche 0 — Famille ipc
Spécification technique — Version 1.0
Vue d’ensemble de la famille
Le module air-sys-syscall::ipc expose les primitives kernel d’IPC modernes : eventfd, pipe, et les opérations zero-copy entre FDs (splice, tee, vmsplice). C’est une petite famille qui complète l’arsenal de communication inter-threads et inter-processus.
Périmètre de la famille.
Trois mécanismes distincts :
-
eventfd: compteur kernel exposé comme FD, lisible quand non-nul. Notifications inter-threads ou inter-processus légères. -
pipe: pipe Unix classique, communication unidirectionnelle. -
Opérations zero-copy :
splice,tee,vmsplice. Déplacement de données entre FDs sans passer par userspace.
Position par rapport aux autres familles.
- Les sockets Unix (famille
net) sont l’IPC principal d’Air, notamment pour AirCom. - La mémoire partagée via
memfd(famillemem) est utilisée pour le data plane. - Cette famille
ipccouvre les cas plus simples : notifications, pipes, optimisations zero-copy.
Caractéristiques transverses.
- CLOEXEC universel.
- Variantes modernes.
eventfd2etpipe2préférés. - Pas d’IPC System V.
shmget,semget,msggetnon wrappés. Listés dansUNSUPPORTED.md. - Pas de POSIX message queues.
mq_openetc. non wrappés.
Sous-section 1 : eventfd
eventfd2
#![allow(unused)]
fn main() {
pub fn eventfd2(
initial: u64,
flags: EventFdFlags,
) -> Result<EventFd, Errno>;
pub struct EventFd { /* possède OwnedFd interne */ }
impl EventFd {
pub fn as_fd(&self) -> BorrowedFd<'_>;
pub fn into_fd(self) -> OwnedFd;
pub fn read(&self) -> Result<u64, Errno>;
pub fn write(&self, value: u64) -> Result<(), Errno>;
}
bitflags! {
pub struct EventFdFlags: i32 {
const CLOEXEC = 0x80000;
const NONBLOCK = 0x800;
const SEMAPHORE = 1;
}
}
}
Syscall sous-jacent. eventfd2 (x86_64 n°290, ARM64 n°19). Disponible depuis Linux 2.6.27.
Comportement.
Crée un FD qui encapsule un compteur 64 bits kernel. Deux modes selon le flag SEMAPHORE :
- Mode normal :
read()retourne la valeur courante et met à zéro. Bloque si le compteur est zéro. - Mode sémaphore :
read()retourne 1 et décrémente. Bloque si zéro.
Cas d’usage typique : notification inter-threads.
#![allow(unused)]
fn main() {
let efd = eventfd2(0, EventFdFlags::empty())?;
// Thread A : attendre une notification
loop {
let count = efd.read()?;
process_events(count);
}
// Thread B : envoyer une notification
efd.write(1)?;
}
Intégration avec io_uring.
#![allow(unused)]
fn main() {
let efd = eventfd2(0, EventFdFlags::empty())?;
let mut buf = [0u8; 8];
let token = ring.submit_read(efd.as_fd(), buf.to_vec(), 0)?;
ring.submit()?;
// Depuis un autre thread :
efd.write(1)?;
// Le ring reçoit la complétion
let completion = ring.wait_completion()?;
}
C’est le pattern canonique pour faire interagir un thread reactor io_uring avec d’autres threads.
Performance. Création ~5-10 µs. Read/write ~500 ns à 1 µs.
Sous-section 2 : pipe
pipe2
#![allow(unused)]
fn main() {
pub fn pipe2(flags: PipeFlags) -> Result<(OwnedFd, OwnedFd), Errno>;
bitflags! {
pub struct PipeFlags: i32 {
const CLOEXEC = 0x80000;
const DIRECT = 0x4000;
const NONBLOCK = 0x800;
}
}
}
Comportement.
Crée un pipe unidirectionnel. Retourne (read_end, write_end).
Les pipes ont une taille de buffer kernel (typiquement 64 KB, ajustable via fcntl).
PipeFlags::DIRECT : mode “packet” où chaque write produit un paquet distinct côté lecture.
Cas d’usage typique.
- Communication parent-enfant après fork.
- Notification simple où eventfd serait sur-dimensionné.
- Implémentation de pipelines.
Sous-section 3 : Opérations zero-copy
splice
#![allow(unused)]
fn main() {
pub fn splice(
fd_in: BorrowedFd<'_>,
off_in: Option<&mut u64>,
fd_out: BorrowedFd<'_>,
off_out: Option<&mut u64>,
length: usize,
flags: SpliceFlags,
) -> Result<usize, Errno>;
bitflags! {
pub struct SpliceFlags: u32 {
const MOVE = 1;
const NONBLOCK = 2;
const MORE = 4;
const GIFT = 8;
}
}
}
Préconditions.
Au moins un des deux FDs doit être un pipe.
Comportement.
Transfère jusqu’à len octets de fd_in vers fd_out sans passer par l’espace utilisateur.
Cas d’usage typiques.
- Servir un fichier sur un socket : transfert depuis FD fichier vers FD socket sans copie.
- Pipeline entre processus.
- Logging haute performance.
Performance. Gain principal : élimination des copies kernel → user → kernel. Débit 2-3 fois supérieur à read/write pour les gros transferts.
tee
#![allow(unused)]
fn main() {
pub fn tee(
fd_in: BorrowedFd<'_>,
fd_out: BorrowedFd<'_>,
length: usize,
flags: SpliceFlags,
) -> Result<usize, Errno>;
}
Les deux FDs doivent être des pipes. Duplique le contenu d’un pipe dans un autre sans consommer du pipe source.
vmsplice
#![allow(unused)]
fn main() {
pub fn vmsplice(
fd: BorrowedFd<'_>,
iov: &[IoSlice<'_>],
flags: SpliceFlags,
) -> Result<usize, Errno>;
}
fd doit être un pipe. Transfère des données depuis des buffers userspace vers un pipe.
Le gifting (mode optimisé) est dangereux : les pages données ne doivent plus être modifiées. Air documente fortement les contraintes.
Récapitulatif de la famille ipc
Fonctions exposées :
| Catégorie | Fonctions principales |
|---|---|
| eventfd | eventfd2, EventFd::read, EventFd::write |
| pipe | pipe2 |
| zero-copy | splice, tee, vmsplice |
Total : ~6 fonctions publiques principales.
Syscalls non-wrappés (listés dans UNSUPPORTED.md) :
eventfd(sans le 2) : remplacé par eventfd2.pipe(sans le 2) : remplacé par pipe2.shmget,shmat,shmdt,shmctl: System V shared memory, deprecated.semget,semop,semctl: System V semaphores, deprecated.msgget,msgsnd,msgrcv,msgctl: System V message queues, deprecated.mq_open,mq_send,mq_receive,mq_close,mq_unlink,mq_notify,mq_setattr: POSIX message queues, marginal.
Types ajoutés à air-sys-types
EventFd,EventFdFlagsPipeFlagsSpliceFlags
Soit ~4 types supplémentaires.
Décisions de fond émergées dans la famille ipc
1. Pas de System V IPC.
Décision claire : on n’expose pas les mécanismes legacy qui ont des problèmes de sémantique et de sécurité connus.
2. Pas de POSIX message queues.
Usage marginal, supplanté par sockets et memfd.
3. EventFd::read retourne u64, pas Vec<u8>.
Air expose directement le compteur comme u64. Conversion interne au wrapper.
4. vmsplice gifting documenté mais peu testé.
Mode puissant mais dangereux. Cas avancé en phase 0.
Licence du document : MPL 2.0
Statut : Spécification technique du module air-sys-syscall::ipc (couche 0).
Spec couche 0 — Famille security
Spécification technique — Version 1.0
Vue d’ensemble de la famille
Le module air-sys-syscall::security expose les primitives de sandboxing kernel : seccomp-BPF (filtrage de syscalls) et Landlock (filtrage d’accès filesystem). Ces deux mécanismes sont au cœur du modèle de sécurité Air (ADR-010 : entitlements et sandbox capability-based).
Périmètre de la famille.
Trois sous-systèmes distincts :
- seccomp-BPF : filtrage des syscalls qu’un thread peut exécuter.
- Landlock : filtrage d’accès au filesystem.
- Capabilities : déjà couvertes dans la famille
process(capget,capset).
Position dans le modèle de sécurité Air.
Le launcher Air (couche 5, ADR-010) applique au démarrage de chaque application :
- Les capabilities selon les entitlements.
- Les filtres Landlock pour restreindre l’accès filesystem.
- Les filtres seccomp pour restreindre les syscalls.
- L’isolation par namespaces.
La couche 0 fournit les primitives ; la couche 5 les orchestre.
Caractéristiques transverses.
-
Opérations privilégiées ou contraintes. Beaucoup demandent
CAP_SYS_ADMINou l’activation préalable deno_new_privs. -
Irréversibilité. Les restrictions de sécurité sont strictement croissantes : une fois appliquées, elles ne peuvent pas être affaiblies.
-
Couche 0 = primitives seules. Pour seccomp-BPF, la couche 0 n’expose pas d’API déclarative ni de compilateur de filtres : elle charge un programme BPF déjà formé (cf. Q4, sous-section 1). L’API déclarative
SeccompFilteret sa compilation vivent en couche 1. De même, les helpers Landlock de haut niveau (combinaison create/add/restrict, masquage d’ABI) relèvent de la couche 1 ; la couche 0 n’expose que les primitives Landlock. -
Pas de wrapper pour AppArmor / SELinux. Ces mécanismes sont gérés au niveau distribution.
Sous-section 1 : seccomp
Périmètre couche 0 : la primitive seulement, pas le compilateur de filtres
Décision d’architecture (Q4, validée le 2026-05-31). La rédaction initiale de cette spec plaçait en couche 0 une API déclarative (
SeccompFilter,SeccompRule,SeccompAction…) compilée en BPF en interne. C’est trop pour la couche 0 : compiler un filtre déclaratif en bytecode BPF est de la logique (encodage d’instructionssock_filter, calcul des offsets de saut, prise en compte de l’architecture), pas un wrapper de syscall. Conformément à la raison d’être de la couche 0 (« abstraire sans cacher », façade fine sur le kernel), la couche 0 n’expose que la primitive : charger dans le kernel un programme BPF déjà compilé, fourni par l’appelant. L’API déclarative et son compilateur (SeccompFilter,SeccompRule,SeccompAction,SyscallArgumentCondition,ConditionOp,SyscallNumber) sont spécifiés et implémentés en couche 1, et sortent du périmètre de la couche 0.
Frontière (Q4, actée — confirmée par le code livré, 2026-06-14). La couche 0 expose uniquement le primitif
seccomp_set_mode_filter(&SockFprog, SeccompFilterFlags)— oùSockFprogemprunte une tranche desock_filterdéjà compilée par l’appelant. La compilation déclarative (règles de filtre → bytecode cBPFsock_filter) est de la logique → couche 1 (cohérent ADR-021 : la couche 0 ne fait pas de logique). Aucunbpf_compiler/type déclaratifSeccompFilteren couche 0. (Clôture de la question Q4 deQUESTIONS-implementation.md: le code mergé implémente exactement ce primitif.)
Types : un programme BPF déjà formé
Ces types sont purs (aucun syscall) et vivent dans air-sys-types::security.
#![allow(unused)]
fn main() {
/// Une instruction de filtre BPF classique — layout exact de `struct sock_filter`.
#[repr(C)]
pub struct SockFilter {
pub code: u16,
pub jt: u8,
pub jf: u8,
pub k: u32,
}
/// Programme BPF complet à charger — équivalent de `struct sock_fprog`.
/// Emprunte une tranche d'instructions appartenant à l'appelant (zéro alloc).
pub struct SockFprog<'a> { /* len: u16, filter: *const SockFilter, marqueur 'a */ }
impl<'a> SockFprog<'a> {
/// Construit un programme à partir d'une tranche d'instructions.
///
/// # Errors
/// `EINVAL` si la tranche est vide ou dépasse `BPF_MAXINSNS` (4096).
pub fn new(instructions: &'a [SockFilter]) -> Result<Self, Errno>;
}
}
Le programme BPF lui-même est produit ailleurs (compilateur déclaratif de couche 1, ou tout autre moyen) et passé ici sous forme de tranche. La couche 0 ne l’inspecte pas et ne le transforme pas : elle le charge tel quel.
seccomp_set_mode_filter
#![allow(unused)]
fn main() {
pub unsafe fn seccomp_set_mode_filter(
prog: &SockFprog<'_>,
flags: SeccompFilterFlags,
) -> Result<(), Errno>;
bitflags! {
pub struct SeccompFilterFlags: u32 {
const TSYNC = 1;
const LOG = 2;
const SPEC_ALLOW = 4;
const NEW_LISTENER = 8;
const TSYNC_ESRCH = 16;
const WAIT_KILLABLE_RECV = 32;
}
}
}
Charge le programme BPF prog dans le kernel pour le thread courant
(seccomp(SECCOMP_SET_MODE_FILTER, flags, prog)).
unsafe — préconditions (# Safety) :
- l’appelant doit avoir appelé
set_no_new_privs()(familleprocess) ou détenirCAP_SYS_ADMIN; - le programme BPF doit autoriser tous les syscalls réellement utilisés par le runtime Rust et par le code exécuté après le chargement, faute de quoi le thread/processus sera tué ;
- l’opération est irréversible et strictement croissante (on ne peut que durcir).
TSYNC applique le filtre à tous les threads du processus. NEW_LISTENER retourne
un descripteur de notification (seccomp_unotify) — exploité en couche 1.
EINTR remonté tel quel, jamais de retry automatique (ADR-021 conv. 2).
seccomp_set_mode_strict
#![allow(unused)]
fn main() {
pub fn seccomp_set_mode_strict() -> Result<(), Errno>;
}
Active le mode strict (SECCOMP_SET_MODE_STRICT) : seuls read, write,
_exit/exit_group et sigreturn restent autorisés. Aucun programme à fournir,
aucune compilation — d’où l’absence d’unsafe. Irréversible.
Performance. Chargement d’un filtre : ~50-200 µs. Coût par syscall ensuite : ~50-100 ns pour un filtre simple.
Tests délicats. Une violation seccomp tue typiquement le thread/processus ; les
tests s’exécutent dans des sous-processus isolés (fork + observation du statut via
waitid), avec skip explicite si l’environnement n’autorise pas no_new_privs.
Prérequis prctl_no_new_privs
Couvert par la famille process (prctl), rappelé ici car prérequis à seccomp
sans privilège. Irréversible.
Sous-section 2 : Landlock
Landlock est un mécanisme Linux moderne pour restreindre les accès filesystem au niveau de chemins, sans privilèges. Disponible depuis Linux 5.13.
L’API est conçue autour de “rulesets” : des ensembles de règles qui définissent quels chemins sont accessibles avec quelles permissions.
landlock_create_ruleset
#![allow(unused)]
fn main() {
pub fn landlock_create_ruleset(
handled_access: LandlockAccessFs,
) -> Result<LandlockRuleset, Errno>;
pub struct LandlockRuleset { /* possède OwnedFd interne */ }
impl LandlockRuleset {
pub fn add_rule_path_beneath(
&mut self,
path: BorrowedFd<'_>,
allowed_access: LandlockAccessFs,
) -> Result<(), Errno>;
pub fn restrict_self(&self) -> Result<(), Errno>;
pub fn as_fd(&self) -> BorrowedFd<'_>;
}
bitflags! {
pub struct LandlockAccessFs: u64 {
const EXECUTE = 1 << 0;
const WRITE_FILE = 1 << 1;
const READ_FILE = 1 << 2;
const READ_DIR = 1 << 3;
const REMOVE_DIR = 1 << 4;
const REMOVE_FILE = 1 << 5;
const MAKE_CHAR = 1 << 6;
const MAKE_DIR = 1 << 7;
const MAKE_REG = 1 << 8;
const MAKE_SOCK = 1 << 9;
const MAKE_FIFO = 1 << 10;
const MAKE_BLOCK = 1 << 11;
const MAKE_SYM = 1 << 12;
const REFER = 1 << 13; // Landlock v2 (Linux 5.19)
const TRUNCATE = 1 << 14; // Landlock v3 (Linux 6.2)
const IOCTL_DEV = 1 << 15; // Landlock v5 (Linux 6.10)
}
}
}
Placement (Q1, validée le 2026-05-31).
LandlockRulesetest un type RAII dont les méthodes (add_rule_path_beneath,restrict_self) et leDropappellent des syscalls Landlock. Il réside donc dansair-sys-syscall::security, et non dansair-sys-types— même règle queMappingcôté famillemem: un type qui appelle un syscall vit dans la crate des wrappers, jamais dans la crate des types purs. Seuls les types sans syscall (LandlockAccessFs) restent dansair-sys-types.
Préconditions.
handled_access indique quelles permissions ce ruleset va gérer.
Les permissions disponibles dépendent de la version d’ABI Landlock du kernel.
Décision (Q6, validée le 2026-05-31) : pas de masquage automatique en couche 0.
landlock_supported_abi()se contente de rapporter la version d’ABI supportée par le kernel.landlock_create_ruleset/add_rule_path_beneath/restrict_selfne masquent pas silencieusement les bits d’accès non supportés : si l’appelant passe un bit que le kernel ne connaît pas, l’erreur kernel (EINVAL) est remontée telle quelle. Masquer en douce serait un comportement « magique », contraire au principe « abstraire sans cacher ». C’est à l’appelant — ou à un helper de couche 1 (landlock_restrict_to_paths) — d’interrogerlandlock_supported_abi()puis de masquer les bits selon l’ABI cible.
Pattern d’usage typique.
#![allow(unused)]
fn main() {
let mut ruleset = landlock_create_ruleset(
LandlockAccessFs::READ_FILE | LandlockAccessFs::READ_DIR | LandlockAccessFs::EXECUTE,
)?;
let usr_fd = openat(DirFd::Cwd, c"/usr", OpenFlags::PATH | OpenFlags::DIRECTORY, Mode::empty())?;
ruleset.add_rule_path_beneath(
usr_fd.as_fd(),
LandlockAccessFs::READ_FILE | LandlockAccessFs::EXECUTE,
)?;
let home_fd = openat(DirFd::Cwd, c"/home/user", OpenFlags::PATH | OpenFlags::DIRECTORY, Mode::empty())?;
ruleset.add_rule_path_beneath(
home_fd.as_fd(),
LandlockAccessFs::READ_FILE | LandlockAccessFs::READ_DIR,
)?;
set_no_new_privs()?;
ruleset.restrict_self()?;
}
Particularités.
- Irréversibilité.
restrict_selfest définitif. - Cumul de rulesets. Plusieurs rulesets successifs ajoutent des restrictions.
- NO_NEW_PRIVS prérequis sauf si
CAP_SYS_ADMIN. - Sémantique “path_beneath”. Une règle s’applique à un chemin et tous ses descendants.
- Versions ABI. Détecter via
landlock_supported_abi().
Performance.
Création : ~10-50 µs. Ajout de règle : ~5-10 µs. restrict_self : ~50-100 µs. Coût par accès filesystem ensuite : ~100-500 ns.
Helpers haut niveau
En couche 0, seule la requête de version d’ABI est exposée — une primitive pure de lecture, sans masquage (cf. Q6) :
#![allow(unused)]
fn main() {
/// Rapporte la version d'ABI Landlock supportée par le kernel courant.
pub fn landlock_supported_abi() -> Result<u32, Errno>;
}
Déplacé en couche 1 (Q4/Q6). Le helper
landlock_restrict_to_paths(paths)— qui combinecreate_ruleset+ plusieursadd_rule_path_beneath+restrict_selfen un appel, et qui peut masquer les bits selon l’ABI — est de la logique de commodité, pas une primitive syscall. Il est donc spécifié en couche 1, aux côtés de l’API seccomp déclarative. La couche 0 n’expose que les briques (landlock_create_ruleset,add_rule_path_beneath,restrict_self,landlock_supported_abi) ; c’est typiquement le launcher Air, via ce helper de couche 1, qui appliquera les entitlements filesystem d’une application.
Récapitulatif de la famille security
Fonctions exposées :
| Catégorie | Fonctions principales |
|---|---|
| seccomp | seccomp_set_mode_filter, seccomp_set_mode_strict |
| Landlock | landlock_create_ruleset, LandlockRuleset::add_rule_path_beneath, LandlockRuleset::restrict_self, LandlockRuleset::as_fd, landlock_supported_abi |
Total : ~6 fonctions publiques principales en couche 0. Reportés en couche 1 (hors de ce périmètre) : l’API seccomp déclarative (SeccompFilter / SeccompRule / SeccompAction / … + compilateur BPF) et le helper landlock_restrict_to_paths.
Syscalls listés mais non-wrappés en phase 0 :
bpf: API générique pour les programmes BPF. Avancé.perf_event_open: profiling et monitoring. Hors phase 0.keyctl,add_key,request_key: keyring kernel. À évaluer.pkey_alloc,pkey_free,pkey_mprotect: memory protection keys. Marginal.
Répartition des types entre les deux crates
Dans air-sys-types (purs, sans syscall)
SockFilter,SockFprog— programme BPF déjà formé.SeccompFilterFlags— drapeaux de chargement seccomp.LandlockAccessFs— bits d’accès filesystem.
Dans air-sys-syscall::security (RAII appelant des syscalls)
LandlockRuleset— possède unOwnedFd; ses méthodes et sonDropappellent des syscalls. Ne vit donc pas dansair-sys-types(décision Q1 ci-dessus ; même règle queMappingcôtémem).
Reportés en couche 1 (hors couche 0, décision Q4)
- API seccomp déclarative :
SeccompFilter,SeccompRule,SeccompAction,SyscallArgumentCondition,ConditionOp,SyscallNumber, et le compilateur déclaratif → BPF.
Décisions de fond émergées dans la famille security
1. Couche 0 = primitive seccomp seulement (Q4).
La couche 0 n’expose ni la construction ni la compilation de filtres : elle charge un programme BPF déjà formé (seccomp_set_mode_filter) ou active le mode strict (seccomp_set_mode_strict). L’API déclarative confortable (qui « évite les bugs courants ») est réelle et utile — mais c’est de la logique, donc spécifiée en couche 1, au-dessus de cette primitive.
2. Helpers haut niveau → couche 1 (Q4/Q6).
landlock_restrict_to_paths (combinaison create/add/restrict + masquage d’ABI éventuel) couvre ~90 % des usages, mais reste de la commodité construite sur les primitives : il vit en couche 1. La couche 0 ne fournit que les briques Landlock et la requête landlock_supported_abi (sans masquage, Q6).
2 bis. Types RAII à syscall hors air-sys-types (Q1).
LandlockRuleset (comme Mapping côté mem) appelle des syscalls dans ses méthodes et son Drop ; il réside dans air-sys-syscall::security, jamais dans la crate des types purs.
3. Pas d’API AppArmor / SELinux.
Ces frameworks sont configurés au niveau distribution.
4. Détection runtime de la version Landlock ABI.
Permet aux applications de s’adapter sans recompilation.
Licence du document : MPL 2.0
Statut : Spécification technique du module air-sys-syscall::security (couche 0).
Spec couche 0 — Famille system
Spécification technique — Version 1.0
Vue d’ensemble de la famille
Le module air-sys-syscall::system expose les primitives système diverses : information sur le système, identité de la machine, génération d’entropie cryptographique. C’est la plus petite famille de la couche 0, mais elle contient des fonctions essentielles comme getrandom.
Périmètre de la famille.
Quatre catégories :
- Informations système :
uname,sysinfo. - Identité machine :
gethostname,sethostname. - Entropie cryptographique :
getrandom. - Actions système privilégiées :
reboot(mentionnée mais non exposée).
Caractéristiques transverses.
- Hétérogénéité. Les fonctions ont peu en commun sémantiquement.
- Quelques opérations privilégiées.
sethostname,rebootdemandentCAP_SYS_ADMIN. getrandomest l’élément le plus important. Cryptographie, génération d’identifiants.
Sous-section 1 : Informations système
uname
#![allow(unused)]
fn main() {
pub fn uname() -> Result<UtsName, Errno>;
#[derive(Debug, Clone)]
pub struct UtsName {
pub sysname: CString,
pub nodename: CString,
pub release: CString,
pub version: CString,
pub machine: CString,
pub domainname: CString,
}
}
Comportement.
Retourne des informations sur le système :
sysname: nom de l’OS (“Linux”).nodename: nom de la machine.release: version du kernel (“5.15.0-91-generic”).version: informations de build du kernel.machine: architecture matérielle (“x86_64”, “aarch64”).domainname: nom de domaine NIS (Linux-spécifique).
Performance. ~1-2 µs.
sysinfo
#![allow(unused)]
fn main() {
pub fn sysinfo() -> Result<SystemInfo, Errno>;
#[derive(Debug, Clone)]
pub struct SystemInfo {
pub uptime: Duration,
pub load_average: [f64; 3], // 1, 5, 15 minutes
pub total_ram: u64,
pub free_ram: u64,
pub shared_ram: u64,
pub buffer_ram: u64,
pub total_swap: u64,
pub free_swap: u64,
pub processes: u16,
pub total_high: u64,
pub free_high: u64,
pub mem_unit: u32,
}
}
Comportement.
Retourne des statistiques globales sur le système. Utile pour les outils de monitoring.
Notes sur le wrapper.
La struct kernel utilise des mem_unit. Le wrapper Air fait la multiplication en interne et expose des valeurs en octets directement.
Les load_average sont convertis en f64 par le wrapper.
Limites de précision.
sysinfo est rapide mais imprécis. Pour des stats détaillées, lire /proc/meminfo etc.
Performance. ~1-2 µs.
Sous-section 2 : Identité machine
gethostname et sethostname
#![allow(unused)]
fn main() {
pub fn gethostname() -> Result<CString, Errno>;
pub fn sethostname(name: &CStr) -> Result<(), Errno>;
}
Préconditions.
Pour sethostname : CAP_SYS_ADMIN requis. name doit avoir une longueur ≤ HOST_NAME_MAX (typiquement 64 octets).
Comportement.
gethostname est implémenté via uname() et extrait le champ nodename.
sethostname change le hostname. Action privilégiée, rarement utilisée par les applications.
Sous-section 3 : Entropie cryptographique
getrandom
#![allow(unused)]
fn main() {
pub fn getrandom(
buf: &mut [u8],
flags: GetrandomFlags,
) -> Result<usize, Errno>;
bitflags! {
pub struct GetrandomFlags: u32 {
const NONBLOCK = 1;
const RANDOM = 2;
const INSECURE = 4;
}
}
}
Syscall sous-jacent. getrandom (x86_64 n°318, ARM64 n°278). Disponible depuis Linux 3.17.
Comportement.
Remplit le buffer avec des octets aléatoires cryptographiquement sûrs. Mécanisme moderne et préféré pour obtenir de l’entropie sur Linux.
Sémantique des flags.
- Aucun flag (défaut) : utilise le pool
urandom. Bloque si pas encore initialisé. NONBLOCK: retourneEAGAINau lieu de bloquer.RANDOM: utilise le poolrandom. À éviter sauf cas spéciaux (cryptographiquement équivalent à urandom sur Linux moderne).INSECURE: retourne immédiatement même si non initialisé, mais qualité réduite. Strictement réservé aux cas non-cryptographiques.
Recommandation Air.
Pour 99% des usages : GetrandomFlags::empty().
Comportement avec grands buffers.
getrandom peut retourner moins d’octets que demandé pour les grands buffers. Boucle si nécessaire :
#![allow(unused)]
fn main() {
fn fill_random(buf: &mut [u8]) -> Result<(), Errno> {
let mut offset = 0;
while offset < buf.len() {
let n = getrandom(&mut buf[offset..], GetrandomFlags::empty())?;
offset += n;
}
Ok(())
}
}
Air pourra fournir un helper getrandom_exact en couche 1.
Performance. ~1-2 µs pour de petits buffers. Pour les grands, débit limité par le CSPRNG kernel.
Tests.
- Test nominal : remplir un buffer de 32 octets, vérifier qu’il n’est pas tout à zéro.
- Test variation : deux appels successifs, vérifier que les buffers sont différents.
- Test buffer grand : 4 KB avec boucle.
Sous-section 4 : Opérations privilégiées non exposées
reboot
reboot(2) permet de redémarrer ou arrêter la machine. Pas exposé en API publique Air. Le contrôle de cycle de vie système est du ressort de systemd.
Listé dans UNSUPPORTED.md avec justification.
Récapitulatif de la famille system
Fonctions exposées :
| Catégorie | Fonctions principales |
|---|---|
| Informations | uname, sysinfo |
| Identité | gethostname, sethostname |
| Entropie | getrandom |
Total : 5 fonctions publiques principales.
Syscalls non-wrappés (listés dans UNSUPPORTED.md) :
reboot: action système réservée à init.syslog(lecture kernel ring buffer) : marginal.setdomainname: NIS legacy, marginal.iopl,ioperm: accès I/O direct ports, privilégié et rare.personality: changement de personality processus, hors phase 0.
Types ajoutés à air-sys-types
UtsNameSystemInfoGetrandomFlags
Soit 3 types supplémentaires.
Décisions de fond émergées dans la famille system
1. gethostname implémenté via uname.
Plutôt qu’un syscall séparé. Économise un syscall théorique.
2. getrandom est volontairement minimaliste.
Pas de helper “génère N octets cryptographiques” sophistiqué en couche 0. Les couches supérieures fourniront ces helpers.
3. INSECURE est exposé mais déconseillé.
Le flag existe en kernel ; Air ne le cache pas. Mais la documentation insiste sur le fait que c’est réservé aux cas non-cryptographiques.
4. reboot non exposé.
Pas le rôle d’une application Air normale.
Bilan global de la couche 0
Avec la famille system, la couche 0 est entièrement spécifiée.
Récapitulatif par famille.
| Famille | Fonctions principales |
|---|---|
process | 18 syscalls |
fs | ~35 syscalls |
mem | 13 syscalls |
io_uring (Temps 1-4) | ~80 fonctions |
signal | ~12 fonctions |
time | ~8 fonctions |
net | ~30 fonctions |
ipc | ~6 fonctions |
security | ~6 fonctions |
system | 5 fonctions |
Total approximatif : ~213 fonctions wrapper publiques dans la couche 0.
Crate air-sys-types : ~187 types publics.
Licence du document : MPL 2.0
Statut : Spécification technique du module air-sys-syscall::system (couche 0).
Spec couche 0 — Famille terminal (termios/tty)
Spécification technique — Version 1.0
Vue d’ensemble de la famille
La famille terminal porte l’interface POSIX termios — la discipline de
ligne des terminaux (POSIX.1-1988, racines System V), terminal-agnostique et
stable. Elle est introduite par le descellement additif de la couche 0
(ADR-060) pour
débloquer la libc (tcgetattr/tcsetattr/PTY pour OpenSSH, isatty pour
stdio) et l’objet ergonomique de couche 1
air-terminal.
Découpage en tranches.
- Tranche 1 — le TYPE (ce document). [
Termios] et ses types associés vivent dansair-sys-types/src/terminal.rs. C’est un objet ABI pur, entièrement testable sur l’hôte (aucun syscall) : structure#[repr(C)], accesseurs typés, encodage/décodage des drapeaux et de la vitesse. Couverture 100 % lignes + branches. - Tranche 2 — les WRAPPERS « Attributs ». [
tcgetattr]/[tcsetattr] (cœur termios) arrivent dansair-sys-syscall/src/terminal.rset consomment le type décrit ici. Documentées ci-dessous (section « Wrappers — Attributs (tranche 2) »). Couverture 100 % lignes + branches. - Tranche 3 — les WRAPPERS « Contrôle de ligne » et « Taille fenêtre ».
Les
tc*POSIX restants ([tcdrain]/[tcflush]/[tcflow]/[tcsendbreak]) et les ioctls de dimension de fenêtre ([get_winsize]/[set_winsize], avec le type [Winsize]) arrivent dansair-sys-syscall/src/terminal.rs. Documentés ci-dessous (sections « Wrappers — Contrôle de ligne (tranche 3) » et « Wrappers — Taille fenêtre (tranche 3) »). Couverture 100 % lignes + branches. - Tranche 4 — les WRAPPERS « PTY ». L’allocation d’une paire
maître/esclave de pseudo-terminal ([
open_pty_master], [pty_number] avec le type [PtyNumber], [pty_unlock], [pty_peer]) arrive dansair-sys-syscall/src/terminal.rs. Documentée ci-dessous (section « Wrappers — PTY (tranche 4) »). Couverture 100 % lignes + branches. - Tranche 5 — les WRAPPERS « Session / job-control ». Le terminal de
contrôle et les groupes de premier plan
(
tcgetpgrp/tcsetpgrp/session_id/set_ctty/clear_ctty). Documentés ci-dessous (section « Wrappers — Session / job-control (tranche 5) »). Couverture 100 % hors exceptions CHILD-EXIT documentées et justifiées (ADR-035 : cycle nominal validé dans un enfant forkésetsid).
Caractéristiques transverses.
- Image ABI exacte du kernel. [
Termios] reproduitstruct termiosde<asm-generic/termbits.h>, identique sur x86_64 et aarch64 (tous deuxasm-generic). Aucune divergence de disposition. - Doctrine « kernel = bible ». Les champs bruts (
c_iflag,c_oflag,c_cflag,c_lflag) sont conservés tels quels ; les bits inconnus (d’un kernel plus récent) sont retenus, jamais silencieusement effacés. - Zéro allocation. Le type est
Copy, sur la pile, sans indirection ni heap — compatibleno_stdfutur. - Accès typé en lecture, champs bruts en écriture directe possible. Les
accesseurs retournent des drapeaux/enum typés ; les champs restent
pubpour l’échange direct avec le kernel via les ioctls (couche syscall).
Sous-section 1 : la structure Termios
#![allow(unused)]
fn main() {
pub const NCCS: usize = 19;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(C)]
pub struct Termios {
pub c_iflag: u32, // drapeaux d'entrée (brut)
pub c_oflag: u32, // drapeaux de sortie (brut)
pub c_cflag: u32, // drapeaux de contrôle (brut, inclut la vitesse CBAUD)
pub c_lflag: u32, // drapeaux locaux (brut)
pub c_line: u8, // discipline de ligne
pub c_cc: [u8; NCCS], // caractères de contrôle
}
}
Disposition. #[repr(C)] reproduit struct termios du kernel ; la même
disposition vaut sur x86_64 et aarch64 (asm-generic). NCCS vaut 19 : le
tableau c_cc a 19 slots, dont seuls les indices 0..=16 sont nommés (cf.
[ControlChar]) — les slots 17 et 18 sont réservés (padding kernel, sans nom
POSIX) et conservés à zéro.
Constructeur.
#![allow(unused)]
fn main() {
pub const fn zeroed() -> Termios;
}
Retourne l’image toute-à-zéro, à passer à un TCGETS qui la remplira. Sur cette
image : drapeaux vides, taille de caractère Bits5 (motif CSIZE = 0), vitesse
B0.
Accesseurs de drapeaux (lecture typée).
#![allow(unused)]
fn main() {
pub const fn input_flags(&self) -> InputFlags;
pub const fn output_flags(&self) -> OutputFlags;
pub const fn control_flags(&self) -> ControlFlags;
pub const fn local_flags(&self) -> LocalFlags;
}
Chacun décode le champ brut correspondant via from_bits_retain : les bits
inconnus sont conservés (doctrine kernel = bible). control_flags() expose le
c_cflag complet, y compris les bits CSIZE/CBAUD — la taille et la vitesse
se lisent via leurs accesseurs dédiés (ci-dessous), pas via ControlFlags.
Mutateurs de drapeaux (écriture typée).
#![allow(unused)]
fn main() {
pub const fn set_input_flags(&mut self, flags: InputFlags);
pub const fn set_output_flags(&mut self, flags: OutputFlags);
pub const fn set_control_flags(&mut self, flags: ControlFlags);
pub const fn set_local_flags(&mut self, flags: LocalFlags);
}
set_input_flags/set_output_flags/set_local_flags remplacent
intégralement le champ brut. set_control_flags est spécial : il préserve les
bits CSIZE et CBAUD de c_cflag (taille de caractère et vitesse), de sorte
qu’écrire les drapeaux de contrôle ne détruise ni la taille ni la vitesse déjà
posées. ControlFlags ne recouvre ni CSIZE ni CBAUD : pas de chevauchement.
Taille de caractère (CSIZE).
#![allow(unused)]
fn main() {
pub const fn char_size(&self) -> CharSize;
pub const fn set_char_size(&mut self, size: CharSize);
}
Lecture/écriture des 2 bits CSIZE de c_cflag, indépendamment de la vitesse et
des autres drapeaux de contrôle.
Vitesse (CBAUD/CBAUDEX).
#![allow(unused)]
fn main() {
pub const fn output_speed(&self) -> BaudRate;
pub const fn input_speed(&self) -> BaudRate; // = output_speed (termios classique Linux)
pub const fn set_speed(&mut self, baud: BaudRate);
}
En termios classique Linux, entrée et sortie partagent le champ CBAUD de
c_cflag : input_speed() et output_speed() renvoient donc la même valeur.
set_speed ne touche que les bits CBAUD|CBAUDEX, indépendamment de la taille.
Caractères de contrôle (c_cc).
#![allow(unused)]
fn main() {
pub const fn control_char(&self, which: ControlChar) -> u8;
pub const fn set_control_char(&mut self, which: ControlChar, value: u8);
}
Indexation typée du tableau c_cc par [ControlChar] — jamais d’entier brut en
indice.
Sous-section 2 : les types associés
ControlChar — indices des caractères de contrôle
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(u8)]
pub enum ControlChar {
Intr = 0, Quit = 1, Erase = 2, Kill = 3, Eof = 4, Time = 5, Min = 6,
Swtc = 7, Start = 8, Stop = 9, Susp = 10, Eol = 11, Reprint = 12,
Discard = 13, Werase = 14, Lnext = 15, Eol2 = 16,
}
}
Chaque variante est l’indice POSIX (V* de <asm-generic/termbits.h>) dans
c_cc. 17 caractères nommés (0..=16) pour 19 slots (NCCS) : les slots 17/18
sont réservés.
Drapeaux — quatre bitflags typés
Quatre types bitflags exposent les bits des quatre champs de drapeaux. Chacun
dérive from_bits_retain (conserve les bits inconnus) et bits() (round-trip
vers l’entier brut).
- [
InputFlags] (c_iflag) :IGNBRK,BRKINT,IGNPAR,PARMRK,INPCK,ISTRIP,INLCR,IGNCR,ICRNL,IUCLC,IXON,IXANY,IXOFF,IMAXBEL,IUTF8. - [
OutputFlags] (c_oflag) :OPOST,OLCUC,ONLCR,OCRNL,ONOCR,ONLRET,OFILL,OFDEL. - [
ControlFlags] (c_cflag, hors taille/vitesse) :CSTOPB,CREAD,PARENB,PARODD,HUPCL,CLOCAL,CRTSCTS. - [
LocalFlags] (c_lflag) :ISIG,ICANON,ECHO,ECHOE,ECHOK,ECHONL,NOFLSH,TOSTOP,ECHOCTL,ECHOPRT,ECHOKE,FLUSHO,PENDIN,IEXTEN.
CharSize — nombre de bits de données (CSIZE)
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
pub enum CharSize { Bits5 = 0x00, Bits6 = 0x10, Bits7 = 0x20, Bits8 = 0x30 }
}
Le masque CSIZE (0x30) n’a que 4 motifs : le décodage est total
(jamais None) ; le motif 0x30 est Bits8.
BaudRate — vitesse de transmission (CBAUD/CBAUDEX)
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BaudRate(u32);
pub const fn from_code(code: u32) -> BaudRate; // masque par CBAUD (0x100f)
pub const fn code(self) -> u32; // réinjection dans c_cflag
}
Newtype sur le code de vitesse encodé (CBAUD|CBAUDEX). Constantes POSIX
standard exposées : B0, B50, …, B38400 (0x0..0xf), puis les vitesses
étendues B57600, B115200, B230400, B460800, B921600 (0x1001..).
from_code/code forment un round-trip qui préserve l’octet de vitesse ;
une valeur kernel inconnue est rendue telle quelle (bits hors CBAUD_MASK
ignorés, le reste conservé) — pas de perte d’information sur la vitesse.
SetAction, FlushQueue, FlowAction — arguments des wrappers à venir
Ces trois enums typent les arguments des ioctls des tranches suivantes
(air-sys-syscall). Ils remplacent les constantes magiques du kernel par des
variantes explicites (ADR-021 conv. 3).
#![allow(unused)]
fn main() {
pub enum SetAction { Now, Drain, Flush } // tcsetattr : quand appliquer (TCSANOW/TCSADRAIN/TCSAFLUSH)
pub enum FlushQueue { Input, Output, Both } // tcflush : quelle file purger (TCIFLUSH/TCOFLUSH/TCIOFLUSH)
pub enum FlowAction { SuspendOutput, ResumeOutput, // tcflow : contrôle de flux (TCOOFF/TCOON/
SuspendInput, ResumeInput } // TCIOFF/TCION)
}
SetAction type l’argument de [tcgetattr]/[tcsetattr] (tranche 2) ;
FlushQueue et FlowAction typent respectivement [tcflush] et [tcflow]
(tranche 3, ci-dessous).
Winsize — dimensions de la fenêtre du terminal
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(C)]
pub struct Winsize {
pub ws_row: u16, // nombre de lignes (rangées)
pub ws_col: u16, // nombre de colonnes
pub ws_xpixel: u16, // largeur en pixels (souvent 0)
pub ws_ypixel: u16, // hauteur en pixels (souvent 0)
}
pub const fn zeroed() -> Winsize;
pub const fn rows(&self) -> u16; // = ws_row
pub const fn cols(&self) -> u16; // = ws_col
pub const fn x_pixels(&self) -> u16; // = ws_xpixel
pub const fn y_pixels(&self) -> u16; // = ws_ypixel
}
Image ABI de struct winsize (<asm-generic/termios.h>), #[repr(C)],
identique x86_64/aarch64. Champs pub bruts (échange direct avec le kernel,
comme [Termios]) plus des accesseurs const nommés pour la lecture. zeroed()
donne l’image nulle à passer à un TIOCGWINSZ. Les champs pixels sont souvent à
0 (nombre de terminaux ne les renseignent pas). Le kernel signale un changement
de taille par SIGWINCH. Échangée par [get_winsize]/[set_winsize] (tranche 3).
Wrappers — Attributs (tranche 2)
Le module air-sys-syscall/src/terminal.rs livre les deux ioctls typés du
cœur termios (ADR-060,
groupe « Attributs »). Ce sont des fonctions dédiées typées (ADR-021 conv. 3 :
jamais d’ioctl(fd, REQ, …) générique) ; zéro allocation (le [Termios] vit sur
la pile) ; EINTR est remonté tel quel à l’appelant, sans retry automatique
(ADR-021 conv. 2).
#![allow(unused)]
fn main() {
pub fn tcgetattr(fd: BorrowedFd<'_>) -> Result<Termios, Errno>;
pub fn tcsetattr(fd: BorrowedFd<'_>, action: SetAction, attributes: &Termios)
-> Result<(), Errno>;
}
tcgetattr (TCGETS). Alloue un [Termios::zeroed] sur la pile, le fait
remplir par le kernel via l’ioctl TCGETS (0x5401), puis le renvoie. Le retour
est normalisé (comme musl, cf. audit §7) : ioctl < 0 → Err(errno) ; sinon
Ok(termios) — jamais une valeur ioctl arbitraire propagée.
tcsetattr (TCSETS/TCSETSW/TCSETSF). Applique attributes au terminal
à l’instant indiqué par action. Le mapping action → requête se fait par un
match exhaustif, jamais par l’arithmétique TCSETS + action (fragilité
pointée par l’audit musl §8, dépendante de la contiguïté des constantes) :
[SetAction] | Requête ioctl | Valeur | Sémantique POSIX |
|---|---|---|---|
Now | TCSETS | 0x5402 | TCSANOW — applique immédiatement |
Drain | TCSETSW | 0x5403 | TCSADRAIN — après vidage de la sortie |
Flush | TCSETSF | 0x5404 | TCSAFLUSH — après vidage sortie + purge entrée |
Les constantes TC* proviennent de <asm-generic/ioctls.h> et sont identiques
sur x86_64 et aarch64. Le module porte son propre helper de syscall ioctl
bas niveau (inline asm syscall/svc 0, SYS_ioctl = 16/29) et son propre
helper de décodage d’errno, sur le modèle de device.rs.
Erreurs. [Errno::ENOTTY] (fd n’est pas un terminal), [Errno::EBADF]
(descripteur invalide), [Errno::EINTR] (interrompu, remonté tel quel). Une
erreur EFAULT est exclue par construction : le Termios échangé est
toujours une variable de pile vivante et alignée.
Terminal de test. Le happy path exige un vrai terminal : les tests ouvrent le
maître du PTY via /dev/ptmx (O_RDWR), qui répond aux ioctls TCGETS/
TCSETS* sur Linux sans privilège (l’allocation PTY complète —
TIOCGPTN/TIOCSPTLCK — relève d’une tranche ultérieure). Le round-trip bascule
un drapeau (ECHO) et vérifie qu’il est bien appliqué ; les trois variantes de
[SetAction] sont chacune exercées. Les chemins d’erreur (Err) s’exercent sur
un fd non-tty (/dev/null → ENOTTY). Couverture 100 % lignes + branches,
sans exception ADR-035.
Wrappers — Contrôle de ligne (tranche 3)
Le module air-sys-syscall/src/terminal.rs livre les quatre tc* POSIX restants
(ADR-060, groupe
« Contrôle de ligne »). Mêmes conventions que la tranche 2 : fonctions dédiées
typées (ADR-021 conv. 3), zéro allocation, EINTR remonté tel quel sans retry.
#![allow(unused)]
fn main() {
pub fn tcdrain(fd: BorrowedFd<'_>) -> Result<(), Errno>;
pub fn tcflush(fd: BorrowedFd<'_>, queue: FlushQueue) -> Result<(), Errno>;
pub fn tcflow(fd: BorrowedFd<'_>, action: FlowAction) -> Result<(), Errno>;
pub fn tcsendbreak(fd: BorrowedFd<'_>) -> Result<(), Errno>;
}
tcdrain (TCSBRK, argument 1). Bloque jusqu’à la transmission de tous les
octets déjà écrits vers fd. Sur Linux, ioctl(fd, TCSBRK, arg) avec un arg
non nul draine la sortie (sémantique tcdrain, cf. musl) ; l’envoi de break
correspond à arg == 0 ([tcsendbreak]). C’est le point d’annulation de la
famille (audit musl §3) : elle peut rendre [Errno::EINTR], remonté tel quel,
aucun retry automatique (ADR-021 conv. 2 — l’appelant qui veut retenter écrit la
boucle).
tcflush (TCFLSH). Jette les octets non traités de la (des) file(s) visée(s).
Le mapping [FlushQueue] → argument entier est un match exhaustif :
[FlushQueue] | Argument | Valeur | Sémantique POSIX |
|---|---|---|---|
Input | TCIFLUSH | 0 | purge les données reçues non lues |
Output | TCOFLUSH | 1 | purge les données écrites non transmises |
Both | TCIOFLUSH | 2 | purge les deux files |
tcflow (TCXONC). Suspend/reprend le flux d’entrée ou de sortie. Le mapping
[FlowAction] → argument entier est un match exhaustif :
[FlowAction] | Argument | Valeur | Sémantique POSIX |
|---|---|---|---|
SuspendOutput | TCOOFF | 0 | suspend la sortie |
ResumeOutput | TCOON | 1 | reprend la sortie |
SuspendInput | TCIOFF | 2 | émet un STOP (suspend l’entrée distante) |
ResumeInput | TCION | 3 | émet un START (reprend l’entrée distante) |
tcsendbreak (TCSBRK, argument 0). Transmet une condition BREAK. Durée
non paramétrée (honnêteté de contrat). POSIX prévoit un paramètre de durée, mais
il est implementation-defined et musl l’ignore silencieusement (audit musl
§6/§8). Plutôt qu’exposer un paramètre avalé sans effet, Air n’en expose aucun
— le break dure la durée par défaut du kernel, et le contrat dit exactement ce
qu’il fait (doctrine « la libc fait ce qu’elle dit »). Le partage du code d’ioctl
TCSBRK avec [tcdrain] est explicite : deux constantes d’argument nommées
(0 = break, 1 = drain) distinguent les sémantiques.
Wrappers — Taille fenêtre (tranche 3)
Deux ioctls typés échangent la [Winsize] du terminal
(ADR-060, groupe
« Taille fenêtre »).
#![allow(unused)]
fn main() {
pub fn get_winsize(fd: BorrowedFd<'_>) -> Result<Winsize, Errno>;
pub fn set_winsize(fd: BorrowedFd<'_>, size: &Winsize) -> Result<(), Errno>;
}
get_winsize (TIOCGWINSZ, 0x5413). Alloue un [Winsize::zeroed] sur la
pile, le fait remplir par le kernel, puis le renvoie (retour normalisé comme
[tcgetattr]). set_winsize (TIOCSWINSZ, 0x5414). Impose size à la
fenêtre ; le kernel émet alors SIGWINCH vers le groupe de premier plan. Les deux
passent un pointeur vers la struct winsize (comme les ioctls termios).
Erreurs (contrôle de ligne + taille fenêtre). [Errno::ENOTTY] (fd n’est pas
un terminal), [Errno::EBADF] (descripteur invalide), et [Errno::EINTR] pour la
seule [tcdrain] (opération bloquante). EFAULT est exclue par construction
pour get_winsize/set_winsize : la Winsize échangée est toujours une variable
de pile vivante et alignée.
Terminal de test. Comme en tranche 2, le happy path s’exerce sur le maître du
PTY via /dev/ptmx (sans privilège) : tcdrain/tcsendbreak réussissent
immédiatement, les trois [FlushQueue] et les quatre [FlowAction] exercent tous
les bras des match, et un round-trip set_winsize(40×120) → get_winsize
vérifie les valeurs relues. Les chemins d’erreur (Err) de chaque fonction
s’exercent sur un fd non-tty (/dev/null → ENOTTY). Couverture 100 % lignes +
branches, sans exception
ADR-035.
Wrappers — PTY (tranche 4)
Quatre wrappers allouent une paire maître/esclave de pseudo-terminal (ADR-060, groupe « PTY ») et un newtype d’identifiant nomme l’esclave.
#![allow(unused)]
fn main() {
pub struct PtyNumber(u32); // N de /dev/pts/N (rendu par TIOCGPTN)
impl PtyNumber {
pub const fn new(value: u32) -> Self;
pub const fn value(self) -> u32;
}
pub fn open_pty_master() -> Result<OwnedFd, Errno>;
pub fn pty_number(master: BorrowedFd<'_>) -> Result<PtyNumber, Errno>;
pub fn pty_unlock(master: BorrowedFd<'_>) -> Result<(), Errno>;
pub fn pty_peer(master: BorrowedFd<'_>) -> Result<OwnedFd, Errno>;
}
PtyNumber. Newtype d’identifiant (ADR-021 : jamais un u32 brut pour
désigner un esclave, comme [Pid]/[Tid]/[PidFd]). Il porte l’N du nœud
/dev/pts/N.
open_pty_master. Ouvre un nouveau maître via /dev/ptmx en
O_RDWR | O_NOCTTY (chaque ouverture alloue une paire distincte). Dogfoode
openat — qui ajoute O_CLOEXEC — plutôt qu’un
open brut. Le maître est rendu en [OwnedFd] (RAII : sa fermeture libère la
paire). O_NOCTTY empêche le maître de devenir le terminal contrôlant.
pty_number (TIOCGPTN, 0x8004_5430). Le kernel écrit un unsigned int (u32) sur la pile ; renvoyé en [PtyNumber].
pty_unlock (TIOCSPTLCK, 0x4004_5431). Le kernel lit un int sur la
pile ; on lui présente un 0 qui déverrouille l’esclave (prérequis avant son
ouverture, équivalent d’unlockpt). Le verrouillage (1) n’est pas exposé —
seul le déverrouillage est utile.
pty_peer (TIOCGPTPEER, 0x5441). Ouvre directement l’esclave du
maître et renvoie son fd (en [OwnedFd]). L’argument ioctl est une valeur
entière de drapeaux d’ouverture (O_RDWR | O_NOCTTY | O_CLOEXEC,
O_CLOEXEC demandé explicitement car cet ioctl ne l’ajoute pas) ; la valeur de
retour de l’ioctl est le nouveau descripteur. Cette voie moderne (Linux 4.13+)
est préférée au chemin /dev/pts/N : elle évite le TOCTOU (l’esclave ne
peut être substitué entre la résolution du nom et l’ouverture) et ne dépend pas du
montage de devpts.
Encodage des requêtes. Contrairement aux TC* numérotées en clair, ces trois
ioctls suivent le schéma d’encodage _IOC arch-générique de
<asm-generic/ioctls.h> (_IOR/_IOW/_IO), identique x86_64/aarch64. La
valeur de retour >= 0 de TIOCGPTPEER est toujours un fd dans l’intervalle
i32 (un descripteur est < 2^31) : la conversion as i32 est exacte et locale
(commentée), sans try_from dont le bras Err serait inatteignable.
Flux d’allocation. open_pty_master → pty_number (identifier l’esclave, si
besoin) → pty_unlock (déverrouiller) → pty_peer (ouvrir l’esclave).
Erreurs. [Errno::ENOTTY] (le fd n’est pas un maître de PTY, ex. /dev/null),
[Errno::EBADF] (descripteur invalide), [Errno::EINVAL] (kernel < 4.13 pour
pty_peer), [Errno::ENOSPC] (plus de PTY libre pour open_pty_master). Aucun
EFAULT par construction : TIOCGPTN/TIOCSPTLCK échangent une variable de pile
vivante et alignée ; TIOCGPTPEER ne prend qu’un entier.
Terminal de test. Le happy path exerce le cycle complet sans privilège :
open_pty_master → pty_number (valeur lisible) → pty_unlock → pty_peer,
puis tcgetattr(slave) prouve que l’esclave est un vrai terminal, et
master != slave prouve la paire. Les chemins d’erreur (Err) de pty_number,
pty_unlock et pty_peer s’exercent sur un fd non-tty (/dev/null → ENOTTY).
Couverture 100 % lignes + branches, sans exception
ADR-035.
Wrappers — Session / job-control (tranche 5)
Cinq wrappers pilotent le terminal de contrôle et les groupes de premier plan (ADR-060, groupe « Session / job-control »).
#![allow(unused)]
fn main() {
pub fn tcgetpgrp(fd: BorrowedFd<'_>) -> Result<Option<Pid>, Errno>;
pub fn tcsetpgrp(fd: BorrowedFd<'_>, process_group: Pid) -> Result<(), Errno>;
pub fn session_id(fd: BorrowedFd<'_>) -> Result<Pid, Errno>;
pub fn set_ctty(fd: BorrowedFd<'_>) -> Result<(), Errno>;
pub fn clear_ctty(fd: BorrowedFd<'_>) -> Result<(), Errno>;
}
tcgetpgrp (TIOCGPGRP, 0x540F). Le kernel écrit un pid_t (i32) sur
la pile : le groupe de premier plan du terminal. Un pid_t 0 signifie
« aucun groupe de premier plan » → converti en [None] (sentinelle → Option,
ADR-021 conv. 1). La conversion passe par [Pid::try_from_raw], qui replie tout
pid_t non strictement positif (donc 0 comme un négatif aberrant que ce
ioctl ne peut pas produire) sur None — défense en profondeur (Principe 5).
tcsetpgrp (TIOCSPGRP, 0x5410). Le kernel lit un pid_t (i32) sur la
pile : le groupe de premier plan à installer. process_group est un [Pid] typé
(non nul), donc aucune sentinelle 0 n’est jamais présentée au kernel.
session_id (TIOCGSID, 0x5429). Le kernel écrit le pid_t du leader
de session du terminal, toujours strictement positif ; la conversion via
[Pid::try_from_raw] est dénotée par .expect (motif de getsid) — un pid_t ≤ 0
serait une violation du contrat kernel, inatteignable par construction.
set_ctty (TIOCSCTTY, 0x540E, argument 0). Fait de fd le terminal de
contrôle du leader de session appelant. L’argument 0 demande une
acquisition sans vol : le vol (arg = 1) exige CAP_SYS_ADMIN et n’est
pas exposé (hors périmètre — listé en UNSUPPORTED.md à la tranche 6).
clear_ctty (TIOCNOTTY, 0x5422). Détache le terminal de contrôle du
processus appelant. Appelé par le leader de session, le kernel envoie
SIGHUP+SIGCONT au groupe de premier plan (à prendre en compte par l’appelant).
Encodage des requêtes. Ces cinq ioctls sont numérotés en clair dans
<asm-generic/ioctls.h> (comme les TC*), identiques x86_64/aarch64.
Erreurs. [Errno::ENOTTY] (le fd n’est pas le terminal de contrôle de
l’appelant, ou pas un terminal du tout : /dev/null, ou pty non-contrôlant),
[Errno::EPERM] (set_ctty par un non-leader, ou tcsetpgrp d’un groupe hors
session), [Errno::EBADF] (descripteur invalide). Aucun EFAULT par
construction : TIOCGPGRP/TIOCGSID écrivent une variable de pile vivante et
alignée, TIOCSPGRP en lit une, TIOCSCTTY/TIOCNOTTY ne prennent qu’un entier.
Terminal de test — validation on-target (enfant forké setsid). Le chemin
nominal de ces ioctls exige un terminal de contrôle, que le kernel
n’accorde qu’à un leader de session. Les chemins d’erreur (Err) sont donc
couverts dans le parent sur un fd non-tty (/dev/null → ENOTTY), et le
cycle nominal complet est validé dans un enfant forké setsid (helper
fork_run, motif de process.rs) : setsid → set_ctty(slave) →
session_id(slave) == SID → tcsetpgrp(slave, SID) → tcgetpgrp(slave) == Some(SID) → clear_ctty(slave) (après avoir bloqué SIGHUP). L’enfant encode
chaque étape dans son code de sortie (0 = tout le cycle a réussi). Le bras
Ok(None) de tcgetpgrp est, lui, couvert dans le parent (un maître
/dev/ptmx neuf n’a aucun groupe de premier plan → TIOCGPGRP rend 0). Les
lignes du bras Ok de tcsetpgrp/session_id/set_ctty/clear_ctty, exécutées
uniquement dans l’enfant, sont des exceptions CHILD-EXIT
(ADR-035 ; profil LLVM
de l’enfant non flushé, async-signal-safe — cf. docs/COVERAGE-EXCEPTIONS.md),
prouvées par le code de sortie.
Périmètre hors tranches 1 à 5
Les cinq groupes d’ioctl de la famille sont désormais livrés en air-sys-syscall
(« Attributs » — tcgetattr/tcsetattr — en tranche 2 ; « Contrôle de ligne » et
« Taille fenêtre » en tranche 3 ; PTY —
open_pty_master/pty_number/pty_unlock/pty_peer — en tranche 4 ; Session
/ job-control — tcgetpgrp/tcsetpgrp/session_id/set_ctty/clear_ctty —
en tranche 5, documentés ci-dessus ; cf.
ADR-060). Restent
hors périmètre : le vol de terminal de contrôle (TIOCSCTTY arg = 1,
exige CAP_SYS_ADMIN) et les ioctls tty legacy/dangereux (TIOCSTI,
TCGETA/TCSETA pré-POSIX) — à consigner dans UNSUPPORTED.md. isatty et
ttyname restent en couche 1 (dérivés), de même que l’objet ergonomique
air-terminal (ADR-061).
Spec couche 0 — Famille device
Spécification technique — Version 1.0
Vue d’ensemble de la famille
Le module air-sys-syscall::device expose les primitives d’interaction avec les
périphériques matériels exposés par le kernel : réception des notifications
uevent (apparition/disparition de matériel, via un socket netlink), accès
aux périphériques d’entrée evdev (/dev/input/eventX : claviers, souris,
manettes, écrans tactiles), et l’articulation avec sysfs (/sys/...) pour
la lecture/écriture des attributs de périphériques.
Cette famille est le socle système de tout ce qui, dans les couches supérieures, construit un modèle de périphériques : énumération du matériel, hotplug, gestion des entrées du compositeur Wayland (ADR-003), services système couche 5.
Périmètre de la famille.
Trois sous-systèmes distincts :
-
uevent(netlink) : un socketNETLINK_KOBJECT_UEVENTreçoit du kernel les messages d’apparition/disparition de périphériques. La couche 0 ouvre le socket, lit les messages, et décode le format de fil (entêteaction@devpath+ pairesCLÉ=VALEURséparées par\0) via un itérateur emprunté, zéro allocation. -
evdev(entrée) : les périphériques d’entrée sont des char devices ouverts via la famillefs(openat). La couche 0 fournit la lecture typée des enregistrementsstruct input_eventet les ioctls dédiés (EVIOC*) sous forme de fonctions typées (jamais un ioctl générique, cf. ADR-021 convention 3). -
sysfs: pseudo-système de fichiers ; ses attributs se lisent et s’écrivent avec la famillefs. La couche 0 n’ajoute aucun wrapper spécifique (cf. sous-section 3) — la construction de chemins et le parsing d’attributs sont de la logique de couche 1.
Caractéristiques transverses de la famille.
-
CLOEXEC universel. Tout FD créé par la famille (socket uevent) porte
CLOEXECpar défaut. Les char devicesevdevsont ouverts parfs::openat, qui applique déjà la même discipline. -
Décodage = miroir de format kernel, pas logique. Le décodage des messages
uevent(pairesclé=valeur) et desinput_eventreflète des formats de fil ABI kernel stables. C’est la même catégorie queSignalFdInfo(famillesignal), que la couche 0 décode déjà. À l’inverse, construire un modèle riche de périphérique (enums de sous-système typés, arbre de devices, corrélation uevent ↔ sysfs) est de la logique : couche 1. -
Parsers empruntés, zéro allocation. Les décodeurs
uevent/input_eventécrivent dans (ou empruntent à) un buffer fourni par l’appelant. Aucune allocation heap dans le happy path (ADR-021 convention 4). -
Pas d’ioctl générique. Chaque opération
EVIOC*est une fonction dédiée et typée (ADR-021 convention 3). Aucune fonctionioctl(fd, request, ...)n’est exposée. -
Sentinelles kernel →
Option<T>/ enums typés (ADR-021 convention 1).EVIOCGRAB(argument1vs pointeur nul) devient deux fonctions distinctesevdev_grab/evdev_release; le clockid de timestamp devient un enum.
Sous-section 1 : uevent — notifications de périphériques (netlink)
Le kernel diffuse, sur un socket netlink du protocole NETLINK_KOBJECT_UEVENT,
un message à chaque événement de cycle de vie d’un périphérique (add, remove,
change, bind, unbind, move, online, offline). C’est le mécanisme de
hotplug moderne, en remplacement de l’ancien /sbin/hotplug.
Le socket : UEventSocket
#![allow(unused)]
fn main() {
pub struct UEventSocket { /* opaque, possède un OwnedFd interne */ }
pub fn uevent_socket_open(
groups: UEventGroups,
flags: UEventSocketFlags,
) -> Result<UEventSocket, Errno>;
bitflags! {
/// Groupes multicast netlink à écouter.
pub struct UEventGroups: u32 {
/// Messages bruts générés par le kernel (groupe netlink 1).
const KERNEL = 1 << 0;
/// Messages re-diffusés par le gestionnaire de périphériques
/// userspace (groupe netlink 2, dit « libudev monitor »).
const USERSPACE = 1 << 1;
}
}
bitflags! {
pub struct UEventSocketFlags: i32 {
const NONBLOCK = 0x800; // SOCK_NONBLOCK
const CLOEXEC = 0x80000; // SOCK_CLOEXEC (toujours activé par le wrapper)
}
}
impl UEventSocket {
pub fn as_fd(&self) -> BorrowedFd<'_>;
pub fn into_fd(self) -> OwnedFd;
/// Lit un message et le décode en place dans `buffer`.
///
/// Le `UEventMessage` retourné **emprunte** `buffer` : il reste valide tant
/// que le buffer n'est pas réutilisé.
pub fn read<'b>(
&self,
buffer: &'b mut [u8],
) -> Result<UEventMessage<'b>, Errno>;
}
}
Syscalls sous-jacents. socket (AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC,
NETLINK_KOBJECT_UEVENT = 15) puis bind sur un struct sockaddr_nl
(nl_family = AF_NETLINK, nl_pid = 0 — le kernel assigne, nl_groups selon
UEventGroups). La lecture utilise recvmsg (et non recv) afin de récupérer
l’adresse source et de vérifier l’authenticité (cf. ci-dessous). Numéros :
socket (x86_64 n°41, ARM64 n°198), bind (x86_64 n°49, ARM64 n°200),
recvmsg (x86_64 n°47, ARM64 n°212).
Préconditions.
groupsne peut pas être vide (EINVALsinon — rien à écouter).- Écouter le groupe
KERNELne requiert pas de privilège. Les messages du groupeKERNELproviennent toujours du kernel (nl_pid == 0). CLOEXECest toujours activé par le wrapper.
Comportement.
Crée et lie le socket en une opération. À chaque read, le kernel délivre un
message uevent complet. Si le buffer est trop petit, le message est tronqué et
l’erreur EMSGSIZE/ENOBUFS peut être rapportée selon le mode — l’appelant
dimensionne donc le buffer généreusement (cf. « Dimensionnement »).
Authenticité — vérification anti-usurpation.
N’importe quel processus disposant de CAP_NET_ADMIN peut émettre vers un
groupe multicast netlink. Pour ne pas confondre un vrai uevent kernel avec un
message forgé, le wrapper vérifie systématiquement, via recvmsg, que l’adresse
source a nl_pid == 0 (kernel) et que le crédential SCM_CREDENTIALS, si le
mode USERSPACE est utilisé, correspond à uid == 0. Un message qui échoue à
cette vérification provoque Errno::EPERM (le message est consommé et rejeté).
Décision (couche 0 « abstraire sans cacher »). Le wrapper valide la source parce que c’est une précondition de sûreté du mécanisme lui-même (sans elle, l’API serait un piège). Il ne va pas plus loin : il ne filtre pas par sous-système, ne déduplique pas, ne corréle pas avec
sysfs. Ces commodités sont de la logique de couche 1.
Dimensionnement du buffer.
Un message uevent kernel tient quasi toujours sous 2 Kio, mais peut atteindre
~16 Kio dans des cas extrêmes (longues listes de propriétés). Recommandation :
buffer de 8192 octets. Une constante UEVENT_RECOMMENDED_BUFFER_SIZE = 8192
est exposée à titre indicatif.
Erreurs.
EINVAL:groupsvide ou flags invalides.EPERM: message rejeté pour cause d’authenticité (source non-kernel).EMSGSIZE/ENOBUFS: buffer trop petit ou file de réception saturée.EAGAIN: socketNONBLOCKsans message disponible.EMFILE,ENFILE,ENOMEM: limites de ressources.
Performance.
Ouverture : ~10-20 µs. Lecture d’un message disponible : ~2-5 µs. Le débit d’événements est intrinsèquement faible (hotplug), donc non critique.
Le message décodé : UEventMessage
#![allow(unused)]
fn main() {
pub struct UEventMessage<'b> { /* emprunte &'b [u8] */ }
impl<'b> UEventMessage<'b> {
/// L'action (entête avant le premier `\0`), p. ex. `add`, `remove`.
/// Sous-ensemble typé pour les actions connues, brut sinon.
pub fn action(&self) -> UEventAction;
/// Le `DEVPATH` relatif à `/sys` (extrait de l'entête ou des propriétés).
pub fn device_path(&self) -> Option<&'b [u8]>;
/// Le sous-système (`SUBSYSTEM=...`), p. ex. `usb`, `input`, `block`.
pub fn subsystem(&self) -> Option<&'b [u8]>;
/// La valeur d'une propriété arbitraire par clé.
pub fn property(&self, key: &[u8]) -> Option<&'b [u8]>;
/// Itère toutes les paires `(clé, valeur)` sans allouer.
pub fn properties(&self) -> UEventProperties<'b>;
/// Les octets bruts du message (pour diagnostic / passthrough).
pub fn as_bytes(&self) -> &'b [u8];
}
pub struct UEventProperties<'b> { /* curseur sur le buffer */ }
impl<'b> Iterator for UEventProperties<'b> {
type Item = (&'b [u8], &'b [u8]); // (clé, valeur), tranches empruntées
fn next(&mut self) -> Option<Self::Item>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UEventAction {
Add,
Remove,
Change,
Move,
Online,
Offline,
Bind,
Unbind,
/// Action non reconnue ; la tranche est l'entête brut.
Other,
}
}
Format de fil décodé.
Un message uevent kernel a la forme :
add@/devices/pci0000:00/.../input/input12\0ACTION=add\0DEVPATH=/devices/.../input12\0SUBSYSTEM=input\0...\0
- Entête : tout ce qui précède le premier
\0—action@devpath. - Propriétés : suite de
CLÉ=VALEURterminées par\0.
Le décodeur ne fait aucune copie : action(), subsystem(), property()
et l’itérateur properties() rendent des tranches qui pointent dans le buffer
de l’appelant. Les clés/valeurs sont des octets (&[u8]), pas du str :
le kernel ne garantit pas l’UTF-8 (ADR — couche 0, « zéro présomption sur les
encodages », Principe 3). La conversion en str est laissée à l’appelant.
Note sur le format USERSPACE (libudev).
Les messages du groupe USERSPACE sont préfixés d’un entête binaire
libudev (magic 0xfeedcafe, offsets). Le décodeur le détecte et expose les
propriétés de la même façon ; l’entête binaire est masqué derrière l’API. Si le
magic est absent ou corrompu, read renvoie EBADMSG.
Tests.
- Décodage d’un message
addsynthétique : vérifieraction,subsystem, itération complète des propriétés, absence d’allocation (via un allocateur de test qui panique suralloc). - Message tronqué / sans entête → erreur propre, pas de panique ni d’OOB
(slicing via
get, jamais d’indexation directe — Principe 3). - Test de bout en bout (privilégié, marqué « ignore » par défaut) : déclencher
un vrai uevent (p. ex.
modprobe/rmmodd’un module factice, ou écriture/sys/.../uevent) et lire le message. - Property-based (proptest) : pour tout buffer d’octets, le décodeur ne panique jamais et l’itérateur termine.
- Fuzzing (
cargo-fuzz) : le décodeur uevent accepte des données externes (le buffer kernel) → harnais de fuzz obligatoire surUEventMessage::parse.
Sous-section 2 : evdev — périphériques d’entrée
Les périphériques d’entrée Linux sont exposés sous /dev/input/eventX. On les
ouvre avec la famille fs (openat en lecture, éventuellement
OpenFlags::NONBLOCK) — la famille device n’a pas de fonction d’ouverture
dédiée, un evdev étant un char device ordinaire. Ce que la couche 0 ajoute :
la lecture typée des événements et les ioctls EVIOC* d’interrogation et
de contrôle.
Lecture des événements : InputEvent
#![allow(unused)]
fn main() {
/// Miroir `#[repr(C)]` de `struct input_event` (24 octets sur LP64).
/// Champs aux noms kernel conservés (ADR-029, nuance « type miroir »).
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct InputEvent {
/// Secondes de l'horodatage (`struct timeval::tv_sec`, `time_t`).
/// **Signé** : `time_t`/`suseconds_t` sont des `long` sur LP64 (nos 2 cibles).
pub sec: i64,
/// Microsecondes de l'horodatage (`struct timeval::tv_usec`, `suseconds_t`).
pub usec: i64,
/// Type d'événement (`EV_KEY`, `EV_REL`, `EV_ABS`, `EV_SYN`...).
pub event_type: u16,
/// Code (touche, axe, bouton) dépendant du type.
pub code: u16,
/// Valeur (1/0 pour une touche, delta pour `EV_REL`, absolu pour `EV_ABS`).
pub value: i32,
}
/// Lit un lot d'événements dans `events`, sans allocation.
/// Retourne le nombre d'événements **complets** lus.
pub fn evdev_read_events(
device: BorrowedFd<'_>,
events: &mut [InputEvent],
) -> Result<usize, Errno>;
}
Syscall sous-jacent. read (x86_64 n°0, ARM64 n°63) sur le FD evdev. Le
kernel délivre un multiple entier de size_of::<input_event>() (24 octets sur
les deux cibles LP64 d’Air, x86_64 et aarch64 — aucun découpage y2038 puisque
long fait 64 bits).
Comportement.
evdev_read_events lit dans la tranche events réinterprétée en octets, puis
retourne le nombre d’événements complets. Si le kernel renvoie un nombre
d’octets non multiple de 24 (ne devrait jamais arriver), le wrapper rapporte
EPROTO plutôt que d’exposer un événement partiel. Sur un FD NONBLOCK sans
donnée : EAGAIN.
Pourquoi sec/usec en u64 plutôt qu’un Instant.
L’horodatage evdev est issu d’une horloge configurable (CLOCK_REALTIME par
défaut, ou CLOCK_MONOTONIC via evdev_set_clock). La couche 0 reflète les
deux champs kernel tels quels ; corréler avec Instant/Duration (famille
time) est de la logique de couche 1, qui sait quelle horloge a été choisie.
Décodeur pur (sans syscall)
#![allow(unused)]
fn main() {
impl InputEvent {
/// Réinterprète un buffer d'octets en tranche d'`InputEvent` (zéro copie).
/// `None` si la longueur n'est pas un multiple de `size_of::<InputEvent>()`
/// ou si l'alignement n'est pas respecté.
pub fn slice_from_bytes(bytes: &[u8]) -> Option<&[InputEvent]>;
}
}
Utile quand les octets viennent d’ailleurs (io_uring, mmap). Type pur, vit
dans air-sys-types.
Identité et description du périphérique
#![allow(unused)]
fn main() {
/// `EVIOCGVERSION` — version du protocole evdev du driver.
pub fn evdev_driver_version(device: BorrowedFd<'_>) -> Result<u32, Errno>;
/// `EVIOCGID` — identifiant bus/vendor/product/version.
pub fn evdev_device_id(device: BorrowedFd<'_>) -> Result<InputId, Errno>;
/// `EVIOCGNAME(len)` — nom du périphérique, écrit dans `buffer`.
/// Retourne la tranche effectivement remplie (octets, possiblement non-UTF-8).
pub fn evdev_name<'b>(
device: BorrowedFd<'_>,
buffer: &'b mut [u8],
) -> Result<&'b [u8], Errno>;
/// `EVIOCGPHYS(len)` — emplacement physique (topologie), p. ex. `usb-0000:00:14.0-1/input0`.
pub fn evdev_physical_location<'b>(
device: BorrowedFd<'_>,
buffer: &'b mut [u8],
) -> Result<&'b [u8], Errno>;
/// `EVIOCGUNIQ(len)` — identifiant unique (souvent vide).
pub fn evdev_unique_id<'b>(
device: BorrowedFd<'_>,
buffer: &'b mut [u8],
) -> Result<&'b [u8], Errno>;
/// Miroir `#[repr(C)]` de `struct input_id` (noms kernel conservés, ADR-029).
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InputId {
pub bustype: u16,
pub vendor: u16,
pub product: u16,
pub version: u16,
}
}
Capacités : bits d’événements et propriétés
#![allow(unused)]
fn main() {
/// `EVIOCGBIT(event_type, len)` — bitmap des codes supportés pour un type
/// d'événement donné. `event_type = None` interroge les **types** supportés
/// (équivalent `EVIOCGBIT(0, len)`).
pub fn evdev_supported_codes<'b>(
device: BorrowedFd<'_>,
event_type: Option<EventType>,
bitmap: &'b mut [u8],
) -> Result<&'b [u8], Errno>;
/// `EVIOCGPROP(len)` — propriétés du périphérique (`INPUT_PROP_*` :
/// pointeur direct, semi-mt, bouton-pad...).
pub fn evdev_properties<'b>(
device: BorrowedFd<'_>,
bitmap: &'b mut [u8],
) -> Result<&'b [u8], Errno>;
/// `EVIOCGABS(axis)` — plage et état d'un axe absolu (`ABS_X`, `ABS_MT_*`...).
pub fn evdev_abs_info(
device: BorrowedFd<'_>,
axis: AbsAxis,
) -> Result<InputAbsInfo, Errno>;
/// Miroir `#[repr(C)]` de `struct input_absinfo` (noms kernel conservés).
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InputAbsInfo {
pub value: i32,
pub minimum: i32,
pub maximum: i32,
pub fuzz: i32,
pub flat: i32,
pub resolution: i32,
}
}
EventType et AbsAxis sont des enums typés (les constantes EV_* et ABS_*
du kernel) — la couche 0 expose des valeurs nommées plutôt que des entiers
magiques (ADR-029). Une variante Raw(u16) permet de passer un code non encore
nommé sans bloquer.
État courant
#![allow(unused)]
fn main() {
/// `EVIOCGKEY(len)` — état actuel (enfoncé/relâché) de toutes les touches.
pub fn evdev_key_state<'b>(d: BorrowedFd<'_>, bitmap: &'b mut [u8]) -> Result<&'b [u8], Errno>;
/// `EVIOCGLED(len)` — état des LED.
pub fn evdev_led_state<'b>(d: BorrowedFd<'_>, bitmap: &'b mut [u8]) -> Result<&'b [u8], Errno>;
/// `EVIOCGSND(len)` — état des sorties son.
pub fn evdev_sound_state<'b>(d: BorrowedFd<'_>, bitmap: &'b mut [u8]) -> Result<&'b [u8], Errno>;
/// `EVIOCGSW(len)` — état des interrupteurs (capot, jack...).
pub fn evdev_switch_state<'b>(d: BorrowedFd<'_>, bitmap: &'b mut [u8]) -> Result<&'b [u8], Errno>;
}
Contrôle exclusif et horloge
#![allow(unused)]
fn main() {
/// `EVIOCGRAB` avec argument `1` — capture exclusive du périphérique.
/// (ADR-021 conv. 1 : pas d'argument magique, deux fonctions distinctes.)
pub fn evdev_grab(device: BorrowedFd<'_>) -> Result<(), Errno>;
/// `EVIOCGRAB` avec pointeur nul — relâche la capture exclusive.
pub fn evdev_release(device: BorrowedFd<'_>) -> Result<(), Errno>;
/// `EVIOCREVOKE` — révoque définitivement l'accès à ce FD (irréversible).
/// Utilisé par les serveurs d'affichage pour neutraliser un FD cédé.
pub fn evdev_revoke(device: BorrowedFd<'_>) -> Result<(), Errno>;
/// `EVIOCSCLOCKID` — choisit l'horloge des horodatages des événements.
pub fn evdev_set_clock(
device: BorrowedFd<'_>,
clock: EventClock,
) -> Result<(), Errno>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EventClock {
/// `CLOCK_REALTIME` (défaut kernel).
Realtime,
/// `CLOCK_MONOTONIC` (recommandé pour la corrélation d'entrées).
Monotonic,
}
}
Syscall sous-jacent (tous les EVIOC*). ioctl (x86_64 n°16, ARM64 n°29),
chaque requête étant une constante EVIOC* distincte. Aucun wrapper ioctl
générique n’est exposé (ADR-021 convention 3) : chaque opération est une
fonction typée ci-dessus. Les valeurs de requête (_IOR('E', ...) etc.) sont
calculées en interne dans air-sys-syscall::device.
Préconditions et erreurs (transverses evdev).
EVIOCGRABéchoue avecEBUSYsi un autre client détient déjà la capture.evdev_set_clockn’accepte queRealtime/Monotonic(typage), donc pas deEINVALsur clockid invalide côté Air.ENOTTYsi le FD n’est pas un evdev (mauvais type de fichier).EFAULTimpossible à atteindre depuis l’API sûre (buffers fournis par Air).
Performance.
ioctl d’interrogation : ~1-3 µs. evdev_read_events : ~1-2 µs par lot. La
capture (grab) est négligeable.
Tests délicats.
- Les tests nominaux des
EVIOC*requièrent un vrai evdev. Stratégie : créer un périphérique virtuel viauinput(/dev/uinput) dans un harnais de test, injecter des événements, les relire via evdev, vérifier round-trip (type/code/value).uinputpermet aussi de testerEVIOCGID,EVIOCGNAME,EVIOCGBITde façon déterministe. - À défaut de privilège
uinput, les tests sont marqués « ignore » avec skip explicite, et la logique de décodage (InputEvent::slice_from_bytes) est testée en pur sur buffers synthétiques + proptest + fuzz. - Couverture : les branches d’erreur ioctl difficiles à provoquer (p. ex.
ENOTTY) sont consignées dansCOVERAGE-EXCEPTIONS.md(catégorie « feature/kernel » ou « valeur-impossible »).
Sous-section 3 : sysfs — pas de wrapper dédié
sysfs (/sys/...) est un pseudo-système de fichiers. Lire un attribut
(/sys/class/input/event3/device/name), écrire dans un uevent
(/sys/.../uevent pour re-déclencher un événement), parcourir l’arbre
(/sys/devices/...) : tout cela se fait avec la famille fs (openat,
read, write, getdents64).
Décision (couche 0, anti-duplication). La famille
devicen’expose aucun wrappersysfs. Réexposerread/writesous un nom « sysfs » serait de la duplication sans valeur, et toute la valeur ajoutée (construire le chemin/sys/class/<subsystem>/<name>/<attr>, parser un attribut en entier/booléen, corrélerDEVPATHd’un uevent avec son entrée sysfs, énumérer un sous-système) est de la logique → couche 1 (futur crateair-device). La couche 0 se contente de fournir les primitivesfsetuevent/evdevsur lesquelles cette logique s’appuiera.
Cette sous-section existe pour lever explicitement l’ambiguïté : si un
développeur cherche « la fonction sysfs de la couche 0 », la réponse est : il n’y
en a pas, c’est fs + (couche 1).
Récapitulatif de la famille device
Fonctions exposées :
| Catégorie | Fonctions principales |
|---|---|
| uevent | uevent_socket_open, UEventSocket::read, UEventMessage::{action,subsystem,device_path,property,properties,as_bytes} |
| evdev — lecture | evdev_read_events, InputEvent::slice_from_bytes |
| evdev — identité | evdev_driver_version, evdev_device_id, evdev_name, evdev_physical_location, evdev_unique_id |
| evdev — capacités | evdev_supported_codes, evdev_properties, evdev_abs_info |
| evdev — état | evdev_key_state, evdev_led_state, evdev_sound_state, evdev_switch_state |
| evdev — contrôle | evdev_grab, evdev_release, evdev_revoke, evdev_set_clock |
| sysfs | (aucune — voir sous-section 3) |
Total : ~22 fonctions publiques principales.
Syscalls non-wrappés (listés dans UNSUPPORTED.md) :
EVIOCSABS(set abs info) : écriture rare, réservée à la calibration ; à ajouter plus tard sans casser l’API si besoin.EVIOCGKEYCODE/EVIOCSKEYCODE(et_V2) : remappage de scancodes ; spécialisé, hors périmètre couche 0 initial.EVIOCGMTSLOTS: état multi-touch par slot ; ajout futur possible.EVIOCSFF/EVIOCRMFF/force feedback: retour de force ; chantier dédié ultérieur.- L’ancien hotplug
/sbin/hotpluget lenetlink genl: obsolètes / hors sujet.
Répartition des types entre les deux crates
Dans air-sys-types (purs, sans syscall)
InputEvent,InputId,InputAbsInfo— miroirs#[repr(C)]de structures kernel (champs aux noms kernel, ADR-029).EventType,AbsAxis,EventClock,UEventAction— enums typés.UEventGroups,UEventSocketFlags— bitflags.UEventMessage<'b>,UEventProperties<'b>— vues empruntées, décodage pur (aucun syscall) ; placées avec les types car le parsing ne touche pas le kernel.- Constante
UEVENT_RECOMMENDED_BUFFER_SIZE.
Dans air-sys-syscall::device (appelle des syscalls)
UEventSocket— RAII possédant unOwnedFd;open/readappellentsocket/bind/recvmsg. Même règle queSignalFd/LandlockRuleset: un type qui appelle un syscall vit dans la crate des wrappers, jamais dansair-sys-types.- Toutes les fonctions
evdev_*(wrappersioctl/read).
Soit ~10 types ajoutés à air-sys-types.
Décisions de fond émergées dans la famille device
1. Décodage uevent/input_event en couche 0 (parsers empruntés).
Les formats clé=valeur (uevent) et struct input_event sont des formats de
fil ABI kernel stables. Les refléter sans allouer est du miroir de
structure, exactement comme SignalFdInfo : la couche 0 le fait. La frontière
avec la couche 1 est nette : décoder le format = couche 0 ; interpréter (enums
de sous-système, modèle de device, corrélation) = couche 1.
2. UEventSocket::read vérifie l’authenticité.
Sans la vérification nl_pid == 0 / uid == 0, l’API serait un piège de
sécurité (uevents forgeables). La validation est donc une précondition de
sûreté de la primitive, légitime en couche 0 — distincte d’une commodité.
3. Pas d’ouverture evdev dédiée.
Un evdev est un char device : on l’ouvre avec fs::openat. La famille device
n’ajoute que ce qui est spécifique (lecture typée + EVIOC*).
4. Pas de wrapper sysfs.
Anti-duplication de fs ; toute la valeur ajoutée sysfs est de la logique de
couche 1. Sous-section dédiée pour lever l’ambiguïté.
5. EVIOCGRAB → deux fonctions (grab/release).
Application de l’ADR-021 conv. 1 : l’argument magique (1 vs pointeur nul)
devient deux fonctions explicites, sans sentinelle.
6. Bitmaps rendus comme tranches empruntées.
EVIOCGBIT/EVIOCGPROP/EVIOCG{KEY,LED,SND,SW} écrivent dans un buffer fourni
par l’appelant : zéro allocation (ADR-021 conv. 4). L’interprétation bit-à-bit
(« la touche K est-elle supportée ? ») est un helper de couche 1.
Licence du document : MPL 2.0
Statut : Spécification technique du module air-sys-syscall::device (couche 0).
Spec couche 0 — Famille ebpf
Spécification technique — Version 1.0
Vue d’ensemble de la famille
Le module air-sys-syscall::ebpf expose les primitives de programmation du
sous-système eBPF du kernel Linux : le syscall bpf() (création et
manipulation de cartes, chargement et attache de programmes, introspection,
épinglage, BTF, itérateurs, statistiques) et le syscall perf_event_open()
(compteurs de performance, échantillonnage, points de trace), qui est le point
d’accroche canonique des programmes eBPF de type tracing.
eBPF est, avec seccomp-BPF (famille security) et Landlock, l’un des piliers
techniques assumés du choix Linux-only d’Air (ADR-004). Il sert l’observabilité
(profiling, traçage), la sécurité (filtrage réseau/LSM), et l’instrumentation
fine du système — capacités exploitées par les services système de la couche 5.
Frontière couche 0 ↔ couche 1 : on charge, on ne fabrique pas
Décision d’architecture (alignée sur la famille
security, Q4). Comme pour seccomp, la couche 0 n’assemble pas et ne compile pas de programme eBPF. Fabriquer du bytecode eBPF (encodage desstruct bpf_insn, allocation de registres, relocations CO-RE, génération de BTF, résolution des helpers) est de la logique générative, pas un wrapper de syscall. La couche 0 expose la primitive : charger dans le kernel un programme déjà assemblé (une tranche deBpfInstructionfournie par l’appelant), créer/interroger des cartes, attacher, épingler, introspecter.Sont donc hors couche 0 (spécifiés en couche 1, futur crate
air-bpf) : l’assembleur d’instructions, le chargeur façon libbpf (parsing ELF, sections, relocations), la génération et la manipulation riche de BTF, le décodage des enregistrements de l’anneau d’échantillonnageperf/ring-buffer. La couche 0 fournit les briques sur lesquelles cette logique s’appuie.
Périmètre de la famille
Couverture exhaustive du syscall bpf() : les 37 sous-commandes de
enum bpf_cmd (cible kernel 6.12 LTS, comme io_uring) sont chacune exposées
par une fonction dédiée et typée, conformément à l’ADR-021 convention 3 —
aucun wrapper bpf(cmd, attr, size) générique n’est offert. Plus le syscall
perf_event_open() et ses ioctl de contrôle, eux aussi en fonctions dédiées.
Caractéristiques transverses de la famille.
-
CLOEXEC universel. Tous les FDs eBPF (cartes, programmes, liens, BTF) et les FDs
perf_eventsont créésCLOEXEC(drapeauBPF_F_*CLOEXEC/PERF_FLAG_FD_CLOEXECposé par le wrapper). -
Pas de syscall multiplexé générique (ADR-021 conv. 3).
bpf()multiplexe 37 opérations,ioctlsur perf en multiplexe une douzaine : chacune devient une fonction typée. La complexité est concentrée côté Air, pas côté appelant. -
Sentinelles kernel →
Option<T>/ enums (ADR-021 conv. 1). Les-1« tous CPU / tout processus » deperf_event_open, lesid0 « début d’itération », les fd optionnels deviennent desOption<T>ou des enums. -
RAII pour toutes les ressources.
BpfMap,BpfProgram,BpfLink,Btf,PerfEventpossèdent unOwnedFdet le ferment auDrop. Aucune fuite de FD. -
EINTR remonté tel quel (ADR-021 conv. 2). Pas de retry automatique.
-
Privilèges. La plupart des opérations exigent
CAP_BPF(+CAP_PERFMONpour le tracing,CAP_NET_ADMINpour le réseau) depuis Linux 5.8, ouCAP_SYS_ADMINavant. Le BPF non privilégié est souvent désactivé (kernel.unprivileged_bpf_disabled). Documenté par fonction ; non masqué. -
Tranches typées plutôt que pointeurs bruts. Les clés/valeurs de cartes, les instructions de programme, les buffers de log du vérifieur sont passés en
&[u8]/&[BpfInstruction]dont la longueur est validée contre la géométrie de la ressource (key_size,value_size…) avant l’appel (Principe 4, « valider en amont »). Mismatch ⇒EINVALcôté Air, sans toucher le kernel.
Sous-section 1 : Types fondamentaux
Ces types sont purs (aucun syscall) et vivent dans air-sys-types::ebpf,
sauf les RAII qui appellent des syscalls (placés dans air-sys-syscall::ebpf).
Instruction et programme
#![allow(unused)]
fn main() {
/// Miroir `#[repr(C)]` de `struct bpf_insn` (8 octets). Type « miroir » :
/// nom de type explicite (ADR-029), champs aux noms kernel conservés.
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BpfInstruction {
pub code: u8,
/// 4 bits `dst_reg` + 4 bits `src_reg` (layout kernel).
pub registers: u8,
pub offset: i16,
pub immediate: i32,
}
}
Le programme est une tranche &[BpfInstruction] appartenant à l’appelant.
La couche 0 ne l’inspecte ni ne le transforme : le vérifieur kernel s’en
charge au chargement. La limite BPF_MAXINSNS (4096) ne concerne que le BPF
classique (seccomp) ; un programme eBPF est borné par la complexité que le
vérifieur accepte (jusqu’à ~1M d’instructions analysées, privilégié).
Énumérations typées
#![allow(unused)]
fn main() {
/// `enum bpf_map_type`. Variante `Other(u32)` pour les types non encore nommés.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BpfMapType {
Hash, Array, ProgramArray, PerfEventArray, PercpuHash, PercpuArray,
StackTrace, CgroupArray, LruHash, LruPercpuHash, LpmTrie, ArrayOfMaps,
HashOfMaps, DevMap, SockMap, Cpumap, XskMap, SockHash, CgroupStorage,
ReuseportSockArray, PercpuCgroupStorage, Queue, Stack, SkStorage, DevmapHash, StructOps,
RingBuf, InodeStorage, TaskStorage, BloomFilter, UserRingBuf, CgrpStorage,
Arena,
Other(u32),
}
/// `enum bpf_prog_type`. Variante `Other(u32)` de repli.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BpfProgramType {
SocketFilter, Kprobe, SchedCls, SchedAct, Tracepoint, Xdp, PerfEvent,
CgroupSkb, CgroupSock, LwtIn, LwtOut, LwtXmit, SockOps, SkSkb, CgroupDevice,
SkMsg, RawTracepoint, CgroupSockAddr, LwtSeg6local, LircMode2, SkReuseport,
FlowDissector, CgroupSysctl, RawTracepointWritable, CgroupSockopt, Tracing,
StructOps, Ext, Lsm, SkLookup, Syscall, Netfilter,
Other(u32),
}
/// `enum bpf_attach_type` (points d'attache des programmes et des liens).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BpfAttachType {
CgroupInetIngress, CgroupInetEgress, CgroupInetSockCreate, CgroupSockOps,
SkSkbStreamParser, SkSkbStreamVerdict, CgroupDevice, SkMsgVerdict,
CgroupInet4Bind, CgroupInet6Bind, CgroupInet4Connect, CgroupInet6Connect,
TraceFentry, TraceFexit, ModifyReturn, LsmMac, TraceIter, XdpDevmap,
XdpCpumap, SkLookup, Xdp, /* … */ Other(u32),
}
}
Nommage (ADR-029). Les noms de types sont explicites (
BpfMapType, pasBpfMt). Les variantes miroitent les constantes kernelBPF_MAP_TYPE_*/BPF_PROG_TYPE_*(autorité kernel, que le développeur retrouve dansuapi/linux/bpf.h), en CamelCase, avec les abréviations d’usage universel tolérées (Lru,Xdp,Lsm). La varianteOther(u32)évite de bloquer un type ajouté par un kernel plus récent que 6.12.
Handles RAII (dans air-sys-syscall::ebpf)
#![allow(unused)]
fn main() {
pub struct BpfMap { /* OwnedFd */ }
pub struct BpfProgram { /* OwnedFd */ }
pub struct BpfLink { /* OwnedFd */ }
pub struct Btf { /* OwnedFd, objet BTF chargé */ }
pub struct PerfEvent { /* OwnedFd */ }
// Chacun expose : as_fd(&self) -> BorrowedFd<'_>, into_fd(self) -> OwnedFd,
// et un constructeur from_fd(OwnedFd) pour ré-adopter un fd obtenu *_get_fd_by_id.
}
Sous-section 2 : Cartes (maps)
Création
#![allow(unused)]
fn main() {
pub fn bpf_map_create(request: &BpfMapCreateRequest<'_>) -> Result<BpfMap, Errno>;
pub struct BpfMapCreateRequest<'a> {
pub map_type: BpfMapType,
pub key_size: u32,
pub value_size: u32,
pub max_entries: u32,
pub flags: BpfMapCreateFlags,
/// Nom (≤ 15 octets utiles + NUL), pour l'introspection. `None` = anonyme.
pub name: Option<&'a CStr>,
/// Pour les map-of-maps : le fd du gabarit de carte interne.
pub inner_map: Option<BorrowedFd<'a>>,
/// Nœud NUMA préféré (`None` = pas de préférence).
pub numa_node: Option<u32>,
/// Infos BTF optionnelles (clé/valeur typées).
pub btf: Option<BpfMapBtfInfo<'a>>,
}
bitflags! {
pub struct BpfMapCreateFlags: u32 {
const NO_PREALLOC = 1 << 0;
const NO_COMMON_LRU = 1 << 1;
const NUMA_NODE = 1 << 2;
const READ_ONLY = 1 << 3;
const WRITE_ONLY = 1 << 4;
const STACK_BUILD_ID = 1 << 5;
const ZERO_SEED = 1 << 6;
const READ_ONLY_PROG = 1 << 7;
const WRITE_ONLY_PROG = 1 << 8;
const CLONE = 1 << 9;
const MMAPABLE = 1 << 10;
const PRESERVE_ELEMS = 1 << 11;
const INNER_LOCK = 1 << 12;
}
}
}
Syscall sous-jacent. bpf(BPF_MAP_CREATE, &attr, size). bpf :
x86_64 n°321, ARM64 n°280. Disponible depuis Linux 3.18 ; types et drapeaux
selon version (6.12 pour le périmètre figé).
Préconditions (validées en amont, Principe 4). key_size/value_size
cohérents avec le map_type (certains types imposent une taille de clé/valeur
fixe ; p. ex. Array ⇒ key_size == 4). max_entries > 0 sauf types sans
entrées. READ_ONLY/WRITE_ONLY mutuellement exclusifs.
Erreurs. EPERM/EACCES (privilège, BPF non privilégié désactivé),
EINVAL (géométrie invalide), E2BIG (carte trop grande), ENOMEM.
Accès élément par élément
#![allow(unused)]
fn main() {
pub fn bpf_map_lookup_element(
map: BorrowedFd<'_>, key: &[u8], value_out: &mut [u8], flags: BpfMapLookupFlags,
) -> Result<(), Errno>;
pub fn bpf_map_update_element(
map: BorrowedFd<'_>, key: &[u8], value: &[u8], flags: BpfMapUpdateFlags,
) -> Result<(), Errno>;
pub fn bpf_map_delete_element(map: BorrowedFd<'_>, key: &[u8]) -> Result<(), Errno>;
/// `None` pour `key` = « première clé » (sentinelle kernel `NULL`, ADR-021 c.1).
pub fn bpf_map_get_next_key(
map: BorrowedFd<'_>, key: Option<&[u8]>, next_key_out: &mut [u8],
) -> Result<bool, Errno>; // false si fin d'itération (ENOENT)
/// `BPF_MAP_LOOKUP_AND_DELETE_ELEM` (cartes Queue/Stack notamment).
pub fn bpf_map_lookup_and_delete_element(
map: BorrowedFd<'_>, key: Option<&[u8]>, value_out: &mut [u8], flags: BpfMapLookupFlags,
) -> Result<(), Errno>;
bitflags! { pub struct BpfMapUpdateFlags: u64 {
const ANY = 0; const NO_EXIST = 1; const EXIST = 2; const F_LOCK = 4;
}}
bitflags! { pub struct BpfMapLookupFlags: u64 { const F_LOCK = 4; }}
}
Validation des longueurs. key.len() doit valoir key_size,
value.len()/value_out.len() valoir value_size (ou value_size * num_possible_cpus pour les cartes per-CPU). Le wrapper vérifie via
BPF_OBJ_GET_INFO_BY_FD mis en cache, ou impose à l’appelant de fournir des
tranches correctes et renvoie EINVAL si le kernel les rejette. Choix retenu :
ne pas introspecter implicitement (coût caché) — l’appelant garantit la
taille ; mismatch ⇒ EINVAL remonté du kernel. Documenté.
Opérations par lot (batch)
#![allow(unused)]
fn main() {
pub fn bpf_map_lookup_batch(map: BorrowedFd<'_>, request: &mut BpfMapBatchRequest<'_>)
-> Result<u32, Errno>; // nombre d'éléments traités
pub fn bpf_map_lookup_and_delete_batch(map: BorrowedFd<'_>, request: &mut BpfMapBatchRequest<'_>)
-> Result<u32, Errno>;
pub fn bpf_map_update_batch(map: BorrowedFd<'_>, request: &BpfMapBatchInput<'_>)
-> Result<u32, Errno>;
pub fn bpf_map_delete_batch(map: BorrowedFd<'_>, request: &BpfMapBatchInput<'_>)
-> Result<u32, Errno>;
}
BpfMapBatchRequest porte les tampons de clés/valeurs, le curseur
in_batch/out_batch (opaque, géré par le kernel), le count demandé et les
flags. Les opérations batch amortissent le coût syscall sur de grandes cartes.
Gel
#![allow(unused)]
fn main() {
/// `BPF_MAP_FREEZE` — rend la carte non modifiable depuis l'espace utilisateur
/// (le programme eBPF peut encore l'écrire selon ses droits). Irréversible.
pub fn bpf_map_freeze(map: BorrowedFd<'_>) -> Result<(), Errno>;
}
Performance (cartes). lookup/update/delete unitaires : ~1-3 µs (dominés par le syscall). Les variantes batch divisent ce coût par le facteur de lot.
Sous-section 3 : Programmes
Chargement (programme déjà assemblé)
#![allow(unused)]
fn main() {
pub fn bpf_program_load(
request: &BpfProgramLoadRequest<'_>,
verifier_log: Option<&mut [u8]>,
) -> Result<BpfProgram, Errno>;
pub struct BpfProgramLoadRequest<'a> {
pub program_type: BpfProgramType,
/// Programme **déjà assemblé** (la couche 0 ne le fabrique pas).
pub instructions: &'a [BpfInstruction],
/// Licence du programme (`GPL`, `Dual BSD/GPL`…). Conditionne l'accès aux
/// helpers marqués GPL-only par le kernel.
pub license: &'a CStr,
pub name: Option<&'a CStr>,
pub expected_attach_type: Option<BpfAttachType>,
/// Pour fentry/fexit/LSM/tracing : BTF cible + id du point d'attache.
pub attach_btf: Option<BorrowedFd<'a>>,
pub attach_btf_id: Option<u32>,
/// Programme à étendre (`Ext` / freplace).
pub attach_program: Option<BorrowedFd<'a>>,
pub flags: BpfProgramLoadFlags,
pub log_level: BpfVerifierLogLevel,
}
bitflags! { pub struct BpfProgramLoadFlags: u32 {
const STRICT_ALIGNMENT = 1 << 0;
const ANY_ALIGNMENT = 1 << 1;
const TEST_RND_HI32 = 1 << 2;
const TEST_STATE_FREQ = 1 << 3;
const SLEEPABLE = 1 << 4;
const XDP_HAS_FRAGS = 1 << 5;
}}
#[derive(Debug, Clone, Copy)]
pub enum BpfVerifierLogLevel { Disabled, Basic, Verbose, Stats }
}
Syscall sous-jacent. bpf(BPF_PROG_LOAD, …). Le vérifieur kernel analyse
le programme : sûreté mémoire, terminaison (pas de boucle non bornée), types.
En cas de rejet, le wrapper renvoie l’Errno kernel (souvent EACCES/EINVAL)
et remplit verifier_log avec le diagnostic textuel du vérifieur — élément
indispensable au débogage. Sur log trop court : ENOSPC (et le log tronqué).
Sûreté.
bpf_program_loadn’est pasunsafeau sens Rust : le vérifieur kernel garantit la sûreté mémoire du programme chargé. Le wrapper n’expose aucun pointeur brut. (L’effet d’un programme attaché sur le système est une autre question, traitée à l’attache.)
Test et binding
#![allow(unused)]
fn main() {
/// `BPF_PROG_TEST_RUN` / `BPF_PROG_RUN` — exécute le programme sur un contexte
/// et des données fournis, sans l'attacher. Retourne valeur de retour + durée.
pub fn bpf_program_test_run(
program: BorrowedFd<'_>, request: &mut BpfProgramTestRunRequest<'_>,
) -> Result<BpfProgramTestRunResult, Errno>;
/// `BPF_PROG_BIND_MAP` — lie explicitement une carte à un programme (durée de vie).
pub fn bpf_program_bind_map(
program: BorrowedFd<'_>, map: BorrowedFd<'_>, flags: u32,
) -> Result<(), Errno>;
}
Sous-section 4 : Attache et liens
Deux modèles coexistent : l’attache historique (PROG_ATTACH/DETACH,
surtout cgroup et sockmap, attache « anonyme » sans objet) et le modèle moderne
bpf_link (objet FD représentant l’attache, détachée au Drop).
#![allow(unused)]
fn main() {
/// `BPF_PROG_ATTACH` — attache historique (cgroup, sk_skb, flow_dissector…).
pub fn bpf_program_attach(
program: BorrowedFd<'_>, target: BorrowedFd<'_>,
attach_type: BpfAttachType, flags: BpfAttachFlags,
) -> Result<(), Errno>;
/// `BPF_PROG_DETACH`.
pub fn bpf_program_detach(
target: BorrowedFd<'_>, attach_type: BpfAttachType,
program: Option<BorrowedFd<'_>>,
) -> Result<(), Errno>;
/// `BPF_LINK_CREATE` — crée un lien moderne (retourne un FD RAII).
pub fn bpf_link_create(request: &BpfLinkCreateRequest<'_>) -> Result<BpfLink, Errno>;
/// `BPF_LINK_UPDATE` — remplace à chaud le programme d'un lien.
pub fn bpf_link_update(
link: BorrowedFd<'_>, new_program: BorrowedFd<'_>,
old_program: Option<BorrowedFd<'_>>, flags: u32,
) -> Result<(), Errno>;
/// `BPF_LINK_DETACH` — détache le programme tout en gardant le lien (auto-détache).
pub fn bpf_link_detach(link: BorrowedFd<'_>) -> Result<(), Errno>;
/// `BPF_RAW_TRACEPOINT_OPEN` — attache un programme à un tracepoint brut.
pub fn bpf_raw_tracepoint_open(
name: &CStr, program: BorrowedFd<'_>,
) -> Result<BpfLink, Errno>;
/// `BPF_ITER_CREATE` — crée un itérateur BPF à partir d'un `BpfLink` d'itération.
/// Retourne un FD lisible qui produit la sortie de l'itérateur.
pub fn bpf_iterator_create(link: BorrowedFd<'_>, flags: u32) -> Result<OwnedFd, Errno>;
bitflags! { pub struct BpfAttachFlags: u32 {
const ALLOW_OVERRIDE = 1 << 0;
const ALLOW_MULTI = 1 << 1;
const REPLACE = 1 << 2;
}}
}
Effet système — note de sûreté (pas
unsafe, mais documentée). Attacher un programme modifie le comportement du système (filtrage réseau, hooks LSM, traçage). Ces fonctions ne sont pasunsafeau sens mémoire, mais leur documentation# Effetsrappelle qu’un programme attaché s’exécute sur des chemins critiques. La couche 5 (services système) orchestre ces attaches sous contrôle d’entitlements (ADR-010).
Performance. Création d’un lien / attache : ~10-50 µs. Le Drop d’un
BpfLink détache automatiquement.
Sous-section 5 : Épinglage (système de fichiers bpf)
#![allow(unused)]
fn main() {
/// `BPF_OBJ_PIN` — épingle un objet (carte/programme/lien) sous un chemin du
/// montage `bpffs` (`/sys/fs/bpf/...`), pour le faire survivre au processus.
pub fn bpf_object_pin(object: BorrowedFd<'_>, path: &CStr) -> Result<(), Errno>;
/// `BPF_OBJ_GET` — récupère un FD vers un objet précédemment épinglé.
pub fn bpf_object_get(path: &CStr, flags: BpfObjectGetFlags) -> Result<OwnedFd, Errno>;
bitflags! { pub struct BpfObjectGetFlags: u32 {
const RDONLY = 1 << 3; const WRONLY = 1 << 4;
}}
}
Le FD retourné par bpf_object_get est ré-adoptable en BpfMap::from_fd /
BpfProgram::from_fd / BpfLink::from_fd selon le type (vérifiable via
bpf_object_get_info_by_fd).
Sous-section 6 : Introspection par identifiant
Le kernel attribue un id stable à chaque objet vivant. Ces commandes itèrent
les ids et convertissent id → FD (avec privilège). La sentinelle « commencer au
début » (start_id = 0) devient Option<u32> (ADR-021 conv. 1).
#![allow(unused)]
fn main() {
pub fn bpf_program_get_next_id(after: Option<u32>) -> Result<Option<u32>, Errno>;
pub fn bpf_map_get_next_id(after: Option<u32>) -> Result<Option<u32>, Errno>;
pub fn bpf_btf_get_next_id(after: Option<u32>) -> Result<Option<u32>, Errno>;
pub fn bpf_link_get_next_id(after: Option<u32>) -> Result<Option<u32>, Errno>;
// `Ok(None)` = fin d'itération (le kernel renvoie ENOENT).
pub fn bpf_program_get_fd_by_id(id: u32) -> Result<BpfProgram, Errno>;
pub fn bpf_map_get_fd_by_id(id: u32) -> Result<BpfMap, Errno>;
pub fn bpf_btf_get_fd_by_id(id: u32) -> Result<Btf, Errno>;
pub fn bpf_link_get_fd_by_id(id: u32) -> Result<BpfLink, Errno>;
/// `BPF_OBJ_GET_INFO_BY_FD` — remplit une structure d'info kernel pour un objet.
/// `info_out` est un buffer dont la taille dépend du type d'objet interrogé.
pub fn bpf_object_get_info_by_fd(
object: BorrowedFd<'_>, info_out: &mut [u8],
) -> Result<u32, Errno>; // octets effectivement écrits
/// `BPF_PROG_QUERY` — liste les programmes attachés à une cible (cgroup…).
pub fn bpf_program_query(request: &mut BpfProgramQueryRequest<'_>) -> Result<u32, Errno>;
/// `BPF_TASK_FD_QUERY` — interroge le programme derrière un fd perf/tracepoint
/// d'une tâche donnée (introspection de kprobe/uprobe).
pub fn bpf_task_fd_query(request: &mut BpfTaskFdQueryRequest<'_>) -> Result<(), Errno>;
}
Privilège. *_get_fd_by_id et PROG_QUERY exigent typiquement
CAP_SYS_ADMIN/CAP_BPF. Documenté.
Sous-section 7 : BTF (BPF Type Format)
#![allow(unused)]
fn main() {
/// `BPF_BTF_LOAD` — charge un blob BTF **déjà formé** dans le kernel.
/// (La *génération* de BTF est de la logique → couche 1.)
pub fn bpf_btf_load(
btf_blob: &[u8],
verifier_log: Option<&mut [u8]>,
flags: BpfBtfLoadFlags,
) -> Result<Btf, Errno>;
bitflags! { pub struct BpfBtfLoadFlags: u32 { const TOKEN_FD = 1 << 0; }}
}
bpf_btf_get_fd_by_id / bpf_btf_get_next_id sont en sous-section 6. La couche
0 charge et référence un blob BTF ; le décodage du format BTF (types, noms,
relocations CO-RE) est de la logique de couche 1.
Sous-section 8 : Statistiques et jetons
#![allow(unused)]
fn main() {
/// `BPF_ENABLE_STATS` — active globalement un type de statistiques (temps CPU
/// passé dans les programmes…). Retourne un FD : les stats restent actives tant
/// qu'il est ouvert (RAII).
pub fn bpf_enable_statistics(kind: BpfStatsType) -> Result<OwnedFd, Errno>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BpfStatsType { RunTime }
/// `BPF_TOKEN_CREATE` (Linux 6.9+) — crée un jeton de délégation de privilèges
/// BPF depuis un montage `bpffs`, pour autoriser des opérations BPF dans un
/// conteneur/namespace sans `CAP_SYS_ADMIN` global. Cohérent avec le modèle de
/// confinement d'Air.
pub fn bpf_token_create(bpffs: BorrowedFd<'_>, flags: u32) -> Result<OwnedFd, Errno>;
}
BpfStatsType est extensible (#[non_exhaustive] ou variante de repli) pour les
types de stats ajoutés après 6.12.
Sous-section 9 : perf_event_open et contrôle des événements
perf_event_open ouvre un compteur/échantillonneur de performance. C’est le
point d’accroche canonique des programmes eBPF de tracing : on ouvre un
perf_event sur un kprobe/uprobe/tracepoint, puis on y attache un programme via
perf_event_set_bpf_program.
Ouverture
#![allow(unused)]
fn main() {
pub fn perf_event_open(
attr: &PerfEventAttr,
scope: PerfEventScope<'_>,
group_leader: Option<BorrowedFd<'_>>,
flags: PerfEventOpenFlags,
) -> Result<PerfEvent, Errno>;
/// `pid`/`cpu` du kernel encodés sans sentinelle `-1` (ADR-021 conv. 1).
pub enum PerfEventScope<'a> {
/// Processus courant, sur n'importe quel CPU (`pid=0, cpu=-1`).
CallingProcessAnyCpu,
/// Processus courant, sur un CPU précis (`pid=0, cpu=N`).
CallingProcessOnCpu(u32),
/// Un processus précis, n'importe quel CPU (`pid=P, cpu=-1`).
ProcessAnyCpu(Pid),
/// Un processus précis sur un CPU précis (`pid=P, cpu=N`).
ProcessOnCpu { process: Pid, cpu: u32 },
/// Tous les processus sur un CPU (échantillonnage système, `pid=-1, cpu=N`).
AllProcessesOnCpu(u32),
/// Surveillance par cgroup (`flags |= PID_CGROUP`, `pid = cgroup_fd`).
Cgroup { cgroup: BorrowedFd<'a>, cpu: u32 },
}
bitflags! { pub struct PerfEventOpenFlags: u64 {
const FD_NO_GROUP = 1 << 0;
const FD_OUTPUT = 1 << 1;
// PID_CGROUP (1<<2) est géré via PerfEventScope::Cgroup, pas exposé ici.
const FD_CLOEXEC = 1 << 3; // toujours posé par le wrapper
}}
}
Syscall sous-jacent. perf_event_open : x86_64 n°298, ARM64 n°241.
Disponible depuis Linux 2.6.31.
PerfEventAttr est un miroir #[repr(C)] de struct perf_event_attr
(~120 octets) : nom de type explicite, champs aux noms kernel (type, config,
sample_period, sample_type, read_format, bitfields disabled, inherit,
exclude_kernel…). Il implémente Default (tout à zéro = valide). Des enums
d’aide nomment les valeurs courantes du champ type
(PerfTypeId::{Hardware, Software, Tracepoint, HwCache, Raw, Breakpoint}) et des
config matériels (PerfHardwareCounter::{CpuCycles, Instructions, …}), sans
masquer la structure brute. Un builder ergonomique (assemblage haut niveau)
relève de la couche 1.
Préconditions / erreurs. EACCES/EPERM (privilège, perf_event_paranoid),
EINVAL (attr incohérent), ENOENT (type/config inconnu), EMFILE,
ENODEV, EOPNOTSUPP.
Lecture des compteurs
#![allow(unused)]
fn main() {
impl PerfEvent {
pub fn as_fd(&self) -> BorrowedFd<'_>;
pub fn into_fd(self) -> OwnedFd;
/// Lit la valeur scalaire du compteur (`read_format` simple).
pub fn read_count(&self) -> Result<u64, Errno>;
}
}
La lecture en mode groupe (PERF_FORMAT_GROUP, plusieurs compteurs d’un
coup) et le décodage de l’anneau d’échantillonnage (mmap du ring buffer,
parcours des struct perf_event_header / enregistrements PERF_RECORD_*) sont
de la logique → couche 1. La couche 0 fournit le FD : l’anneau se mmap via
mem::mmap (famille mem), son interprétation est faite au-dessus.
Contrôle (ioctls dédiés — pas d’ioctl générique, ADR-021 c.3)
#![allow(unused)]
fn main() {
pub fn perf_event_enable(event: BorrowedFd<'_>) -> Result<(), Errno>; // IOC_ENABLE
pub fn perf_event_disable(event: BorrowedFd<'_>) -> Result<(), Errno>; // IOC_DISABLE
pub fn perf_event_reset(event: BorrowedFd<'_>) -> Result<(), Errno>; // IOC_RESET
pub fn perf_event_refresh(event: BorrowedFd<'_>, count: u32) -> Result<(), Errno>; // IOC_REFRESH
pub fn perf_event_set_period(event: BorrowedFd<'_>, period: u64) -> Result<(), Errno>; // IOC_PERIOD
pub fn perf_event_set_filter(event: BorrowedFd<'_>, filter: &CStr) -> Result<(), Errno>; // IOC_SET_FILTER
pub fn perf_event_id(event: BorrowedFd<'_>) -> Result<u64, Errno>; // IOC_ID
/// `PERF_EVENT_IOC_SET_BPF` — attache un programme eBPF à ce perf_event
/// (kprobe/uprobe/tracepoint). LE PONT eBPF ↔ perf.
pub fn perf_event_set_bpf_program(
event: BorrowedFd<'_>, program: BorrowedFd<'_>,
) -> Result<(), Errno>;
/// `PERF_EVENT_IOC_SET_OUTPUT` — redirige la sortie vers l'anneau d'un autre
/// event ; `None` détache la redirection (sentinelle `-1`, ADR-021 c.1).
pub fn perf_event_set_output(
event: BorrowedFd<'_>, output: Option<BorrowedFd<'_>>,
) -> Result<(), Errno>;
pub fn perf_event_pause_output(event: BorrowedFd<'_>, pause: bool) -> Result<(), Errno>; // IOC_PAUSE_OUTPUT
pub fn perf_event_query_bpf(event: BorrowedFd<'_>, ids_out: &mut [u32]) -> Result<u32, Errno>; // IOC_QUERY_BPF
pub fn perf_event_modify_attributes(event: BorrowedFd<'_>, attr: &PerfEventAttr) -> Result<(), Errno>; // IOC_MODIFY_ATTRIBUTES
}
Syscall sous-jacent (contrôle). ioctl sur le FD perf, une constante
PERF_EVENT_IOC_* par fonction. Aucun ioctl générique exposé.
Performance. Ouverture : ~10-30 µs. enable/disable/reset : ~1-2 µs.
read_count : ~1-2 µs.
Récapitulatif de la famille ebpf
Fonctions exposées, par catégorie :
| Catégorie | Fonctions principales |
|---|---|
| Cartes — vie | bpf_map_create, bpf_map_freeze |
| Cartes — éléments | bpf_map_lookup_element, bpf_map_update_element, bpf_map_delete_element, bpf_map_get_next_key, bpf_map_lookup_and_delete_element |
| Cartes — batch | bpf_map_lookup_batch, bpf_map_lookup_and_delete_batch, bpf_map_update_batch, bpf_map_delete_batch |
| Programmes | bpf_program_load, bpf_program_test_run, bpf_program_bind_map |
| Attache / liens | bpf_program_attach, bpf_program_detach, bpf_link_create, bpf_link_update, bpf_link_detach, bpf_raw_tracepoint_open, bpf_iterator_create |
| Épinglage | bpf_object_pin, bpf_object_get |
| Introspection | bpf_program_get_next_id, bpf_map_get_next_id, bpf_btf_get_next_id, bpf_link_get_next_id, bpf_program_get_fd_by_id, bpf_map_get_fd_by_id, bpf_btf_get_fd_by_id, bpf_link_get_fd_by_id, bpf_object_get_info_by_fd, bpf_program_query, bpf_task_fd_query |
| BTF | bpf_btf_load |
| Stats / jetons | bpf_enable_statistics, bpf_token_create |
| perf — vie | perf_event_open, PerfEvent::read_count |
| perf — contrôle | perf_event_enable, perf_event_disable, perf_event_reset, perf_event_refresh, perf_event_set_period, perf_event_set_filter, perf_event_id, perf_event_set_bpf_program, perf_event_set_output, perf_event_pause_output, perf_event_query_bpf, perf_event_modify_attributes |
Total : 37 fonctions bpf_* (couverture exhaustive des 37 sous-commandes de
enum bpf_cmd, cible 6.12) + ~14 fonctions perf_event_*, soit ~51
fonctions publiques principales.
Reporté en couche 1 (hors périmètre) : assembleur d’instructions eBPF,
chargeur ELF/libbpf, génération et décodage riche de BTF, relocations CO-RE,
décodage de l’anneau d’échantillonnage perf / ring-buffer, builders ergonomiques
de PerfEventAttr et de programmes.
Syscalls / opérations non-wrappés (listés dans UNSUPPORTED.md) :
bpf(): aucune sous-commande omise — la couverture est exhaustive pour 6.12. Les sous-commandes ajoutées par un kernel postérieur à 6.12 seront ajoutées par RFC, sans casser l’API (variantesOther(u32)déjà prévues).perf_event_open: le décodage du ring-buffer d’échantillonnage est en couche 1 (logique), pas un syscall manquant.
Répartition des types entre les deux crates
Dans air-sys-types (purs, sans syscall)
BpfInstruction— miroirstruct bpf_insn.BpfMapType,BpfProgramType,BpfAttachType,BpfStatsType,BpfVerifierLogLevel,PerfTypeId,PerfHardwareCounter,PerfEventScope,EventClock(réutilisé dedevicesi pertinent) — enums typés.BpfMapCreateFlags,BpfMapUpdateFlags,BpfMapLookupFlags,BpfProgramLoadFlags,BpfAttachFlags,BpfObjectGetFlags,BpfBtfLoadFlags,PerfEventOpenFlags— bitflags.PerfEventAttr— miroirstruct perf_event_attr(champs aux noms kernel).- Les structs-requête (
BpfMapCreateRequest,BpfProgramLoadRequest,BpfLinkCreateRequest,BpfMapBatchRequest, …) — vues/agrégats purs passés aux wrappers.
Dans air-sys-syscall::ebpf (RAII appelant des syscalls)
BpfMap,BpfProgram,BpfLink,Btf,PerfEvent— possèdent unOwnedFd, ferment auDrop. Même règle queSignalFd/LandlockRuleset/UEventSocket: un type qui appelle un syscall ne vit pas dansair-sys-types.
Soit ~25 types ajoutés à air-sys-types (dont 2 miroirs lourds) + 5 RAII.
Tests (stratégie)
- Cartes — round-trip pur kernel : créer une
Hash/Array,updatepuislookupune clé,get_next_keypour itérer,delete, vérifierENOENT. Déterministe sans programme, mais requiertCAP_BPF→ harnais qui skippe proprement si privilège absent, et le marque enCOVERAGE-EXCEPTIONS.md(catégorie « privilège »). - Programme minimal : charger un programme
SocketFiltertrivial (« retourne 0 ») pré-assemblé en constante de test (quelquesBpfInstructionécrites à la main), vérifier le succès ; charger un programme invalide (registre non initialisé) et vérifier queverifier_logcontient le diagnostic. Cela teste le chemin de log sans assembleur. - Attache/liens : attacher un programme
Tracepoint/Kprobeviaperf_event_open+perf_event_set_bpf_program, déclencher l’événement, vérifier un compteur de carte ;Dropdétache. - Introspection :
*_get_next_id+*_get_fd_by_idsur un objet créé dans le test. - perf : ouvrir un compteur
Software/PERF_COUNT_SW_TASK_CLOCKsur le processus courant,enable, faire du travail,read_count> 0,disable. - Property-based (proptest) : encodage/décodage des miroirs (
BpfInstruction,PerfEventAttr,input-like) round-trip ; tout&[u8]passé en clé/valeur ne panique jamais (validation de longueur en amont). - Fuzzing (
cargo-fuzz) : le décodage des structures d’info retournées parBPF_OBJ_GET_INFO_BY_FDaccepte des données externes (kernel) → harnais de fuzz sur les décodeurs d’info. - Couverture : les branches privilégiées non atteignables en CI non root sont
consignées dans
COVERAGE-EXCEPTIONS.md(catégories « privilège », « feature/kernel »). Validation cross-arch x86_64 + aarch64.
Décisions de fond émergées dans la famille ebpf
1. Couverture exhaustive de bpf(), une fonction par sous-commande.
Les 37 sous-commandes de enum bpf_cmd (6.12) sont toutes exposées en fonctions
dédiées typées. Aucun bpf(cmd, attr, size) générique (ADR-021 conv. 3). La
famille est volumineuse, c’est assumé : la complexité va côté Air.
2. On charge des programmes/BTF déjà formés (frontière couche 1).
Comme seccomp (Q4 famille security), la couche 0 charge un programme ou un
blob BTF déjà assemblé ; elle n’assemble pas, ne compile pas, ne relocalise pas.
L’assembleur, le chargeur libbpf, la génération BTF, le décodage du ring-buffer
perf sont en couche 1.
3. bpf_program_load n’est pas unsafe ; il remplit le log du vérifieur.
Le vérifieur kernel garantit la sûreté mémoire ⇒ pas d’unsafe. Le buffer de log
est un paramètre de premier ordre : sans lui, déboguer un rejet est impossible.
4. Sentinelles kernel → Option/enums partout.
start_id = 0, pid/cpu = -1, key = NULL (première clé), fd de sortie nul :
tous remplacés par Option<T> ou des enums (PerfEventScope), conformément à
l’ADR-021 conv. 1.
5. RAII pour cartes, programmes, liens, BTF, perf_event.
Aucune fuite de FD ; Drop détache les liens. *_get_fd_by_id / obj_get
rendent des FDs ré-adoptables via from_fd.
6. perf_event dans la même famille qu’eBPF.
Parce que perf_event_open + PERF_EVENT_IOC_SET_BPF est le pont d’attache
des programmes de tracing. Les regrouper évite une famille perf orpheline et
reflète l’usage réel.
7. Variantes Other(u32) / #[non_exhaustive] pour la pérennité.
Les enums de types (map/prog/attach/stats) prévoient un repli pour ne pas casser quand un kernel postérieur à 6.12 ajoute une valeur, sans renoncer au typage.
Bilan : la couche 0 est complète
Avec les familles device et ebpf, les onze familles de la couche 0 sont
spécifiées : process, fs, mem, signal, time, net, ipc,
security, system, device, ebpf, plus le module transverse io_uring et
la crate de types air-sys-types. Il ne reste, pour livrer la couche 0
entièrement, que l’implémentation de device, ebpf et des Temps io_uring
restants (2a→4, 3a–3f), selon la méthode squelette-documenté-d’abord.
Licence du document : MPL 2.0
Statut : Spécification technique du module air-sys-syscall::ebpf (couche 0).
Spec couche 0 — Famille fs, sous-module inotify (surveillance de fichiers)
Spécification technique — Version 1.0 (cible kernel : Linux 6.12 LTS). Extension
de la famille fs.
Position
Ce document spécifie le primitif inotify de la couche 0, manquant
identifié en spécifiant air-filesystem (couche 1) : AirFileSystemWatcher en a
besoin et aucun wrapper inotify n’existait. C’est une petite extension de la
famille fs (sous-module air-sys-syscall::fs::inotify), dans l’esprit
FD-événementiel de signalfd/timerfd/eventfd et du socket uevent de device.
Émergence (méthode doc-d’abord). Ce manque a été révélé par la spécification de la couche 1 — exactement le mécanisme attendu : consommer la couche 0 fait apparaître ses trous. Cette extension doit être implémentée avant les watchers d’
air-filesystem(PR coordonnée).
Périmètre. inotify (notification de changements de fichiers, non
privilégié, basé sur les chemins). Hors périmètre : fanotify (surveillance
à l’échelle d’un mount/FS + décisions d’accès, privilégié) — primitif distinct, à
produire plus tard pour un service de sécurité/audit (couche 5), pas requis ici.
Caractéristiques transverses (cohérentes couche 0) :
- CLOEXEC par défaut sur le FD inotify.
- Décodage = miroir de format kernel : la lecture du FD rend un flux de
struct inotify_eventde taille variable (un champnamesuit) ; on le décode via un itérateur emprunté, zéro allocation (même approche que le parseur uevent dedevice, et précédentSignalFdInfo). 2 bis. Zéro perte (ADR-032) : tous les événements d’un buffer lu sont rendus ; un événement tronqué (buffer trop court) est signalé, pas avalé. - Pas d’ioctl générique (ADR-021 c.3) — inotify a ses syscalls dédiés.
name= octets (&[u8]), pas d’UTF-8 présumé (Principe 3).
Types (purs → air-sys-types::fs)
#![allow(unused)]
fn main() {
/// Descripteur de surveillance (retour d'`inotify_add_watch`). Newtype typé
/// (jamais un `i32` brut) — ADR-029.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct WatchDescriptor(i32);
bitflags! {
/// Masque d'événements / d'options (`IN_*`). Noms kernel conservés (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;
// bits de lecture (positionnés par le kernel dans l'événement) :
const UNMOUNT = 0x0000_2000;
const Q_OVERFLOW = 0x0000_4000;
const IGNORED = 0x0000_8000;
const ISDIR = 0x4000_0000;
// options d'ajout de watch :
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, posé par défaut)
}
}
}
RAII + wrappers (air-sys-syscall::fs::inotify)
#![allow(unused)]
fn main() {
/// Instance inotify : possède un `OwnedFd`, fermé au `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;
/// Ajoute (ou met à jour) un watch sur `path` pour les événements `mask`.
/// `path` : `&CStr` (octets terminés NUL, convention couche 0).
pub fn add_watch(&self, path: &CStr, mask: InotifyEventMask)
-> Result<WatchDescriptor, Errno>;
/// Retire un watch.
pub fn remove_watch(&self, wd: WatchDescriptor) -> Result<(), Errno>;
/// Lit un lot d'événements **dans `buffer`** et le décode sans allocation.
/// Le `InotifyEvents` retourné **emprunte** `buffer`.
pub fn read_events<'b>(&self, buffer: &'b mut [u8])
-> Result<InotifyEvents<'b>, Errno>;
}
}
Syscalls sous-jacents. inotify_init1 (x86_64 n°294, ARM64 n°26),
inotify_add_watch (x86_64 n°254, ARM64 n°27), inotify_rm_watch (x86_64 n°255,
ARM64 n°28), read pour les événements. IN_CLOEXEC posé par défaut. Disponible
depuis Linux 2.6.27 (inotify_init1).
Préconditions / erreurs. add_watch : ENOSPC (limite
max_user_watches), ENOENT (chemin), EACCES. read_events : EAGAIN
(NONBLOCK sans événement), EINVAL (buffer trop petit pour le prochain
événement — l’appelant agrandit). remove_watch : EINVAL (wd invalide).
Recommandation de taille de buffer. Un événement = 16 octets d’entête + name
(jusqu’à NAME_MAX+1). Recommander ≥ 4096 octets ; constante indicative
INOTIFY_RECOMMENDED_BUFFER_SIZE = 4096.
Le flux décodé : InotifyEvents / InotifyEvent
#![allow(unused)]
fn main() {
/// Vue empruntée sur un buffer rempli par `read` : suite d'événements de taille
/// variable. Itérateur zéro-alloc.
pub struct InotifyEvents<'b> { /* curseur sur &'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, // corrèle MOVED_FROM / MOVED_TO
pub name: Option<&'b [u8]>, // nom (octets) si présent, sans le NUL de padding
}
}
Décodage (miroir ABI). Chaque enregistrement : wd:i32, mask:u32,
cookie:u32, len:u32, puis len octets de name (padded NUL). Le décodeur
avance de 16 + len à chaque pas, sans copie ; name est tranché dans le
buffer de l’appelant (NUL de padding retiré). Slicing via get() (jamais
d’indexation paniquante). Un enregistrement final tronqué (buffer coupé au milieu)
→ l’itérateur s’arrête proprement et read_events peut le signaler (cohérent
ADR-032 : on n’avale pas un événement à moitié).
Non récursif (note d’usage). inotify surveille un répertoire par watch, pas ses sous-répertoires. La surveillance récursive (ajouter un watch par sous-répertoire, gérer les nouveaux dossiers via
CREATE|ISDIR) est de la logique → c’estAirFileSystemWatcher(couche 1) qui la porte, pas la couche 0.
Récapitulatif
| Fonction | Rôle |
|---|---|
inotify_init | crée l’instance (RAII Inotify, CLOEXEC) |
Inotify::add_watch / remove_watch | ajoute/retire un watch sur un chemin |
Inotify::read_events | lit + décode (itérateur emprunté zéro-alloc) |
Inotify::as_fd / into_fd | intégration event loop / transfert d’ownership |
Total : ~5 fonctions publiques. Types ajoutés à air-sys-types::fs :
WatchDescriptor, InotifyEventMask, InotifyFlags, InotifyEvents<'b>,
InotifyEvent<'b> (≈ 5).
Tests
- Intégration (kernel réel) :
inotify_init→add_watchsur un répertoire temporaire → créer/modifier/déplacer/supprimer un fichier →read_eventsrend les événements attendus (CREATE,MODIFY,MOVED_FROM/MOVED_TOcorrélés parcookie,DELETE), avec le bonname;remove_watch;Q_OVERFLOWsous rafale. - Décodeur pur : buffers synthétiques (multi-événements, avec/sans
name, enregistrement final tronqué) — zéro panique, zéro OOB, zéro alloc (allocateur de test qui panique suralloc). - Property-based (proptest) : pour tout
&[u8], l’itérateur termine et ne panique jamais. - Fuzzing (cargo-fuzz) : le décodeur ingère des données externes (buffer
kernel) → harnais de fuzz obligatoire sur
InotifyEvents::parse(Principe 3). - Couverture 100 % lignes + branches ; erreurs ressources
(
ENOSPC/EMFILE) non déclenchables en CI →COVERAGE-EXCEPTIONS.md(catégorie STRUCTURAL).
Décisions de fond
- inotify en sous-module de
fs(fs::inotify), pas une famille à part — il est filesystem par nature, et petit. - Décodage zéro-alloc emprunté des
inotify_eventde taille variable (précédent uevent/SignalFdInfo) ; zéro perte (ADR-032). fanotifyhors périmètre — primitif distinct, privilégié, pour un service de sécurité/audit ultérieur (couche 5).- Récursivité = couche 1 (
AirFileSystemWatcher), pas la couche 0.
Licence du document : MPL 2.0
Statut : Spécification technique du sous-module air-sys-syscall::fs::inotify
(couche 0), cible kernel 6.12 LTS. Extension de la famille fs.
Spec couche 0 — Famille mem : MmapRegion (mapping partageable + handle de vivacité io_uring)
Spécification technique — Version 1.0 (cible kernel : Linux 6.12 LTS). Extension
de la famille mem.
Position
Ce document spécifie MmapRegion, le mapping partageable et compatible
io_uring dont dépendent deux opérations io_uring jusqu’ici différées :
submit_madvise (Temps 2a §6.2) et submit_futex_wait/wake/waitv
(Temps 2c §6.1). Il débloque les deux.
Émergence (méthode doc-d’abord). Le besoin est apparu en spécifiant io_uring (madvise au 2a, confirmé par futex au 2c) : une opération io_uring qui référence de la mémoire userspace la lit de façon asynchrone — après le retour de la façade, jusqu’à la complétion. La mémoire doit rester valide jusque-là. Le type
Mapping(RAII unique,Drop=munmap) ne le garantit pas : rien n’empêche sonDrop(doncmunmap) pendant qu’une op est en vol → use-after-unmap (le kernel lit de la mémoire démappée). Il faut un mapping dont la durée de vie peut être partagée avec le slot S1 d’une op en vol.
Le mystère du nommage, résolu
Les specs io_uring nomment MmapRegion ; la famille mem a Mapping. Ce sont
deux types distincts, à dessein :
Mapping(existant, inchangé) : RAII unique (move-only),Drop=munmap, zéro-coût (pas d’allocation, ADR-021 c.4). Pour tout usage synchrone ordinaire. On ne le touche pas — sa couverture 100 % reste intacte.MmapRegion(nouveau) : mapping partageable, compté par référence,munmapau dernier drop. C’est le seul type passable aux opérations mémoire asynchrones d’io_uring (madvise, futex). Opt-in : on ne paie le partage que si on en a besoin.
1. MmapRegion
#![allow(unused)]
fn main() {
/// Mapping mmap **partageable** et compatible io_uring : ses pages restent
/// valides tant qu'une opération io_uring en vol (madvise/futex) y référence de
/// la mémoire. Compté par référence — `munmap` au **dernier** drop (la région
/// **et** toutes les gardes de vivacité d'ops en vol).
#[derive(Clone)]
pub struct MmapRegion { /* Arc<MmapRegionInner> : ptr, len, prot */ }
impl MmapRegion {
/// Mapping anonyme partageable (équivalent `mmap_anonymous` mais `MmapRegion`).
/// # Errors `Errno` (cf. famille `mem`).
pub fn new_anonymous(len: usize, prot: ProtectionFlags, flags: MapFlags)
-> Result<Self, Errno>;
/// Mapping de fichier partageable.
pub fn from_file(fd: BorrowedFd<'_>, len: usize, prot: ProtectionFlags,
flags: MapFlags, offset: u64) -> Result<Self, Errno>;
/// Promeut un `Mapping` unique en région partageable : **transfère** la
/// responsabilité du `munmap` à l'inner partagé (le `Mapping` est consommé,
/// son `Drop` ne munmap plus). Une seule allocation (l'inner). Le `prot`
/// **doit refléter** celui avec lequel le `Mapping` a été créé (un `Mapping`
/// nu ne mémorise pas ses protections) — **pas de défaut `READ|WRITE`**,
/// sinon `bytes()`/`futex_word()` décideraient de l'accessibilité sur une
/// base fausse (référence qui faute à l'usage).
pub fn from_mapping(mapping: Mapping, prot: ProtectionFlags) -> Self;
pub fn len(&self) -> usize;
pub fn is_empty(&self) -> bool;
/// Tranche d'octets en lecture (si `prot` lisible). Accès **sûr**, borné.
pub fn bytes(&self) -> &[u8];
/// Référence à un **mot futex** (`AtomicU32`) à `offset` octets. La référence
/// est **mutable par partage** (`store`/`load` via `&`) : la région doit donc
/// être **inscriptible**.
/// # Errors `EINVAL` si `prot` ne contient pas `WRITE`, ou si `offset` est
/// hors bornes (`offset + 4 > len`) ou non aligné sur 4.
pub fn futex_word(&self, offset: usize) -> Result<&AtomicU32, Errno>;
/// Pointeur brut + longueur (consommateurs avancés ; pas de propriété).
pub fn as_ptr(&self) -> *const u8;
}
}
Modèle de propriété. MmapRegion enrobe un Arc<MmapRegionInner> ;
MmapRegionInner::Drop appelle munmap (en ignorant l’erreur, comme Mapping).
Cloner une MmapRegion incrémente le compteur fort ; munmap n’a lieu qu’au
dernier drop. Aucune copie de pages — seulement du partage de propriété.
Validation amont (Principe 4). futex_word vérifie inscriptibilité
(prot ⊇ WRITE, car la réf rendue est mutable), bornes et alignement (un
futex est un u32 aligné) avant de rendre la référence — EINVAL sinon. Les
accès bytes() sont bornés par construction (tranche vide si prot sans READ).
Exception « pas d’alloc dans le happy path » (ADR-021 c.4), justifiée.
MmapRegionfait une allocation (l’Arcinner). C’est admis et documenté : (1) c’est opt-in — le chemin communMappingreste zéro-coût ; (2) le coût est négligeable et amorti devant le syscallmmapqui crée la région (qui le domine de plusieurs ordres de grandeur) ; (3) le partage de durée de vie est intrinsèquement nécessaire à la sûreté de l’usage asynchrone (cf. ADR-021 c.4, clause « nécessité documentée »). À consigner dans les notes de la famille.
2. Handle de vivacité (consommé par le slot S1 d’io_uring)
#![allow(unused)]
fn main() {
/// Garde de vivacité retenue par le slot S1 d'une opération io_uring en vol qui
/// référence la région. Garde les pages **mappées** sans donner d'accès :
/// tant qu'une garde existe, `munmap` ne peut pas se produire.
pub struct MmapRegionLiveness { /* clone de l'Arc inner */ }
impl MmapRegion {
/// Produit une garde de vivacité (clone interne). Utilisé par les façades
/// io_uring `submit_madvise`/`submit_futex_*` : la garde est **garée dans le
/// slot**, et libérée à la complétion → `munmap` au dernier drop.
pub(crate) fn liveness_handle(&self) -> MmapRegionLiveness;
}
}
Mécanique de sûreté (le cœur). Quand une façade io_uring soumet une op référençant la région :
- elle valide la plage/le mot futex contre les bornes de la région (Principe 4) ;
- elle clone une
MmapRegionLiveness(compteur fort +1) ; - elle gare la garde dans le slot S1 à côté de l’op.
À la complétion, le slot est libéré → la garde tombe (compteur −1). munmap ne
survient que lorsque la région utilisateur ET toutes les gardes en vol sont
droppées. Le kernel ne lit donc jamais de mémoire démappée : ni
use-after-unmap, ni fuite (munmap au dernier drop, pas « fuite plutôt qu’UAF »).
Raffinement d’ADR-028 / Temps 2a §6.2. La rédaction initiale envisageait, pour madvise, un schéma « fuite plutôt qu’UAF » (ne pas munmap si une op est en vol → fuite).
MmapRegionfait strictement mieux : compté par référence, il ne fuit pas (munmap au dernier drop) et ne peut pas UAF. C’est le mécanisme retenu.
3. Consommation par io_uring (signatures corrigées)
madvise (Temps 2a §6.2) — déjà conforme
#![allow(unused)]
fn main() {
pub fn submit_madvise(&mut self, region: &MmapRegion, range: Range<usize>, advice: MadviseAdvice)
-> Result<SubmissionToken, Errno>;
}
range.end ≤ region.len() validé en amont ; le slot retient
region.liveness_handle().
futex (Temps 2c §6.1) — CORRECTION de signature
La spec 2c montrait futex: &AtomicU32 : insuffisant pour l’async (le borrow
ne survit pas au retour de la façade). On le remplace par une référence dans une
MmapRegion + offset (le slot retient la garde de vivacité) :
#![allow(unused)]
fn main() {
pub fn submit_futex_wait(&mut self, region: &MmapRegion, offset: usize,
expected: u64, mask: u64, flags: FutexFlags)
-> Result<SubmissionToken, Errno>; // OP 51
pub fn submit_futex_wake(&mut self, region: &MmapRegion, offset: usize,
nr: u64, mask: u64, flags: FutexFlags)
-> Result<SubmissionToken, Errno>; // OP 52
pub fn submit_futex_waitv(&mut self, waiters: Vec<FutexWaiter>, flags: FutexFlags)
-> Result<SubmissionToken, Errno>; // OP 53
/// Un attendu de `futex_waitv` : la région + l'offset du mot et la valeur
/// attendue. La région est gardée vivante par le slot. **Pas de masque** : le
/// `struct futex_waitv` kernel n'en porte pas (cf. note ci-dessous).
pub struct FutexWaiter {
pub region: MmapRegion, // possédée (clone) ⇒ garde de vivacité naturelle
pub offset: usize,
pub expected: u64,
}
}
offset localise le mot futex dans la région (inscriptible, aligné 4, borné —
EINVAL sinon). Note ABI : struct futex_waitv kernel = { val, uaddr, flags, __reserved } (pas de masque par-attendu) ; FutexWaiter n’expose donc
pas de masque (un champ silencieusement ignoré serait un footgun, ADR-032). Pour
une attente masquée, utiliser submit_futex_wait (mono-attente, qui garde
mask). La spec io-uring-2c-async.md et la famille mem sont réconciliées
sur ces signatures.
4. Répartition des types
air-sys-types::mem: aucun nouveau type pur requis (réutiliseProtectionFlags/MapFlagsexistants).air-sys-syscall::mem:MmapRegion,MmapRegionLiveness(RAII appelantmunmap→ vit dans la crate des wrappers, commeMapping).Mappingreste inchangé (aucune modification de sa représentation ni de sonDrop) → couverturememexistante intacte.
5. Tests
- Sûreté (Miri) — le point central : parker une op (madvise/futex simulée) qui
détient une
MmapRegionLiveness, dropper laMmapRegionutilisateur → pas demunmaptant que la garde vit ; libérer le slot →munmapau dernier drop. Vérifier aucun UAF (la mémoire reste lisible tant qu’une garde existe) et aucune fuite (munmap survient bien au dernier drop). Compteur fort exact sous loom (clones/drops concurrents). - Validation :
futex_word/rangehors bornes ou non alignés →EINVALavant soumission. - Intégration (une fois consommé par io_uring) :
madviseetfutex_waitréveillé parfutex_wakesur uneMmapRegionpartagée entre deux threads. from_mapping: leMappingconsommé ne munmap plus ; la région le fait au dernier drop (pas de double munmap).- Couverture 100 % lignes + branches ; erreurs ressources non déclenchables →
COVERAGE-EXCEPTIONS.md(STRUCTURAL).
6. Décisions de fond
MmapRegiondistinct deMapping:Mappingreste unique/zéro-coût et intouché (couverture préservée) ;MmapRegionest l’opt-in partageable pour l’async io_uring.- Compté par référence → ni UAF ni fuite : raffine le « fuite plutôt qu’UAF »
envisagé (ADR-028/2a §6.2) ;
munmapau dernier drop. - Garde de vivacité
MmapRegionLivenessgarée dans le slot S1 : la sûreté mémoire de madvise/futex est par construction, sansunsafeexposé. - Signature futex corrigée (
&MmapRegion+ offset, pas&AtomicU32) — réconcilie le 2c avec le modèle d’ownership asynchrone. - Une allocation (Arc) assumée (ADR-021 c.4 « nécessité documentée ») : opt-in,
négligeable devant
mmap.
7. Travail à reprendre
Cette spec débloque : implémentation MmapRegion (PR mem coordonnée), puis
submit_madvise (Temps 2a, reliquat) et submit_futex_* (Temps 2c, reliquat) —
réconcilier les signatures dans io-uring-2a-filesystem.md et
io-uring-2c-async.md. fanotify, epoll (selon retour 2c) : indépendants.
Licence du document : MPL 2.0
Statut : Spécification technique de MmapRegion (extension air-sys-syscall::mem),
cible kernel 6.12 LTS. Débloque madvise (2a) et futex (2c).
Spec couche 0 — Famille process : extension privsep (setgroups / setresgid / setresuid + getres*)
Spécification technique — Version 1.0 (cible kernel : Linux 6.12 LTS). Extension
de la famille process.
Position
Cette extension ajoute les primitives manquantes indispensables à une
réduction de privilèges correcte (drop_privileges d’air-process, couche 1,
Principe 10). La famille process n’expose ni setgroups, setresgid,
setresuid, ni les getres* de vérification (ni même setuid/setgid), et
pas de types Uid/Gid — ces types sont ajoutés par cette extension
(constaté à l’implémentation, PR #34).
Émergence (méthode doc-d’abord). Manque révélé en spécifiant
air-process: faire un privsep avecsetuid/setgidseuls est un footgun de sécurité — les groupes supplémentaires ne sont pas largués, et le saved-set-uid/gid n’est pas fixé → possibilité de regagner les privilèges. 4ᵉ manque couche 0 identifié, et le plus prioritaire (sécurité), à côté defs::inotify,MmapRegion(résolu) etepoll.
Sous-module : air-sys-syscall::process (extension). Privilégié : ces
opérations exigent les capabilities adéquates (CAP_SETUID/CAP_SETGID) ou d’être
root au moment de l’appel.
1. Types
Réutilise les newtypes typés Uid/Gid de la famille process (ADR-029 :
jamais d’u32 brut pour un identifiant). S’ils n’existent pas encore, les
ajouter (air-sys-types::process) en même temps — Uid(u32)/Gid(u32),
#[repr(transparent)], avec as_raw/from_raw. (À confirmer à l’implémentation.)
2. Largage des groupes supplémentaires
#![allow(unused)]
fn main() {
/// `setgroups` — remplace la liste des groupes supplémentaires du processus.
/// Pour un privsep, on passe **`&[]`** (largage total) tant qu'on est privilégié.
/// # Errors `EPERM` (sans `CAP_SETGID`), `EINVAL` (trop de groupes), `EFAULT`.
pub fn set_groups(groups: &[Gid]) -> Result<(), Errno>;
/// `getgroups` — lit la liste courante (vérification défensive). Buffer fourni,
/// zéro alloc ; retourne la tranche remplie.
pub fn get_groups<'b>(buffer: &'b mut [Gid]) -> Result<&'b [Gid], Errno>;
}
Syscalls. setgroups (x86_64 n°116, ARM64 n°159), getgroups (x86_64 n°115,
ARM64 n°158). Pas de sentinelle ; la liste vide est la valeur normale du
privsep (et non un cas magique).
3. Identité réelle/effective/sauvegardée
#![allow(unused)]
fn main() {
/// `setresgid` — fixe **real + effective + saved** GID en un appel. À faire
/// **avant** `setresuid`. `None` pour un composant = « inchangé » (sentinelle
/// kernel `-1` typée en `Option`, ADR-021 conv. 1).
/// # Errors `EPERM`, `EINVAL`.
pub fn set_resgid(real: Option<Gid>, effective: Option<Gid>, saved: Option<Gid>)
-> Result<(), Errno>;
/// `setresuid` — fixe **real + effective + saved** UID en un appel. Après le GID.
/// `None` = « inchangé » (`-1` typé).
/// # Errors `EPERM`, `EINVAL`.
pub fn set_resuid(real: Option<Uid>, effective: Option<Uid>, saved: Option<Uid>)
-> Result<(), Errno>;
/// `getresgid` / `getresuid` — lecture des trois composantes (vérification
/// défensive : confirmer qu'on **ne peut plus** revenir en arrière).
pub fn get_resgid() -> Result<ResGid, Errno>;
pub fn get_resuid() -> Result<ResUid, Errno>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResUid { pub real: Uid, pub effective: Uid, pub saved: Uid }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResGid { pub real: Gid, pub effective: Gid, pub saved: Gid }
}
Syscalls. setresgid (x86_64 n°119, ARM64 n°149), setresuid (x86_64 n°117,
ARM64 n°147), getresgid (x86_64 n°120, ARM64 n°150), getresuid (x86_64 n°118,
ARM64 n°148). (Numéros vérifiés sur l’uapi 6.12 par arche à l’implémentation —
setresgid ARM64 corrigé 143 → 149, PR #34.)
Pourquoi
setres*et nonset*.setresuid/setresgidfixent explicitement les trois identités (real/effective/saved) en un syscall. Fixer le saved-set est ce qui rend le retour aux privilèges impossible — garantie qu’unsetuid/setgidsimple ne donne pas de façon fiable. C’est la raison d’être de cette extension.None(=-1kernel) laisse une composante inchangée, typé proprement (pas de-1magique).
4. Récapitulatif
| Fonction | Rôle | Syscall |
|---|---|---|
set_groups / get_groups | larguer / lire les groupes supplémentaires | setgroups / getgroups |
set_resgid / get_resgid | GID real+effective+saved | setresgid / getresgid |
set_resuid / get_resuid | UID real+effective+saved | setresuid / getresuid |
Total : ~6 fonctions. Types ajoutés à air-sys-types::process : ResUid, ResGid
(+ Uid/Gid si absents).
5. Tests
- Privilégiés (root /
CAP_SETUID+CAP_SETGID), en sous-processus isolés (fork+ observation viawaitid), skip propre si non privilégié →COVERAGE-EXCEPTIONS.md(catégorie PRIVILEGE) :set_groups(&[])puisget_groupsrend une liste vide.set_resgidpuisset_resuidvers un uid/gid non-root, puis vérifier l’impossibilité de revenir :set_resuid(Some(root), …)→EPERM;get_resuidconfirme real==effective==saved==cible.None(composante inchangée) : une seule composante modifiée.
- Erreurs :
EPERMsans capability (couvert sur exécuteur non-root),EINVAL. - Property-based : encodage des
Option<Uid/Gid>→-1pourNone, round-trip. - Validation cross-arch x86_64 + aarch64.
6. Décisions de fond
setres*(et nonset*) pour fixer le saved-set — interdiction du retour aux privilèges par construction.setgroups(&[])de première classe — largage des groupes supplémentaires, étape obligatoire du privsep.None= composante inchangée (-1kernel typé, ADR-021 conv. 1).getres*/getgroupsexposés pour la vérification défensive dedrop_privileges(Principe 5 : confirmer la réduction).
Licence du document : MPL 2.0
Statut : Spécification technique de l’extension privsep de
air-sys-syscall::process (couche 0), cible kernel 6.12 LTS. Prérequis de
air-process::drop_privileges.
Spec couche 0 — Famille process, affinité CPU (sched_setaffinity)
Spécification technique — Version 1.0 (cible kernel : Linux 6.12 LTS). Extension
de la famille process.
Position
Ce document spécifie le primitif d’affinité CPU de la couche 0 — épingler une
tâche sur un sous-ensemble de CPU logiques. Manque identifié en spécifiant la
couche 1 : air-thread::cpu_affinity en a besoin et aucun wrapper
sched_setaffinity/sched_getaffinity n’existait.
Placement : famille process. L’affinité est une propriété d’ordonnancement
d’une tâche (identifiée par un Tid) ; elle vit donc avec gettid,
clone3, waitid, setpgid… dans air-sys-syscall::process. Le masque
réutilise CpuSet (déjà défini dans air-sys-types::system au Temps 3a pour
IORING_REGISTER_IOWQ_AFF) — type partagé, non recréé. (Alternative écartée :
famille system — mais system couvre l’information machine (uname, sysinfo,
entropie), pas l’ordonnancement d’une tâche précise.)
Émergence (méthode doc-d’abord). Comme inotify, ce manque a été révélé par la spécification de la couche 1 — consommer la couche 0 fait apparaître ses trous. Pas de spec préalable : ce document est produit avant l’implémentation.
Surface
#![allow(unused)]
fn main() {
/// `sched_setaffinity(2)` — fixe l'affinité CPU d'une tâche : elle ne sera
/// ordonnancée que sur les CPU présents dans `cpus`. `tid = None` = tâche
/// appelante (sentinelle kernel `0` typée en `Option`, ADR-021 conv. 1).
pub fn set_cpu_affinity(tid: Option<Tid>, cpus: &CpuSet) -> Result<(), Errno>;
/// `sched_getaffinity(2)` — lit l'affinité courante **dans** `cpus` (buffer
/// fourni par l'appelant, zéro allocation). `tid = None` = tâche appelante.
pub fn get_cpu_affinity(tid: Option<Tid>, cpus: &mut CpuSet) -> Result<(), Errno>;
}
CpuSetréutilisé (air-sys-types::system,CPU_SETSIZE = 1024bits / 128 octets, layoutcpu_set_t) :new/set/clear/contains/count/is_empty/as_bytes.get_cpu_affinityécrit dans le masque via un pointeur sur le&mut CpuSet(#[repr(C)]sans padding ⇒ tout motif d’octets valide) — aucun nouvel accesseur ajouté au type.None= tâche appelante : traduit enpid = 0(jamais exposé comme entier magique — ADR-021 conv. 1).- EINTR :
sched_*affinityne sont pas interruptibles ; aucun retry caché (ADR-021 conv. 2 reste la règle générale).
Syscalls sous-jacents (vérifiés uapi 6.12, par arche)
| Syscall | x86_64 | aarch64 |
|---|---|---|
sched_setaffinity | 203 | 122 |
sched_getaffinity | 204 | 123 |
set : le kernel lit len octets du masque (asm readonly). get : le
kernel écrit dans le masque (asm sans readonly) et retourne le nombre
d’octets remplis (≥ 0) ou -errno. La taille passée est size_of::<CpuSet>()
(128 octets) ≥ la taille kernel de cpumask — le kernel remplit ce qu’il a et
laisse le reste à zéro.
Préconditions / erreurs
set_cpu_affinity:- [
Errno::EINVAL] :cpusne contient aucun CPU autorisé / en ligne. - [
Errno::ESRCH] : aucune tâche ne porte cetid. - [
Errno::EPERM] : privilèges insuffisants (changer l’affinité d’une tâche d’un autre utilisateur, ou contrainte cpuset cgroup).
- [
get_cpu_affinity:- [
Errno::ESRCH] :tidinexistant. - [
Errno::EINVAL] : taille de masque incohérente — ne se produit pas avec unCpuSetdeCPU_SETSIZEbits (toujours ≥ la taille kernel).
- [
Tests
- Round-trip (réel) :
getl’affinité courante (count ≥ 1),setà un sous-ensemble (CPU 0, toujours en ligne),getet vérifiercontains(0)+count == 1, puis restaurer l’affinité d’origine. NonevsSome(tid): les deux ciblent la tâche appelante (Some(gettid())).- Erreurs : masque vide →
EINVAL;tidinexistant →ESRCH. - Cross-arch : numéros vérifiés x86_64 + aarch64 ; tests exécutés sur les deux.
- Couverture 100 % lignes + branches (les chemins
Ok/Errréels couvrent les deux bras ;affinity_pidNone/Somecouverts) ; aucune exception attendue.
Décisions de fond
- Affinité dans
process(ordonnancement d’une tâche), masque partagé depuissystem::CpuSet— pas de duplication de type. None= tâche appelante (pid = 0typé), conforme ADR-021 conv. 1.- Buffer fourni, zéro allocation pour
get(l’appelant possède leCpuSet). - Primitif débloquant la couche 1 (
air-thread::cpu_affinity).
Licence du document : MPL 2.0
Statut : Spécification technique de l’affinité CPU (sched_set/getaffinity),
extension de la famille process (couche 0), cible kernel 6.12 LTS.
Spec couche 0 — Module io_uring (redirection)
Document de redirection — Version 2.0
Ce document ne fait plus autorité. Il était la synthèse early-stage du module
io_uring, calibrée sur un kernel ancien. Sa fonction d’inventaire et de découpage a été entièrement reprise et corrigée par le document maître et les spécifications détaillées par Temps (cible kernel 6.12 LTS figée).Il est conservé comme point de redirection pour ne pas casser les liens entrants ; tout son contenu de fond vit désormais dans les documents ci-dessous.
Où lire la spécification du module io_uring
Décisions d’architecture
- ADR-022 — Architecture du module io_uring (10 décisions structurantes)
- ADR-028 — Soundness et téardown (S1/S2/S3) (complète ADR-022)
Document maître (point d’entrée)
- io_uring — inventaire maître (cible 6.12) : énumération exhaustive des 8 axes, classement retenu/obsolète, décisions de soundness, découpage en Temps, squelette rustdoc du cœur.
Spécifications détaillées par Temps
- Temps 1 — cœur de l’API
- Temps 2a — filesystem
- Temps 2b — réseau
- Temps 2c — async-spécifiques
- Temps 2d — URING_CMD (passthrough)
- Temps 3a — registration
- Temps 3b — buffers fournis (ring)
- Temps 3c — opérations liées
- Temps 3d — multishot
- Temps 3e — multi-thread
- Temps 3f — confinement (sandbox)
- Temps 4 — accès brut (raw)
Note historique — sémantique soft/hard link (corrigé — réf. spec 3c §2). Une version antérieure de ce document décrivait la sémantique des liens soft/hard à l’envers et un inventaire incomplet (calibré sur ~5.15). La sémantique correcte, vérifiée kernel et faisant foi dans
io-uring-3c-linked.md§2, est :
- soft (
IOSQE_IO_LINK) : la chaîne est rompue sur erreur de complétion d’un maillon —short readinclus (une lecture partielle compte comme une erreur de chaînage) → les maillons suivants reçoivent-ECANCELED;- hard (
IOSQE_IO_HARDLINK) : la chaîne n’est pas rompue par une erreur de complétion d’un maillon (les suivants s’exécutent quand même) ;- dans les deux cas, l’ordre d’exécution des maillons est garanti ;
- un échec de soumission du maillon parent rompt la chaîne.
En cas de divergence, le document maître et les specs par Temps font foi.
Licence du document : MPL 2.0 Statut : Document de redirection. Le contenu normatif vit dans le document maître et les specs par Temps.
Spec couche 0 — Module io_uring : document maître d’inventaire
Spécification technique — Version 1.0 (cible kernel figée : Linux 6.12 LTS)
Rôle de ce document. C’est la carte de référence du module
air-sys-syscall::io_uring. Il énumère exhaustivement la surface ABI d’io_uring telle qu’elle existe dans le kernel 6.12 (plancher figé), la classe (retenue / obsolète-évacuée), la rattache à un Temps de spécification et à un symbole de façade Rust, puis se termine par le squelette rustdoc du cœur de l’API.Il rend l’objectif « exposer toutes les fonctionnalités d’io_uring » vérifiable : toute entrée du header uapi
io_uring.hde la v6.12 doit apparaître ici avec une décision. C’est l’entrée à valider avant la production des specs détaillées par Temps et avant que l’implémentation (corps des fonctions) soit confiée.Relation avec l’existant. Ce document remplace le rôle d’inventaire de
io-uring-overview.md(calibré sur un kernel ~5.15, devenu incomplet). Les décisions structurantes restent celles de l’ADR-022 ; les deux décisions de soundness de la section 3 étendent ADR-022 et feront l’objet d’un amendement RFC (cf. §3.4).
1. Cible kernel figée et méthodologie
1.1 Décision de périmètre
- Plancher = Linux 6.12 LTS. Tout ce qui est antérieur à 6.12 est évacué au démarrage. On n’implémentera des chemins pour kernels plus anciens que s’il y a une demande explicite (et alors par feature-gate + fallback documenté).
- Source de vérité = le header
include/uapi/linux/io_uring.hdu tagv6.12. Tout symbole présent dans ce header est dans le périmètre ; tout symbole présent uniquement dansmaster(postérieur) est hors périmètre de démarrage et n’entrera que plus tard, par détection runtime, sans amendement (ADR-022, statut futur). - Une seule façade : la moderne. Quand le kernel 6.12 offre déjà une
variante moderne d’une fonctionnalité, Air n’expose que la moderne.
L’ancienne est consignée dans
docs/UNSUPPORTED.mdavec justification (cohérent avec « variantes modernes préférées » du CLAUDE.md). Cf. §4.
1.2 Hors périmètre de démarrage (présent dans master, absent de 6.12)
Recensé ici pour mémoire, pour ne pas le réintroduire par erreur :
- Opcodes :
RECV_ZC,EPOLL_WAIT,READV_FIXED,WRITEV_FIXED,PIPE,NOP128,URING_CMD128. - Register opcodes :
SEND_MSG_RING,ZCRX_IFQ,ZCRX_CTRL,RESIZE_RINGS,MEM_REGION,QUERY,BPF_FILTER. - Setup flags :
HYBRID_IOPOLL,CQE_MIXED,SQE_MIXED,SQ_REWIND. - CQE flags :
IORING_CQE_F_SKIP,IORING_CQE_F_32. - Enter flags :
EXT_ARG_REG,NO_IOWAIT. - Features :
RW_ATTR,NO_IOWAIT. - uring_cmd socket op :
SOCKET_URING_OP_TX_TIMESTAMP,SOCKET_URING_OP_GETSOCKNAME(6.12 n’a que SIOCINQ/SIOCOUTQ/GET/SETSOCKOPT).
2. Le modèle io_uring en bref (rappel d’ancrage)
io_uring repose sur trois syscalls et un protocole d’anneaux mmappés :
| Syscall | Rôle |
|---|---|
io_uring_setup(entries, params) | Crée le ring, retourne un FD, remplit les offsets de mmap. |
io_uring_enter(fd, to_submit, min_complete, flags, arg, argsz) | Soumet des SQE et/ou attend des CQE. |
io_uring_register(fd, opcode, arg, nr_args) | Enregistre/configure des ressources (buffers, files, restrictions…). |
Trois zones mmappées : l’anneau de soumission (SQ), l’anneau de complétion
(CQ), et le tableau de SQE (64 octets, ou 128 avec SETUP_SQE128). Le
CQE fait 16 octets (ou 32 avec SETUP_CQE32). Le couplage user_data
(posé dans le SQE, rendu dans le CQE) est le fil qui relie une soumission à sa
complétion — c’est le pivot de la façade Air (cf. §3.1).
La surface fonctionnelle se décompose en 8 axes énumérables ; les sections 5.1 à 5.8 les épuisent un par un.
2.1 Terminologie et sigles (référence)
io_uring emploie des sigles standardisés (kernel, man pages, liburing). Ce tableau les définit une fois pour toutes ; la documentation Air privilégie la forme verbeuse et n’utilise les sigles que là où ils correspondent à un nom ABI kernel littéral (qu’on ne renomme jamais, par exactitude).
| Sigle | Forme complète (EN) | Forme complète (FR) | Statut dans la façade Air |
|---|---|---|---|
| SQ | submission queue | file de soumission | sigle = anneau kernel ; façade verbeuse (submission_queue_*) |
| CQ | completion queue | file de complétion | sigle = anneau kernel ; façade verbeuse (completion_queue_*) |
| SQE | submission queue entry | entrée de soumission | type façade RawSubmissionQueueEntry ; struct kernel io_uring_sqe (gardé) |
| CQE | completion queue entry | entrée de complétion | type façade RawCompletionQueueEntry ; struct kernel io_uring_cqe (gardé) |
| FD | file descriptor | descripteur de fichier | types std OwnedFd/BorrowedFd/RawFd (gardés) |
| ZC | zero-copy | copie zéro | façade verbeuse : submit_send_zero_copy, ZeroCopyFlags, into_zero_copy_buffer |
| op / opcode | operation code | code d’opération | façade verbeuse : IoUringOpcode, RawOpcode |
| SQPOLL | submission-queue polling | scrutation de la file de soumission | nom de flag kernel IORING_SETUP_SQPOLL (gardé) ; type SqpollIoUring |
| NAPI | New API (busy-poll réseau) | — | nom de mécanisme kernel (gardé) |
sq_entries / cq_entries | submission/completion queue depth | profondeur des files | champs ABI de io_uring_params (gardés littéralement) |
Règle appliquée. Les identifiants de la façade Air sont écrits en toutes
lettres (submission_queue_*, completion_queue_*, IoUringOpcode,
ZeroCopyFlags, in_flight, etc.) — verbeux et sans ambiguïté pour le
développeur (Principe 7). Les noms ABI du kernel (io_uring_sqe,
io_uring_cqe, IORING_*, sqe->res, cq_entries…) sont conservés
verbatim : les renommer introduirait une divergence avec le kernel et les man
pages, donc moins de précision, pas plus.
⚠️ Précision factuelle. Le « S » de SQ/SQE est Submission (soumission), pas « Send ». SQ = submission queue, SQE = submission queue entry.
3. Décisions de soundness gelées (étendent ADR-022)
Ces décisions déterminent toutes les signatures de la façade. Elles sont gelées avant l’écriture du moindre corps de fonction.
3.1 Décision S1 — État des opérations en vol : slab pré-alloué
Problème. Le modèle « transfert d’ownership » (ADR-022, Décision 3) prend
le buffer en argument et le rend à la complétion. Entre soumission et
complétion, il faut garer quelque part {buffer, métadonnées, type d'op},
retrouvable par user_data. Une HashMap<u64, _> allouerait par opération —
ce qui viole la règle CLAUDE.md « pas d’allocation heap dans le happy
path ».
Décision. L’IoUring possède un slab pré-alloué dimensionné à la
profondeur de la SQ (sq_entries). Chaque opération en vol occupe un slot.
Le SubmissionToken encapsule un index de slot + génération (compteur de génération anti-réutilisation),
pas un user_data brut. user_data côté kernel = l’index de slot encodé.
Conséquences :
- Zéro allocation par opération dans le happy path : le buffer transféré est déplacé (move) dans le slot, pas copié ni réalloué.
- Back-pressure naturelle : slab plein ⇒
submit_*retourneErr(Errno::EBUSY)(ou un type d’erreur dédié) avant d’atteindre le kernel. - Le slot est libéré quand la complétion correspondante est consommée et que l’ownership du buffer est rendu à l’appelant.
3.2 Décision S2 — Téardown sûr : Drop quiesce, shutdown() explicite
Problème. Si l’IoUring (ou un buffer en vol) est libéré alors que le
kernel a encore des opérations en cours, le kernel peut écrire dans de la
mémoire libérée. C’est le hasard de soundness io_uring + Rust. ADR-022 ne
le traitait pas.
Décision (fidèle à « sur-sécuriser puis dégraisser après mesure », Principe 5).
shutdown(self) -> Result<(), Errno>: voie propre et explicite. Émet une annulation globale (IORING_REGISTER_SYNC_CANCELouASYNC_CANCEL_ANY), draine les complétions restantes, puis ferme le FD. Ne bloque pas indéfiniment (timeout).Drop: filet de sécurité. Si des opérations sont encore en vol,Dropquiesce (annule + draine de façon bloquante) avant de rendre la mémoire. Le coût (blocage potentiel enDrop) est assumé et documenté ; l’utilisateur soucieux de performance appelleshutdown()explicitement.- Les slots de la Décision S1 retiennent l’ownership des buffers tant que l’op n’est pas complétée ⇒ le buffer ne peut pas être libéré avant le kernel.
3.3 Décision S3 — Restrictions & sandbox comme primitive capability
IORING_REGISTER_RESTRICTIONS + SETUP_R_DISABLED + REGISTER_ENABLE_RINGS
forment une primitive de confinement : créer un ring désactivé, restreindre
les opcodes/flags/register-ops autorisés, puis l’activer. Cela mappe
directement sur le modèle d’entitlements signés d’ADR-010 et les capabilities
AirCom (ADR-001).
Décision. Cette primitive est traitée en première classe (Temps 3f,
cf. §6), pas noyée dans la registration générique. La façade expose un
constructeur IoUringBuilder capable d’appliquer un jeu de restrictions
avant l’activation, de sorte qu’un service Air confiné reçoive un ring dont
le kernel garantit qu’il ne peut émettre que les opérations autorisées par son
manifeste.
3.4 Statut RFC
S1, S2, S3 étendent ADR-022 sans contredire ses 10 décisions. Elles sont
consignées dans l’ADR-028 (« Soundness et téardown du module io_uring »),
complément d’ADR-022 — cf. ../../adrs/ADR-028-soundness-io-uring-fr.md.
4. Règle « pas d’obsolète » : décisions concrètes
Variantes legacy évacuées vers UNSUPPORTED.md (le kernel 6.12 offre déjà
la moderne) :
| Legacy (évacué) | Remplaçant moderne (exposé) | Raison |
|---|---|---|
IORING_REGISTER_BUFFERS (reg 0) | IORING_REGISTER_BUFFERS2 (reg 15) + BUFFERS_UPDATE (16) | Tagging des ressources (FEAT_RSRC_TAGS), sparse, update partiel. |
IORING_REGISTER_FILES (reg 2) | IORING_REGISTER_FILES2 (reg 13) + FILES_UPDATE2 (14) | Idem ; struct io_uring_files_update est marqué deprecated dans le header. |
IORING_REGISTER_FILES_UPDATE (reg 6) | IORING_REGISTER_FILES_UPDATE2 (reg 14) | Idem. |
IORING_OP_PROVIDE_BUFFERS (op 31) | IORING_REGISTER_PBUF_RING (reg 22) | Provided buffers ring-mapped, sans syscall par lot, consommation incrémentale (IOU_PBUF_RING_INC). |
IORING_OP_REMOVE_BUFFERS (op 32) | IORING_UNREGISTER_PBUF_RING (reg 23) | Idem. |
Champ SQE poll_events (__u16) | poll32_events (__u32) | Le header note « compatibility » ; on n’expose que la version 32 bits. |
Note : UNREGISTER_BUFFERS (1) et UNREGISTER_FILES (3) restent exposés —
ce sont les désenregistrements des ressources enregistrées via les variantes
*2. EVENTFD_ASYNC (7) et EPOLL_CTL (op 29) restent (non obsolètes :
respectivement variante utile et seule intégration epoll en 6.12).
5. Inventaire exhaustif des 8 axes
Légende colonne « Décision » : R = retenu/exposé · O = obsolète, évacué
vers UNSUPPORTED.md · G = gated runtime (probe/feature, exposé mais
faillible).
5.1 Axe A — Setup flags IORING_SETUP_* (17 en 6.12)
Exposés comme bitflags SetupFlags, appliqués via IoUringBuilder.
| Flag | Bit | Décision | Symbole façade / note |
|---|---|---|---|
IOPOLL | 0 | R | SetupFlags::IOPOLL — polling I/O (stockage O_DIRECT). |
SQPOLL | 1 | R | SetupFlags::SQPOLL — thread kernel de poll de la SQ (Temps 3e). |
SQ_AFF | 2 | R | SetupFlags::SQ_AFF — affinité CPU du thread SQPOLL. |
CQSIZE | 3 | R | IoUringBuilder::cq_entries(). |
CLAMP | 4 | R | SetupFlags::CLAMP. |
ATTACH_WQ | 5 | R | IoUringBuilder::attach_work_queue(&IoUring) — partage du pool io-wq. |
R_DISABLED | 6 | R | primitive sandbox (Temps 3f, §3.3). |
SUBMIT_ALL | 7 | R | SetupFlags::SUBMIT_ALL. |
COOP_TASKRUN | 8 | R | SetupFlags::COOP_TASKRUN. |
TASKRUN_FLAG | 9 | R | SetupFlags::TASKRUN_FLAG. |
SQE128 | 10 | R | requis pour URING_CMD à payload large (Temps 2d, Temps 4). |
CQE32 | 11 | R | requis pour certaines complétions étendues. |
SINGLE_ISSUER | 12 | R | recommandé Air : reactor thread-per-core. |
DEFER_TASKRUN | 13 | R | recommandé Air (combiné SINGLE_ISSUER) : latence basse. |
NO_MMAP | 14 | R | mémoire des anneaux fournie par l’appelant. |
REGISTERED_FD_ONLY | 15 | R | ring fd uniquement enregistré (combine REG_REG_RING). |
NO_SQARRAY | 16 | R | supprime l’indirection du tableau d’index SQ. |
5.2 Axe B — Opérations IORING_OP_* (58 en 6.12, indices 0–57)
Colonne « Temps » = spec détaillée de destination (cf. §6).
| Opcode | # | Décision | Temps | Symbole façade |
|---|---|---|---|---|
NOP | 0 | R | 2c | submit_nop |
READV | 1 | R | 2a | submit_readv |
WRITEV | 2 | R | 2a | submit_writev |
FSYNC | 3 | R | 2a | submit_fsync |
READ_FIXED | 4 | R | 2a/3a | submit_read_fixed (buffer enregistré) |
WRITE_FIXED | 5 | R | 2a/3a | submit_write_fixed |
POLL_ADD | 6 | R | 2c | submit_poll_add (+ flags multi/level) |
POLL_REMOVE | 7 | R | 2c | submit_poll_remove |
SYNC_FILE_RANGE | 8 | R | 2a | submit_sync_file_range |
SENDMSG | 9 | R | 2b | submit_send_message |
RECVMSG | 10 | R | 2b | submit_receive_message |
TIMEOUT | 11 | R | 2c | submit_timeout |
TIMEOUT_REMOVE | 12 | R | 2c | submit_timeout_remove / update |
ACCEPT | 13 | R | 2b | submit_accept (+ multishot Temps 3d) |
ASYNC_CANCEL | 14 | R | 2c | submit_cancel |
LINK_TIMEOUT | 15 | R | 2c/3c | submit_link_timeout |
CONNECT | 16 | R | 2b | submit_connect |
FALLOCATE | 17 | R | 2a | submit_fallocate |
OPENAT | 18 | O | — | évacué → OPENAT2 (superset, OpenHow). |
CLOSE | 19 | R | 2a | submit_close |
FILES_UPDATE | 20 | R | 2c/3a | submit_files_update (op, distinct du register) |
STATX | 21 | R | 2a | submit_statx |
READ | 22 | R | 2a | submit_read |
WRITE | 23 | R | 2a | submit_write |
FADVISE | 24 | R | 2a | submit_fadvise |
MADVISE | 25 | R | 2a | submit_madvise |
SEND | 26 | R | 2b | submit_send |
RECV | 27 | R | 2b | submit_receive (+ multishot Temps 3d) |
OPENAT2 | 28 | R | 2a | submit_openat2 |
EPOLL_CTL | 29 | R | 2c | submit_epoll_ctl |
SPLICE | 30 | R | 2a | submit_splice |
PROVIDE_BUFFERS | 31 | O | — | évacué → PBUF_RING (reg 22). |
REMOVE_BUFFERS | 32 | O | — | évacué → UNREGISTER_PBUF_RING. |
TEE | 33 | R | 2a | submit_tee |
SHUTDOWN | 34 | R | 2b | submit_shutdown |
RENAMEAT | 35 | R | 2a | submit_renameat (renameat2 sémantique) |
UNLINKAT | 36 | R | 2a | submit_unlinkat |
MKDIRAT | 37 | R | 2a | submit_mkdirat |
SYMLINKAT | 38 | R | 2a | submit_symlinkat |
LINKAT | 39 | R | 2a | submit_linkat |
MSG_RING | 40 | R | 2c | submit_msg_ring (data / send_fd) |
FSETXATTR | 41 | R | 2a | submit_fsetxattr |
SETXATTR | 42 | R | 2a | submit_setxattr |
FGETXATTR | 43 | R | 2a | submit_fgetxattr |
GETXATTR | 44 | R | 2a | submit_getxattr |
SOCKET | 45 | R | 2b | submit_socket |
URING_CMD | 46 | R | 2d | submit_uring_cmd (+ socket cmds) |
SEND_ZC | 47 | R | 2b | submit_send_zero_copy (zero-copy, CQE NOTIF) |
SENDMSG_ZC | 48 | R | 2b | submit_send_message_zero_copy |
READ_MULTISHOT | 49 | R | 3d | submit_read_multishot |
WAITID | 50 | R | 2c | submit_waitid |
FUTEX_WAIT | 51 | R | 2c | submit_futex_wait |
FUTEX_WAKE | 52 | R | 2c | submit_futex_wake |
FUTEX_WAITV | 53 | R | 2c | submit_futex_waitv |
FIXED_FD_INSTALL | 54 | R | 2c/3a | submit_fixed_fd_install |
FTRUNCATE | 55 | R | 2a | submit_ftruncate |
BIND | 56 | R | 2b | submit_bind |
LISTEN | 57 | R | 2b | submit_listen |
Bilan opcodes : 55 retenus, 3 évacués (OPENAT, PROVIDE_BUFFERS,
REMOVE_BUFFERS).
5.3 Axe C — Register opcodes IORING_REGISTER_* (31 en 6.12, 0–30)
| Register op | # | Décision | Temps | Symbole façade |
|---|---|---|---|---|
REGISTER_BUFFERS | 0 | O | — | → BUFFERS2. |
UNREGISTER_BUFFERS | 1 | R | 3a | RegisteredBuffers::unregister |
REGISTER_FILES | 2 | O | — | → FILES2. |
UNREGISTER_FILES | 3 | R | 3a | FixedFdTable::unregister |
REGISTER_EVENTFD | 4 | R | 3a | IoUring::register_eventfd |
UNREGISTER_EVENTFD | 5 | R | 3a | IoUring::unregister_eventfd |
REGISTER_FILES_UPDATE | 6 | O | — | → FILES_UPDATE2. |
REGISTER_EVENTFD_ASYNC | 7 | R | 3a | register_eventfd_async |
REGISTER_PROBE | 8 | R | 1 | IoUring::probe / supports_op |
REGISTER_PERSONALITY | 9 | R | 3a | IoUring::register_personality |
UNREGISTER_PERSONALITY | 10 | R | 3a | unregister_personality |
REGISTER_RESTRICTIONS | 11 | R | 3f | IoUringBuilder::restrict (§3.3) |
REGISTER_ENABLE_RINGS | 12 | R | 3f | IoUringBuilder::enable |
REGISTER_FILES2 | 13 | R | 3a | FixedFdTable::register |
REGISTER_FILES_UPDATE2 | 14 | R | 3a | FixedFdTable::update |
REGISTER_BUFFERS2 | 15 | R | 3a | RegisteredBuffers::register |
REGISTER_BUFFERS_UPDATE | 16 | R | 3a | RegisteredBuffers::update |
REGISTER_IOWQ_AFF | 17 | R | 3a | IoUring::set_work_queue_affinity |
UNREGISTER_IOWQ_AFF | 18 | R | 3a | clear_work_queue_affinity |
REGISTER_IOWQ_MAX_WORKERS | 19 | R | 3a | set_work_queue_max_workers |
REGISTER_RING_FDS | 20 | R | 3a | IoUring::register_ring_fd |
UNREGISTER_RING_FDS | 21 | R | 3a | unregister_ring_fd |
REGISTER_PBUF_RING | 22 | R | 3b | ProvidedBufferRing::register |
UNREGISTER_PBUF_RING | 23 | R | 3b | ProvidedBufferRing::unregister |
REGISTER_SYNC_CANCEL | 24 | R | 1/2c | IoUring::sync_cancel (utilisé par S2) |
REGISTER_FILE_ALLOC_RANGE | 25 | R | 3a | FixedFdTable::set_alloc_range |
REGISTER_PBUF_STATUS | 26 | R | 3b | ProvidedBufferRing::status |
REGISTER_NAPI | 27 | R | 3a | IoUring::register_napi |
UNREGISTER_NAPI | 28 | R | 3a | unregister_napi |
REGISTER_CLOCK | 29 | R | 3a | IoUring::register_clock |
REGISTER_CLONE_BUFFERS | 30 | R | 3a | RegisteredBuffers::clone_from |
USE_REGISTERED_RING (flag 1<<31) | — | R | 1 | appliqué de façon transparente quand le ring fd est enregistré. |
Bilan register : 28 retenus, 3 évacués (REGISTER_BUFFERS,
REGISTER_FILES, REGISTER_FILES_UPDATE).
5.4 Axe D — Flags par SQE IOSQE_* (7)
| Flag | Décision | Symbole façade |
|---|---|---|
FIXED_FILE | R | implicite via les variantes « fixed » (FD enregistré). |
IO_DRAIN | R | SubmitOptions::drain(). |
IO_LINK | R | LinkedChainBuilder (Temps 3c). |
IO_HARDLINK | R | LinkedChainBuilder::add_hard_link. |
ASYNC | R | SubmitOptions::force_async(). |
BUFFER_SELECT | R | implicite via ProvidedBufferRing (Temps 3b). |
CQE_SKIP_SUCCESS | R | SubmitOptions::skip_cqe_on_success() (FEAT_CQE_SKIP). |
5.5 Axe E — Flags de CQE IORING_CQE_F_* (5 en 6.12)
| Flag | Décision | Exposition |
|---|---|---|
BUFFER | R | Completion::buffer_id() -> Option<u16>. |
MORE | R | Completion::has_more() (multishot, Temps 3d). |
SOCK_NONEMPTY | R | Completion::socket_has_pending_data(). |
NOTIF | R | Completion::is_notif() (zero-copy send, Temps 2b). |
BUF_MORE | R | consommation incrémentale (IOU_PBUF_RING_INC, Temps 3b). |
5.6 Axe F — Flags d’io_uring_enter (6 en 6.12)
Gérés en interne par la façade (pas exposés crûment) :
| Flag | Décision | Usage interne |
|---|---|---|
GETEVENTS | R | submit_and_wait, wait_completion. |
SQ_WAKEUP | R | réveil du thread SQPOLL (Temps 3e). |
SQ_WAIT | R | attente que la SQ se vide (SQPOLL). |
EXT_ARG | R | timeout d’attente (getevents_arg), wait_completion_timeout. |
REGISTERED_RING | R | appliqué si ring fd enregistré (transparent). |
ABS_TIMER | R | timeout absolu (FEAT_MIN_TIMEOUT). |
5.7 Axe G — Features IORING_FEAT_* (16 en 6.12)
Lues dans io_uring_params.features après setup, exposées via
IoUringCapabilities :
SINGLE_MMAP, NODROP, SUBMIT_STABLE, RW_CUR_POS, CUR_PERSONALITY,
FAST_POLL, POLL_32BITS, SQPOLL_NONFIXED, EXT_ARG, NATIVE_WORKERS,
RSRC_TAGS, CQE_SKIP, LINKED_FILE, REG_REG_RING, RECVSEND_BUNDLE,
MIN_TIMEOUT. → tous R, mappés sur des prédicats IoUringCapabilities::*.
5.8 Axe H — Protocole d’anneau brut (Temps 4)
Structures kernel exposées en #[repr(C)] dans ::raw, + flags d’anneau :
io_uring_sqe(64 o / 128 o),io_uring_cqe(16 o / 32 o).io_sqring_offsets,io_cqring_offsets,io_uring_params.- Flags d’anneau SQ :
SQ_NEED_WAKEUP,SQ_CQ_OVERFLOW,SQ_TASKRUN. - Flag d’anneau CQ :
CQ_EVENTFD_DISABLED. - Offsets mmap :
OFF_SQ_RING,OFF_CQ_RING,OFF_SQES,OFF_PBUF_RING. - Structures d’argument register :
io_uring_rsrc_register,io_uring_rsrc_update(2),io_uring_buf(_ring|_reg|_status),io_uring_napi,io_uring_clock_register,io_uring_clone_buffers,io_uring_sync_cancel_reg,io_uring_file_index_range,io_uring_probe(_op),io_uring_restriction,io_uring_getevents_arg,io_uring_recvmsg_out. - Enums :
io_uring_op,io_uring_register_op,io_uring_register_restriction_op,io_uring_msg_ring_flags,io_uring_socket_op,io_uring_register_pbuf_ring_flags,io_wq_type.
6. Découpage en Temps révisé
Le découpage d’ADR-022 est conservé et étendu (ajouts : 2d uring_cmd,
séparation 3b provided-buffers-ring, 3f restrictions/sandbox) :
| Temps | Sous-module | Périmètre | Symboles |
|---|---|---|---|
| 1 | (racine) | Cycle de vie du ring, setup flags, slab (S1), Drop/shutdown (S2), soumission/complétion, Completion, probe/capabilities. | ~20 |
| 2a | (racine) | Filesystem (read/write/sync/open/stat/dir/perm/xattr/splice/ftruncate…). | 26 |
| 2b | (racine) | Réseau (accept/connect/send/recv/msg/shutdown/socket/bind/listen + zero-copy send/sendmsg). | 12 |
| 2c | (racine) | Async-spécifiques (nop/timeout/cancel/poll/futex/waitid/msg_ring/epoll_ctl/files_update/fixed_fd_install). | 15 |
| 2d | ::cmd | URING_CMD : passthrough + commandes socket (SIOCINQ/OUTQ, get/setsockopt). | 3-5 |
| 3a | ::registration | Ressources fixes modernes : files2/buffers2/update, ring_fds, eventfd, personality, iowq aff/workers, napi, clock, clone_buffers, alloc_range. | ~25 |
| 3b | ::provided | Provided buffers ring-mapped (PBUF_RING/STATUS, incrémental). | 6-8 |
| 3c | ::linked | Chaînes liées (soft/hard link, link_timeout). | 5-7 |
| 3d | ::multishot | Multishot (accept/recv/poll/read_multishot). | 5-7 |
| 3e | ::shared | Multi-thread : LockedIoUring, RingPool, SqpollIoUring. | 10-12 |
| 3f | ::sandbox | Restrictions + R_DISABLED + ENABLE_RINGS (capability, §3.3). | 6-8 |
| 4 | ::raw | Protocole brut (SQE/CQE, anneaux, structs register). | 8-12 |
7. Squelette rustdoc du cœur (Temps 1) — à valider avant implémentation
Ce squelette est le contrat que Claude Code devra implémenter (corps =
todo!()). Lessubmit_*exhaustifs par opcode (Temps 2a–2d) et les sous-modules (3a–3f, 4) sont produits dans leurs specs dédiées sur le même modèle. Conventions couche 0 (ADR-021) appliquées :Result<_, Errno>, EINTR remonté,Option<T>au lieu de sentinelles, FD enOwnedFd/BorrowedFd, pas d’alloc heap dans le happy path (S1).
#![allow(unused)]
fn main() {
//! Module `air-sys-syscall::io_uring` — façade typée d'io_uring (cible 6.12).
//!
//! Niveau d'abstraction 2 (ADR-022, Décision 1) : soumission/complétion typée.
//! Le niveau 1 (anneaux bruts) vit dans [`raw`]. Les buffers suivent le modèle
//! de transfert d'ownership (ADR-022, Décision 3), garés dans un slab
//! pré-alloué (S1) ; le téardown est sûr par `Drop` quiescent + [`IoUring::shutdown`]
//! (S2).
use crate::Errno;
use core::num::NonZeroU32;
use std::os::fd::{BorrowedFd, OwnedFd};
// ---------------------------------------------------------------------------
// Construction & cycle de vie
// ---------------------------------------------------------------------------
/// Anneau io_uring. `Send` mais pas `Sync` (ADR-022, Décision 6) : un reactor
/// par thread. Pour le multi-thread, voir [`shared`].
pub struct IoUring {
/* fd: OwnedFd, mmaps SQ/CQ/SQE, slab d'ops en vol (S1), capabilities */
}
/// Construit un [`IoUring`], applique setup flags et restrictions (S3) avant
/// activation.
pub struct IoUringBuilder { /* ... */ }
impl IoUringBuilder {
/// Démarre un builder pour `entries` SQE (arrondi à la puissance de 2 sup.).
pub fn new(entries: NonZeroU32) -> Self { todo!() }
/// Taille explicite de la CQ (`SETUP_CQSIZE`).
pub fn with_completion_queue_entries(self, entries: NonZeroU32) -> Self { todo!() }
/// Active des flags de setup (cf. [`SetupFlags`]).
pub fn with_flags(self, flags: SetupFlags) -> Self { todo!() }
/// Partage le pool io-wq d'un ring existant (`SETUP_ATTACH_WQ`).
pub fn attach_work_queue(self, other: &IoUring) -> Self { todo!() }
/// Crée le ring désactivé (`R_DISABLED`) et applique des restrictions
/// (S3). Doit être suivi de [`IoUringBuilder::enable`].
pub fn restrict(self, restrictions: &[Restriction]) -> Self { todo!() }
/// Finalise : `io_uring_setup(2)`. Si `restrict` a été utilisé, le ring
/// est créé désactivé et doit être activé via [`IoUring::enable`].
pub fn build(self) -> Result<IoUring, Errno> { todo!() }
}
impl IoUring {
/// Raccourci : `IoUringBuilder::new(entries).build()`.
pub fn new(entries: NonZeroU32) -> Result<Self, Errno> { todo!() }
/// Active un ring créé avec `R_DISABLED` (`REGISTER_ENABLE_RINGS`).
pub fn enable(&mut self) -> Result<(), Errno> { todo!() }
/// Téardown propre (S2) : annule les ops en vol, draine, ferme le FD.
/// Préférer à un `drop` implicite sur chemin chaud.
pub fn shutdown(self) -> Result<(), Errno> { todo!() }
}
impl Drop for IoUring {
/// Filet de sécurité (S2) : si des ops sont en vol, quiesce (annule +
/// draine, **bloquant**) avant de libérer la mémoire. Coût assumé.
fn drop(&mut self) { todo!() }
}
// ---------------------------------------------------------------------------
// Soumission & complétion
// ---------------------------------------------------------------------------
impl IoUring {
/// Soumet les SQE en attente (`io_uring_enter`, sans attente). Retourne le
/// nombre soumis. EINTR remonté (ADR-021, conv. 2).
pub fn submit(&mut self) -> Result<u32, Errno> { todo!() }
/// Soumet puis attend au moins `want` complétions.
pub fn submit_and_wait(&mut self, want: u32) -> Result<u32, Errno> { todo!() }
/// Attend (bloquant) la prochaine complétion et la consomme.
pub fn wait_completion(&mut self) -> Result<Completion, Errno> { todo!() }
/// Attend une complétion avec délai maximal (`EXT_ARG`/`ABS_TIMER`).
pub fn wait_completion_timeout(
&mut self,
timeout: core::time::Duration,
) -> Result<Option<Completion>, Errno> { todo!() }
/// Récupère une complétion si disponible, sans bloquer.
pub fn try_completion(&mut self) -> Option<Completion> { todo!() }
/// Itère les complétions disponibles dans la CQ.
pub fn completions(&mut self) -> CompletionIter<'_> { todo!() }
/// Options par-opération (link, drain, async, skip-cqe) appliquées à la
/// prochaine soumission.
pub fn with(&mut self, opts: SubmitOptions) -> &mut Self { todo!() }
}
/// Jeton opaque reliant une soumission à sa complétion. Encapsule un index de
/// slot du slab + génération (compteur de génération anti-réutilisation) — *pas* un `user_data` brut (S1).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SubmissionToken { /* slot: u32, gen: u32 */ }
/// Jeton d'une opération multishot (plusieurs complétions). Voir [`multishot`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MultishotToken { /* ... */ }
/// Options de soumission (flags `IOSQE_*` exposés sûrement).
#[derive(Debug, Clone, Copy, Default)]
pub struct SubmitOptions { /* ... */ }
impl SubmitOptions {
pub fn drain(self) -> Self { todo!() }
pub fn force_async(self) -> Self { todo!() }
pub fn skip_cqe_on_success(self) -> Self { todo!() }
}
// ---------------------------------------------------------------------------
// Complétion
// ---------------------------------------------------------------------------
/// Une complétion (CQE) typée. Méthodes d'interprétation selon l'op soumise
/// (ADR-022, Décision 9) ; en cas d'échec, retournent `Err(Errno)`.
pub struct Completion { /* user_data décodé, res, flags */ }
impl Completion {
/// Jeton de l'opération qui a produit cette complétion.
pub fn token(&self) -> SubmissionToken { todo!() }
/// Résultat brut du kernel (sémantique dépendante de l'op).
pub fn raw_result(&self) -> i32 { todo!() }
/// Flags de complétion (cf. [`CompletionFlags`]).
pub fn flags(&self) -> CompletionFlags { todo!() }
/// `IORING_CQE_F_MORE` : d'autres complétions suivront (multishot).
pub fn has_more(&self) -> bool { todo!() }
/// `IORING_CQE_F_NOTIF` : complétion de notification zero-copy.
pub fn is_notif(&self) -> bool { todo!() }
/// ID du buffer fourni consommé (`IORING_CQE_F_BUFFER`).
pub fn buffer_id(&self) -> Option<u16> { todo!() }
/// `IORING_CQE_F_SOCK_NONEMPTY` : données restantes après un recv.
pub fn socket_has_pending_data(&self) -> bool { todo!() }
// Interprétations typées (échantillon ; complétées par Temps 2a–2d) :
/// Octets lus + restitution du buffer transféré (S1).
pub fn into_read_result(self) -> Result<(Vec<u8>, usize), Errno> { todo!() }
/// Octets écrits + restitution du buffer.
pub fn into_write_result(self) -> Result<(Vec<u8>, usize), Errno> { todo!() }
/// FD accepté (`submit_accept`), CLOEXEC par défaut.
pub fn accepted_fd(self) -> Result<OwnedFd, Errno> { todo!() }
/// FD ouvert (`submit_openat2`).
pub fn opened_fd(self) -> Result<OwnedFd, Errno> { todo!() }
/// Succès sans valeur de retour (close, fsync…).
pub fn completed(&self) -> Result<(), Errno> { todo!() }
}
/// Itérateur sur les complétions disponibles.
pub struct CompletionIter<'ring> { /* ... */ }
// ---------------------------------------------------------------------------
// Capacités & introspection
// ---------------------------------------------------------------------------
impl IoUring {
/// Vrai si le kernel courant supporte `op` (`REGISTER_PROBE`, Décision 8).
pub fn supports_op(&self, op: IoUringOpcode) -> bool { todo!() }
/// Features négociées au setup (axe G).
pub fn capabilities(&self) -> IoUringCapabilities { todo!() }
/// Annulation synchrone globale ou ciblée (`REGISTER_SYNC_CANCEL`),
/// brique de [`IoUring::shutdown`] (S2).
pub fn sync_cancel(&mut self, target: CancelTarget) -> Result<u32, Errno> { todo!() }
}
/// Features io_uring du kernel courant (axe G). Prédicats stables.
#[derive(Debug, Clone, Copy)]
pub struct IoUringCapabilities { /* bits features */ }
impl IoUringCapabilities {
pub fn single_mmap(&self) -> bool { todo!() }
pub fn nodrop(&self) -> bool { todo!() }
pub fn cqe_skip(&self) -> bool { todo!() }
pub fn min_timeout(&self) -> bool { todo!() }
pub fn recvsend_bundle(&self) -> bool { todo!() }
pub fn reg_reg_ring(&self) -> bool { todo!() }
/* … un prédicat par feature de l'axe G … */
}
/// Énumération des opérations (axe B) pour le probe et les restrictions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum IoUringOpcode { Nop, Readv, Writev, /* … 55 variantes retenues … */ }
/// Cible d'annulation pour [`IoUring::sync_cancel`].
#[derive(Debug, Clone, Copy)]
pub enum CancelTarget {
Token(SubmissionToken),
Fd(BorrowedFd<'static>),
Op(IoUringOpcode),
Any,
}
// ---------------------------------------------------------------------------
// Bitflags & sandbox (Temps 3f, détaillé dans ::sandbox)
// ---------------------------------------------------------------------------
bitflags::bitflags! {
/// Flags de `io_uring_setup` (axe A, 17 flags en 6.12).
pub struct SetupFlags: u32 {
const IOPOLL = 1 << 0;
const SQPOLL = 1 << 1;
const SQ_AFF = 1 << 2;
const CLAMP = 1 << 4;
const R_DISABLED = 1 << 6;
const SUBMIT_ALL = 1 << 7;
const COOP_TASKRUN = 1 << 8;
const TASKRUN_FLAG = 1 << 9;
const SQE128 = 1 << 10;
const CQE32 = 1 << 11;
const SINGLE_ISSUER = 1 << 12;
const DEFER_TASKRUN = 1 << 13;
const NO_MMAP = 1 << 14;
const REGISTERED_FD_ONLY = 1 << 15;
const NO_SQARRAY = 1 << 16;
/* CQSIZE / ATTACH_WQ exposés via méthodes du builder */
}
/// Flags de complétion (axe E, 5 flags en 6.12).
pub struct CompletionFlags: u32 {
const BUFFER = 1 << 0;
const MORE = 1 << 1;
const SOCK_NONEMPTY = 1 << 2;
const NOTIF = 1 << 3;
const BUF_MORE = 1 << 4;
}
}
/// Une restriction appliquée avant activation (S3 ; `io_uring_restriction`).
#[derive(Debug, Clone, Copy)]
pub enum Restriction {
/// N'autoriser que cet opcode de soumission.
AllowOp(IoUringOpcode),
/// N'autoriser que ce register op.
AllowRegister(u8),
/// Flags SQE autorisés.
SqeFlagsAllowed(SubmitOptions),
/// Flags SQE requis sur chaque soumission.
SqeFlagsRequired(SubmitOptions),
}
}
8. Traits transverses pressentis (à arbitrer en Temps 1)
À valider : faut-il un trait commun aux opérations soumissibles pour factoriser linked/multishot, ou des méthodes inhérentes suffisent-elles (verbosité au service de la clarté, Principe 7) ?
trait Submittable— une opération qui peut être soumise (utilisée parLinkedChainBuilder). Candidat ; risque d’abstraction prématurée.trait FixedResource— ressource enregistrable (FD / buffer). Candidat.
Recommandation : ne pas introduire ces traits au Temps 1. Les ajouter seulement si les Temps 3a/3c en révèlent le besoin par répétition mesurée.
9. Travail à reprendre (specs détaillées par Temps)
Produire, au niveau de détail des autres familles (signature complète, syscall
sous-jacent, préconditions/# Safety, comportement, erreurs, perf, tests,
exemples), les fichiers : io-uring-1-core.md, io-uring-2a-filesystem.md,
io-uring-2b-network.md, io-uring-2c-async.md, io-uring-2d-cmd.md,
io-uring-3a-registration.md, io-uring-3b-provided.md,
io-uring-3c-linked.md, io-uring-3d-multishot.md, io-uring-3e-shared.md,
io-uring-3f-sandbox.md, io-uring-4-raw.md.
Puis : version anglaise de ce document maître.
Licence du document : MPL 2.0
Statut : Document maître d’inventaire du module air-sys-syscall::io_uring, cible kernel 6.12 LTS. À valider avant production des specs détaillées par Temps.
Spec couche 0 — Module io_uring, Temps 1 : cœur de l’API
Spécification technique — Version 1.0 (cible kernel : Linux 6.12 LTS)
Position. Ce document spécifie le Temps 1 du module
air-sys-syscall::io_uring: le cœur sur lequel reposent tous les autres Temps. Il dérive du document maître d’inventaire (io-uring-0-inventaire.md) et de l’ADR-022. Les décisions de soundness S1 (slab pré-alloué), S2 (téardown sûr) et S3 (sandbox) y sont rendues opérationnelles. Les opérations métier (submit_read, etc.) relèvent des Temps 2a–2d et 3a–3f ; ici on spécifie le ring, la soumission, la complétion, et leur sûreté.Conventions couche 0 (ADR-021) appliquées sans exception :
Result<_, Errno>partout, EINTR remonté à l’appelant (jamais de retry automatique),Option<T>au lieu des sentinelles kernel, FD typés (OwnedFd/BorrowedFd), pas d’allocation heap dans le happy path (S1),// SAFETY:sur chaque blocunsafe.
1. Périmètre du Temps 1
Le Temps 1 couvre :
- Le modèle mémoire : les trois zones mmappées (SQ, CQ, SQE) et le protocole de publication par têtes/queues avec ordering acquire/release (§3). C’est la fondation de correction de tout le module.
- La construction du ring :
IoUringBuilder,IoUring::new,io_uring_setup(2), négociation des features, application des setup flags (§5). - Le slab d’opérations en vol (S1) : stockage sans allocation des buffers
transférés et des métadonnées, encodage
SubmissionTokenslot+génération, back-pressure (§4). - La soumission :
submit,submit_and_wait,with(SubmitOptions),io_uring_enter(2)(§6). - La complétion :
wait_completion,wait_completion_timeout,try_completion,completions(), et le typeCompletionavec ses interprétations typées génériques (§7). - Le téardown sûr (S2) :
shutdown()explicite +Dropquiescent, bâtis sursync_cancel(§8). - L’introspection :
supports_op(probe) etcapabilities(§9).
Les trois syscalls sous-jacents (numéros identiques x86_64 / ARM64) :
| Syscall | n° x86_64 | n° ARM64 | Usage au Temps 1 |
|---|---|---|---|
io_uring_setup | 425 | 425 | construction (§5) |
io_uring_enter | 426 | 426 | soumission + attente (§6, §7) |
io_uring_register | 427 | 427 | probe, sync_cancel, eventfd… (§8, §9) |
2. Types fondamentaux introduits au Temps 1
| Type | Rôle | air-sys-types ? |
|---|---|---|
IoUring | l’anneau (FD + mmaps + slab + capabilities). Send, !Sync. | oui |
IoUringBuilder | construction configurée (flags, tailles, restrictions). | oui |
SetupFlags | bitflags des flags de io_uring_setup (axe A). | oui |
IoUringParams | miroir typé de io_uring_params (entrées/sorties du setup). | oui |
IoUringCapabilities | features négociées (axe G), prédicats stables. | oui |
IoUringOpcode | énumération des 55 opcodes retenus (probe, restrictions). | oui |
SubmissionToken | jeton opaque slot+génération (S1). | oui |
SubmitOptions | options par-op (drain, async, skip-cqe, link — exposé Temps 3c). | oui |
Completion | une complétion typée (CQE décodé). | oui |
CompletionFlags | bitflags des flags de CQE (axe E). | oui |
CompletionIter<'ring> | itérateur sur les complétions disponibles. | non (emprunt) |
CancelTarget | cible d’annulation (token / fd / op / any). | oui |
3. Modèle mémoire et protocole d’anneau
Le Temps 1 implémente ce protocole mais ne l’expose pas crûment (le brut est au Temps 4,
::raw). Cette section fixe le contrat de correction.
3.1 Les trois zones mmappées
io_uring_setup retourne un FD et remplit io_uring_params avec deux jeux
d’offsets (io_sqring_offsets, io_cqring_offsets). On mmappe :
- Anneau SQ à l’offset
IORING_OFF_SQ_RING: contienthead,tail,ring_mask,ring_entries,flags,dropped, et (saufNO_SQARRAY) le tableau d’indexarray. - Tableau SQE à l’offset
IORING_OFF_SQES:ring_entriesentrées de 64 o (ou 128 o siSQE128). - Anneau CQ à l’offset
IORING_OFF_CQ_RING:head,tail,ring_mask,ring_entries,overflow,flags, et le tableaucqes(16 o, ou 32 o siCQE32).
Avec IORING_FEAT_SINGLE_MMAP (toujours présent en 6.12), SQ et CQ partagent
une seule mmap ; on le détecte et on adapte (deux mmaps sinon). Avec NO_MMAP,
l’appelant fournit la mémoire — non couvert au Temps 1 (option avancée, gated).
3.2 Protocole de publication — ordering mémoire
Les têtes/queues sont des compteurs 32 bits monotones partagés avec le kernel ;
l’index réel = compteur & ring_mask. L’ordering est non négociable :
Soumission (userspace producteur de la SQ) :
- Écrire le SQE complet dans
sqes[tail & mask]. - (Sauf
NO_SQARRAY) écriretail & maskdansarray[tail & mask]. - Publier la nouvelle
tailpar un store release (AtomicU32::store(.., Release)) — garantit que le kernel voit les écritures du SQE avant de voir la queue avancer. headde la SQ est lue par un load acquire pour calculer la place disponible.
Complétion (userspace consommateur de la CQ) :
- Lire
tailde la CQ par un load acquire — garantit que les CQE écrits par le kernel sont visibles. - Lire les CQE entre
headettail. - Publier la nouvelle
headpar un store release pour rendre les entrées au kernel.
Toute implémentation doit respecter ce protocole ; il est testé par modèle
(loom, §11) en plus des tests fonctionnels. Référence : io_uring(7) et
io_uring_setup(2).
3.3 Réveil et drapeaux d’anneau
IORING_SQ_NEED_WAKEUP(flags SQ) : en modeSQPOLL, si positionné, il faut réveiller le thread kernel viaio_uring_enter(.., SQ_WAKEUP)(géré au Temps 3e ; au Temps 1 on lit/expose le drapeau).IORING_SQ_CQ_OVERFLOW: la CQ a débordé ; avecIORING_FEAT_NODROP(présent en 6.12) le kernel retient les complétions et les re-livre, mais signale l’état. Le Temps 1 le surface (IoUring::completion_queue_overflowed()).IORING_SQ_TASKRUN: du task-work est en attente (avecTASKRUN_FLAG) ; indique qu’une entrée kernel est utile.
4. Le slab d’opérations en vol (décision S1)
4.1 Problème et forme
Entre une soumission et sa complétion, le kernel détient potentiellement un
buffer (modèle de transfert d’ownership, ADR-022 Décision 3) et la façade
doit retrouver, à la complétion, quelle opération et quel buffer
correspondent au CQE. Le lien est user_data (u64, posé dans le SQE, rendu
dans le CQE).
Une table dynamique allouerait par opération → interdit (CLAUDE.md). On utilise un slab pré-alloué :
struct InflightSlab {
slots: Box<[Slot]>, // alloué UNE fois à la construction
free_head: Option<u32>,
in_flight: u32,
}
struct Slot {
generation: u32, // incrémenté à chaque réutilisation (compteur de génération anti-réutilisation)
state: SlotState, // Free | Inflight { kind, buffer } | Multishot { .. }
}
- Capacité = nombre maximal d’opérations simultanément en vol, par défaut
cq_entries(le kernel ne peut pas avoir plus de complétions pendantes que la CQ, modulo overflow géré par NODROP). Configurable via le builder. - Le buffer transféré est déplacé (move) dans le slot : aucune copie, aucune réallocation dans le happy path.
4.2 Encodage du SubmissionToken
SubmissionToken encapsule { slot: u32, generation: u32 }. Le user_data
kernel = ((generation as u64) << 32) | (slot as u64). À la complétion :
- décoder
user_data→(generation, slot); - si
slots[slot].generation != generation→ complétion périmée (op déjà annulée/recyclée, p. ex. CQE multishot tardif) : on l’ignore proprement ; - sinon, on consomme le slot et on restitue le buffer à l’appelant.
4.3 Back-pressure
submit_* réserve un slot avant d’écrire le SQE. Slab plein (in_flight == capacity) ⇒ retour Err(Errno::EBUSY) sans toucher au kernel. C’est la
back-pressure structurelle d’Air : on refuse poliment plutôt que de déborder.
4.4 Multishot
Une opération multishot (Temps 3d) occupe un slot mais produit plusieurs
CQE. Le slot reste vivant tant que les complétions portent
IORING_CQE_F_MORE ; il est libéré à la complétion finale (sans F_MORE) ou à
l’annulation. La génération protège contre les CQE arrivant après annulation.
5. Construction du ring
5.1 IoUringBuilder
#![allow(unused)]
fn main() {
pub struct IoUringBuilder { /* ... */ }
impl IoUringBuilder {
pub fn new(entries: NonZeroU32) -> Self;
pub fn with_completion_queue_entries(self, entries: NonZeroU32) -> Self; // SETUP_CQSIZE
pub fn max_inflight(self, n: NonZeroU32) -> Self; // capacité du slab
pub fn with_flags(self, flags: SetupFlags) -> Self;
pub fn with_sqpoll_idle(self, dur: Duration) -> Self; // SQPOLL (Temps 3e)
pub fn with_sqpoll_cpu(self, cpu: u32) -> Self; // SQ_AFF
pub fn attach_work_queue(self, other: &IoUring) -> Self; // ATTACH_WQ
pub fn restrict(self, restrictions: &[Restriction]) -> Self; // S3 (Temps 3f)
pub fn build(self) -> Result<IoUring, Errno>;
}
}
build — comportement.
- Traduit la configuration en
io_uring_params, appelleio_uring_setup(2). entriesest arrondi par le kernel à la puissance de 2 supérieure ; on lit les tailles effectives en retour (sq_entries,cq_entries).- mmappe SQ/CQ/SQE selon §3.1 (RAII : les mmaps sont possédées par
IoUring). - alloue le slab (§4) une fois, dimensionné à
max_inflightoucq_entries. - lit
params.features→IoUringCapabilities. - si
REGISTERED_FD_ONLY/REG_REG_RING: enregistre le ring fd et bascule en mode fd enregistré de façon transparente. - si
restrict(..)non vide : poseR_DISABLED, appliqueREGISTER_RESTRICTIONS, laisse le ring désactivé (l’appelant appelleenable(); cf. Temps 3f).
Préconditions. entries ≤ limite kernel (sinon EINVAL). Combinaisons
de flags incompatibles refusées (p. ex. IOPOLL + SQPOLL selon contexte) :
on remonte l’EINVAL du kernel sans le masquer.
Erreurs. EINVAL (params invalides), ENOMEM (mémoire), EPERM
(SQPOLL/affinité sans privilège selon config), EFAULT, ENOSYS (io_uring
indisponible — sandbox/container : cf. §10).
Performance. Coût dominé par io_uring_setup + mmaps + alloc unique du
slab : ~quelques dizaines de µs. Fait une fois, hors chemin chaud.
Exemple.
#![allow(unused)]
fn main() {
use core::num::NonZeroU32;
let ring = IoUringBuilder::new(NonZeroU32::new(256).unwrap())
.with_flags(SetupFlags::SINGLE_ISSUER | SetupFlags::DEFER_TASKRUN)
.build()?;
}
Tests. unit (tailles arrondies, lecture features), property (entries
arbitraires → invariants tailles), intégration (création réelle sur 6.12),
simulateur de syscall pour forcer ENOSYS/ENOMEM, fuzzing des combinaisons
de flags.
5.2 IoUring::new et enable
#![allow(unused)]
fn main() {
impl IoUring {
pub fn new(entries: NonZeroU32) -> Result<Self, Errno>; // = builder.build()
pub fn enable(&mut self) -> Result<(), Errno>; // REGISTER_ENABLE_RINGS
pub fn completion_queue_overflowed(&self) -> bool; // lit SQ_CQ_OVERFLOW
pub fn submission_queue_space_left(&self) -> u32;
pub fn in_flight(&self) -> u32; // slots occupés (S1)
}
}
enable n’est utile que pour un ring créé désactivé (via restrict) ; sur un
ring déjà actif il retourne Err(EINVAL) (remonté tel quel).
6. Soumission
6.1 submit et submit_and_wait
#![allow(unused)]
fn main() {
impl IoUring {
pub fn submit(&mut self) -> Result<u32, Errno>;
pub fn submit_and_wait(&mut self, want: u32) -> Result<u32, Errno>;
}
}
Comportement. submit publie la queue SQ (§3.2) puis appelle
io_uring_enter(fd, to_submit, 0, 0, ..) ; retourne le nombre de SQE consommés
par le kernel. submit_and_wait(want) ajoute IORING_ENTER_GETEVENTS et
min_complete = want.
En mode SQPOLL, si le thread kernel tourne, submit peut n’avoir aucun
syscall à faire (juste la publication release) ; il ne réveille le thread que
si SQ_NEED_WAKEUP.
EINTR. Remonté tel quel (ADR-021 conv. 2). L’appelant qui veut réessayer écrit sa boucle.
Erreurs. EINTR, EAGAIN (ressources momentanées), EBUSY (CQ pleine non
drainée selon contexte), EINVAL, EFAULT, EBADF.
Performance. Le gain d’io_uring est ici : on batch N opérations pour un
seul io_uring_enter. Un submit sans attente sur ring SQPOLL chaud ≈ coût
d’un store atomique.
6.2 Options par-opération
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, Default)]
pub struct SubmitOptions { /* ... */ }
impl SubmitOptions {
pub fn drain(self) -> Self; // IOSQE_IO_DRAIN
pub fn force_async(self) -> Self; // IOSQE_ASYNC
pub fn skip_cqe_on_success(self) -> Self; // IOSQE_CQE_SKIP_SUCCESS (FEAT_CQE_SKIP)
// link / hardlink exposés via LinkedChainBuilder (Temps 3c)
}
impl IoUring {
pub fn with(&mut self, opts: SubmitOptions) -> &mut Self;
}
}
with applique les options à la prochaine soumission submit_*. Note :
skip_cqe_on_success libère le slot S1 à la soumission (pas de CQE attendu en
cas de succès) — la spec du slot documente ce cas particulier de libération.
7. Complétion
7.1 Récupération
#![allow(unused)]
fn main() {
impl IoUring {
pub fn wait_completion(&mut self) -> Result<Completion, Errno>;
pub fn wait_completion_timeout(&mut self, timeout: Duration)
-> Result<Option<Completion>, Errno>;
pub fn try_completion(&mut self) -> Option<Completion>;
pub fn completions(&mut self) -> CompletionIter<'_>;
}
}
wait_completion: bloque jusqu’à au moins une complétion (io_uring_enter(.., GETEVENTS, min_complete=1)), la décode et la consomme.wait_completion_timeout: utiliseIORING_ENTER_EXT_ARG(io_uring_getevents_arg+__kernel_timespec) ouABS_TIMER(FEAT_MIN_TIMEOUT) ;Ok(None)à l’expiration.try_completion: non bloquant, lit la CQ sans syscall si des CQE sont présents (load acquire de la queue).completions(): draine ce qui est disponible, avancehead(store release) à la fin de l’itération ou par lot.
Complétion périmée. Si le user_data décodé pointe un slot dont la
génération ne correspond pas (§4.2), la complétion est filtrée : ces
fonctions passent à la suivante sans la rendre à l’appelant.
7.2 Le type Completion
#![allow(unused)]
fn main() {
pub struct Completion { /* token, res, flags, accès au slot pour reprise buffer */ }
impl Completion {
pub fn token(&self) -> SubmissionToken;
pub fn raw_result(&self) -> i32; // brut, sémantique selon l'op
pub fn flags(&self) -> CompletionFlags;
pub fn has_more(&self) -> bool; // CQE_F_MORE (multishot)
pub fn is_notif(&self) -> bool; // CQE_F_NOTIF (zero-copy)
pub fn buffer_id(&self) -> Option<u16>; // CQE_F_BUFFER
pub fn socket_has_pending_data(&self) -> bool; // CQE_F_SOCK_NONEMPTY
// Interprétation générique (les variantes typées riches sont aux Temps 2x) :
pub fn into_result(self) -> Result<i32, Errno>; // res<0 => Err(-res)
pub fn into_buffer_result(self) -> Result<(Vec<u8>, usize), Errno>; // reprise buffer S1
pub fn completed(&self) -> Result<(), Errno>; // succès sans valeur
}
}
Convention de résultat. Un res négatif est un -errno ; les méthodes
into_* le convertissent en Err(Errno). Un res ≥ 0 est la valeur utile
(octets, fd, etc.), interprétée par la méthode appropriée — le développeur sait
quelle opération il a soumise (ADR-022 Décision 9).
Reprise du buffer. into_buffer_result récupère le buffer déplacé hors
du slot (S1) et le rend à l’appelant avec le nombre d’octets — zéro copie.
7.3 CompletionFlags
bitflags des 5 flags de l’axe E : BUFFER, MORE, SOCK_NONEMPTY, NOTIF,
BUF_MORE.
8. Téardown sûr (décision S2)
8.1 sync_cancel (brique)
#![allow(unused)]
fn main() {
pub enum CancelTarget {
Token(SubmissionToken),
Fd(BorrowedFd<'_>),
Op(IoUringOpcode),
Any,
}
impl IoUring {
pub fn sync_cancel(&mut self, target: CancelTarget) -> Result<u32, Errno>;
}
}
S’appuie sur IORING_REGISTER_SYNC_CANCEL (io_uring_sync_cancel_reg, avec
timeout). Mappe CancelTarget sur les flags IORING_ASYNC_CANCEL_*
(USERDATA / FD / OP / ANY). Retourne le nombre d’opérations annulées.
8.2 shutdown et Drop
#![allow(unused)]
fn main() {
impl IoUring {
pub fn shutdown(self) -> Result<(), Errno>;
}
impl Drop for IoUring { fn drop(&mut self); }
}
shutdown (voie propre).
sync_cancel(Any)avec timeout borné.- Drainer la CQ jusqu’à
in_flight() == 0(les buffers des slots sont libérés proprement à mesure). munmapdes anneaux, fermeture du FD (RAII), libération du slab.- Retourne
Errsi le drainage échoue/expire (l’appelant décide).
Drop (filet de sécurité). Si in_flight() > 0, exécute la même séquence de
quiescence mais de façon bloquante et best-effort (boucle bornée). Le coût
(blocage potentiel) est documenté : sur chemin chaud, préférer shutdown().
Justification : « sur-sécuriser puis dégraisser » (Principe 5) — un Drop qui
laisse le kernel écrire dans de la mémoire libérée est un défaut de soundness
inacceptable en couche 0.
Invariant de sûreté. Tant qu’une opération est en vol, le buffer associé vit dans son slot (S1) et n’est pas libérable par l’appelant ; le ring ne peut pas être détruit sans quiescence. Ces deux invariants garantissent qu’aucune écriture kernel ne tombe sur de la mémoire libérée.
9. Introspection : probe et capabilities
#![allow(unused)]
fn main() {
impl IoUring {
pub fn supports_op(&self, op: IoUringOpcode) -> bool;
pub fn capabilities(&self) -> IoUringCapabilities;
}
}
supports_op: interrogeIORING_REGISTER_PROBE(mis en cache à la construction). Permet le fallback vers les syscalls synchrones quand une op n’est pas supportée par le kernel courant (ADR-022 Décision 8).capabilities: expose les 16 features de l’axe G via des prédicats stables (single_mmap,nodrop,cqe_skip,min_timeout,recvsend_bundle,reg_reg_ring, …).
Exemple de fallback.
#![allow(unused)]
fn main() {
if ring.supports_op(IoUringOpcode::Openat2) {
let tok = ring.submit_openat2(/* ... */)?; // Temps 2a
} else {
// bascule sur air-sys-syscall::fs::openat2 synchrone
}
}
10. Indisponibilité d’io_uring (ADR-022 Décision 10)
Si le kernel ne supporte pas io_uring ou si l’environnement l’a désactivé
(seccomp, sandbox, container durci), build() retourne Err(Errno::ENOSYS)
(ou EPERM selon le filtre). Aucun fallback automatique caché : l’appelant
choisit de basculer en synchrone ou de refuser de démarrer.
11. Stratégie de tests (couche 0 — 100 % lignes + branches)
- Unitaires : encodage/décodage
SubmissionToken(slot+génération), arithmétique des têtes/queues (masquage, wrap), back-pressureEBUSY, filtrage des complétions périmées. - Property-based (proptest) : pour toute séquence de submit/complete, les
invariants
in_flight ≤ capacity,head ≤ tail, aucun slot doublement libéré, aucun buffer perdu. - Modèle de concurrence (loom) : le protocole acquire/release de §3.2 sur un modèle producteur(userspace)/consommateur(kernel simulé) ; détecte tout ordering manquant.
- Intégration : sur kernel 6.12 réel — création, nop, submit/wait, timeout, shutdown propre, Drop avec ops en vol, overflow CQ (NODROP).
- Simulateur de syscalls : harnais injectant
EINTR,EAGAIN,ENOSYS,EFAULTaux frontièresenter/setup/registerpour couvrir les branches d’erreur sans dépendre du kernel. - Fuzzing (cargo-fuzz) : décodage des CQE et des
io_uring_paramsretournés (toute donnée venant du kernel est traitée comme externe, Principe 3). - Tests de Drop : Miri/valgrind pour confirmer l’absence d’usage après libération sur les chemins de quiescence.
Branches non couvrables (p. ex. erreurs kernel impossibles à provoquer) :
consignées dans docs/COVERAGE-EXCEPTIONS.md avec justification.
12. Récapitulatif des erreurs (Temps 1)
| Fonction | Erreurs notables |
|---|---|
build / new | EINVAL, ENOMEM, EPERM, ENOSYS, EFAULT |
enable | EINVAL, EBADF |
submit / submit_and_wait | EINTR, EAGAIN, EBUSY, EINVAL, EBADF, EFAULT |
wait_completion* | EINTR, ETIME (timeout interne, selon mapping), EBADF |
submit_* (réservation slot) | EBUSY (slab plein) avant tout syscall |
sync_cancel | EINTR, EALREADY, ENOENT, EINVAL |
shutdown | propage les erreurs de drainage/cancel |
13. Types ajoutés à air-sys-types (Temps 1)
IoUring, IoUringBuilder, IoUringParams, SetupFlags,
IoUringCapabilities, IoUringOpcode, SubmissionToken, SubmitOptions,
Completion, CompletionFlags, CancelTarget. Soit ~11 types publics (le
slab et les wrappers de mmap sont internes, non exposés).
14. Décisions de fond émergées au Temps 1
SubmissionToken= slot+génération, pasuser_databrut. L’API ne laisse jamais l’appelant manipuler unuser_dataarbitraire : la génération protège contre l’ABA et les complétions périmées (multishot/cancel).- Slab dimensionné par défaut à
cq_entries. Aligne la back-pressure applicative sur la capacité réelle de complétions du kernel. Dropbloquant assumé. Choix de soundness sur performance, réversible après mesure (mais jamais dans le sens dangereux).- Pas de trait
Submittableau Temps 1. Méthodes inhérentes ; on n’introduit l’abstraction que si les Temps 3c/3d en prouvent le besoin (Principe 7). - Timeouts via
EXT_ARG/ABS_TIMER, pas via une opérationTIMEOUTliée — celle-ci reste disponible au Temps 2c pour les cas explicites.
15. Travail à reprendre
Specs suivantes, sur ce modèle : io-uring-2a-filesystem.md (les 26 opérations
FS s’appuyant sur le cœur ci-dessus), puis 2b, 2c, 2d, 3a–3f, 4. La traduction
anglaise globale est produite une fois les documents français validés.
Licence du document : MPL 2.0
Statut : Spécification technique du Temps 1 (cœur) du module air-sys-syscall::io_uring, cible kernel 6.12 LTS.
Spec couche 0 — Module io_uring, Temps 2a : opérations filesystem
Spécification technique — Version 1.0 (cible kernel : Linux 6.12 LTS)
Position. Le Temps 2a spécifie les 26 opérations filesystem exposées via io_uring en 6.12 (cf. document maître
io-uring-0-inventaire.md, axe B). Toutes réutilisent tel quel le cœur du Temps 1 (io-uring-1-core.md) : soumission, slab S1, complétion, téardown S2. Ce document ne re-spécifie ni le ring ni le slab — il spécifie les opérations.
1. Conventions transverses du Temps 2a
1.1 Renvoi aux familles synchrones (ADR-022 Décision 2)
Chaque opération io_uring a un équivalent syscall synchrone déjà spécifié
dans family-fs.md / family-mem.md. Pour ne pas dupliquer (et risquer
d’introduire des divergences), ce document :
- identifie l’op par son opcode
IORING_OP_*et son numéro (source de vérité : header uapi v6.12) ; - nomme l’équivalent syscall et renvoie à la famille pour son numéro x86_64/ARM64, ses flags et sa sémantique fine ;
- réutilise les types partagés (
OpenHow,StatxFlags,StatxMask,Statx,RenameFlags,Mode,DirFd,Advice,SpliceFlags,FallocateMode,FsyncFlags,XattrFlags…) définis avec les familles — un développeur qui connaît l’API synchrone retrouve les mêmes types.
Le « syscall sous-jacent » réel de toute op io_uring est io_uring_enter(2)
(n° 426) ; les noms ci-dessous désignent l’équivalent sémantique.
1.2 Modèle de buffers (rappel ADR-022 Décision 3 + S1)
Trois mécanismes, transfert d’ownership par défaut :
- Ownership (défaut) : le buffer (
Vec<u8>,Box<Statx>,CString…) est déplacé dans le slot S1 à la soumission, restitué à la complétion. Sûr par construction : impossible d’y toucher pendant que le kernel le détient. - Buffer enregistré (
READ_FIXED/WRITE_FIXED) : référence un buffer pré-enregistré par index → évite la traduction d’adresses. TypeRegisteredBufferSlicedéfini au Temps 3a ; ici on expose seulement la formesubmit_read_fixed. - Raw unsafe : pointeur brut, validité garantie par l’appelant. Temps 4.
1.3 Offset : Option<u64> plutôt qu’une sentinelle (ADR-021 conv. 1)
Les opérations à offset (read, write, readv, writev, read_fixed,
write_fixed) prennent offset: Option<u64> :
Some(n): offset absolun;None: « position courante du fichier » (transmis comme-1au kernel, requiertIORING_FEAT_RW_CUR_POS, présent en 6.12).
On n’expose jamais le -1 magique : le None typé le remplace.
1.4 Descripteurs de répertoire
Les opérations *at prennent dirfd: DirFd (newtype partagé avec family-fs),
où DirFd::CWD matérialise AT_FDCWD — encore une sentinelle kernel remplacée
par un constructeur typé.
1.5 Chemins
Les chemins sont des CString (octets terminés NUL, pas forcément UTF-8 ;
cohérent avec Principe 3 et l’usage de Path/OsStr côté couche 1). Le buffer
de chemin est déplacé dans le slot (le kernel le lit de façon asynchrone, il
doit rester valide jusqu’à la complétion — S1 le garantit).
2. Lecture / écriture
2.1 submit_read / submit_write
#![allow(unused)]
fn main() {
impl IoUring {
pub fn submit_read(&mut self, fd: BorrowedFd<'_>, buffer: Vec<u8>, offset: Option<u64>)
-> Result<SubmissionToken, Errno>;
pub fn submit_write(&mut self, fd: BorrowedFd<'_>, buffer: Vec<u8>, offset: Option<u64>)
-> Result<SubmissionToken, Errno>;
}
}
- Opcode :
IORING_OP_READ(22) /IORING_OP_WRITE(23). - Équivalent :
pread/pwrite(offsetSome) ouread/write(offsetNone). Cf.family-fs.md. - Buffer :
bufdéplacé dans le slot. Pourread, sa capacité borne le nombre d’octets lus (len = buf.len()ou capacité — préciser : on utilise la longueur logique duVec). Pourwrite,buf.len()octets sont écrits. - Complétion :
completion.into_buffer_result() -> (Vec<u8>, usize)rend le buffer et le nombre d’octets transférés.res < 0⇒Err(Errno). - Préconditions :
bufnon vide pour un read utile (un read de 0 octet est légal et complète à 0).fdouvert dans le bon mode. - Erreurs :
EBUSY(slab plein, avant syscall), puis à la complétionEBADF,EINVAL,EFAULT,EIO,EAGAIN(FD non bloquant sans données). - Performance : zéro copie côté façade (move). Sur ring SQPOLL chaud, la soumission ≈ un store atomique.
Exemple.
#![allow(unused)]
fn main() {
let buf = vec![0u8; 4096];
let tok = ring.submit_read(file.as_fd(), buf, Some(0))?;
ring.submit_and_wait(1)?;
let cmp = ring.wait_completion()?;
let (buf, n) = cmp.into_buffer_result()?; // buf récupéré, n octets lus
}
2.2 submit_readv / submit_writev
#![allow(unused)]
fn main() {
impl IoUring {
pub fn submit_readv(&mut self, fd: BorrowedFd<'_>, buffers: Vec<Vec<u8>>, offset: Option<u64>)
-> Result<SubmissionToken, Errno>;
pub fn submit_writev(&mut self, fd: BorrowedFd<'_>, buffers: Vec<Vec<u8>>, offset: Option<u64>)
-> Result<SubmissionToken, Errno>;
}
}
- Opcode :
IORING_OP_READV(1) /IORING_OP_WRITEV(2). - Équivalent :
preadv/pwritev. Cf.family-fs.md. - Buffers : on possède un vecteur de buffers possédés (
Vec<Vec<u8>>) pour rester sûr (transfert d’ownership). Le tableau d’iovectransmis au kernel est construit en interne et garé dans le slot ; il ne survit pas à l’appelant. (Le chemin haute perf vectorisé sans copie relève du Temps 4.) - Complétion :
completion.into_vectored_result() -> (Vec<Vec<u8>>, usize). - Erreurs : comme read/write +
EINVALsi nombre d’iovecs >IOV_MAX.
2.3 submit_read_fixed / submit_write_fixed
#![allow(unused)]
fn main() {
impl IoUring {
pub fn submit_read_fixed(&mut self, fd: BorrowedFd<'_>, buf: RegisteredBufferSlice, offset: Option<u64>)
-> Result<SubmissionToken, Errno>;
pub fn submit_write_fixed(&mut self, fd: BorrowedFd<'_>, buf: RegisteredBufferSlice, offset: Option<u64>)
-> Result<SubmissionToken, Errno>;
}
}
- Opcode :
IORING_OP_READ_FIXED(4) /IORING_OP_WRITE_FIXED(5). - Buffer :
RegisteredBufferSlicedésigne une tranche d’un buffer pré-enregistré (IORING_REGISTER_BUFFERS2, Temps 3a). Pas de transfert d’ownership par opération : le buffer appartient à l’enregistrement. - Gain : le kernel n’a pas à épingler/traduire les pages à chaque op (déjà fait à l’enregistrement). Cas chaud (data plane AirCom).
- Complétion :
completion.into_result() -> i32(octets) ; le buffer reste détenu par l’enregistrement. - Renvoi : sémantique d’enregistrement détaillée au Temps 3a.
3. Synchronisation, allocation, troncature
3.1 submit_fsync
#![allow(unused)]
fn main() {
pub fn submit_fsync(&mut self, fd: BorrowedFd<'_>, flags: FsyncFlags)
-> Result<SubmissionToken, Errno>;
}
- Opcode :
IORING_OP_FSYNC(3). Équivalent :fsync/fdatasync(FsyncFlags::DATASYNC⇒IORING_FSYNC_DATASYNC). - Complétion :
completed() -> Result<(), Errno>. - Erreurs :
EBADF,EIO,EINVAL.
3.2 submit_sync_file_range
#![allow(unused)]
fn main() {
pub fn submit_sync_file_range(&mut self, fd: BorrowedFd<'_>, offset: u64, nbytes: u64, flags: SyncFileRangeFlags)
-> Result<SubmissionToken, Errno>;
}
- Opcode :
IORING_OP_SYNC_FILE_RANGE(8). Équivalent :sync_file_range. Cf.family-fs.mdpour les flags. - Complétion :
completed().
3.3 submit_fallocate
#![allow(unused)]
fn main() {
pub fn submit_fallocate(&mut self, fd: BorrowedFd<'_>, mode: FallocateMode, offset: i64, length: i64)
-> Result<SubmissionToken, Errno>;
}
- Opcode :
IORING_OP_FALLOCATE(17). Équivalent :fallocate. - Note :
offset/leneni64conformes à l’ABI ; validation amont (len > 0, pas de débordement) avant soumission (Principe 4). - Complétion :
completed(). Erreurs :EBADF,EFBIG,ENOSPC,EINVAL.
3.4 submit_ftruncate
#![allow(unused)]
fn main() {
pub fn submit_ftruncate(&mut self, fd: BorrowedFd<'_>, length: u64)
-> Result<SubmissionToken, Errno>;
}
- Opcode :
IORING_OP_FTRUNCATE(55). Équivalent :ftruncate. - Complétion :
completed(). Erreurs :EBADF,EINVAL,EFBIG,EPERM.
4. Ouverture / fermeture
4.1 submit_openat2
#![allow(unused)]
fn main() {
pub fn submit_openat2(&mut self, dirfd: DirFd, path: CString, how: OpenHow)
-> Result<SubmissionToken, Errno>;
}
- Opcode :
IORING_OP_OPENAT2(28). Équivalent :openat2(n° 437 x86_64/ARM64). Cf.family-fs.mdpourOpenHow(flags, mode, resolve). - Note :
IORING_OP_OPENAT(18) est évacué (UNSUPPORTED.md) au profit d’openat2, superset (cf. doc maître §4). - Complétion :
completion.opened_fd() -> Result<OwnedFd, Errno>(CLOEXEC selonhow). La variante descripteur direct (résultat placé dans un slot de table de FD enregistrée viafile_index) relève du Temps 3a ;submit_openat2_directy sera exposé. - Erreurs :
ENOENT,EACCES,ELOOP,ENFILE,EINVAL…
4.2 submit_close
#![allow(unused)]
fn main() {
pub fn submit_close(&mut self, fd: OwnedFd) -> Result<SubmissionToken, Errno>;
}
- Opcode :
IORING_OP_CLOSE(19). Équivalent :close. - Ownership :
submit_closeconsomme l’OwnedFd(déplacé dans le slot) — il ne peut plus être réutilisé, garantissant l’absence de double close. Le FD n’est effectivement fermé qu’à l’exécution kernel. - Complétion :
completed(). Erreur :EBADF(improbable, FD possédé). - Variante fixed : fermeture d’un slot de table de FD enregistrée → Temps 3a.
5. Métadonnées
5.1 submit_statx
#![allow(unused)]
fn main() {
pub fn submit_statx(&mut self, dirfd: DirFd, path: CString, flags: StatxFlags, mask: StatxMask, out: Box<Statx>)
-> Result<SubmissionToken, Errno>;
}
- Opcode :
IORING_OP_STATX(21). Équivalent :statx(n° 332 x86_64 / 291 ARM64). Cf.family-fs.mdpourStatxFlags,StatxMask,Statx. - Buffer de sortie : le kernel écrit la structure dans
out; on transfère l’ownership duBox<Statx>dans le slot (il doit rester valide jusqu’à la complétion — S1). - Complétion :
completion.into_statx() -> Result<Box<Statx>, Errno>. - Erreurs :
ENOENT,EACCES,EINVAL.
6. Conseils d’accès (advise)
6.1 submit_fadvise
#![allow(unused)]
fn main() {
pub fn submit_fadvise(&mut self, fd: BorrowedFd<'_>, offset: u64, length: u64, advice: Advice)
-> Result<SubmissionToken, Errno>;
}
- Opcode :
IORING_OP_FADVISE(24). Équivalent :posix_fadvise. - Complétion :
completed().
6.2 submit_madvise — implémenté (PR coordonnée family-mem)
#![allow(unused)]
fn main() {
pub fn submit_madvise(&mut self, region: &MmapRegion, range: Range<usize>, advice: MadviseAdvice)
-> Result<SubmissionToken, Errno>;
}
- Opcode :
IORING_OP_MADVISE(25). Équivalent :madvise. - Sûreté (décision arrêtée, réalisée) : pas de plage brute (
addr/len) ni d’APIunsafe.submit_madviseprend uneMmapRegionpartageable (type defamily-mem, cf.family-mem-mmap-region.md) et un sous-rangevalidé contre les bornes de la région (Principe 4 ;range.end ≤ region.len()etrange.start ≤ range.endsinonErr(EINVAL)avant soumission). La région doit rester mappée jusqu’à la complétion : le slot S1 retient uneMmapRegionLiveness(clone de l’Arcinterne,region.liveness_handle()), de sorte quemunmapne puisse pas survenir tant qu’une opmadviseest en vol — sûreté par construction (ni use-after-unmap, ni fuite :munmapau dernier drop), prouvée Miri + loom. Le même handle est réutilisé parfutex(Temps 2c §6.1) et le sera par l’enregistrement mémoire du Temps 3a. - Complétion :
completed().
7. Zero-copy entre FDs
7.1 submit_splice
#![allow(unused)]
fn main() {
pub fn submit_splice(&mut self, fd_in: BorrowedFd<'_>, offset_in: Option<u64>,
fd_out: BorrowedFd<'_>, offset_out: Option<u64>,
length: u32, flags: SpliceFlags)
-> Result<SubmissionToken, Errno>;
}
- Opcode :
IORING_OP_SPLICE(30). Équivalent :splice. Cf.family-ipc.md(la familleipcdocumente déjàsplice/tee). - Précondition : au moins un des deux FDs est un pipe (comme
splice(2)).off_in/off_outenOption<u64>(None = position courante / FD non seekable). - Complétion :
completion.into_result() -> i32(octets transférés). - Note :
SPLICE_F_FD_IN_FIXED(bit 31) permet d’utiliser un FD enregistré comme entrée → exposé proprement quandfd_inest un FD fixed (Temps 3a).
7.2 submit_tee
#![allow(unused)]
fn main() {
pub fn submit_tee(&mut self, fd_in: BorrowedFd<'_>, fd_out: BorrowedFd<'_>, length: u32, flags: SpliceFlags)
-> Result<SubmissionToken, Errno>;
}
- Opcode :
IORING_OP_TEE(33). Équivalent :tee. Les deux FDs sont des pipes ; duplique sans consommer la source. Complétion :into_result.
8. Répertoires et liens
Opérations homogènes : chemins en CString déplacés dans le slot, dirfd: DirFd, complétion completed(). Équivalents et flags : cf. family-fs.md.
#![allow(unused)]
fn main() {
impl IoUring {
pub fn submit_mkdirat(&mut self, dirfd: DirFd, path: CString, mode: Mode)
-> Result<SubmissionToken, Errno>; // OP 37
pub fn submit_unlinkat(&mut self, dirfd: DirFd, path: CString, flags: UnlinkFlags)
-> Result<SubmissionToken, Errno>; // OP 36
pub fn submit_renameat(&mut self, old_dirfd: DirFd, old_path: CString,
new_dirfd: DirFd, new_path: CString, flags: RenameFlags)
-> Result<SubmissionToken, Errno>; // OP 35 (sémantique renameat2)
pub fn submit_linkat(&mut self, old_dirfd: DirFd, old_path: CString,
new_dirfd: DirFd, new_path: CString, flags: LinkFlags)
-> Result<SubmissionToken, Errno>; // OP 39
pub fn submit_symlinkat(&mut self, target: CString, new_dirfd: DirFd, link_path: CString)
-> Result<SubmissionToken, Errno>; // OP 38
}
}
renameatporte desRenameFlags(RENAME_NOREPLACE,RENAME_EXCHANGE,RENAME_WHITEOUT) — sémantiquerenameat2.- Erreurs typiques :
ENOENT,EEXIST,ENOTEMPTY,EACCES,EXDEV(linkat cross-device),EINVAL(flags). - Note : chaque op porte deux
CStringpour rename/link → deux buffers déplacés dans le slot ; le slot S1 stocke un état composite documenté.
9. Attributs étendus (xattr)
#![allow(unused)]
fn main() {
impl IoUring {
pub fn submit_setxattr(&mut self, path: CString, name: CString, value: Vec<u8>, flags: XattrFlags)
-> Result<SubmissionToken, Errno>; // OP 42
pub fn submit_getxattr(&mut self, path: CString, name: CString, value: Vec<u8>)
-> Result<SubmissionToken, Errno>; // OP 44
pub fn submit_fsetxattr(&mut self, fd: BorrowedFd<'_>, name: CString, value: Vec<u8>, flags: XattrFlags)
-> Result<SubmissionToken, Errno>; // OP 41
pub fn submit_fgetxattr(&mut self, fd: BorrowedFd<'_>, name: CString, value: Vec<u8>)
-> Result<SubmissionToken, Errno>; // OP 43
}
}
- Équivalents :
setxattr/getxattr/fsetxattr/fgetxattr. Cf.family-fs.md. - Buffers :
name(CString) etvalue(Vec<u8>, octets bruts — Principe 3) déplacés dans le slot. Pour lesget*,valuesert de buffer de sortie ; complétioncompletion.into_xattr_result() -> (Vec<u8>, usize)(la taille rendue permet de retailler). - Erreurs :
ENODATA,ERANGE(buffer trop court),ENOTSUP,EACCES.
10. Récapitulatif des 26 opérations
| # opcode | Opcode | Façade | Complétion |
|---|---|---|---|
| 1 | READV | submit_readv | into_vectored_result |
| 2 | WRITEV | submit_writev | into_vectored_result |
| 3 | FSYNC | submit_fsync | completed |
| 4 | READ_FIXED | submit_read_fixed | into_result |
| 5 | WRITE_FIXED | submit_write_fixed | into_result |
| 8 | SYNC_FILE_RANGE | submit_sync_file_range | completed |
| 17 | FALLOCATE | submit_fallocate | completed |
| 19 | CLOSE | submit_close | completed |
| 21 | STATX | submit_statx | into_statx |
| 22 | READ | submit_read | into_buffer_result |
| 23 | WRITE | submit_write | into_buffer_result |
| 24 | FADVISE | submit_fadvise | completed |
| 25 | MADVISE | submit_madvise | completed |
| 28 | OPENAT2 | submit_openat2 | opened_fd |
| 30 | SPLICE | submit_splice | into_result |
| 33 | TEE | submit_tee | into_result |
| 35 | RENAMEAT | submit_renameat | completed |
| 36 | UNLINKAT | submit_unlinkat | completed |
| 37 | MKDIRAT | submit_mkdirat | completed |
| 38 | SYMLINKAT | submit_symlinkat | completed |
| 39 | LINKAT | submit_linkat | completed |
| 41 | FSETXATTR | submit_fsetxattr | completed |
| 42 | SETXATTR | submit_setxattr | completed |
| 43 | FGETXATTR | submit_fgetxattr | into_xattr_result |
| 44 | GETXATTR | submit_getxattr | into_xattr_result |
| 55 | FTRUNCATE | submit_ftruncate | completed |
Absents volontairement (faux opcodes de l’ancien overview, inexistants
dans l’ABI io_uring 6.12) : copy_file_range, fchmodat, fchownat. Les
changements de permissions/propriété restent synchrones (family-fs).
11. Types ajoutés / partagés
Aucun nouveau type de ring ; réutilise le cœur (Temps 1). Types métier
partagés avec les familles : OpenHow, Statx, StatxFlags, StatxMask,
Mode, DirFd, RenameFlags, UnlinkFlags, LinkFlags, FsyncFlags,
SyncFileRangeFlags, FallocateMode, Advice, SpliceFlags, XattrFlags.
Nouveaux au Temps 2a : RegisteredBufferSlice (forme ; défini Temps 3a).
madvise réutilise MmapRegion de family-mem (handle de vivacité partagé,
spécifié conjointement). Nouvelles méthodes de Completion :
into_vectored_result, into_statx, into_xattr_result.
12. Stratégie de tests
- Intégration (kernel 6.12, tmpfs/ext4) : aller-retour read/write avec
vérification du contenu, offset
Some/None, readv multi-buffers, openat2 + read + close en chaîne, statx, rename/link/unlink, xattr set/get round-trip, splice fichier→pipe. - Property-based : pour read/write,
octets transférés ≤ lenet restitution fidèle du buffer ; pour les chemins, robustesse aux octets non-UTF-8. - Erreurs via simulateur :
ENOENT,EACCES,ERANGE(xattr),ENOSPC(fallocate),EXDEV(linkat). - Soundness : Miri sur les chemins de transfert/restitution de buffer ; vérifier qu’un buffer en vol ne peut être ni libéré ni réutilisé (le type le rend impossible).
- Fuzzing : décodage des résultats
statx/getxattr(données venant du kernel = externes). - Couverture 100 % lignes + branches ; exceptions légitimes dans
COVERAGE-EXCEPTIONS.md.
13. Décisions de fond émergées au Temps 2a
offset: Option<u64>— application directe d’ADR-021 conv. 1 (None = position courante, pas de-1magique).readv/writevenVec<Vec<u8>>possédé — sûreté d’abord ; la voie vectorisée sans copie est au Temps 4, pas un raccourci ici.submit_close(OwnedFd)consomme le FD — interdit le double close par typage.- Pas de
copy_file_range/fchmodat/fchownat— ces opcodes n’existent pas dans io_uring 6.12 ; on ne fabrique pas de fausse façade. Les opérations correspondantes restent synchrones. - Variantes « direct descriptor » et « fixed buffer » renvoyées au Temps 3a — on n’introduit pas l’enregistrement ici pour garder 2a centré sur les opérations à ownership simple.
14. Travail à reprendre
Spec suivante : io-uring-2b-network.md (12 opérations réseau, dont les
variantes zero-copy send_zc/sendmsg_zc et bind/listen). La traduction
anglaise globale est produite une fois les documents français validés.
Licence du document : MPL 2.0
Statut : Spécification technique du Temps 2a (filesystem) du module air-sys-syscall::io_uring, cible kernel 6.12 LTS.
Spec couche 0 — Module io_uring, Temps 2b : opérations réseau
Spécification technique — Version 1.0 (cible kernel : Linux 6.12 LTS)
Position. Le Temps 2b spécifie les 12 opérations réseau d’io_uring en 6.12 (doc maître
io-uring-0-inventaire.md, axe B). Elles réutilisent le cœur du Temps 1 et les conventions du Temps 2a (renvoi aux familles, modèle de buffers S1, offsets/sentinelles typés). Ce Temps est stratégique pour AirCom (ADR-001) : c’est ici que vivent le zero-copy (send_zc,sendmsg_zc) du data plane et le passage de FD (capabilities) via les messages de contrôle desendmsg/recvmsg.
1. Conventions transverses du Temps 2b
- Types scalaires partagés avec
family-net(ADR-022 D2) :SocketAddr,SocketDomain,SocketType,MessageFlags,ShutdownMode,AcceptFlags. Renvoi àfamily-net.mdpour leur définition et les numéros de syscall équivalents. - Messages : jumeaux possédés propres au Temps 2b (correction de spec,
2026-06-11). La rédaction initiale annonçait réutiliser
SendMessageRequest/ReceiveMessageRequest/ControlMessagesdefamily-net. C’est impossible : les types message defamily-netsont à base d’emprunts (&[IoSlice],&[BorrowedFd]), adaptés au chemin synchrone (les données restent sur la pile pendant le syscall). io_uring est asynchrone : le kernel lit les données après le retour de la façade, ce qui exige un transfert d’ownership (S1). Le Temps 2b introduit donc des types possédés dédiés —OwnedSendMessage/OwnedReceiveMessage— au lieu de redéfinir ou détourner les types empruntés.ControlMessagesn’existe pas dansfamily-net(le passage de FD y est un champfdsdirect) : on suit la même modélisation (OwnedSendMessage::fds: Vec<OwnedFd>). Les helpers de sérialisationsockaddr/cmsg defamily-netsont réutilisés (renduspub(crate)), sans duplication. MSG_NOSIGNALpar défaut sur tous les envois : pas deSIGPIPEsur socket fermé (l’erreur remonte enEPIPEdans la complétion). C’est un invariant Air, surchargeable explicitement.SOCK_CLOEXECpar défaut sur les FD créés (accept,socket) : cohérent avec la famillenetet les autres familles couche 0.- Adresses possédées : une
SocketAddrà transmettre est sérialisée en un stockagesockaddrpossédé, déplacé dans le slot S1 (le kernel la lit de façon asynchrone — elle doit survivre jusqu’à la complétion). - Variantes « direct descriptor » (résultat rangé dans une table de FD
enregistrée plutôt qu’un FD ordinaire) et multishot : renvoyées
respectivement aux Temps 3a et Temps 3d.
bind/listenexistent précisément pour opérer sur ces FD directs sans FD ordinaire.
2. Établissement de connexion
2.1 submit_accept
#![allow(unused)]
fn main() {
impl IoUring {
pub fn submit_accept(&mut self, listener: BorrowedFd<'_>, flags: AcceptFlags)
-> Result<SubmissionToken, Errno>;
pub fn submit_accept_with_peer(&mut self, listener: BorrowedFd<'_>, flags: AcceptFlags)
-> Result<SubmissionToken, Errno>;
}
}
- Opcode :
IORING_OP_ACCEPT(13). Équivalent :accept4. - Complétion :
completion.accepted_fd() -> Result<OwnedFd, Errno>(CLOEXEC par défaut). La variante_with_peercapture l’adresse pair dans un stockage possédé du slot ⇒completion.into_accept_result() -> (OwnedFd, SocketAddr). - Multishot :
submit_accept_multishotau Temps 3d (un seul SQE accepte en continu). Direct fd : variante_directau Temps 3a. - Erreurs :
EAGAIN(rien à accepter, FD non bloquant),EMFILE/ENFILE,ECONNABORTED,EINVAL.
2.2 submit_connect
#![allow(unused)]
fn main() {
pub fn submit_connect(&mut self, sock: BorrowedFd<'_>, address: SocketAddr)
-> Result<SubmissionToken, Errno>;
}
- Opcode :
IORING_OP_CONNECT(16). Équivalent :connect. - Complétion :
completed(). Erreurs :ECONNREFUSED,ETIMEDOUT,EINPROGRESS(ne devrait pas remonter en io_uring : l’op complète à la fin),EALREADY,EISCONN.
2.3 submit_socket
#![allow(unused)]
fn main() {
pub fn submit_socket(&mut self, domain: SocketDomain, ty: SocketType, protocol: i32)
-> Result<SubmissionToken, Errno>;
}
- Opcode :
IORING_OP_SOCKET(45). Équivalent :socket. - Complétion :
completion.into_socket_fd() -> Result<OwnedFd, Errno>(CLOEXEC par défaut). Direct fd : variante_directau Temps 3a. - Intérêt : créer un socket dans le ring, chaînable (Temps 3c) avec
connect/bindsans aller-retour synchrone.
2.4 submit_bind / submit_listen
#![allow(unused)]
fn main() {
pub fn submit_bind(&mut self, sock: BorrowedFd<'_>, address: SocketAddr)
-> Result<SubmissionToken, Errno>; // OP 56
pub fn submit_listen(&mut self, sock: BorrowedFd<'_>, backlog: u32)
-> Result<SubmissionToken, Errno>; // OP 57
}
- Opcodes :
IORING_OP_BIND(56) /IORING_OP_LISTEN(57) — ajoutés en 6.11. Équivalents :bind/listen. - Raison d’être : permettre
bind/listensur un socket créé en descripteur direct (submit_socket_direct), qui n’a pas de FD ordinaire utilisable par lesbind(2)/listen(2)classiques. - Complétion :
completed(). Erreurs :EADDRINUSE,EACCES,EINVAL.
3. Transfert de données (one-shot)
3.1 submit_send / submit_receive
#![allow(unused)]
fn main() {
pub fn submit_send(&mut self, sock: BorrowedFd<'_>, buffer: Vec<u8>, flags: MessageFlags)
-> Result<SubmissionToken, Errno>; // OP 26
pub fn submit_receive(&mut self, sock: BorrowedFd<'_>, buffer: Vec<u8>, flags: MessageFlags)
-> Result<SubmissionToken, Errno>; // OP 27
}
- Équivalents :
send/recv.MSG_NOSIGNALajouté par défaut ausend. - Buffer : transfert d’ownership (S1). Complétion :
into_buffer_result() -> (Vec<u8>, usize). recv:completion.socket_has_pending_data()(CQE_F_SOCK_NONEMPTY) indique qu’il reste des données à lire — utile pour décider d’un nouveaurecv.- Multishot recv et buffers fournis (
BUFFER_SELECT) : Temps 3d/3b. - Erreurs :
ECONNRESET,EPIPE(send sur socket fermé, grâce à NOSIGNAL),EAGAIN,EMSGSIZE.
3.2 submit_send_message / submit_receive_message
#![allow(unused)]
fn main() {
pub fn submit_send_message(&mut self, sock: BorrowedFd<'_>, request: OwnedSendMessage)
-> Result<SubmissionToken, Errno>; // OP 9
pub fn submit_receive_message(&mut self, sock: BorrowedFd<'_>, request: OwnedReceiveMessage)
-> Result<SubmissionToken, Errno>; // OP 10
}
- Équivalents :
sendmsg/recvmsg. OwnedSendMessage(possédé, déplacé dans le slot — jumeau possédé, cf. §1) agrège : buffers de données (Vec<Vec<u8>>), adresse de destination optionnelle, et les FD à passer (fds: Vec<OwnedFd>, sérialisés enSCM_RIGHTS). C’est le mécanisme par lequel AirCom transporte une capability (un FD non-falsifiable) entre deux pairs (ADR-001). La façade construit lestruct msghdr+cmsgen interne, garé dans le slot.OwnedReceiveMessage(possédé) : buffers de réception + buffer de contrôle pré-dimensionné pour les cmsg entrants (FD reçus). Complétion :completion.into_receive_message_result() -> Result<(OwnedReceiveMessage, ReceiveMessageMeta), Errno>oùReceiveMessageMeta(adossé àio_uring_recvmsg_out) donnenamelen,controllen,payloadlen,flags(dontMSG_TRUNC/MSG_CTRUNCà vérifier), les FD reçus (OwnedFdCLOEXEC) et l’adresse pair. Aucune donnée n’est perdue : les buffers de réception sont restitués dansOwnedReceiveMessage, les FD et la méta dansReceiveMessageMeta.- Sûreté FD passing : les FD reçus sont matérialisés en
OwnedFd; un cmsg tronqué (MSG_CTRUNC) est signalé et les FD partiels fermés proprement (pas de fuite). - Erreurs : comme send/recv +
EINVAL(cmsg malformés en émission).
4. Zero-copy (data plane AirCom)
4.1 Cycle de vie à deux complétions — le point clé
send_zc/sendmsg_zc évitent la copie kernel du payload : le kernel référence
directement les pages userspace. Conséquence majeure sur l’ownership :
Une soumission zero-copy produit deux complétions :
- Complétion de résultat — porte le nombre d’octets envoyés (
res ≥ 0) et le flagCQE_F_MORE(une notification suivra).- Complétion de notification — porte
CQE_F_NOTIF; elle signale que le kernel ne référence plus le buffer, qui peut être réutilisé/libéré.
Le ou les buffers doivent rester vivants jusqu’à la complétion NOTIF, pas seulement jusqu’au résultat. La façade gère cela via le slot S1 :
- le slot retient tous les buffers jusqu’à réception du
CQE_F_NOTIF; - la complétion de résultat (
F_MORE) expose les octets viainto_result()sans restituer les buffers ; - la complétion NOTIF (
is_notif()) restitue tous les buffers (viainto_zero_copy_buffer()poursend_zc,into_zero_copy_buffers()poursendmsg_zc) et libère le slot.
Principe « zéro perte de donnée » (à respecter partout). Une API Air ne discarde jamais une donnée que l’appelant lui a confiée : on restitue tout, proprement et sûrement. En zero-copy,
into_zero_copy_buffers()rend l’intégralité des buffers d’unsendmsg_zc(unVec<Vec<u8>>), jamais seulement le premier. (Ce principe transverse mérite d’être élevé en convention d’ingénierie — cf. §9.)
C’est l’unique cas du module où un slot survit à sa première complétion ; il est spécifié explicitement ici et testé (séquencement résultat→NOTIF, et échec précoce, cf. §4.2).
4.2 submit_send_zero_copy / submit_send_message_zero_copy
#![allow(unused)]
fn main() {
pub fn submit_send_zero_copy(&mut self, sock: BorrowedFd<'_>, buffer: Vec<u8>, flags: MessageFlags, zero_copy: ZeroCopyFlags)
-> Result<SubmissionToken, Errno>; // OP 47
pub fn submit_send_message_zero_copy(&mut self, sock: BorrowedFd<'_>, request: OwnedSendMessage, zero_copy: ZeroCopyFlags)
-> Result<SubmissionToken, Errno>; // OP 48
}
- Équivalents : variantes zero-copy de
send/sendmsg. - Restitution (zéro perte, ADR-032) : au NOTIF,
send_zcrend sonVec<u8>viainto_zero_copy_buffer();sendmsg_zcrend l’intégralité de ses buffers (Vec<Vec<u8>>) viainto_zero_copy_buffers(). ZeroCopyFlags: exposeREPORT_USAGE(IORING_SEND_ZC_REPORT_USAGE→ la complétion NOTIF indique si le zero-copy a effectivement eu lieu ou si le kernel a dû copier ; lisible viaCompletion::zero_copy_copied()). Bundle (RECVSEND_BUNDLE,FEAT_RECVSEND_BUNDLE) avec buffers fournis : Temps 3b.- Quand l’utiliser : payloads volumineux (≳ 10 Ko, à mesurer) ; pour les
petits messages, le
sendordinaire est plus simple et souvent plus rapide (le zero-copy a un coût fixe de gestion de notification). Recommandation alignée sur le Principe 5 (mesurer). - Complétion et échec précoce (comportement réel kernel 6.12, corrigé). Voir
§4.1. La rédaction initiale décrivait l’échec précoce comme « NOTIF sans
F_MOREpréalable ». En pratique sur 6.12, un échec se présente le plus souvent comme une complétion de résultatres < 0portantF_MORE, suivie du NOTIF (le buffer reste tenu jusqu’au NOTIF, puis restitué) ; le cas du CQE unique (vrai échec immédiat sans NOTIF distinct) existe aussi et est géré. Dans tous les cas, les buffers sont restitués (jamais perdus) et l’erreur propagée.
5. Fermeture
5.1 submit_shutdown
#![allow(unused)]
fn main() {
pub fn submit_shutdown(&mut self, sock: BorrowedFd<'_>, how: ShutdownMode)
-> Result<SubmissionToken, Errno>; // OP 34
}
- Équivalent :
shutdown.ShutdownMode:Read/Write/Both. - Complétion :
completed(). Erreurs :ENOTCONN,EINVAL. - Note : fermer le FD lui-même se fait via
submit_close(Temps 2a) ou la fermeture RAII de l’OwnedFd.
6. Récapitulatif des 12 opérations
| # opcode | Opcode | Façade | Complétion |
|---|---|---|---|
| 9 | SENDMSG | submit_send_message | into_result |
| 10 | RECVMSG | submit_receive_message | into_receive_message_result |
| 13 | ACCEPT | submit_accept / _with_peer | accepted_fd / into_accept_result |
| 16 | CONNECT | submit_connect | completed |
| 26 | SEND | submit_send | into_buffer_result |
| 27 | RECV | submit_receive | into_buffer_result |
| 34 | SHUTDOWN | submit_shutdown | completed |
| 45 | SOCKET | submit_socket | into_socket_fd |
| 47 | SEND_ZC | submit_send_zero_copy | into_result + NOTIF into_zero_copy_buffer (Vec<u8>) |
| 48 | SENDMSG_ZC | submit_send_message_zero_copy | into_result + NOTIF into_zero_copy_buffers (Vec<Vec<u8>>, tous) |
| 56 | BIND | submit_bind | completed |
| 57 | LISTEN | submit_listen | completed |
7. Types ajoutés / partagés
Scalaires partagés avec family-net : SocketAddr, SocketDomain,
SocketType, MessageFlags, ShutdownMode, AcceptFlags. Jumeaux possédés
propres au Temps 2b (cf. §1) : OwnedSendMessage (dont fds: Vec<OwnedFd>),
OwnedReceiveMessage — les types message de family-net, à base d’emprunts, sont
inadaptés à l’ownership asynchrone S1. Helpers de sérialisation sockaddr/cmsg
réutilisés de family-net (pub(crate)). Autres nouveaux au Temps 2b :
ZeroCopyFlags, ReceiveMessageMeta (adossé à io_uring_recvmsg_out : méta +
FD reçus + adresse pair). Nouvelles méthodes de Completion : into_socket_fd,
into_accept_result, into_receive_message_result, into_zero_copy_buffer
(send_zc, Vec<u8>), into_zero_copy_buffers (sendmsg_zc, Vec<Vec<u8>>,
restitue tous les buffers), zero_copy_copied.
8. Stratégie de tests
- Intégration (sockets locaux AF_UNIX + AF_INET loopback) : accept/connect, send/recv round-trip, sendmsg/recvmsg avec passage de FD (envoyer un FD, le recevoir, vérifier qu’il pointe le même objet — test cœur AirCom), shutdown, socket+bind+listen en chaîne (Temps 3c), accept d’un fd direct.
- Zero-copy : vérifier la séquence résultat (
F_MORE) → NOTIF (F_NOTIF), la non-restitution des buffers avant NOTIF, la restitution de TOUS les buffers au NOTIF (into_zero_copy_buffers, y compris unsendmsg_zcmulti-buffers), le rapportzero_copy_copied, et l’échec précoce sous ses deux formes (res<0+F_MORE+NOTIF, et CQE unique) — buffers toujours restitués. - Sûreté FD passing :
MSG_CTRUNC(buffer de contrôle trop petit) ⇒ pas de fuite de FD ; FD reçus bien enOwnedFdCLOEXEC. - Property-based : octets envoyés/reçus ≤ taille buffer ; robustesse du
parsing
recvmsg_out(données kernel = externes, Principe 3). - Erreurs via simulateur :
ECONNRESET,EPIPE,EAGAIN,EADDRINUSE. - Soundness : Miri sur le cycle à deux complétions du zero-copy (le buffer ne doit être ni libéré ni lu entre soumission et NOTIF).
- Couverture 100 % lignes + branches.
9. Décisions de fond émergées au Temps 2b
MSG_NOSIGNALetSOCK_CLOEXECpar défaut — invariants Air, cohérents couche 0 ; surcharge explicite possible.- Cycle zero-copy à deux complétions explicite (S1 étendu) — un slot peut
survivre à sa première complétion jusqu’au
CQE_F_NOTIF. Seul cas du module ; documenté et testé. - FD passing de première classe via
sendmsg/recvmsg— c’est la matérialisation kernel des capabilities AirCom (ADR-001). Les FD reçus sont desOwnedFd, jamais des entiers bruts. bind/listenexposés bien qu’« évidents » — indispensables pour les sockets en descripteur direct (sans FD ordinaire).- Variantes direct/multishot/bundle renvoyées aux Temps 3a/3d/3b — 2b reste centré sur le one-shot à ownership clair.
- Jumeaux possédés
OwnedSendMessage/OwnedReceiveMessage(correction de spec, §1) — les types message defamily-netsont à base d’emprunts (chemin synchrone) et incompatibles avec l’ownership asynchrone S1 ; on ne les réutilise pas, on en a des équivalents possédés. Helpers de sérialisation partagés (pub(crate)), zéro duplication. - Zéro perte de donnée (
into_zero_copy_buffers) — la restitution rend l’intégralité des buffers d’unsendmsg_zc, jamais seulement le premier. Application directe de l’ADR-032 (« Préservation des données confiées : zéro discard silencieux »), convention transverse gravée à partir de ce cas. - Échec précoce zero-copy — comportement réel kernel 6.12 (§4.2) : souvent
« résultat
res<0+F_MOREpuis NOTIF » plutôt que « NOTIF seul » ; les deux formes sont gérées, les buffers toujours restitués.
10. Travail à reprendre
Spec suivante : io-uring-2c-async.md (15 opérations sans équivalent
filesystem/réseau direct : nop, timeout, cancel, poll, futex, waitid, msg_ring,
epoll_ctl, files_update, fixed_fd_install). Traduction anglaise globale après
validation des documents français.
Licence du document : MPL 2.0
Statut : Spécification technique du Temps 2b (réseau) du module air-sys-syscall::io_uring, cible kernel 6.12 LTS.
Spec couche 0 — Module io_uring, Temps 2c : opérations async-spécifiques
Spécification technique — Version 1.0 (cible kernel : Linux 6.12 LTS)
Position. Le Temps 2c spécifie les 15 opérations d’io_uring qui n’ont pas d’équivalent filesystem/réseau direct : contrôle du ring, temporisation, annulation, surveillance d’événements, synchronisation et communication inter-ring (doc maître
io-uring-0-inventaire.md, axe B). Elles réutilisent le cœur du Temps 1. Plusieurs sont la base d’API affinées aux Temps 3c (linked), 3d (multishot) ou 3a (ressources fixes) — les renvois sont indiqués.
1. Conventions transverses du Temps 2c
poll32_events: on n’expose que la variante 32 bits (PollEvents= bitflags suru32) ; le champ legacypoll_events(u16) est évacué (doc maître §4).- Temporisations d’attente vs opérations
TIMEOUT: l’attente bornée d’une complétion passe parwait_completion_timeout(EXT_ARG/ABS_TIMER, décision Temps 1, §14.5). L’opTIMEOUTde ce Temps est pour les temporisations dans le flux (échéances autonomes, comptage de complétions, link-timeout) — usages explicites distincts. - Mémoire partagée (futex) : un mot futex vit dans de la mémoire qui doit
rester valide jusqu’à la complétion ; on réutilise le handle de vivacité
adossé à
MmapRegion(family-mem) introduit pourmadvise(Temps 2a §6.2). - Types partagés :
WaitTarget/WaitOptions/SignalInfo(family-process),EpollOp/EpollEvent(family-*),PollEvents. Renvoi aux familles pour les numéros de syscall équivalents.
2. Contrôle : nop
#![allow(unused)]
fn main() {
pub fn submit_nop(&mut self) -> Result<SubmissionToken, Errno>; // OP 0
pub fn submit_nop_with_result(&mut self, injected: i32)
-> Result<SubmissionToken, Errno>; // NOP_INJECT_RESULT
}
- Opcode :
IORING_OP_NOP(0). Aucune action ; complète immédiatement. - Usage : amorçage/mesure du ring, jalon dans une chaîne (Temps 3c),
tests (la variante
_with_resultinjecte unresviaIORING_NOP_INJECT_RESULT— précieux pour tester les chemins d’erreur sans provoquer une vraie erreur kernel). - Complétion :
completed()/into_result().
3. Temporisation
3.1 submit_timeout
#![allow(unused)]
fn main() {
pub fn submit_timeout(&mut self, spec: TimeoutSpec, flags: TimeoutFlags)
-> Result<SubmissionToken, Errno>; // OP 11
}
- Opcode :
IORING_OP_TIMEOUT(11). TimeoutSpec: durée (__kernel_timespec) et/ou seuil de complétions (off= nombre de CQE après lequel le timeout se déclenche). Un timeout peut donc être « après 10 ms » et/ou « après N complétions ».TimeoutFlags:ABS(échéance absolue),BOOTTIME/REALTIME(choix d’horloge),ETIME_SUCCESS,MULTISHOT(déclenchements répétés → variante multishot, Temps 3d).- Complétion :
completed();res == -ETIMEà l’expiration. Précision (comportement réel kernel 6.12) :ETIME_SUCCESSne change pas leresdu CQE (toujours-ETIMEà l’expiration) ; il n’altère que le marquage « échec » utilisé par les chaînes liées (Temps 3c) — l’expiration n’y casse pas la chaîne. (La rédaction initiale « l’expiration est un succès » était trompeuse hors contexte de chaîne.)
3.2 submit_timeout_remove / submit_timeout_update
#![allow(unused)]
fn main() {
pub fn submit_timeout_remove(&mut self, target: SubmissionToken)
-> Result<SubmissionToken, Errno>; // OP 12
pub fn submit_timeout_update(&mut self, target: SubmissionToken, spec: TimeoutSpec, flags: TimeoutFlags)
-> Result<SubmissionToken, Errno>; // OP 12 + TIMEOUT_UPDATE
}
- Opcode :
IORING_OP_TIMEOUT_REMOVE(12) ; l’update porteIORING_TIMEOUT_UPDATE. Annule ou re-arme un timeout en vol. - Erreurs :
ENOENT(cible inconnue/déjà déclenchée),EBUSY.
3.3 submit_link_timeout
#![allow(unused)]
fn main() {
pub fn submit_link_timeout(&mut self, spec: TimeoutSpec, flags: TimeoutFlags)
-> Result<SubmissionToken, Errno>; // OP 15
}
- Opcode :
IORING_OP_LINK_TIMEOUT(15). Sémantique : borne dans le temps l’opération précédente d’une chaîne liée ; si le timeout expire en premier, l’op liée est annulée (ECANCELED), et inversement. - Contrainte : n’a de sens qu’attaché à une op via
IOSQE_IO_LINK. La façade sûre l’expose via leLinkedChainBuilderdu Temps 3c (.with_link_timeout(spec)), pas en op isolée — l’émettre seule retourne une erreur de validation amont (Principe 4).
4. Annulation : cancel (asynchrone)
#![allow(unused)]
fn main() {
pub fn submit_cancel(&mut self, target: CancelTarget, flags: CancelFlags)
-> Result<SubmissionToken, Errno>; // OP 14
}
- Opcode :
IORING_OP_ASYNC_CANCEL(14). Variante asynchrone dusync_canceldu Temps 1 (qui, lui, passe parregisteret est bloquant). CancelTarget/CancelFlags: parToken(user_data), parFd,FdFixed, parOp, ouAny(IORING_ASYNC_CANCEL_*).ALL= annuler toutes les correspondances.- Complétion :
into_result();-ENOENTsi rien ne correspond,-EALREADYsi déjà en cours d’annulation. Précision kernel : le nombre d’ops annulées n’est rendu (res = N) qu’avecCancelFlags::ALL; sansALL, une annulation réussie rendres = 0. (Pour compter, utiliserALL.) - Interaction slab : l’annulation d’une op fait arriver sa complétion avec
-ECANCELED; le slot S1 (et son buffer) est restitué normalement. Pour le multishot, la complétion finale (sansF_MORE) libère le slot.
5. Surveillance d’événements
5.1 submit_poll_add / submit_poll_remove / submit_poll_update
#![allow(unused)]
fn main() {
pub fn submit_poll_add(&mut self, fd: BorrowedFd<'_>, events: PollEvents)
-> Result<SubmissionToken, Errno>; // OP 6
pub fn submit_poll_remove(&mut self, target: SubmissionToken)
-> Result<SubmissionToken, Errno>; // OP 7
pub fn submit_poll_update(&mut self, target: SubmissionToken, events: PollEvents)
-> Result<SubmissionToken, Errno>; // OP 7 + POLL_UPDATE
}
- Opcodes :
POLL_ADD(6),POLL_REMOVE(7). Surveille un FD pour des événements (lecture/écriture/erreur), à lapoll. - Complétion (poll_add) :
into_poll_result() -> PollEvents(événements prêts). Mono-coup ici ; multishot (IORING_POLL_ADD_MULTI, plusieurs notifications) et level-triggered (POLL_ADD_LEVEL) au Temps 3d. poll_update: modifie les événements surveillés ou le user_data d’un poll en vol (POLL_UPDATE_EVENTS/POLL_UPDATE_USER_DATA).
5.2 submit_epoll_ctl
#![allow(unused)]
fn main() {
pub fn submit_epoll_ctl(&mut self, epfd: BorrowedFd<'_>, op: EpollOp, fd: BorrowedFd<'_>, event: EpollEvent)
-> Result<SubmissionToken, Errno>; // OP 29
}
- Opcode :
IORING_OP_EPOLL_CTL(29). Équivalent :epoll_ctl(add/mod/del sur un set epoll), exécuté en asynchrone. - Complétion :
completed(). Note : en 6.12, c’est la seule intégration epoll d’io_uring (EPOLL_WAITn’arrive qu’après 6.12, hors périmètre). Utile pour piloter un set epoll existant sans syscall synchrone. - Note d’implémentation (manque couche 0) : il n’existe pas de famille
epollen couche 0. Les typesEpollOp/EpollEvent(miroir#[repr(C, packed)]) ont été ajoutés àair-sys-types::io_uring(consommés par cette façade) ; la création d’un epfd (epoll_create1) reste test-only (pas de wrapper public). Une vraie petite familleepollcouche 0 est à produire si d’autres consommateurs apparaissent (3ᵉ manque couche 0 révélé par les couches sup., avec inotify etMmapRegion).
6. Synchronisation
6.1 submit_futex_wait / submit_futex_wake / submit_futex_waitv — implémenté
Implémenté (PR coordonnée
family-mem, 2026-06-12). La signature historique&AtomicU32(insoundable en async : le borrow ne survit pas au retour de la façade) a été remplacée par une référence dans uneMmapRegion+ offset (cf.family-mem-mmap-region.md §3) : le mot futex vit dans une région partageable que le slot S1 garde vivante (uneMmapRegionLiveness), interdisantmunmaptant que l’op est en vol — ni UAF, ni fuite (prouvé Miri + loom).
#![allow(unused)]
fn main() {
pub fn submit_futex_wait(&mut self, region: &MmapRegion, offset: usize,
expected: u64, mask: u64, flags: FutexFlags)
-> Result<SubmissionToken, Errno>; // OP 51
pub fn submit_futex_wake(&mut self, region: &MmapRegion, offset: usize,
nr: u64, mask: u64, flags: FutexFlags)
-> Result<SubmissionToken, Errno>; // OP 52
pub fn submit_futex_waitv(&mut self, waiters: Vec<FutexWaiter>, flags: FutexFlags)
-> Result<SubmissionToken, Errno>; // OP 53
pub struct FutexWaiter {
pub region: MmapRegion, // possédée (clone) ⇒ garde de vivacité naturelle
pub offset: usize, // borné, aligné sur 4 (sinon EINVAL)
pub expected: u64, // pas de masque : futex_waitv kernel n'en porte pas
}
}
- Opcodes :
FUTEX_WAIT(51),FUTEX_WAKE(52),FUTEX_WAITV(53). - Intérêt : un reactor io_uring peut attendre un futex sans bloquer son
thread — il intègre la synchronisation userspace dans le même mécanisme que
les I/O.
futex_waitvattend sur plusieurs futex à la fois. offsetlocalise le mot futex (u32) dans la région : validé en amont viaMmapRegion::futex_word— région inscriptible (WRITE, car la réf rendue est mutable), bornes + alignement sur 4 —EINVALsinon, avant soumission (Principe 4). La tailleFUTEX2_SIZE_U32est imposée (le mot d’uneMmapRegionest unAtomicU32) ; seulPRIVATEest exposé parFutexFlags.mask(pourwait/wake) = bitset futex (addr3).- Sûreté : le slot S1 retient la (les)
MmapRegionLiveness(cf. §1) ⇒ la région reste mappée jusqu’à la complétion. - Note ABI :
struct futex_waitvdu kernel ne porte pas de masque par-attendu ({ val, uaddr, flags, __reserved }) ;FutexWaitern’expose donc pas de masque (un champ silencieusement ignoré serait un footgun, ADR-032). Pour une attente masquée, utilisersubmit_futex_wait(mono-attente). - Complétion :
wait/waitvcomplètent quand réveillés (ou-EAGAINsi la valeur ≠expectedà l’armement) viacompleted();wakerend le nombre de réveillés viainto_result().
6.2 submit_waitid
#![allow(unused)]
fn main() {
pub fn submit_waitid(&mut self, target: WaitTarget, options: WaitOptions, info: Box<SignalInfo>)
-> Result<SubmissionToken, Errno>; // OP 50
}
- Opcode :
IORING_OP_WAITID(50). Équivalent :waitid(cf.family-process.md; conv. couche 0 :waitidpréféré àwait/waitpid). WaitTarget: pid / pgid / pidfd / tous — typé (pas d’entier brut).- Sortie : le kernel remplit
info(SignalInfo), buffer possédé déplacé dans le slot. Complétion :into_waitid_result() -> Result<Box<SignalInfo>, Errno>. - Intérêt : récolter la fin d’un processus enfant sans bloquer le
reactor — brique d’un superviseur (pertinent pour
air-launchd, couche 5).
7. Inter-ring et ressources
7.1 submit_msg_ring
#![allow(unused)]
fn main() {
pub fn submit_message_ring_data(&mut self, target: &IoUring, res: i32, user_data: u64, flags: MessageRingFlags)
-> Result<SubmissionToken, Errno>; // OP 40 (MSG_DATA)
pub fn submit_message_ring_fd(&mut self, target: &IoUring, src_slot: u32, dst_slot: FixedSlotTarget, flags: MessageRingFlags)
-> Result<SubmissionToken, Errno>; // OP 40 (MSG_SEND_FD)
}
- Opcode :
IORING_OP_MSG_RING(40). Envoie un message d’un ring vers un autre :MSG_DATA: poste un CQE dans le ring cible (res+user_dataarbitraires). Mécanisme de notification inter-thread/inter-service très léger — pertinent pour réveiller un reactor pair (lien AirCom).MSG_SEND_FD: transfère un descripteur enregistré vers la table de FD d’un autre ring, sans passer parsendmsg/socket.
MessageRingFlags:CQE_SKIP(pas de CQE côté cible),FLAGS_PASS(propager des flags vers le CQE cible).- Complétion (côté émetteur) :
completed(). Côté cible : un CQE apparaît dans sa CQ. - Sûreté : le ring cible est emprunté (
&IoUring) ; en pratique on transmet souvent un ring fd enregistré (Temps 3a) pour découpler les durées de vie — détaillé au Temps 3a/3e.
7.2 submit_files_update
#![allow(unused)]
fn main() {
pub fn submit_files_update(&mut self, offset: u32, fds: Vec<RawFd>)
-> Result<SubmissionToken, Errno>; // OP 20
}
- Opcode :
IORING_OP_FILES_UPDATE(20). Met à jour des slots de la table de FD enregistrée de façon asynchrone (équivalent op deREGISTER_FILES_UPDATE2, mais dans le flux de soumission). - Renvoi : la table de FD fixes (
FixedFdTable) est définie au Temps 3a. - Complétion :
into_result()= nombre de slots mis à jour.
7.3 submit_fixed_fd_install
#![allow(unused)]
fn main() {
pub fn submit_fixed_fd_install(&mut self, fixed_slot: u32, flags: InstallFdFlags)
-> Result<SubmissionToken, Errno>; // OP 54
}
- Opcode :
IORING_OP_FIXED_FD_INSTALL(54). Convertit un descripteur direct/enregistré (qui n’a pas de FD ordinaire) en un FD ordinaire, rendu dans la complétion. InstallFdFlags:NO_CLOEXEC(par défaut le FD installé est CLOEXEC).- Complétion :
completion.installed_fd() -> Result<OwnedFd, Errno>. - Usage : passer à du code tiers un FD réel à partir d’un descripteur direct
obtenu via
accept/socket/openat2direct (Temps 3a).
8. Récapitulatif des 15 opérations
| # opcode | Opcode | Façade | Complétion |
|---|---|---|---|
| 0 | NOP | submit_nop / _with_result | completed / into_result |
| 6 | POLL_ADD | submit_poll_add | into_poll_result |
| 7 | POLL_REMOVE | submit_poll_remove / submit_poll_update | completed |
| 11 | TIMEOUT | submit_timeout | completed (-ETIME) |
| 12 | TIMEOUT_REMOVE | submit_timeout_remove / _update | completed |
| 14 | ASYNC_CANCEL | submit_cancel | into_result |
| 15 | LINK_TIMEOUT | (via LinkedChainBuilder, Temps 3c) | — |
| 20 | FILES_UPDATE | submit_files_update | into_result |
| 29 | EPOLL_CTL | submit_epoll_ctl | completed |
| 40 | MSG_RING | submit_message_ring_data / _fd | completed |
| 50 | WAITID | submit_waitid | into_waitid_result |
| 51 | FUTEX_WAIT | submit_futex_wait | completed |
| 52 | FUTEX_WAKE | submit_futex_wake | into_result |
| 53 | FUTEX_WAITV | submit_futex_waitv | completed |
| 54 | FIXED_FD_INSTALL | submit_fixed_fd_install | installed_fd |
9. Types ajoutés / partagés
Nouveaux au Temps 2c : TimeoutSpec, TimeoutFlags, PollEvents,
CancelFlags, FutexFlags, FutexWaiter, MessageRingFlags, InstallFdFlags,
FixedSlotTarget. Partagés : WaitTarget, WaitOptions, SignalInfo
(family-process), EpollOp, EpollEvent. Nouvelles méthodes de Completion :
into_poll_result, into_waitid_result, installed_fd. FixedFdTable et le
ring fd enregistré : Temps 3a.
10. Stratégie de tests
- Intégration : nop (+ injection de résultat), timeout (expiration +
ETIME_SUCCESS+ comptage de complétions), cancel d’une op en vol (-ECANCELEDreçu, slot restitué), poll_add sur un pipe puis écriture, epoll_ctl add/mod/del, futex_wait réveillé par futex_wake, futex_waitv multi-attente, waitid sur un enfant qui se termine, msg_ring data entre deux rings (CQE apparaît côté cible), fixed_fd_install d’un fd direct. - Property-based : cancel idempotent (
ENOENT/EALREADY), poll events cohérents. - Sûreté : Miri/handle de vivacité pour les futex (la mémoire du mot futex
survit à l’op) ; pas de fuite de FD sur
msg_ring_fd/fixed_fd_install. - Erreurs via simulateur :
ETIME,ECANCELED,ENOENT,EAGAIN(futex). - Couverture 100 % lignes + branches.
11. Décisions de fond émergées au Temps 2c
link_timeoutjamais en op isolée — exposé uniquement via leLinkedChainBuilder(Temps 3c) ; émission isolée refusée en amont.cancelasync vssync_cancel— deux voies assumées : asynchrone dans le flux (ici) et synchrone bloquante (Temps 1, base du téardown S2).msg_ringreconnu comme primitive inter-reactor — notification et transfert de FD entre rings ; brique potentielle du réveil de pairs AirCom. La gestion fine des durées de vie inter-ring est détaillée au Temps 3a/3e.- Futex async adossé au handle de vivacité
MmapRegion— même mécanisme de sûreté mémoire quemadvise(Temps 2a), pas d’unsafeexposée. nop_with_result— exposé surtout pour l’outillage de test (couverture des branches d’erreur sans vraie erreur kernel).
12. Travail à reprendre
Spec suivante : io-uring-2d-cmd.md (URING_CMD : passthrough générique +
commandes socket SIOCINQ/SIOCOUTQ/get/setsockopt). Traduction anglaise
globale après validation des documents français.
Licence du document : MPL 2.0
Statut : Spécification technique du Temps 2c (async-spécifiques) du module air-sys-syscall::io_uring, cible kernel 6.12 LTS.
Spec couche 0 — Module io_uring, Temps 2d : URING_CMD (passthrough)
Spécification technique — Version 1.0 (cible kernel : Linux 6.12 LTS)
Position. Le Temps 2d spécifie l’opération
IORING_OP_URING_CMD(46), un sous-protocole de passthrough par lequel io_uring transmet une commande opaque au gestionnaireuring_cmddu sous-système propriétaire du fichier visé (socket, NVMe, ublk…). Sous-module dédiéair-sys-syscall::io_uring::cmd. C’est l’opération la plus « brute » des Temps 2x : sa sémantique dépend du sous-système cible, pas d’io_uring. Réutilise le cœur du Temps 1.
1. Nature et contraintes
1.1 Le mécanisme
URING_CMD porte deux choses dans le SQE :
cmd_op(__u32) : l’opération propre au sous-système.- une zone de commande
cmd[]: 16 octets dans un SQE standard (la zoneaddr3/padding), ou 80 octets si le ring est créé avecIORING_SETUP_SQE128.
Le contenu de cmd[] est opaque pour io_uring : c’est le gestionnaire
f_op->uring_cmd du fichier qui l’interprète. Conséquence : la façade ne peut
garantir la sûreté que pour les commandes qu’elle connaît typées (socket) ;
le reste est un mécanisme générique encadré.
1.2 Conventions du Temps 2d
- Détection
SQE128: si une commande nécessite plus de 16 octets et que le ring n’a pasSetupFlags::SQE128, la soumission échoue en amont (Err(EINVAL), Principe 4) — pas de troncature silencieuse. - Flag
FIXED:IORING_URING_CMD_FIXED(bit 0) permet d’utiliser un buffer enregistré (index dansbuf_index) comme zone de données associée à la commande. Seul flag disponible en 6.12. - Pas de multishot :
IORING_URING_CMD_MULTISHOTest postérieur à 6.12 → hors périmètre (doc maître §1.2).
2. Commandes socket (couche typée sûre)
io_uring 6.12 expose, via URING_CMD sur un FD de socket, quatre commandes
(enum io_uring_socket_op). La façade les enveloppe typées et sûres :
#![allow(unused)]
fn main() {
impl IoUring {
/// SIOCINQ : octets disponibles en lecture. cmd_op = SOCKET_URING_OP_SIOCINQ (0).
pub fn submit_socket_inq(&mut self, sock: BorrowedFd<'_>)
-> Result<SubmissionToken, Errno>;
/// SIOCOUTQ : octets en attente d'émission. cmd_op = SOCKET_URING_OP_SIOCOUTQ (1).
pub fn submit_socket_outq(&mut self, sock: BorrowedFd<'_>)
-> Result<SubmissionToken, Errno>;
/// getsockopt. cmd_op = SOCKET_URING_OP_GETSOCKOPT (2).
pub fn submit_getsockopt(&mut self, sock: BorrowedFd<'_>, level: SocketOptionLevel,
optname: i32, value: Vec<u8>)
-> Result<SubmissionToken, Errno>;
/// setsockopt. cmd_op = SOCKET_URING_OP_SETSOCKOPT (3).
pub fn submit_setsockopt(&mut self, sock: BorrowedFd<'_>, level: SocketOptionLevel,
optname: i32, value: Vec<u8>)
-> Result<SubmissionToken, Errno>;
}
}
inq/outq: complétioninto_result() -> i32(nombre d’octets). Équivalents :ioctl(SIOCINQ/SIOCOUTQ), mais dans le ring, sansioctlsynchrone — utile pour piloter le flux d’un socket dans un reactor.getsockopt:valueest le buffer de sortie (ownership transféré, S1) ; le SQE portelevel/optname/optlen/optval. Complétioninto_buffer_result() -> (Vec<u8>, usize)(valeur + longueur effective).setsockopt:value(octets, Principe 3) déplacé dans le slot. Complétioncompleted().SocketOptionLevel: type partagé avecfamily-net(SOL_SOCKET, IPPROTO_TCP…).- Erreurs :
ENOPROTOOPT,EINVAL,ENOTSOCK,EFAULT.
Ces quatre commandes remplacent avantageusement les
ioctl/getsockoptsynchrones dans un chemin asynchrone, sans quitter le reactor.
3. Mécanisme générique (sous-systèmes arbitraires)
Pour les sous-systèmes que la façade ne connaît pas typés (NVMe passthrough, ublk, futurs gestionnaires), deux niveaux.
3.1 Niveau typé via trait UringCommand
#![allow(unused)]
fn main() {
/// Une commande passthrough d'un sous-système, sérialisable dans la zone cmd[].
///
/// # Safety
/// L'implémenteur garantit que `encode` produit une commande valide pour le
/// gestionnaire `uring_cmd` du `fd` ciblé, et que `cmd_op`/les buffers associés
/// respectent le contrat du sous-système.
pub unsafe trait UringCommand {
/// Opération propre au sous-système (cmd_op).
fn cmd_op(&self) -> u32;
/// Taille requise de la zone de commande (≤ 16, ou ≤ 80 si SQE128).
fn cmd_len(&self) -> usize;
/// Sérialise la commande dans `out` (long. `cmd_len`).
fn encode(&self, out: &mut [u8]);
/// Interprétation typée de la complétion.
type Output;
fn interpret(&self, completion: &Completion) -> Result<Self::Output, Errno>;
}
impl IoUring {
pub fn submit_uring_cmd<C: UringCommand>(&mut self, fd: BorrowedFd<'_>, cmd: C)
-> Result<SubmissionToken, Errno>;
}
}
- La façade vérifie
cmd.cmd_len() ≤ 16(ou≤ 80siSQE128), sinonErr(EINVAL). Letraitestunsafecar la validité de la commande dépend du sous-système, hors du contrôle d’io_uring. - Exemple de consommateur : un crate de stockage (couche supérieure) définit
un
NvmePassthroughCmd: UringCommand(struct ~72 octets ⇒ exigeSQE128). Air couche 0 fournit le mécanisme, pas les structures NVMe spécifiques (elles vivent là où elles sont utilisées).
3.2 Niveau brut unsafe (échappatoire)
#![allow(unused)]
fn main() {
impl IoUring {
/// # Safety
/// `cmd_data` doit être une commande valide pour le gestionnaire uring_cmd
/// du `fd`. Tout buffer référencé par la commande doit rester valide
/// jusqu'à la complétion. `cmd_data.len()` ≤ 16 (ou ≤ 80 si SQE128).
pub unsafe fn submit_uring_cmd_raw(&mut self, fd: BorrowedFd<'_>,
cmd_op: u32, cmd_data: &[u8], flags: UringCmdFlags)
-> Result<SubmissionToken, Errno>;
}
}
- Dernier recours pour les sous-systèmes non encore typés. Documentation
# Safetyexhaustive (conv. couche 0).UringCmdFlags:FIXED.
4. Récapitulatif
| # opcode | Opcode | Façade (sûre) | Façade (générique) |
|---|---|---|---|
| 46 | URING_CMD | submit_socket_inq/outq, submit_getsockopt/setsockopt | submit_uring_cmd<C> / submit_uring_cmd_raw (unsafe) |
cmd_op socket couverts (6.12) : SIOCINQ (0), SIOCOUTQ (1), GETSOCKOPT
(2), SETSOCKOPT (3). TX_TIMESTAMP/GETSOCKNAME = post-6.12, hors périmètre.
5. Types ajoutés / partagés
Nouveaux : UringCmdFlags (bitflags, FIXED), trait UringCommand. Partagés :
SocketOptionLevel (family-net). Méthode Completion réutilisée :
into_result/into_buffer_result/completed.
6. Stratégie de tests
- Intégration :
inq/outqsur un socket loopback avec données connues en file ;getsockopt(SO_RCVBUF)/setsockopt(SO_REUSEADDR)round-trip ; comparaison avec lesgetsockopt/ioctlsynchrones (family-net). - SQE128 : commande > 16 octets refusée sans
SQE128(EINVAL) ; acceptée avec. - Sûreté : Miri sur le chemin
submit_uring_cmd<C>(sérialisation danscmd[]bornée) ; test quecmd_len> capacité est refusé. - Fuzzing : décodage des complétions
getsockopt(données kernel externes). - Couverture 100 % lignes + branches ; le
submit_uring_cmd_rawunsafetesté via un faux gestionnaire en harnais, ou consigné enCOVERAGE-EXCEPTIONS.mdsi non provoquable.
7. Décisions de fond émergées au Temps 2d
- Deux niveaux d’exposition : commandes socket typées sûres d’un côté ;
trait UringCommand(unsafe trait) + forme bruteunsafepour les sous-systèmes arbitraires de l’autre. On ne prétend pas typer ce que le sous-système seul connaît. - Détection
SQE128en amont — refus net plutôt que troncature silencieuse. - Structures NVMe hors couche 0 — Air fournit le mécanisme passthrough ; les commandes spécifiques (NVMe, ublk) sont définies par leurs consommateurs.
- Pas de multishot uring_cmd — postérieur à 6.12.
8. Travail à reprendre
Spec suivante : io-uring-3a-registration.md (ressources fixes modernes :
FixedFdTable, RegisteredBuffers, ring fd enregistré, eventfd, personality,
affinités io-wq, napi, clock, clone_buffers, alloc_range). Traduction anglaise
globale après validation des documents français.
Licence du document : MPL 2.0
Statut : Spécification technique du Temps 2d (URING_CMD) du module air-sys-syscall::io_uring, cible kernel 6.12 LTS.
Spec couche 0 — Module io_uring, Temps 3a : registration (ressources fixes)
Spécification technique — Version 1.0 (cible kernel : Linux 6.12 LTS)
Position. Le Temps 3a spécifie l’enregistrement de ressources auprès du kernel (
io_uring_register(2), n° 427) : tables de descripteurs fixes, buffers enregistrés, ring fd enregistré, eventfd, personality, réglage du pool io-wq, NAPI, horloge. Sous-moduleair-sys-syscall::io_uring::registration. C’est ce Temps qui débloque les variantes « fixed »/« direct » référencées depuis les Temps 2a–2c. Réutilise le cœur du Temps 1.Périmètre : 21 register opcodes (doc maître, axe C — Temps 3a). Les variantes legacy (
REGISTER_BUFFERS/FILES/FILES_UPDATE) sont évacuées au profit des variantes taguées*2(doc maître §4).
1. Conventions transverses du Temps 3a
- Registration explicite (ADR-022 D4) : Air n’enregistre jamais automatiquement. L’application qui veut le bénéfice (moins de traduction d’adresses, FD directs) le demande.
- Ownership des ressources enregistrées : une ressource enregistrée
(buffer, FD) doit rester valide tant qu’elle est enregistrée. Les types
RegisteredBuffers/FixedFdTablepossèdent la ressource ; les références d’usage (RegisteredBufferSlice,FixedSlot) y sont liées par lifetime, donc inutilisables après désenregistrement (sûreté par construction). - Lien avec le téardown S2 : un ring ne peut pas être détruit avec des
ressources encore enregistrées sans les libérer ;
shutdown()/Dropdésenregistrent proprement (les buffers possédés sont restitués). - Tags et sparse : les variantes
*2acceptent un enregistrement sparse (IORING_RSRC_REGISTER_SPARSE: slots vides à remplir plus tard) et des tags (FEAT_RSRC_TAGS: une notification de CQE signale quand une ressource remplacée n’est plus référencée). - Ring fd enregistré : si le ring a enregistré son propre fd (Temps 1,
REG_REG_RING), tous lesregister/enterl’utilisent de façon transparente (USE_REGISTERED_RING).
2. Table de descripteurs fixes — FixedFdTable
#![allow(unused)]
fn main() {
pub struct FixedFdTable { /* possède les OwnedFd des slots remplis */ }
impl FixedFdTable {
/// REGISTER_FILES2 (13). `capacity` slots, sparse par défaut (slots vides).
pub fn register(ring: &mut IoUring, capacity: NonZeroU32) -> Result<Self, Errno>;
/// REGISTER_FILES2 avec un jeu initial de FD.
pub fn register_with(ring: &mut IoUring, fds: Vec<OwnedFd>) -> Result<Self, Errno>;
/// REGISTER_FILES_UPDATE2 (14) : place/remplace un FD dans un slot.
pub fn set(&mut self, ring: &mut IoUring, slot: u32, fd: OwnedFd) -> Result<(), Errno>;
/// Vide un slot (FD rendu à l'appelant).
pub fn clear(&mut self, ring: &mut IoUring, slot: u32) -> Result<Option<OwnedFd>, Errno>;
/// REGISTER_FILE_ALLOC_RANGE (25) : borne la plage d'auto-allocation.
pub fn set_alloc_range(&mut self, ring: &mut IoUring, range: Range<u32>) -> Result<(), Errno>;
/// UNREGISTER_FILES (3) : désenregistre tout, rend les FD restants.
pub fn unregister(self, ring: &mut IoUring) -> Result<Vec<OwnedFd>, Errno>;
/// Référence un slot pour usage en opération (IOSQE_FIXED_FILE).
pub fn slot(&self, slot: u32) -> Option<FixedSlot<'_>>;
}
/// Référence empruntée à un slot rempli ; liée à la table par lifetime.
pub struct FixedSlot<'t> { /* index + emprunt */ }
}
2.1 Ce que FixedFdTable débloque
- Opérations sur FD fixe :
read/write/send/recv… acceptent unFixedSlotau lieu d’unBorrowedFd(drapeauIOSQE_FIXED_FILE) → le kernel évite la résolution du FD à chaque op. - Variantes « direct descriptor » des Temps 2a/2b — le résultat est rangé dans un slot plutôt que rendu en FD ordinaire :
#![allow(unused)]
fn main() {
impl IoUring {
pub fn submit_openat2_direct(&mut self, dirfd: DirFd, path: CString, how: OpenHow,
slot: FixedSlotTarget) -> Result<SubmissionToken, Errno>;
pub fn submit_accept_direct(&mut self, listener: BorrowedFd<'_>, flags: AcceptFlags,
slot: FixedSlotTarget) -> Result<SubmissionToken, Errno>;
pub fn submit_socket_direct(&mut self, domain: SocketDomain, ty: SocketType, protocol: i32,
slot: FixedSlotTarget) -> Result<SubmissionToken, Errno>;
}
/// Slot cible : indice précis, ou auto-allocation (IORING_FILE_INDEX_ALLOC).
pub enum FixedSlotTarget { Index(u32), Alloc }
}
FixedSlotTarget::Alloc⇒ le kernel choisit un slot libre (dans la plage d’set_alloc_range) et le rend danscqe->res; sinon-ENFILE.- Pour revenir à un FD ordinaire :
submit_fixed_fd_install(Temps 2c). - Mise à jour asynchrone dans le flux :
submit_files_update(Temps 2c, op 20).
Intérêt Air : les services à fort taux de connexions (compositeur, AirCom) gardent leurs sockets en descripteurs directs — gain mesurable, et confinement (un FD direct n’est pas visible comme FD ordinaire du process).
3. Buffers enregistrés — RegisteredBuffers
#![allow(unused)]
fn main() {
pub struct RegisteredBuffers { /* possède les buffers épinglés */ }
impl RegisteredBuffers {
/// REGISTER_BUFFERS2 (15). Épingle les buffers ; sparse/tags supportés.
pub fn register(ring: &mut IoUring, buffers: Vec<Vec<u8>>) -> Result<Self, Errno>;
/// Variante adossée à des MmapRegion possédées (data plane).
pub fn register_mmap(ring: &mut IoUring, regions: Vec<MmapRegion>) -> Result<Self, Errno>;
/// REGISTER_BUFFERS_UPDATE (16) : remplace un buffer (tag → notif quand
/// l'ancien n'est plus référencé).
pub fn update(&mut self, ring: &mut IoUring, index: u32, buffer: Vec<u8>) -> Result<(), Errno>;
/// CLONE_BUFFERS (30) : clone les buffers enregistrés d'un autre ring.
pub fn clone_from(ring: &mut IoUring, src: &IoUring) -> Result<Self, Errno>;
/// UNREGISTER_BUFFERS (1) : rend les buffers.
pub fn unregister(self, ring: &mut IoUring) -> Result<Vec<Vec<u8>>, Errno>;
/// Tranche d'un buffer enregistré, pour read_fixed/write_fixed (Temps 2a).
pub fn slice(&self, index: u32, range: Range<usize>) -> Option<RegisteredBufferSlice<'_>>;
}
/// Référence à une tranche d'un buffer enregistré ; liée par lifetime.
pub struct RegisteredBufferSlice<'b> { /* index + range + emprunt */ }
}
- Gain : l’épinglage et la traduction d’adresses sont faits une fois à
l’enregistrement →
read_fixed/write_fixed(Temps 2a) etsend_zc/recv fixed (Temps 2b) évitent ce coût par op. Cas chaud du data plane AirCom (register_mmapsur unmemfdpartagé). clone_from: partage de buffers entre rings d’un même processus (thread-per-core) sans réépinglage.- Ownership :
RegisteredBufferSlicene peut survivre àunregister(lifetime) —read_fixedsur une tranche périmée ne compile pas.
4. Ring fd enregistré
#![allow(unused)]
fn main() {
impl IoUring {
pub fn register_ring_fd(&mut self) -> Result<(), Errno>; // RING_FDS (20)
pub fn unregister_ring_fd(&mut self) -> Result<(), Errno>; // UNREGISTER_RING_FDS (21)
}
}
- Enregistre le fd du ring dans une table interne → les
io_uring_entersuivants utilisentIORING_ENTER_REGISTERED_RING(pas de résolution de FD). Gain net sur les chemins àenterfréquents. Requiert/activeFEAT_REG_REG_RING. - Utilisé aussi pour désigner un ring cible dans
msg_ring(Temps 2c) de façon découplée.
5. Notification par eventfd
#![allow(unused)]
fn main() {
impl IoUring {
pub fn register_eventfd(&mut self, efd: BorrowedFd<'_>) -> Result<(), Errno>; // EVENTFD (4)
pub fn register_eventfd_async(&mut self, efd: BorrowedFd<'_>) -> Result<(), Errno>; // EVENTFD_ASYNC (7)
pub fn unregister_eventfd(&mut self) -> Result<(), Errno>; // UNREGISTER_EVENTFD (5)
}
}
- Lie le ring à un
eventfd(familleipc) : le kernel y écrit à chaque complétion postée → un reactor peut attendre les complétions via l’eventfd (intégration epoll, ou réveil cross-thread). register_eventfd_asyncne notifie que pour les complétions traitées en asynchrone (filtre le bruit des complétions inline). Désactivation ponctuelle via le drapeauIORING_CQ_EVENTFD_DISABLED(anneau CQ, Temps 1).
6. Personality (identités)
#![allow(unused)]
fn main() {
pub struct Personality(/* id */);
impl IoUring {
/// REGISTER_PERSONALITY (9) : enregistre les credentials courants.
pub fn register_personality(&mut self) -> Result<Personality, Errno>;
/// UNREGISTER_PERSONALITY (10).
pub fn unregister_personality(&mut self, p: Personality) -> Result<(), Errno>;
}
}
- Enregistre les credentials du process appelant et rend un id ; une op peut
ensuite s’exécuter avec ces credentials en plaçant l’id dans
sqe->personality. Permet à un service privilégié d’effectuer une op pour le compte d’une identité moins privilégiée (et inversement) de façon contrôlée. - Lien sécurité : combiné aux restrictions (Temps 3f) et au modèle d’entitlements (ADR-010), c’est une brique de moindre privilège — exposé ici comme primitive, l’usage politique relève des couches supérieures.
7. Réglage du pool io-wq
#![allow(unused)]
fn main() {
impl IoUring {
/// IOWQ_AFF (17) : affinité CPU des workers io-wq.
pub fn set_work_queue_affinity(&mut self, cpus: &CpuSet) -> Result<(), Errno>;
/// UNREGISTER_IOWQ_AFF (18).
pub fn clear_work_queue_affinity(&mut self) -> Result<(), Errno>;
/// IOWQ_MAX_WORKERS (19) : plafonds de workers bornés/non-bornés.
pub fn set_work_queue_max_workers(&mut self, bounded: u32, unbounded: u32)
-> Result<WorkQueueWorkerLimits, Errno>;
}
}
- Le pool io-wq exécute les opérations qui ne peuvent pas être faites inline
(ex. I/O bloquante).
set_work_queue_max_workersborne le nombre de threads (catégoriesIO_WQ_BOUND/IO_WQ_UNBOUND) — important sur matériel modeste (Charte principe 4 : maîtriser l’empreinte sur Raspberry Pi 4). CpuSetpartagé avecfamily-system(affinités).
8. NAPI busy-poll
#![allow(unused)]
fn main() {
impl IoUring {
pub fn register_napi(&mut self, cfg: NapiConfig) -> Result<NapiConfig, Errno>; // NAPI (27)
pub fn unregister_napi(&mut self) -> Result<(), Errno>; // UNREGISTER_NAPI (28)
}
pub struct NapiConfig { /* busy_poll_to, prefer_busy_poll */ }
}
- Active le busy-polling réseau (NAPI) pour réduire la latence sur les sockets à très haut débit, au prix de CPU. À réserver aux profils où la latence prime (mesure avant activation, Principe 5).
9. Source d’horloge
#![allow(unused)]
fn main() {
impl IoUring {
pub fn register_clock(&mut self, clock: ClockSource) -> Result<(), Errno>; // CLOCK (29)
}
pub enum ClockSource { Monotonic, Boottime, Realtime }
}
- Fixe l’horloge utilisée par les timeouts du ring (cohérent avec les
TimeoutFlagsdu Temps 2c etfamily-time).
10. Récapitulatif des register opcodes (Temps 3a)
| # | Register op | Façade |
|---|---|---|
| 1 | UNREGISTER_BUFFERS | RegisteredBuffers::unregister |
| 3 | UNREGISTER_FILES | FixedFdTable::unregister |
| 4 | REGISTER_EVENTFD | register_eventfd |
| 5 | UNREGISTER_EVENTFD | unregister_eventfd |
| 7 | REGISTER_EVENTFD_ASYNC | register_eventfd_async |
| 9 | REGISTER_PERSONALITY | register_personality |
| 10 | UNREGISTER_PERSONALITY | unregister_personality |
| 13 | REGISTER_FILES2 | FixedFdTable::register(_with) |
| 14 | REGISTER_FILES_UPDATE2 | FixedFdTable::set / clear |
| 15 | REGISTER_BUFFERS2 | RegisteredBuffers::register(_mmap) |
| 16 | REGISTER_BUFFERS_UPDATE | RegisteredBuffers::update |
| 17 | REGISTER_IOWQ_AFF | set_work_queue_affinity |
| 18 | UNREGISTER_IOWQ_AFF | clear_work_queue_affinity |
| 19 | REGISTER_IOWQ_MAX_WORKERS | set_work_queue_max_workers |
| 20 | REGISTER_RING_FDS | register_ring_fd |
| 21 | UNREGISTER_RING_FDS | unregister_ring_fd |
| 25 | REGISTER_FILE_ALLOC_RANGE | FixedFdTable::set_alloc_range |
| 27 | REGISTER_NAPI | register_napi |
| 28 | UNREGISTER_NAPI | unregister_napi |
| 29 | REGISTER_CLOCK | register_clock |
| 30 | REGISTER_CLONE_BUFFERS | RegisteredBuffers::clone_from |
Soit 21 register opcodes. (PROBE → Temps 1 ; SYNC_CANCEL → Temps 1/2c ;
RESTRICTIONS/ENABLE_RINGS → Temps 3f ; PBUF_RING/PBUF_STATUS → Temps 3b.)
11. Types ajoutés / partagés
Nouveaux : FixedFdTable, FixedSlot<'t>, FixedSlotTarget,
RegisteredBuffers, RegisteredBufferSlice<'b>, Personality,
WorkQueueWorkerLimits, NapiConfig, ClockSource. Partagés : OpenHow,
AcceptFlags, SocketDomain/Type (Temps 2a/2b), CpuSet (family-system),
MmapRegion (family-mem).
12. Stratégie de tests
- Intégration : enregistrer une
FixedFdTable, faire unaccept_direct/openat2_direct(slotAllocpuisIndex),read/writeviaFixedSlot,fixed_fd_installretour en FD ordinaire ;RegisteredBuffers+read_fixed/write_fixedround-trip ;clone_fromentre deux rings ;register_eventfdpuis vérifier la notification ;register_personality+ op avec personality ;set_work_queue_max_workers(lecture des valeurs rendues) ;register_napi;register_clock. - Sûreté (compile-fail + Miri) : un
RegisteredBufferSlice/FixedSlotne doit pas survivre au désenregistrement (testtrybuildcompile-fail) ; pas de fuite de FD/buffer à l’unregisterni au téardown S2. - Property-based : enregistrement sparse + remplissage progressif, cohérence des tags (notif quand l’ancien buffer n’est plus référencé).
- Erreurs via simulateur :
ENFILE(slot Alloc plein),EINVAL,EBUSY,EFAULT. - Couverture 100 % lignes + branches.
13. Décisions de fond émergées au Temps 3a
- Variantes legacy abandonnées — seules les
*2(taguées) sont exposées (doc maître §4) ; cela simplifie l’API et impose les bénéfices du tagging. - Ressources possédées + références liées par lifetime — sûreté par construction : impossible d’utiliser une ressource désenregistrée.
FixedSlotTarget::{Index, Alloc}— l’auto-allocation est unenumtypé, pas la sentinelleIORING_FILE_INDEX_ALLOC(~0U) exposée.register_mmappour le data plane — buffers enregistrés adossés à desMmapRegionpossédées (memfd partagé AirCom), réutilisant le handle de vivacité defamily-mem.- Plafonds io-wq exposés tôt — maîtrise de l’empreinte CPU sur matériel modeste, conformément à la Charte.
13 bis. Notes d’implémentation — écarts kernel 6.12/6.17 (PR #40)
Implémentation : sous-module air-sys-syscall::io_uring::registration (PR #40,
mergée). La surface validée ci-dessus n’a pas été modifiée ; les écarts
kernel observés à l’implémentation sont consignés ici (jamais corrigés en
silence — ADR-032) :
- Opcodes 27-30 absents des headers exécuteurs.
<linux/io_uring.h>(linux-libc-dev des exécuteurs) borne l’énumération à l’opcode 26 (IORING_REGISTER_LAST == 27) :REGISTER_NAPI/UNREGISTER_NAPI(27/28),REGISTER_CLOCK(29),REGISTER_CLONE_BUFFERS(30) — ainsi que les structuresio_uring_napi/io_uring_clock_register/io_uring_clone_buffers— sont repris de l’uapi 6.12 amont (cible de la spec) et validés au runtime sur le kernel 6.17 des exécuteurs. register_clock(ClockSource::Realtime)→EINVAL. io_uring (6.12/6.17) n’accepte queCLOCK_MONOTONIC/CLOCK_BOOTTIMEpour l’horloge des timeouts ;REALTIMEest rejeté par le kernel. Décision BDFL : la varianteRealtimeest conservée (surface inchangée) ; son rejet est testé et documenté.- CLOEXEC refusé sur descripteur direct. Le kernel rend
EINVALsifile_slot && (flags & CLOEXEC)(socket/accept/openat2 directs : descripteurs kernel-managed, non hérités à l’exec par nature). Les façades directes ne posent donc pasSOCK_CLOEXEC/O_CLOEXEC; leO_CLOEXECest appliqué à la matérialisation (fixed_fd_install,install_fd_flags = 0). register_napi→EINVALhors config NIC/NAPI. Le busy-poll NAPI exigeCONFIG_NET_RX_BUSY_POLLet un NIC NAPI ; absent des exécuteurs, l’appel rendEINVAL(chemin d’erreur couvert). Le chemin succès est en exception de couverture FEATURE-KERNEL (cf.docs/COVERAGE-EXCEPTIONS.md).
Différés (dette, décision BDFL) : submit_files_update (op 20, mise à jour
asynchrone de la table de FD fixes dans le flux) et msg_ring_fd
(MSG_SEND_FD) — plomberie d’op async tangentielle à la registration. La mise à
jour synchrone (FixedFdTable::set/clear, register opcode 14) couvre le
besoin immédiat ; à reprendre avec le runtime async (ou un addendum 3a-bis).
14. Travail à reprendre
Spec suivante : io-uring-3b-provided.md (buffers fournis ring-mapped :
PBUF_RING/PBUF_STATUS, consommation incrémentale, sélection automatique de
buffer pour recv/read multishot). Traduction anglaise globale après validation
des documents français.
Licence du document : MPL 2.0
Statut : Spécification technique du Temps 3a (registration) du module air-sys-syscall::io_uring, cible kernel 6.12 LTS.
Spec couche 0 — Module io_uring, Temps 3b : buffers fournis (ring-mapped)
Spécification technique — Version 1.0 (cible kernel : Linux 6.12 LTS)
Position. Le Temps 3b spécifie les buffers fournis ring-mapped (
IORING_REGISTER_PBUF_RING), qui remplacent l’ancien mécanisme op-basedPROVIDE_BUFFERS/REMOVE_BUFFERS(évacué, doc maître §4). Sous-moduleair-sys-syscall::io_uring::provided. Réutilise le cœur du Temps 1 et la registration du Temps 3a.Périmètre : register opcodes
PBUF_RING(22),UNREGISTER_PBUF_RING(23),PBUF_STATUS(26) ; drapeau de soumissionIOSQE_BUFFER_SELECT; flags de complétionCQE_F_BUFFERetCQE_F_BUF_MORE.
1. Le problème résolu
Pour un recv classique, il faut pré-engager un buffer par opération — donc
par connexion en attente. Un serveur à 10 000 connexions oisives gaspille 10 000
buffers immobilisés. Les buffers fournis inversent le modèle :
L’application enregistre un groupe de buffers ; elle soumet un
recv/readavecIOSQE_BUFFER_SELECTsans buffer attaché ; le kernel choisit un buffer du groupe au moment où des données arrivent, et rend son identifiant dans le CQE (CQE_F_BUFFER, id dans les 16 bits hauts decqe->flags).
Les buffers ne sont consommés que par les connexions effectivement actives. Combiné au recv multishot (Temps 3d), un seul SQE sert un flux entier, chaque datagramme atterrissant dans un buffer choisi par le kernel.
2. Le groupe de buffers — ProvidedBufferRing
#![allow(unused)]
fn main() {
pub struct ProvidedBufferRing { /* bgid, mémoire de l'anneau, buffers backing */ }
pub struct ProvidedBufferRingOptions {
/// Mémoire de l'anneau allouée par le kernel (IOU_PBUF_RING_MMAP) puis
/// mmappée par la façade, au lieu d'être fournie par l'application.
pub kernel_mmap: bool,
/// Consommation incrémentale (IOU_PBUF_RING_INC) : un buffer peut servir
/// plusieurs complétions, consommé au fur et à mesure (cf. §5).
pub incremental: bool,
}
impl ProvidedBufferRing {
/// PBUF_RING (22). Enregistre un groupe `group_id` de `count` buffers de
/// `buf_size` octets (count = puissance de 2). `count` borne aussi
/// l'anneau de buffers.
pub fn register(ring: &mut IoUring, group_id: u16, count: NonZeroU16,
buf_size: NonZeroU32, opts: ProvidedBufferRingOptions)
-> Result<Self, Errno>;
/// UNREGISTER_PBUF_RING (23). Rend la mémoire des buffers.
pub fn unregister(self, ring: &mut IoUring) -> Result<(), Errno>;
/// PBUF_STATUS (26). Tête courante du groupe (diagnostic / régulation).
pub fn status(&self, ring: &IoUring) -> Result<ProvidedBufferRingStatus, Errno>;
pub fn group_id(&self) -> u16;
/// Nombre de buffers actuellement disponibles (non checkout).
pub fn available(&self) -> u16;
}
pub struct ProvidedBufferRingStatus { pub head: u32 }
}
- Deux modes mémoire : buffers fournis par l’application (par défaut, sur une
MmapRegionpossédée — data plane) ; ou anneau alloué par le kernel (kernel_mmap) puis mmappé àIORING_OFF_PBUF_RING | (group_id << IORING_OFF_PBUF_SHIFT). - Ownership :
ProvidedBufferRingpossède la mémoire des buffers tant que le groupe est enregistré ;unregisterla restitue. Lien téardown S2 : désenregistrement avant destruction du ring. - Réapprovisionnement : l’application rend les buffers consommés au groupe (avance la queue de l’anneau de buffers) — cf. §4.
3. Soumission avec sélection automatique
#![allow(unused)]
fn main() {
impl IoUring {
/// recv sur sélection auto de buffer dans `group` (IOSQE_BUFFER_SELECT).
/// `bundle` ⇒ IORING_RECVSEND_BUNDLE (plusieurs buffers contigus en un recv).
pub fn submit_receive_provided(&mut self, sock: BorrowedFd<'_>, group: &ProvidedBufferRing, flags: MessageFlags, bundle: bool)
-> Result<SubmissionToken, Errno>;
/// read sur sélection auto de buffer (`bundle` ⇒ IORING_RECVSEND_BUNDLE).
pub fn submit_read_provided(&mut self, fd: BorrowedFd<'_>, group: &ProvidedBufferRing, length: u32, offset: Option<u64>, bundle: bool)
-> Result<SubmissionToken, Errno>;
}
}
- Aucun buffer n’est attaché à la soumission : seul le
group_idest transmis, avecIOSQE_BUFFER_SELECT. Le slot S1 ne détient donc pas de buffer (le buffer appartient au groupe). - Variantes multishot (
recv_multishot/read_multishot) : Temps 3d — elles s’appuient sur ce même groupe. - Bundle (
IORING_RECVSEND_BUNDLE,FEAT_RECVSEND_BUNDLE) : un seul recv/send peut consommer plusieurs buffers contigus du groupe d’un coup (référencé en Temps 2b) — exposé via un paramètrebundle: boolici. - Pénurie : si le groupe est vide quand des données arrivent, la complétion
porte
-ENOBUFS; l’application doit réapprovisionner et resoumettre.
4. Consommation : le guard ProvidedBuffer
#![allow(unused)]
fn main() {
impl Completion {
/// Récupère le buffer choisi par le kernel pour cette complétion.
/// `None` si la complétion n'a pas utilisé de buffer fourni.
pub fn into_provided_buffer<'r>(self, group: &'r mut ProvidedBufferRing)
-> Option<ProvidedBuffer<'r>>;
}
/// Accès RAII aux données reçues dans un buffer fourni. Rend le buffer au
/// groupe (réapprovisionnement) à la libération du guard.
pub struct ProvidedBuffer<'r> { /* id, longueur utile, emprunt du groupe */ }
impl<'r> ProvidedBuffer<'r> {
pub fn id(&self) -> u16;
pub fn data(&self) -> &[u8]; // longueur = octets reçus (cqe->res)
pub fn data_mut(&mut self) -> &mut [u8];
}
impl Drop for ProvidedBuffer<'_> { /* réapprovisionne le groupe */ }
}
- Cycle checkout → traitement → return, rendu sûr par RAII : le kernel a
« sorti » un buffer (id dans le CQE) ;
into_provided_buffermatérialise un accès borné aux octets reçus ; la libération du guard rend le buffer au groupe (avance la queue de l’anneau). Oublier de rendre = buffer perdu pour le groupe → le guard le rend automatiquement. - L’emprunt
&'r mut ProvidedBufferRinggarantit qu’on ne désenregistre pas le groupe tant qu’un buffer est en cours de traitement.
5. Consommation incrémentale (incremental)
Avec ProvidedBufferRingOptions::incremental (IOU_PBUF_RING_INC) :
- Un gros buffer peut être consommé partiellement sur plusieurs
complétions. Le CQE porte alors
CQE_F_BUF_MORE(Completion::flags()), signalant que le même buffer recevra d’autres complétions et n’est donc pas rendu automatiquement. - Le guard
ProvidedBufferreflète cela : siCQE_F_BUF_MOREest positionné, sa libération ne réapprovisionne pas (le kernel continue d’utiliser le buffer) ; le réapprovisionnement n’a lieu qu’à la complétion finale (sansBUF_MORE). L’application et la façade suivent ensemble l’index de consommation (contrat documenté). - Intérêt : enregistrer de grandes plages (un seul gros
memfd) et ne consommer que ce qui est nécessaire par réception — économe en mémoire pour le data plane AirCom.
6. Récapitulatif
| # | Élément | Façade |
|---|---|---|
| reg 22 | PBUF_RING | ProvidedBufferRing::register |
| reg 23 | UNREGISTER_PBUF_RING | ProvidedBufferRing::unregister |
| reg 26 | PBUF_STATUS | ProvidedBufferRing::status |
| flag SQE | IOSQE_BUFFER_SELECT | submit_receive_provided / submit_read_provided |
| flag CQE | CQE_F_BUFFER | ProvidedBuffer::id (via into_provided_buffer) |
| flag CQE | CQE_F_BUF_MORE | consommation incrémentale (§5) |
Remplace IORING_OP_PROVIDE_BUFFERS (31) et REMOVE_BUFFERS (32),
évacués vers UNSUPPORTED.md.
7. Types ajoutés / partagés
Nouveaux : ProvidedBufferRing, ProvidedBufferRingOptions, ProvidedBufferRingStatus,
ProvidedBuffer<'r>. Nouvelle méthode de Completion : into_provided_buffer.
Partagés : MmapRegion (family-mem), MessageFlags (Temps 2b).
8. Stratégie de tests
- Intégration : enregistrer un groupe,
recv_providedsur un socket loopback, vérifier l’id de buffer rendu et les octets reçus, libérer le guard, vérifier le réapprovisionnement (available()remonte) ; pénurie de buffers (-ENOBUFS) puis réapprovisionnement ; modekernel_mmap; bundle multi-buffers ;status(). - Incrémental : gros buffer consommé sur plusieurs recv, séquence
CQE_F_BUF_MOREpuis complétion finale, réapprovisionnement uniquement à la fin. - Sûreté (compile-fail + Miri) : impossible de désenregistrer le groupe tant
qu’un
ProvidedBufferest vivant (emprunt mut) ; pas de double rendu d’un buffer ; pas de lecture hors desresoctets reçus. - Property-based : pour toute séquence checkout/return,
available ≤ count, aucun id rendu deux fois, aucun buffer perdu. - Couverture 100 % lignes + branches.
9. Décisions de fond émergées au Temps 3b
- Seul le mécanisme ring-mapped — l’op-based classique est abandonné ; l’API est plus simple et sans syscall par lot de buffers.
- Guard RAII
ProvidedBuffer— le réapprovisionnement est automatique à la libération ; on ne peut pas « oublier » de rendre un buffer. - Emprunt
&mutdu groupe pendant le traitement — interdit le désenregistrement sous les pieds d’un buffer en cours de lecture. - Incrémental traité explicitement —
CQE_F_BUF_MOREchange la sémantique de rendu du guard ; documenté et testé, pas laissé implicite. - Adossement
MmapRegion— les buffers fournis du data plane vivent sur un memfd partagé possédé (réutilise le handle de vivacité defamily-mem).
9 bis. Note d’implémentation — écarts kernel 6.12/6.17 (PR #42)
Implémentation : sous-module air-sys-syscall::io_uring::provided (PR #42,
mergée). La surface validée n’a pas été modifiée (le bloc §3 a été aligné sur
la prose : le paramètre bundle: bool, déjà décrit, est désormais visible dans
les signatures). Écarts kernel observés (jamais corrigés en silence — ADR-032) :
- Constantes 6.11/6.12 absentes des headers exécuteurs.
IOU_PBUF_RING_INC(2),IORING_RECVSEND_BUNDLE(1<<4deioprio) etIORING_CQE_F_BUF_MORE(1<<4; déjà porté parCompletionFlags) sont absents de<linux/io_uring.h>installé → valeurs reprises de l’uapi amont, validées au runtime (kernel 6.17). Consigné dansraw.rs(« Écart headers »). - Modèle
&mut groupdu guard.into_provided_buffer(self, &'r mut group)sérialise le traitement (unProvidedBuffervivant à la fois) ⇒available()n’est pas observable pendant un checkout. La décrémentation est vérifiée via le cheminmem::forget. Conforme à la surface, pas une divergence. - Accounting incrémental idempotent. En mode
IOU_PBUF_RING_INC, un même buffer produit plusieurs complétions (BUF_MORE) ;availableest décrémenté une seule fois au premier checkout et ré-incrémenté au rendu final (suivi par buffer,out_flags).
10. Travail à reprendre
Spec suivante : io-uring-3c-linked.md (chaînes d’opérations liées :
soft/hard link, LinkedChainBuilder, intégration du link_timeout du
Temps 2c). Traduction anglaise globale après validation des documents français.
Licence du document : MPL 2.0
Statut : Spécification technique du Temps 3b (buffers fournis ring-mapped) du module air-sys-syscall::io_uring, cible kernel 6.12 LTS.
Spec couche 0 — Module io_uring, Temps 3c : opérations liées (linked)
Spécification technique — Version 1.0 (cible kernel : Linux 6.12 LTS)
Position. Le Temps 3c spécifie les chaînes d’opérations liées : exprimer qu’une opération ne démarre qu’après le succès (ou l’achèvement) de la précédente, sans aller-retour userspace entre les deux. Sous-module
air-sys-syscall::io_uring::linked. Repose sur les drapeaux SQEIOSQE_IO_LINK/IOSQE_IO_HARDLINK(axe D) et intègre l’opLINK_TIMEOUT(15, Temps 2c). Réutilise le cœur du Temps 1. Aucun register opcode.
⚠️ Correction de
io-uring-overview.md. L’ancien overview décrit la sémantique soft/hard link à l’envers. La sémantique correcte (vérifiée contre le man pageio_uring_enter(2)) est fixée en §2 ci-dessous. L’overview sera corrigé lors de sa réconciliation (son rôle d’inventaire est déjà repris par le doc maître).
1. Principe
Plusieurs SQE soumis ensemble peuvent former une chaîne : chaque maillon ne démarre qu’après l’achèvement du précédent. Le marquage se fait par un drapeau sur le maillon prédécesseur :
IOSQE_IO_LINKsur l’op A ⇒ l’op B (SQE suivant) est liée à A.- La queue de chaîne est le premier SQE sans drapeau de lien.
Sans chaîne, des SQE soumis ensemble peuvent s’exécuter dans un ordre
arbitraire et en parallèle. La chaîne impose l’ordre et la dépendance —
indispensable pour, p. ex., socket → connect → send ou openat2 → read → close en une seule soumission.
2. Sémantique soft vs hard (point critique)
Les deux types de lien diffèrent par leur propagation des erreurs, pas par l’ordre (l’ordre est garanti dans les deux cas) :
2.1 Soft link — IOSQE_IO_LINK
Si un maillon se termine en erreur, la chaîne est rompue : tous les maillons restants non démarrés sont annulés et complétés avec
-ECANCELED.
Définition large de l’« erreur » : io_uring considère tout résultat
inattendu comme une erreur rompant la chaîne — y compris un read/recv
court (moins d’octets que demandé). C’est le piège classique : une chaîne
read(N) → process est rompue si le read rend moins de N octets. À documenter
fortement dans la façade.
2.2 Hard link — IOSQE_IO_HARDLINK
Un hard link ne rompt pas la chaîne sur un résultat d’erreur de complétion : les maillons suivants s’exécutent quand même.
Nuances :
- Le hard link implique le soft link (l’ordre reste garanti).
- Il ne protège que des erreurs de complétion : si la soumission du parent échoue, la chaîne casse quand même.
2.3 Tableau
| Ordre garanti | Erreur de complétion d’un maillon | Échec de soumission du parent | |
|---|---|---|---|
soft (IO_LINK) | oui | rompt la chaîne (-ECANCELED sur les suivants) ; short read = erreur | rompt |
hard (IO_HARDLINK) | oui | n’interrompt pas (suivants exécutés) | rompt |
3. API : LinkedChainBuilder
#![allow(unused)]
fn main() {
impl IoUring {
/// Démarre la construction d'une chaîne liée.
pub fn link_chain(&mut self) -> LinkedChainBuilder<'_>;
}
pub struct LinkedChainBuilder<'ring> { /* &mut IoUring en mode staging */ }
impl<'ring> LinkedChainBuilder<'ring> {
/// Premier maillon. `op` appelle un `submit_*` normal, mis en attente
/// (staging) au lieu d'être publié.
pub fn first<F>(self, op: F) -> Result<Self, Errno>
where F: FnOnce(&mut IoUring) -> Result<SubmissionToken, Errno>;
/// Maillon **soft-lié** au précédent (pose `IO_LINK` sur le précédent).
pub fn then<F>(self, op: F) -> Result<Self, Errno>
where F: FnOnce(&mut IoUring) -> Result<SubmissionToken, Errno>;
/// Maillon **hard-lié** au précédent (pose `IO_HARDLINK` sur le précédent).
pub fn then_hard<F>(self, op: F) -> Result<Self, Errno>
where F: FnOnce(&mut IoUring) -> Result<SubmissionToken, Errno>;
/// Borne dans le temps le **maillon précédent** via un `LINK_TIMEOUT`
/// (op 15). Doit suivre immédiatement l'op à borner.
pub fn with_link_timeout(self, spec: TimeoutSpec, flags: TimeoutFlags)
-> Result<Self, Errno>;
/// Publie la chaîne **entière, atomiquement**. Rend les jetons dans l'ordre.
pub fn submit(self) -> Result<ChainTokens, Errno>;
}
/// Jetons des maillons d'une chaîne, dans l'ordre de soumission.
pub struct ChainTokens { /* Vec<SubmissionToken> */ }
}
3.1 Mode staging (réutilise les 55 submit_* sans duplication)
Pendant la durée du builder, le &mut IoUring est en mode staging : un
submit_* appelé dans la closure réserve un slot S1 et écrit un SQE (avec le
drapeau de lien adéquat sur le prédécesseur), sans publier la queue. La
publication n’a lieu qu’au submit() final, qui avance la queue SQ une seule
fois (§3.3). On évite ainsi de dupliquer 55 méthodes prepare_* : la même
surface submit_* sert en mode direct et en mode chaîne (cohérent avec
Principe 7 — l’usage est explicitement délimité par link_chain()).
3.2 Réservation atomique des slots
submit() ne publie que si tous les slots de la chaîne ont pu être réservés
(S1). Si le slab ne peut pas contenir toute la chaîne, le builder retourne
Err(EBUSY) avant d’écrire quoi que ce soit — pas de chaîne partielle.
3.3 Publication
Tous les SQE de la chaîne sont écrits puis la queue SQ est avancée par un seul store release (§3.2 du Temps 1) → le kernel voit la chaîne complète d’un coup, garantissant le respect des liens.
3.4 Exemple
#![allow(unused)]
fn main() {
// socket → connect → send, en une soumission liée.
let tokens = ring.link_chain()
.first(|r| r.submit_socket(SocketDomain::Inet, SocketType::Stream, 0))?
.then(|r| r.submit_connect(sock, addr))? // démarre après le socket
.then(|r| r.submit_send(sock, buf, MessageFlags::empty()))?
.submit()?;
}
4. Complétions d’une chaîne
- Une complétion par maillon, dans l’ordre. Chaque maillon a son slot S1 et restitue son buffer normalement.
- Maillon annulé (soft link rompu) : complétion avec
res == -ECANCELED; slot/buffer restitués. - Avec
IOSQE_CQE_SKIP_SUCCESS(Temps 1,SubmitOptions) sur les maillons intermédiaires, on peut n’attendre que la complétion finale (les succès intermédiaires ne postent pas de CQE) — utile pour une chaîne dont seul le résultat final importe. ChainTokenspermet de corréler chaque complétion à son maillon.
5. link_timeout (intégration de l’op 15)
with_link_timeout(spec, flags) insère un LINK_TIMEOUT qui borne le maillon
précédent :
- si le timeout expire avant la fin du maillon ⇒ le maillon est annulé
(
-ECANCELED) et la chaîne est rompue (le timeout, lui, complète) ; - si le maillon finit avant ⇒ le timeout est annulé automatiquement.
C’est l’unique manière sûre d’émettre un LINK_TIMEOUT dans Air (l’émettre en
op isolée est refusé, Temps 2c §3.3). Exemple : borner un connect à 2 s.
#![allow(unused)]
fn main() {
let tokens = ring.link_chain()
.first(|r| r.submit_connect(sock, addr))?
.with_link_timeout(TimeoutSpec::after(Duration::from_secs(2)), TimeoutFlags::empty())?
.submit()?;
}
6. Types ajoutés / partagés
Nouveaux : LinkedChainBuilder<'ring>, ChainTokens. Réutilise :
SubmissionToken, SubmitOptions (drain, skip-cqe), TimeoutSpec,
TimeoutFlags (Temps 2c). Aucun nouveau type kernel.
7. Stratégie de tests
- Intégration : chaîne
socket→connect→sendréussie (ordre observé) ; chaîne soft avec erreur au milieu ⇒ maillons suivants-ECANCELED; short read rompant une chaîne soft (test explicite du piège) ; chaîne hard où une erreur n’interrompt pas ;link_timeoutexpirant (maillon annulé) et non expirant (timeout annulé) ;skip_cqe_on_successsur intermédiaires (seule la complétion finale arrive). - Property-based : pour toute chaîne, ordre des complétions = ordre de soumission ; nombre de CQE cohérent avec les skip ; aucun slot/buffer perdu même en cas de rupture.
- Atomicité : slab insuffisant ⇒
EBUSYsans publication partielle (aucun SQE publié). - Sûreté : Miri sur la restitution des buffers des maillons annulés.
- Couverture 100 % lignes + branches.
8. Décisions de fond émergées au Temps 3c
- Sémantique correcte gelée — soft = rompt sur erreur (short read inclus) ; hard = ne rompt pas sur erreur de complétion. (Corrige l’overview.)
- Mode staging sur
submit_*— pas de duplicationprepare_*; usage délimité explicitement parlink_chain(). - Réservation atomique des slots + publication unique — jamais de chaîne
partielle ;
EBUSYen amont si le slab ne suffit pas. link_timeoutexclusivement via le builder — émission isolée refusée (cohérent Temps 2c).skip_cqe_on_successpour les intermédiaires — n’attendre que le résultat final quand c’est le seul utile.
9. Travail à reprendre
Spec suivante : io-uring-3d-multishot.md (opérations multishot : accept/recv/
poll/read multishot, jeton MultishotToken, cycle de vie CQE_F_MORE,
interaction avec les buffers fournis du Temps 3b). Traduction anglaise globale
après validation des documents français.
Licence du document : MPL 2.0
Statut : Spécification technique du Temps 3c (opérations liées) du module air-sys-syscall::io_uring, cible kernel 6.12 LTS.
Spec couche 0 — Module io_uring, Temps 3d : opérations multishot
Spécification technique — Version 1.0 (cible kernel : Linux 6.12 LTS)
Position. Le Temps 3d spécifie les opérations multishot : une unique soumission produit plusieurs complétions. Sous-module
air-sys-syscall::io_uring::multishot. Repose sur le drapeau de complétionCQE_F_MORE(axe E) et sur les buffers fournis du Temps 3b (pour recv/read) et les descripteurs directs du Temps 3a (pour accept). Réutilise le cœur du Temps 1. Aucun register opcode, un opcode dédié (READ_MULTISHOT, 49) et des drapeaux d’op (ACCEPT_MULTISHOT,RECV_MULTISHOT,POLL_ADD_MULTI,TIMEOUT_MULTISHOT).
1. Principe et cycle de vie
Une opération mono-coup soumet un SQE, reçoit un CQE, libère son slot. Une opération multishot soumet un SQE et reçoit un flux de CQE :
- chaque complétion intermédiaire porte
CQE_F_MORE: « d’autres complétions suivront pour ce même SQE » ; - la complétion finale (terminaison) n’a pas
CQE_F_MORE: le multishot est terminé (erreur, pénurie de buffers, ou annulation).
Interaction avec le slab S1. Le slot reste vivant tant que les
complétions portent CQE_F_MORE ; il n’est libéré qu’à la complétion sans
F_MORE. C’est le second cas (après le NOTIF zero-copy du Temps 2b) où un
slot survit à sa première complétion — la génération du SubmissionToken
protège contre les CQE tardifs après annulation (Temps 1 §4.2).
Jeton. Une opération multishot rend un MultishotToken (distinct du
SubmissionToken mono-coup). Toutes ses complétions le portent :
#![allow(unused)]
fn main() {
impl Completion {
/// Jeton multishot si cette complétion provient d'un multishot.
pub fn multishot_token(&self) -> Option<MultishotToken>;
// has_more() (Temps 1) = CQE_F_MORE : true tant que le flux continue.
}
}
Réarmement. Quand un multishot se termine (has_more() == false),
l’application resoumet si elle veut continuer. La façade rend la terminaison
explicite ; pas de réarmement caché.
2. Accept multishot
#![allow(unused)]
fn main() {
impl IoUring {
pub fn submit_accept_multishot(&mut self, listener: BorrowedFd<'_>, flags: AcceptFlags)
-> Result<MultishotToken, Errno>;
/// Variante descripteurs directs : chaque connexion atterrit dans un slot
/// auto-alloué de la FixedFdTable (Temps 3a).
pub fn submit_accept_multishot_direct(&mut self, listener: BorrowedFd<'_>, flags: AcceptFlags)
-> Result<MultishotToken, Errno>;
}
}
- Op :
ACCEPT(13) +IORING_ACCEPT_MULTISHOT. Un seul SQE accepte en continu : chaque connexion entrante produit une complétion portant le FD accepté (accepted_fd()),CQE_F_MOREmaintenu. - Variante directe : chaque connexion va dans un slot direct (auto-alloc,
FixedSlotTarget::Alloc) — idéal pour un serveur à très haut taux de connexions (compositeur, AirCom) : pas de FD ordinaire, pas de table userspace à gérer. Lien Temps 3a. - Terminaison : sur erreur (p. ex. listener fermé) ⇒ complétion sans
F_MORE. Cas d’usage : un serveur fait une soumission au démarrage et consomme les connexions au fil de l’eau.
3. Recv / Read multishot (avec buffers fournis)
#![allow(unused)]
fn main() {
impl IoUring {
pub fn submit_receive_multishot(&mut self, sock: BorrowedFd<'_>, group: &ProvidedBufferRing, flags: MessageFlags)
-> Result<MultishotToken, Errno>;
pub fn submit_read_multishot(&mut self, fd: BorrowedFd<'_>, group: &ProvidedBufferRing, offset: Option<u64>)
-> Result<MultishotToken, Errno>;
}
}
- Ops :
RECV(27) +IORING_RECV_MULTISHOT;READ_MULTISHOT(49, opcode dédié). Exigent des buffers fournis (Temps 3b,IOSQE_BUFFER_SELECT) : chaque arrivée de données prend un buffer du groupe et produit une complétion. - Complétion :
into_provided_buffer(group)rend le buffer choisi (id + octets), guard RAII qui réapprovisionne à la libération (Temps 3b §4). La consommation incrémentale (CQE_F_BUF_MORE, Temps 3b §5) se combine au multishot. - Pénurie de buffers : si le groupe est vide à l’arrivée de données, la
complétion porte
-ENOBUFSet termine le multishot (pas deF_MORE). L’application réapprovisionne le groupe puis resoumet. La façade signale ce cas distinctement (terminaison pour pénurie vs erreur réseau). - Intérêt majeur AirCom / serveurs : un seul SQE par connexion sert tout son flux entrant, sans buffer pré-engagé par connexion oisive.
4. Poll multishot
#![allow(unused)]
fn main() {
impl IoUring {
pub fn submit_poll_multishot(&mut self, fd: BorrowedFd<'_>, events: PollEvents)
-> Result<MultishotToken, Errno>;
}
}
- Op :
POLL_ADD(6) +IORING_POLL_ADD_MULTI. Chaque transition d’état du FD vers leseventssurveillés produit une complétion (into_poll_result() -> PollEvents),F_MOREmaintenu. - Level-triggered :
IORING_POLL_ADD_LEVELexposé via une option (PollEvents+ niveau). À défaut, edge-triggered. - Annulation :
submit_poll_remove/cancel_multishot.
5. Timeout multishot
#![allow(unused)]
fn main() {
impl IoUring {
pub fn submit_timeout_multishot(&mut self, interval: Duration, flags: TimeoutFlags)
-> Result<MultishotToken, Errno>;
}
}
- Op :
TIMEOUT(11) +IORING_TIMEOUT_MULTISHOT. Émet une complétion à intervalle régulier (timer répétitif) jusqu’à annulation — utile pour un battement périodique dans le reactor sans resoumettre à chaque tick. - Terminaison :
cancel_multishotou erreur.
6. Annulation
#![allow(unused)]
fn main() {
impl IoUring {
pub fn cancel_multishot(&mut self, token: MultishotToken) -> Result<(), Errno>;
}
}
- Annule un multishot en vol (via
ASYNC_CANCEL, Temps 2c, ciblant le jeton). La complétion terminale (sansF_MORE, souvent-ECANCELED) libère le slot ; les CQE tardifs éventuels sont filtrés par la génération (S1). submit_cancel(CancelTarget::…)(Temps 2c) reste utilisable pour des annulations groupées (par FD, par op,Any).
7. Récapitulatif
| Op / drapeau | Façade | Complétions |
|---|---|---|
ACCEPT + ACCEPT_MULTISHOT | submit_accept_multishot[_direct] | flux de FD acceptés |
RECV + RECV_MULTISHOT | submit_receive_multishot | flux de buffers fournis |
READ_MULTISHOT (49) | submit_read_multishot | flux de buffers fournis |
POLL_ADD + POLL_ADD_MULTI | submit_poll_multishot | flux d’événements |
TIMEOUT + TIMEOUT_MULTISHOT | submit_timeout_multishot | flux de ticks |
ASYNC_CANCEL | cancel_multishot | terminaison |
Tous partagent : MultishotToken, le maintien CQE_F_MORE, la libération du
slot à la complétion terminale.
8. Types ajoutés / partagés
Nouveau : MultishotToken (déjà déclaré au Temps 1, sémantique fixée ici).
Réutilise : AcceptFlags, MessageFlags, PollEvents, TimeoutFlags,
ProvidedBufferRing/ProvidedBuffer (Temps 3b), FixedSlotTarget (Temps 3a).
Méthode Completion : multishot_token.
9. Stratégie de tests
- Intégration :
accept_multishotsur un listener, N connexions ⇒ N complétions avecF_MORE, fermeture du listener ⇒ complétion terminale ; variante directe (FD dans slots) ;recv_multishot+ groupe de buffers, flux de datagrammes, pénurie ⇒-ENOBUFSterminant le multishot, réapprovisionne + resoumet ;poll_multishotsur un pipe écrit plusieurs fois ;timeout_multishot(N ticks puis cancel) ;cancel_multishot(terminaison, CQE tardif filtré). - Cycle de vie S1 : le slot reste occupé tant que
F_MORE, libéré à la terminale ; génération filtre les CQE post-annulation (property-based). - Sûreté : Miri sur la restitution du slot multishot ; pas de double libération ; buffers fournis correctement rendus en multishot.
- Couverture 100 % lignes + branches.
10. Décisions de fond émergées au Temps 3d
MultishotTokendistinct — un flux de complétions, pas une op mono-coup ; le type empêche de confondre les deux cycles de vie.- Slot vivant jusqu’à la terminale (
!F_MORE) — extension assumée de S1 (comme le NOTIF zero-copy) ; génération anti-CQE-tardif. - Terminaison explicite, réarmement explicite — pas de re-soumission cachée
(Principe 7) ;
-ENOBUFSsignalé distinctement de l’erreur réseau. accept_multishot_directprivilégié pour les serveurs à fort taux de connexions — connexions en descripteurs directs (lien Temps 3a), pertinent AirCom/compositeur.- recv/read multishot adossés aux buffers fournis — pas de buffer pré-engagé par connexion (lien Temps 3b).
10 bis. Note d’implémentation (PR #46)
Implémentation : sous-module air-sys-syscall::io_uring::multishot (PR #46,
mergée). La surface validée ci-dessus n’a pas été modifiée ; deux choix
d’implémentation sont consignés ici (jamais corrigés en silence — ADR-032) :
cancel_multishotsynchrone. Implémenté viaIORING_REGISTER_SYNC_CANCEL(CancelTarget::Token, annulation synchrone par jeton) plutôt que l’opASYNC_CANCEL(§6) : cohérent avec le retourResult<()>— pas de CQE d’annulation supplémentaire à drainer ; la complétion terminale (-ECANCELED, sansF_MORE) libère le slot. Effet identique, surface inchangée.submit_cancel(ASYNC_CANCEL, Temps 2c) reste disponible pour les annulations groupées (par FD, par op,Any).poll_multishotedge-triggered seul.IORING_POLL_ADD_LEVEL(1<<3) n’est pas exposé : la signature validéesubmit_poll_multishot(fd, events)ne porte pas d’option de niveau. Décision BDFL : conserver l’edge-triggered ; le level-triggered sera reconsidéré par RFC si un consommateur le requiert.
11. Travail à reprendre
Spec suivante : io-uring-3e-shared.md (usage multi-thread : LockedIoUring,
RingPool thread-per-core, SqpollIoUring). Traduction anglaise globale après
validation des documents français.
Licence du document : MPL 2.0
Statut : Spécification technique du Temps 3d (multishot) du module air-sys-syscall::io_uring, cible kernel 6.12 LTS.
Spec couche 0 — Module io_uring, Temps 3e : usage multi-thread (shared)
Spécification technique — Version 1.0 (cible kernel : Linux 6.12 LTS)
Position. Le Temps 3e spécifie les modèles multi-thread du ring. Sous-module
air-sys-syscall::io_uring::shared. Repose sur l’invariant d’ADR-022 Décision 6 (IoUringestSendmais pasSync), sur le flagIORING_SETUP_SQPOLL(Temps 1),ATTACH_WQ(Temps 3a) etmsg_ring(Temps 2c). Réutilise le cœur du Temps 1. Aucun register opcode propre.
1. Le point de départ : Send mais pas Sync
IoUring peut être déplacé entre threads (Send) mais pas partagé par
référence (!Sync). Raison : le protocole d’ordering du Temps 1 (§3.2)
synchronise userspace ↔ kernel, pas userspace ↔ userspace. Les
têtes/queues SQ/CQ et le slab S1 ne sont pas protégés contre des accès
userspace concurrents. Trois réponses, par ordre de préférence Air.
2. Modèle recommandé : thread-per-core (un ring par thread)
C’est le modèle par défaut d’Air. Chaque thread (idéalement épinglé à un cœur) possède son propre
IoUring, créé avecSINGLE_ISSUER | DEFER_TASKRUN(Temps 1 §5.1). Aucun partage sur le chemin chaud, donc aucun verrou, latence basse et prévisible.
SINGLE_ISSUERinforme le kernel qu’un seul thread soumet → optimisations internes.DEFER_TASKRUNrepousse le task-work au moment de l’attente → moins d’interruptions.- Communication entre rings : on ne partage pas le ring ; on passe des
messages via
msg_ring(Temps 2c,MSG_DATApour réveiller un pair,MSG_SEND_FDpour lui transférer un descripteur). C’est le pendant, à l’échelle des reactors, du modèle peer-to-peer d’AirCom. - Sûreté : triviale — pas d’accès concurrent à un même ring. Le
!Syncd’IoUringinterdit par typage le partage accidentel.
Ce modèle n’introduit aucun type nouveau : c’est l’usage direct d’IoUring,
un par thread. Les sections 3–5 couvrent les cas où il ne suffit pas.
3. LockedIoUring — partage simple par verrou
#![allow(unused)]
fn main() {
pub struct LockedIoUring { /* IoUring + verrou interne */ }
impl LockedIoUring {
pub fn new(entries: NonZeroU32) -> Result<Self, Errno>;
pub fn from_builder(builder: IoUringBuilder) -> Result<Self, Errno>;
// API miroir d'IoUring, mais en &self (verrou interne) :
pub fn submit_read(&self, fd: BorrowedFd<'_>, buffer: Vec<u8>, offset: Option<u64>)
-> Result<SubmissionToken, Errno>;
pub fn submit_and_wait(&self, want: u32) -> Result<u32, Errno>;
pub fn wait_completion(&self) -> Result<Completion, Errno>;
// … (l'ensemble des submit_*/complétions, en &self) …
}
// LockedIoUring : Send + Sync.
}
- Un seul ring partagé entre threads, protégé par un verrou interne
(sérialise les accès userspace au SQ/CQ/slab). Devient
Sync. - Avertissement : le verrou est un point de contention — sous forte charge multi-thread, il sérialise tout. À réserver aux cas simples ou peu sollicités. Pour la perf, préférer le thread-per-core (§2).
- Granularité : verrou unique au départ (clarté, Principe 7) ; un affinage (verrous séparés soumission/complétion) n’est envisagé qu’après mesure (Principe 5), jamais a priori.
4. RingPool — assistant thread-per-core
#![allow(unused)]
fn main() {
pub struct RingPool { /* N rings, io-wq partagé, ring fds enregistrés */ }
impl RingPool {
/// Crée `workers` rings : le premier crée le pool io-wq, les suivants s'y
/// attachent (ATTACH_WQ) → nombre total de threads workers borné.
pub fn new(workers: NonZeroUsize, entries: NonZeroU32) -> Result<Self, Errno>;
/// Distribue les rings : un `IoUring` possédé par worker (à déplacer dans
/// chaque thread). Les ring fds sont mutuellement enregistrés pour msg_ring.
pub fn into_rings(self) -> Vec<IoUring>;
/// Poignée de routage vers un ring pair (pour msg_ring).
pub fn handle(&self, worker: usize) -> Option<RingHandle>;
}
/// Référence à un ring pair, utilisable comme cible de msg_ring (Temps 2c).
pub struct RingHandle { /* ring fd enregistré du pair */ }
}
- Rôle : créer N rings cohérents pour le modèle §2, en :
- partageant le pool io-wq (
ATTACH_WQ, Temps 3a) ⇒ le nombre total de threads workers kernel est borné quel que soit N — crucial sur matériel modeste (Pi 4, Charte principe 4) ; - enregistrant les ring fds mutuellement (Temps 3a) ⇒
msg_ringpeut router vers un pair de façon découplée (réveil, transfert de FD).
- partageant le pool io-wq (
- Chaque
IoUringrendu resteSend/!Sync: il est déplacé dans son thread, jamais partagé.RingPoolne crée pas de partage, il organise.
5. SqpollIoUring — thread kernel de poll de la SQ
#![allow(unused)]
fn main() {
pub struct SqpollIoUring { /* IoUring avec SETUP_SQPOLL */ }
impl SqpollIoUring {
/// `idle` : délai d'inactivité avant que le thread kernel se mette en
/// veille (NEED_WAKEUP). `cpu` : épinglage optionnel du thread (SQ_AFF).
pub fn new(entries: NonZeroU32, idle: Duration, cpu: Option<u32>)
-> Result<Self, Errno>;
// submit_* habituels ; la soumission peut ne nécessiter AUCUN syscall.
}
}
- Avec
IORING_SETUP_SQPOLL, un thread kernel scrute la SQ : en régime établi, la soumission ne fait aucunio_uring_enter(juste la publication release). Latence minimale. - Réveil : si le thread s’est endormi (après
idle), le drapeauIORING_SQ_NEED_WAKEUPest positionné ; la façade le détecte et émetio_uring_enter(.., SQ_WAKEUP)automatiquement à la soumission (c’est le seul cas de réveil géré pour le compte de l’appelant — documenté). SQ_AFF: épinglage du thread kernel sur un cœur (cpu).- Coût : un cœur kernel dédié à scruter. Excellent pour la latence sous forte charge, mauvais choix sur Pi 4 où les cœurs sont rares. À réserver aux profils où la latence prime et le CPU abonde (mesure, Principe 5).
SQPOLL_NONFIXED(FEAT_SQPOLL_NONFIXED, présent en 6.12) lève l’ancienne contrainte d’usage exclusif de FD enregistrés.
6. Matrice Send / Sync
| Type | Send | Sync | Modèle |
|---|---|---|---|
IoUring | oui | non | un ring par thread (§2) — recommandé |
LockedIoUring | oui | oui | ring partagé, verrou (§3) — simple, contention |
RingPool | oui | non (distribue) | organise N rings thread-per-core (§4) |
SqpollIoUring | oui | non | poll kernel de la SQ (§5) — latence vs CPU |
7. Sûreté
- L’invariant
!Syncd’IoUringempêche par typage le partage d’un ring non protégé entre threads — pas de course possible sur le SQ/CQ/slab. LockedIoUringajoute la synchronisation userspace↔userspace manquante ; le protocole d’ordering userspace↔kernel du Temps 1 reste inchangé en dessous.RingPool/thread-per-core : pas de partage ⇒ pas de synchronisation requise ; les seuls échanges passent parmsg_ring(le kernel sérialise l’écriture dans la CQ cible).- Le téardown S2 s’applique par ring (chaque ring quiesce le sien).
8. Types ajoutés / partagés
Nouveaux : LockedIoUring, RingPool, RingHandle, SqpollIoUring. Réutilise :
IoUring, IoUringBuilder, SetupFlags (SQPOLL/SQ_AFF/SINGLE_ISSUER/
DEFER_TASKRUN/ATTACH_WQ), msg_ring (Temps 2c), ring fd enregistré (Temps 3a).
9. Stratégie de tests
- Thread-per-core : N threads, un ring chacun, charge indépendante ; hand-off
via
msg_ring(un thread réveille/transfère vers un pair, vérifié). - LockedIoUring : accès concurrent de plusieurs threads, cohérence des complétions ; loom sur le verrou + le protocole d’anneau (pas de course, pas d’interblocage).
- RingPool :
ATTACH_WQborne bien le nombre de threads workers (observé via/procou compteur) ; routage inter-ring correct. - SqpollIoUring : soumission sans syscall en régime établi ; réveil correct
après
idle(NEED_WAKEUP) ; épinglageSQ_AFF. - Sûreté : compile-fail confirmant qu’
IoUringne traverse pas un&entre threads (!Sync) ; Miri/loom pourLockedIoUring. - Couverture 100 % lignes + branches.
10. Décisions de fond émergées au Temps 3e
- Thread-per-core comme modèle par défaut — un ring par thread,
SINGLE_ISSUER + DEFER_TASKRUN, pas de verrou ; communication parmsg_ring. Aligne le runtime async (ADR-023) sur le modèle peer-to-peer d’AirCom. !Syncassumé surIoUring— la sûreté multi-thread est un choix explicite de l’appelant (LockedIoUring ou RingPool), pas un coût imposé à tous.RingPoolorganise, ne partage pas —ATTACH_WQborne les threads workers (Pi 4) ; les rings restent possédés par thread.SqpollIoUringréservé — gain de latence réel mais coût d’un cœur ; déconseillé sur matériel modeste. Réveil NEED_WAKEUP géré pour l’appelant (seule exception au « pas de magie cachée », car indispensable et documentée).- Verrou unique d’abord dans
LockedIoUring— affinage seulement après mesure (Principe 5).
10 bis. Note d’implémentation (PR #49)
Implémentation : sous-module air-sys-syscall::io_uring::shared (PR #49,
mergée). La surface validée ci-dessus n’a pas été modifiée ; trois choix
d’implémentation sont consignés ici (jamais corrigés en silence — ADR-032) :
unsafe impl Send for IoUringréalisé ici. L’invariant ADR-022 D6 (Send + !Sync) était documenté depuis le Temps 1 mais jamais implémenté : les pointeurs bruts des anneaux mmappés rendaientIoUringauto-!Send. Leunsafe impl Sendest posé dans ce Temps, justifié// SAFETY:— mmaps et FD du ring sont process-global (valides quel que soit le thread), le slab et les charges utiles sontSend;Sendexprime un transfert d’ownership exclusif (pas un partage), et le!Syncest préservé (unPhantomData<Cell<…>>interdit&IoUringpartagé). Aucune surface publique ajoutée — c’est une impl de trait auto. C’est l’invariant habilitant de tout le Temps 3e (sans lui, niLockedIoUringni le déplacement thread-per-core).LockedIoUring: primitivewith_lock+ raccourcis nommés. Plutôt que recopier ~50 forwarders&self(un parsubmit_*/accesseur d’IoUring), une primitive uniquewith_lock(|ring| …)couvre toute opération sous le verrou ; seuls les cas fréquents reçoivent un raccourci nommé (Principe 7, verbosité au service de la clarté sans duplication). Verrou insensible à l’empoisonnement (PoisonError::into_inner), cohérent avec la future synchro sans poisoning d’air-thread.submit_message_ring_data_to(&RingHandle)ajouté. Lemsg_ringdu Temps 2c prend une&IoUring(même contexte) — inadapté au routage cross-thread aprèsinto_rings(les rings sont déplacés dans leurs threads). Cette variante route par FD de ring (porté parRingHandle, capturé avant distribution), process-global donc valable entre threads. Le 2c reste inchangé.
Couverture (après PR #49 + complément
046) :shared.rsbranches 100 %, « couvrable vide » (résidus = fallbackSQPOLL-EPERMdes tests,PRIVILEGE; timeout du thread pair,TEST-HARNESS). Soundness prouvée :!Syncpar doctestcompile_fail, discipline de verrou par loom.
11. Travail à reprendre
Spec suivante : io-uring-3f-sandbox.md (confinement : R_DISABLED +
REGISTER_RESTRICTIONS + ENABLE_RINGS, lien capabilities ADR-010/AirCom).
Traduction anglaise globale après validation des documents français.
Licence du document : MPL 2.0
Statut : Spécification technique du Temps 3e (multi-thread) du module air-sys-syscall::io_uring, cible kernel 6.12 LTS.
Spec couche 0 — Module io_uring, Temps 3f : confinement (sandbox)
Spécification technique — Version 1.0 (cible kernel : Linux 6.12 LTS)
Position. Le Temps 3f rend opérationnelle la décision de soundness S3 du document maître : confiner un ring pour qu’il ne puisse émettre que des opérations explicitement autorisées. Sous-module
air-sys-syscall::io_uring::sandbox. Repose sur le flagIORING_SETUP_R_DISABLED(Temps 1), et les register opcodesREGISTER_RESTRICTIONS(11) etENABLE_RINGS(12). C’est la brique io_uring du modèle de capabilities d’Air (ADR-001 AirCom, ADR-010 entitlements signés), en défense en profondeur avecfamily-security(seccomp/Landlock).
1. Pourquoi confiner un ring ?
io_uring exécute des opérations (read, openat2, connect…) qui, soumises
par le ring, ne passent pas par l’interface syscall classique. Un filtre
seccomp qui bloque le syscall openat2 n’empêche pas un IORING_OP_OPENAT2
soumis via le ring. Historiquement, c’est une voie de contournement des bacs à
sable. La réponse propre : restreindre le ring lui-même.
Un ring confiné ne peut soumettre que les opcodes, register-ops et drapeaux SQE explicitement mis en liste blanche, et le kernel l’impose. Même un service compromis ne peut pas élargir ce que son ring autorise.
C’est la matérialisation, au niveau io_uring, des entitlements déclarés dans
le manifeste signé d’un .airservice/.airapp (ADR-010), et le pendant
kernel des capabilities AirCom (ADR-001).
2. Le mécanisme en trois temps
- Créer désactivé : le ring est créé avec
IORING_SETUP_R_DISABLED— il existe mais ne peut rien soumettre. - Restreindre :
REGISTER_RESTRICTIONSapplique une liste de restrictions (liste blanche). Possible uniquement tant que le ring est désactivé. - Activer :
REGISTER_ENABLE_RINGSactive le ring. Après activation, les restrictions sont immuables — on ne peut plus les assouplir.
L’ordre est imposé par le kernel et reflété par l’API : on ne peut pas restreindre un ring déjà actif, ni soumettre avant activation.
3. API
3.1 RestrictionSet
#![allow(unused)]
fn main() {
/// Liste blanche de ce qu'un ring confiné pourra faire. Default-deny : ce qui
/// n'est pas explicitement autorisé est refusé par le kernel.
#[derive(Debug, Clone, Default)]
pub struct RestrictionSet { /* ... */ }
impl RestrictionSet {
pub fn new() -> Self;
/// Autorise un opcode de soumission (RESTRICTION_SQE_OP).
pub fn allow_op(self, op: IoUringOpcode) -> Self;
/// Autorise un register opcode (RESTRICTION_REGISTER_OP).
pub fn allow_register(self, op: RegisterOp) -> Self;
/// Drapeaux SQE autorisés (RESTRICTION_SQE_FLAGS_ALLOWED).
pub fn allow_sqe_flags(self, flags: SqeFlagSet) -> Self;
/// Drapeaux SQE requis sur CHAQUE soumission (RESTRICTION_SQE_FLAGS_REQUIRED).
pub fn require_sqe_flags(self, flags: SqeFlagSet) -> Self;
/// Conversion depuis un jeu d'entitlements (couche supérieure).
/// Fournie comme point d'intégration ; la politique vit en couche 5.
pub fn from_entitlements(/* … */) -> Self;
}
}
3.2 Intégration au builder (Temps 1)
#![allow(unused)]
fn main() {
// IoUringBuilder::restrict(&[Restriction]) → R_DISABLED + REGISTER_RESTRICTIONS.
let mut ring = IoUringBuilder::new(entries)
.restrict(restrictions.as_slice()) // ring créé désactivé
.build()?;
ring.enable()?; // REGISTER_ENABLE_RINGS ; immuable ensuite
// À partir d'ici, toute soumission hors liste blanche échoue (-EACCES/-EINVAL).
}
build()avecrestrictnon vide laisse le ring désactivé ;enable()(Temps 1 §5.2) l’active. Toute tentative desubmit_*avantenable()⇒ erreur. Toute tentative de re-restreindre aprèsenable()⇒ erreur.- Sémantique default-deny : si
RestrictionSetcontient au moins unallow_op, seuls les opcodes listés sont permis (le reste est refusé par le kernel). Idem pour les register-ops.
4. Les quatre types de restriction
Type (io_uring_register_restriction_op) | RestrictionSet | Effet |
|---|---|---|
RESTRICTION_SQE_OP (1) | allow_op | liste blanche des opcodes soumissibles |
RESTRICTION_REGISTER_OP (0) | allow_register | liste blanche des register-ops |
RESTRICTION_SQE_FLAGS_ALLOWED (2) | allow_sqe_flags | drapeaux SQE autorisés |
RESTRICTION_SQE_FLAGS_REQUIRED (3) | require_sqe_flags | drapeaux imposés à chaque SQE |
Exemple — un service AirCom « réseau seul » :
#![allow(unused)]
fn main() {
let r = RestrictionSet::new()
.allow_op(IoUringOpcode::Socket)
.allow_op(IoUringOpcode::Connect)
.allow_op(IoUringOpcode::Send)
.allow_op(IoUringOpcode::Receive)
.allow_op(IoUringOpcode::SendZeroCopy)
.allow_op(IoUringOpcode::Close);
// Le ring ne pourra jamais émettre openat2, unlinkat, etc. — garanti kernel.
}
5. Lien avec le modèle de sécurité d’Air
- ADR-010 (entitlements signés) : le manifeste d’un
.airservicedéclare ses entitlements ; un composant de couche 5 (air-launchd/air-trust) traduit ces entitlements enRestrictionSetet fournit au service un ring déjà confiné. La couche 0 fournit le mécanisme (RestrictionSet,restrict,enable) ; la politique vit en couche supérieure — séparation nette. - ADR-001 (AirCom) : un service ne reçoit que les capabilities (FD) de son manifeste, et son ring ne peut émettre que les opérations correspondantes — double verrou (FD non falsifiables + ring restreint).
family-security(seccomp/Landlock) : les restrictions io_uring complètent seccomp/Landlock, elles ne les remplacent pas. seccomp filtre les syscalls, Landlock les accès FS, les restrictions io_uring les opérations du ring — défense en profondeur. C’est précisément ce qui ferme la voie de contournement évoquée en §1.- Personality (Temps 3a) : combinée aux restrictions, permet d’exécuter des ops sous une identité contrôlée — moindre privilège.
Note hors périmètre. Un filtrage encore plus fin par programme BPF (
IORING_REGISTER_BPF_FILTER) est postérieur à 6.12 ; il pourra enrichir ce confinement plus tard, par détection runtime, sans amendement.
6. Récapitulatif
| Élément | Façade |
|---|---|
SETUP_R_DISABLED | IoUringBuilder::restrict (crée désactivé) |
REGISTER_RESTRICTIONS (11) | IoUringBuilder::restrict (applique la liste) |
REGISTER_ENABLE_RINGS (12) | IoUring::enable |
4 restriction_op | RestrictionSet::{allow_op, allow_register, allow_sqe_flags, require_sqe_flags} |
7. Types ajoutés / partagés
Nouveaux : RestrictionSet, RegisterOp (énumération des register-ops
autorisables), SqeFlagSet. Réutilise : Restriction et IoUringOpcode (déclarés
au Temps 1), IoUringBuilder::restrict, IoUring::enable. Point d’intégration
from_entitlements (type d’entitlements défini en couche supérieure).
8. Stratégie de tests
- Intégration : créer un ring
R_DISABLED, appliquer une liste blanche,enable(), vérifier qu’un opcode autorisé passe et qu’un opcode non autorisé échoue (-EACCES/-EINVAL) ; idem register-ops ;require_sqe_flagsimposé (une soumission sans le drapeau échoue) ; soumission avantenablerefusée ; re-restriction aprèsenablerefusée (immuabilité). - Sécurité : test démontrant qu’un
openat2soumis via un ring « réseau seul » est refusé même si le process a le droit d’ouvrir le fichier (preuve de la défense en profondeur vs seccomp). - Property-based : pour tout sous-ensemble d’opcodes autorisés, exactement ceux-là passent.
- Couverture 100 % lignes + branches.
9. Décisions de fond émergées au Temps 3f
- Confinement = mécanisme couche 0, politique couche 5 —
RestrictionSetet le fluxrestrict → enablesont neutres ; la traduction des entitlements signés (ADR-010) vit en couche supérieure. - Default-deny — dès qu’une liste blanche d’opcodes est posée, tout le reste est refusé par le kernel ; pas de demi-mesure.
- Immuabilité après
enable— garantie par le kernel et reflétée par l’API ; impossible d’assouplir un ring confiné. - Défense en profondeur explicite — les restrictions io_uring complètent seccomp/Landlock et ferment la voie de contournement du filtre syscall.
- BPF filtering noté mais hors périmètre (post-6.12).
10. Travail à reprendre
Spec suivante et dernière du module : io-uring-4-raw.md (accès niveau 1 au
protocole d’anneau : RawSubmissionQueueEntry/RawCompletionQueueEntry, manipulation directe des SQE/CQE,
opérations non encore wrappées, # Safety extensif). Traduction anglaise globale
après validation des documents français.
Licence du document : MPL 2.0
Statut : Spécification technique du Temps 3f (confinement) du module air-sys-syscall::io_uring, cible kernel 6.12 LTS.
Spec couche 0 — Module io_uring, Temps 4 : accès brut (niveau 1)
Spécification technique — Version 1.0 (cible kernel : Linux 6.12 LTS)
Position. Le Temps 4 spécifie l’accès niveau 1 au protocole d’anneau : manipulation directe des SQE/CQE bruts, pour les cas que l’API typée (niveaux Temps 1–3f) ne couvre pas. Sous-module
air-sys-syscall::io_uring::raw. C’est la soupape de sécurité d’ADR-022 (Décision 1) : presque tout passe par le niveau 2 ; le niveau 1 reste disponible, encadré par un contrat# Safetyexhaustif.
1. Quand (ne pas) utiliser le niveau brut
Cas d’usage légitimes :
- Opérations non encore wrappées : un opcode kernel apparu après la façade (rappel : périmètre figé à 6.12 ; un opcode 6.13+ détecté par probe peut être piloté en brut en attendant son wrapper niveau 2).
- Optimisations de pointe : batching agressif, agencement de SQE que l’API typée n’exprime pas.
- Outils : debug, monitoring, inspection de l’état des anneaux.
À éviter sinon. Le niveau brut contourne le slab S1 (donc l’ownership sûr des buffers) et expose des champs où une erreur corrompt le ring. La règle Air : rester au niveau 2 sauf nécessité mesurée (Principe 5).
2. Types bruts
2.1 RawSubmissionQueueEntry / RawCompletionQueueEntry
#![allow(unused)]
fn main() {
/// Miroir exact de `struct io_uring_sqe` (64 octets ; 128 si SETUP_SQE128).
#[repr(C)]
pub struct RawSubmissionQueueEntry { /* opcode, flags, ioprio, fd, off/addr2, addr, len,
op_flags, user_data, buf_index, personality,
splice_fd_in/file_index, addr3/cmd… */ }
/// Miroir exact de `struct io_uring_cqe` (16 octets ; 32 si SETUP_CQE32).
#[repr(C)]
pub struct RawCompletionQueueEntry {
pub user_data: u64,
pub res: i32,
pub flags: u32,
// big_cqe[] (16 octets) si CQE32
}
/// Opcode brut (couvre aussi les opcodes hors `IoUringOpcode`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RawOpcode(pub u8);
}
#[repr(C)]et layout bit-pour-bit identique au header uapi v6.12 ; testé par assertions de taille/offset (statique) — toute dérive casse la compilation.- Les variantes
SQE128/CQE32sont prises en compte : les accesseurs respectent la taille configurée du ring (unRawSubmissionQueueEntryde 64 o sur un ring nonSQE128; la zonecmd[]de 80 o n’existe que sousSQE128). - Constructeurs/accesseurs typés pour remplir un SQE sans calcul d’offset
manuel :
RawSubmissionQueueEntry::nop(),.set_fd(),.set_addr(),.set_len(),.set_user_data(),.set_flags(), etc.
3. Soumission brute
#![allow(unused)]
fn main() {
impl IoUring {
/// Réserve un emplacement SQE libre et le rend pour remplissage manuel.
/// `None` si la SQ est pleine.
///
/// # Safety
/// L'appelant doit écrire un SQE **valide** : opcode supporté, `fd`/`addr`/
/// `len` cohérents, et **tout buffer référencé par `addr` doit rester
/// valide jusqu'à la complétion** (le slab S1 ne gère PAS ce buffer). Le
/// `user_data` doit respecter la règle de coexistence (§5).
pub unsafe fn raw_get_submission_queue_entry(&mut self) -> Option<&mut RawSubmissionQueueEntry>;
/// Capacité totale de la SQ (entries effectives).
pub fn submission_queue_capacity(&self) -> u32; // sûr
/// Emplacements SQ disponibles.
pub fn submission_queue_available(&self) -> u32; // sûr
/// Capacité totale de la CQ.
pub fn completion_queue_capacity(&self) -> u32; // sûr
}
}
-
- Publication =
submit()(Temps 1), qui reste sûr. L’appelant remplit un ou plusieursRawSubmissionQueueEntryviaraw_get_submission_queue_entry, puis appellesubmit()/submit_and_wait() - c’est la façade qui exécute le store release sur la queue (Temps 1 §3.2).
Le protocole d’ordering reste donc géré par la façade ; l’
unsafene porte que sur le contenu du SQE et la validité des buffers, pas sur le protocole d’anneau.
- Publication =
4. Complétion brute
#![allow(unused)]
fn main() {
impl IoUring {
/// Inspecte la prochaine complétion brute sans la consommer. `None` si la CQ
/// est vide. (Sûr : lecture seule via load acquire interne.)
pub fn raw_peek_completion_queue_entry(&self) -> Option<&RawCompletionQueueEntry>;
/// Consomme `n` complétions de la CQ (avance la tête, store release).
pub fn raw_advance_completion_queue(&mut self, n: u32);
}
}
- Lecture/avance bruts pour les outils de monitoring et les opérations soumises
en brut.
raw_peek_completion_queue_entryest sûr (lecture seule) ;raw_advance_completion_queueest sûr (la façade fait l’ordering), mais l’interprétation deres/flagsest à la charge de l’appelant (selon l’opcode qu’il a soumis).
5. Coexistence brut / niveau 2 : règle du user_data (essentiel)
Le slab S1 (Temps 1 §4.2) encode le user_data des opérations de niveau 2 sous
la forme (génération << 32) | slot, avec slot < capacité. Une opération brute
fixe librement son user_data — risque de collision avec l’encodage du
slab.
Règle gelée : une opération brute doit positionner le bit de poids fort
(RAW_USER_DATA_TAG = 1 << 63) de son user_data. La boucle de complétion de la
façade route alors :
user_data & RAW_USER_DATA_TAG == 0⇒ complétion gérée : décodée via le slab, livrée commeCompletion(Temps 1) ;user_data & RAW_USER_DATA_TAG != 0⇒ complétion brute : livrée telle quelle (RawCompletionQueueEntry) à l’appelant, sans toucher au slab.
#![allow(unused)]
fn main() {
pub const RAW_USER_DATA_TAG: u64 = 1 << 63;
}
Conséquence : le slab n’utilise jamais le bit 63 (slot+génération tiennent sur
63 bits — largement suffisant). Brut et niveau 2 coexistent sur le même ring
sans collision. La façade rejette (debug_assert + erreur) un raw_get_submission_queue_entry
dont le user_data n’a pas le tag, en build de test.
6. Contrat # Safety (récapitulatif)
L’appelant du niveau brut garantit :
- Opcode valide et supporté par le kernel courant (vérifier via
supports_op/ probe). - Cohérence des champs du SQE (
fd,addr,len, flags) pour l’opcode. - Validité mémoire : tout buffer pointé par
addr/addr2/addr3reste valide et non déplacé jusqu’à la complétion (le slab S1 ne le protège pas). - Tag
user_data(§5) positionné sur toute op brute. - Pas de double consommation d’une complétion (
raw_advance_completion_queuecohérent avec lesraw_peek_completion_queue_entry).
La façade garantit en retour : l’ordering d’anneau (publication/consommation release/acquire) et la non-corruption des structures internes (le brut ne donne pas accès aux pointeurs mmap nus, seulement aux SQE/CQE via emplacements bornés).
7. Types ajoutés / partagés
Nouveaux : RawSubmissionQueueEntry, RawCompletionQueueEntry, RawOpcode, constante RAW_USER_DATA_TAG. Réutilise
le ring et son ordering (Temps 1). Les structures d’argument register brutes
(io_uring_rsrc_register, io_uring_buf_reg, etc.) sont exposées en
#[repr(C)] ici pour les outils, mais leur usage normal passe par les types
typés des Temps 3a/3b.
8. Stratégie de tests
- Layout : assertions statiques de taille/offset de
RawSubmissionQueueEntry/RawCompletionQueueEntrycontre le header v6.12 (64/128 et 16/32 octets) ; échec de compilation si dérive. - Intégration : soumettre un
NOPen brut (tag user_data), le voir revenir viaraw_peek_completion_queue_entry/raw_advance_completion_queue; implémenter en brut une op simple et comparer au wrapper niveau 2 équivalent. - Coexistence : mélanger ops niveau 2 (slab) et ops brutes (taguées) sur le même ring ; vérifier le routage correct des complétions, aucune collision.
- Sûreté :
debug_assertdu tag manquant ; Miri surraw_get_submission_queue_entry/avance (bornes des emplacements) ; fuzzing du décodageRawCompletionQueueEntry(données kernel externes, Principe 3). - Couverture : le niveau brut
unsafeest testé via NOP et opcodes simples ; les branches non provoquables sont consignées dansCOVERAGE-EXCEPTIONS.mdavec justification.
9. Décisions de fond émergées au Temps 4
- Ordering toujours géré par la façade — même en brut,
submit()/raw_advance_completion_queuefont les barrières release/acquire ; l’unsafene porte que sur le contenu du SQE et la validité des buffers, pas sur le protocole d’anneau. On ne donne jamais accès aux têtes/queues nues. - Tag
user_data(bit 63) — coexistence brut/niveau 2 sans collision sur le même ring ; le slab n’utilise jamais ce bit. - Pas de pointeurs mmap nus exposés — le brut passe par des emplacements SQE/CQE bornés, pas par les anneaux nus ; réduit la surface d’erreur.
# Safetyexhaustif et localisé — conforme aux conventions couche 0 ; chaque fonctionunsafedocumente ses préconditions.- Soupape, pas voie royale — le niveau brut existe pour les 5 % de cas que le niveau 2 ne couvre pas ; la documentation décourage son usage par défaut.
10. Fin du module io_uring
Avec ce Temps 4, l’inventaire du document maître est entièrement couvert :
- Temps 1 (cœur) ; 2a (fs) ; 2b (réseau) ; 2c (async) ; 2d (uring_cmd) ; 3a (registration) ; 3b (buffers fournis) ; 3c (linked) ; 3d (multishot) ; 3e (multi-thread) ; 3f (confinement) ; 4 (raw).
Toutes les fonctionnalités io_uring de la cible 6.12 sont spécifiées en façade
Rust, hors interfaces obsolètes (évacuées vers UNSUPPORTED.md).
Travail à reprendre : traduction anglaise globale des documents du module
(après validation des versions françaises), puis confier l’implémentation des
corps (todo!()) à partir des squelettes rustdoc validés.
Licence du document : MPL 2.0
Statut : Spécification technique du Temps 4 (accès brut) du module air-sys-syscall::io_uring, cible kernel 6.12 LTS. Clôt la spécification du module.
Layer-0 Spec — io_uring Module: Master Inventory Document
Technical specification — Version 1.0 (frozen kernel target: Linux 6.12 LTS)
Role of this document. This is the reference map of the
air-sys-syscall::io_uringmodule. It exhaustively enumerates the ABI surface of io_uring as it exists in kernel 6.12 (the frozen baseline), classifies each entry (retained / obsolete-dropped), links it to a specification Stage and a Rust façade symbol, and concludes with the rustdoc skeleton of the core API.It makes the goal of “exposing all io_uring features” verifiable: every entry in the uapi header
io_uring.hof v6.12 must appear here with a decision. This document is the entry point to validate before producing the detailed per-Stage specs and before the implementation (function bodies) is handed off.Relation to existing documents. This document replaces the inventory role of
io-uring-overview.md(calibrated against a ~5.15 kernel, now incomplete). Structural decisions remain those of ADR-022; the two soundness decisions in section 3 extend ADR-022 and will be the subject of an RFC amendment (see §3.4).
1. Frozen Kernel Target and Methodology
1.1 Scope Decision
- Baseline = Linux 6.12 LTS. Everything predating 6.12 is dropped at the outset. Paths for older kernels will only be implemented if there is an explicit request (and then via feature-gate + documented fallback).
- Source of truth = the
include/uapi/linux/io_uring.hheader from thev6.12tag. Every symbol present in that header is in scope; any symbol present only inmaster(later) is out of scope at startup and will only be added later, via runtime detection, without amendment (ADR-022, future status). - A single façade: the modern one. When kernel 6.12 already offers a
modern variant of a feature, Air exposes only the modern one. The
legacy variant is recorded in
docs/UNSUPPORTED.mdwith justification (consistent with “modern variants preferred” from CLAUDE.md). See §4.
1.2 Out of Startup Scope (present in master, absent from 6.12)
Listed here for reference, to avoid accidentally reintroducing them:
- Opcodes:
RECV_ZC,EPOLL_WAIT,READV_FIXED,WRITEV_FIXED,PIPE,NOP128,URING_CMD128. - Register opcodes:
SEND_MSG_RING,ZCRX_IFQ,ZCRX_CTRL,RESIZE_RINGS,MEM_REGION,QUERY,BPF_FILTER. - Setup flags:
HYBRID_IOPOLL,CQE_MIXED,SQE_MIXED,SQ_REWIND. - CQE flags:
IORING_CQE_F_SKIP,IORING_CQE_F_32. - Enter flags:
EXT_ARG_REG,NO_IOWAIT. - Features:
RW_ATTR,NO_IOWAIT. - uring_cmd socket op:
SOCKET_URING_OP_TX_TIMESTAMP,SOCKET_URING_OP_GETSOCKNAME(6.12 only has SIOCINQ/SIOCOUTQ/GET/SETSOCKOPT).
2. The io_uring Model in Brief (Orientation Recap)
io_uring is built on three syscalls and a protocol of mmapped rings:
| Syscall | Role |
|---|---|
io_uring_setup(entries, params) | Creates the ring, returns an FD, fills in the mmap offsets. |
io_uring_enter(fd, to_submit, min_complete, flags, arg, argsz) | Submits SQEs and/or waits for CQEs. |
io_uring_register(fd, opcode, arg, nr_args) | Registers/configures resources (buffers, files, restrictions, …). |
Three mmapped regions: the submission queue (SQ), the completion queue
(CQ), and the SQE array (64 bytes, or 128 with SETUP_SQE128). The
CQE is 16 bytes (or 32 with SETUP_CQE32). The user_data coupling
(written into the SQE, returned in the CQE) is the thread linking a submission
to its completion — it is the pivot of the Air façade (see §3.1).
The functional surface breaks down into 8 enumerable axes; sections 5.1 through 5.8 cover them exhaustively.
2.1 Terminology and Abbreviations (reference)
io_uring uses standardized abbreviations (kernel, man pages, liburing). This table defines them once and for all; the Air documentation prefers the spelled-out form and only uses the abbreviations where they correspond to a literal kernel ABI name (which is never renamed, for accuracy).
| Abbrev. | Full form | Status in the Air façade |
|---|---|---|
| SQ | submission queue | abbreviation = kernel ring; façade is spelled out (submission_queue_*) |
| CQ | completion queue | abbreviation = kernel ring; façade is spelled out (completion_queue_*) |
| SQE | submission queue entry | façade type RawSubmissionQueueEntry; kernel struct io_uring_sqe (kept) |
| CQE | completion queue entry | façade type RawCompletionQueueEntry; kernel struct io_uring_cqe (kept) |
| FD | file descriptor | std types OwnedFd/BorrowedFd/RawFd (kept) |
| ZC | zero-copy | façade spelled out: submit_send_zero_copy, ZeroCopyFlags, into_zero_copy_buffer |
| op / opcode | operation code | façade spelled out: IoUringOpcode, RawOpcode |
| SQPOLL | submission-queue polling | kernel flag name IORING_SETUP_SQPOLL (kept); type SqpollIoUring |
| NAPI | New API (network busy-poll) | kernel mechanism name (kept) |
sq_entries / cq_entries | submission/completion queue depth | ABI fields of io_uring_params (kept verbatim) |
Applied rule. Air façade identifiers are spelled out
(submission_queue_*, completion_queue_*, IoUringOpcode, ZeroCopyFlags,
in_flight, etc.) — verbose and unambiguous for the developer (Principle 7).
Kernel ABI names (io_uring_sqe, io_uring_cqe, IORING_*, sqe->res,
cq_entries…) are kept verbatim: renaming them would diverge from the
kernel and the man pages, hence less precision, not more.
⚠️ Factual note. The “S” in SQ/SQE stands for Submission, not “Send”. SQ = submission queue, SQE = submission queue entry.
3. Frozen Soundness Decisions (Extending ADR-022)
These decisions determine all façade signatures. They are frozen before writing any function body.
3.1 Decision S1 — In-flight Operation State: Pre-allocated Slab
Problem. The “ownership transfer” model (ADR-022, Decision 3) takes the
buffer as an argument and returns it at completion. Between submission and
completion, {buffer, metadata, op type} must be parked somewhere,
retrievable by user_data. A HashMap<u64, _> would allocate per
operation — which violates the CLAUDE.md rule “no heap allocation in the
happy path.”
Decision. The IoUring owns a pre-allocated slab sized to the SQ
depth (sq_entries). Each in-flight operation occupies a slot. The
SubmissionToken encapsulates a slot index + generation (compteur de génération anti-réutilisation),
not a raw user_data. The kernel-side user_data = the encoded slot index.
Consequences:
- Zero allocation per operation in the happy path: the transferred buffer is moved into the slot, not copied or reallocated.
- Natural back-pressure: slab full ⇒
submit_*returnsErr(Errno::EBUSY)(or a dedicated error type) before reaching the kernel. - The slot is freed when the corresponding completion is consumed and ownership of the buffer is returned to the caller.
3.2 Decision S2 — Safe Teardown: Drop Quiesces, Explicit shutdown()
Problem. If the IoUring (or an in-flight buffer) is dropped while the
kernel still has operations in progress, the kernel may write into freed
memory. This is the soundness hazard of io_uring + Rust. ADR-022 did not
address it.
Decision (faithful to “over-secure then trim after measurement,” Principle 5).
shutdown(self) -> Result<(), Errno>: the clean, explicit path. Issues a global cancellation (IORING_REGISTER_SYNC_CANCELorASYNC_CANCEL_ANY), drains remaining completions, then closes the FD. Does not block indefinitely (timeout).Drop: safety net. If operations are still in flight,Dropquiesces (cancels + drains in a blocking fashion) before releasing memory. The cost (potential blocking inDrop) is acknowledged and documented; performance-conscious users callshutdown()explicitly.- The slots from Decision S1 retain ownership of buffers until the op completes ⇒ the buffer cannot be freed before the kernel is done with it.
3.3 Decision S3 — Restrictions & Sandbox as a Capability Primitive
IORING_REGISTER_RESTRICTIONS + SETUP_R_DISABLED + REGISTER_ENABLE_RINGS
form a confinement primitive: create a disabled ring, restrict the
allowed opcodes/flags/register-ops, then enable it. This maps directly
onto the signed-entitlement model of ADR-010 and the AirCom capabilities
(ADR-001).
Decision. This primitive is treated as a first-class citizen (Stage 3f,
see §6), not buried in generic registration. The façade exposes an
IoUringBuilder constructor capable of applying a set of restrictions
before activation, so that a confined Air service receives a ring whose
kernel guarantees it can only issue the operations authorized by its manifest.
3.4 RFC Status
S1, S2, S3 extend ADR-022 without contradicting its 10 decisions. They are
recorded in ADR-028 (“Soundness and teardown of the io_uring module”),
a supplement to ADR-022 — see ../../adrs/ADR-028-soundness-io-uring-en.md.
4. “No Legacy” Rule: Concrete Decisions
Legacy variants dropped to UNSUPPORTED.md (kernel 6.12 already offers
the modern version):
| Legacy (dropped) | Modern replacement (exposed) | Reason |
|---|---|---|
IORING_REGISTER_BUFFERS (reg 0) | IORING_REGISTER_BUFFERS2 (reg 15) + BUFFERS_UPDATE (16) | Resource tagging (FEAT_RSRC_TAGS), sparse, partial update. |
IORING_REGISTER_FILES (reg 2) | IORING_REGISTER_FILES2 (reg 13) + FILES_UPDATE2 (14) | Same; struct io_uring_files_update is marked deprecated in the header. |
IORING_REGISTER_FILES_UPDATE (reg 6) | IORING_REGISTER_FILES_UPDATE2 (reg 14) | Same. |
IORING_OP_PROVIDE_BUFFERS (op 31) | IORING_REGISTER_PBUF_RING (reg 22) | Ring-mapped provided buffers, no syscall per batch, incremental consumption (IOU_PBUF_RING_INC). |
IORING_OP_REMOVE_BUFFERS (op 32) | IORING_UNREGISTER_PBUF_RING (reg 23) | Same. |
SQE field poll_events (__u16) | poll32_events (__u32) | The header notes “compatibility”; only the 32-bit version is exposed. |
Note: UNREGISTER_BUFFERS (1) and UNREGISTER_FILES (3) remain exposed —
they are the deregistration counterparts for resources registered via the *2
variants. EVENTFD_ASYNC (7) and EPOLL_CTL (op 29) remain (not
obsolete: respectively a useful variant and the only epoll integration in 6.12).
5. Exhaustive Inventory of the 8 Axes
Legend for the “Decision” column: R = retained/exposed · O = obsolete,
dropped to UNSUPPORTED.md · G = runtime-gated (probe/feature, exposed
but fallible).
5.1 Axis A — Setup Flags IORING_SETUP_* (17 in 6.12)
Exposed as bitflags SetupFlags, applied via IoUringBuilder.
| Flag | Bit | Decision | Façade symbol / note |
|---|---|---|---|
IOPOLL | 0 | R | SetupFlags::IOPOLL — I/O polling (O_DIRECT storage). |
SQPOLL | 1 | R | SetupFlags::SQPOLL — kernel thread polling the SQ (Stage 3e). |
SQ_AFF | 2 | R | SetupFlags::SQ_AFF — CPU affinity for the SQPOLL thread. |
CQSIZE | 3 | R | IoUringBuilder::cq_entries(). |
CLAMP | 4 | R | SetupFlags::CLAMP. |
ATTACH_WQ | 5 | R | IoUringBuilder::attach_work_queue(&IoUring) — shares the io-wq pool. |
R_DISABLED | 6 | R | sandbox primitive (Stage 3f, §3.3). |
SUBMIT_ALL | 7 | R | SetupFlags::SUBMIT_ALL. |
COOP_TASKRUN | 8 | R | SetupFlags::COOP_TASKRUN. |
TASKRUN_FLAG | 9 | R | SetupFlags::TASKRUN_FLAG. |
SQE128 | 10 | R | required for URING_CMD with large payload (Stage 2d, Stage 4). |
CQE32 | 11 | R | required for certain extended completions. |
SINGLE_ISSUER | 12 | R | Air recommended: thread-per-core reactor. |
DEFER_TASKRUN | 13 | R | Air recommended (combined with SINGLE_ISSUER): low latency. |
NO_MMAP | 14 | R | ring memory provided by the caller. |
REGISTERED_FD_ONLY | 15 | R | ring fd as registered-only (combines REG_REG_RING). |
NO_SQARRAY | 16 | R | removes the SQ index-array indirection. |
5.2 Axis B — Operations IORING_OP_* (58 in 6.12, indices 0–57)
“Stage” column = destination detailed spec (see §6).
| Opcode | # | Decision | Stage | Façade symbol |
|---|---|---|---|---|
NOP | 0 | R | 2c | submit_nop |
READV | 1 | R | 2a | submit_readv |
WRITEV | 2 | R | 2a | submit_writev |
FSYNC | 3 | R | 2a | submit_fsync |
READ_FIXED | 4 | R | 2a/3a | submit_read_fixed (registered buffer) |
WRITE_FIXED | 5 | R | 2a/3a | submit_write_fixed |
POLL_ADD | 6 | R | 2c | submit_poll_add (+ multi/level flags) |
POLL_REMOVE | 7 | R | 2c | submit_poll_remove |
SYNC_FILE_RANGE | 8 | R | 2a | submit_sync_file_range |
SENDMSG | 9 | R | 2b | submit_send_message |
RECVMSG | 10 | R | 2b | submit_receive_message |
TIMEOUT | 11 | R | 2c | submit_timeout |
TIMEOUT_REMOVE | 12 | R | 2c | submit_timeout_remove / update |
ACCEPT | 13 | R | 2b | submit_accept (+ multishot Stage 3d) |
ASYNC_CANCEL | 14 | R | 2c | submit_cancel |
LINK_TIMEOUT | 15 | R | 2c/3c | submit_link_timeout |
CONNECT | 16 | R | 2b | submit_connect |
FALLOCATE | 17 | R | 2a | submit_fallocate |
OPENAT | 18 | O | — | dropped → OPENAT2 (superset, OpenHow). |
CLOSE | 19 | R | 2a | submit_close |
FILES_UPDATE | 20 | R | 2c/3a | submit_files_update (op, distinct from register) |
STATX | 21 | R | 2a | submit_statx |
READ | 22 | R | 2a | submit_read |
WRITE | 23 | R | 2a | submit_write |
FADVISE | 24 | R | 2a | submit_fadvise |
MADVISE | 25 | R | 2a | submit_madvise |
SEND | 26 | R | 2b | submit_send |
RECV | 27 | R | 2b | submit_receive (+ multishot Stage 3d) |
OPENAT2 | 28 | R | 2a | submit_openat2 |
EPOLL_CTL | 29 | R | 2c | submit_epoll_ctl |
SPLICE | 30 | R | 2a | submit_splice |
PROVIDE_BUFFERS | 31 | O | — | dropped → PBUF_RING (reg 22). |
REMOVE_BUFFERS | 32 | O | — | dropped → UNREGISTER_PBUF_RING. |
TEE | 33 | R | 2a | submit_tee |
SHUTDOWN | 34 | R | 2b | submit_shutdown |
RENAMEAT | 35 | R | 2a | submit_renameat (renameat2 semantics) |
UNLINKAT | 36 | R | 2a | submit_unlinkat |
MKDIRAT | 37 | R | 2a | submit_mkdirat |
SYMLINKAT | 38 | R | 2a | submit_symlinkat |
LINKAT | 39 | R | 2a | submit_linkat |
MSG_RING | 40 | R | 2c | submit_msg_ring (data / send_fd) |
FSETXATTR | 41 | R | 2a | submit_fsetxattr |
SETXATTR | 42 | R | 2a | submit_setxattr |
FGETXATTR | 43 | R | 2a | submit_fgetxattr |
GETXATTR | 44 | R | 2a | submit_getxattr |
SOCKET | 45 | R | 2b | submit_socket |
URING_CMD | 46 | R | 2d | submit_uring_cmd (+ socket cmds) |
SEND_ZC | 47 | R | 2b | submit_send_zero_copy (zero-copy, CQE NOTIF) |
SENDMSG_ZC | 48 | R | 2b | submit_send_message_zero_copy |
READ_MULTISHOT | 49 | R | 3d | submit_read_multishot |
WAITID | 50 | R | 2c | submit_waitid |
FUTEX_WAIT | 51 | R | 2c | submit_futex_wait |
FUTEX_WAKE | 52 | R | 2c | submit_futex_wake |
FUTEX_WAITV | 53 | R | 2c | submit_futex_waitv |
FIXED_FD_INSTALL | 54 | R | 2c/3a | submit_fixed_fd_install |
FTRUNCATE | 55 | R | 2a | submit_ftruncate |
BIND | 56 | R | 2b | submit_bind |
LISTEN | 57 | R | 2b | submit_listen |
Opcode summary: 55 retained, 3 dropped (OPENAT, PROVIDE_BUFFERS,
REMOVE_BUFFERS).
5.3 Axis C — Register Opcodes IORING_REGISTER_* (31 in 6.12, 0–30)
| Register op | # | Decision | Stage | Façade symbol |
|---|---|---|---|---|
REGISTER_BUFFERS | 0 | O | — | → BUFFERS2. |
UNREGISTER_BUFFERS | 1 | R | 3a | RegisteredBuffers::unregister |
REGISTER_FILES | 2 | O | — | → FILES2. |
UNREGISTER_FILES | 3 | R | 3a | FixedFdTable::unregister |
REGISTER_EVENTFD | 4 | R | 3a | IoUring::register_eventfd |
UNREGISTER_EVENTFD | 5 | R | 3a | IoUring::unregister_eventfd |
REGISTER_FILES_UPDATE | 6 | O | — | → FILES_UPDATE2. |
REGISTER_EVENTFD_ASYNC | 7 | R | 3a | register_eventfd_async |
REGISTER_PROBE | 8 | R | 1 | IoUring::probe / supports_op |
REGISTER_PERSONALITY | 9 | R | 3a | IoUring::register_personality |
UNREGISTER_PERSONALITY | 10 | R | 3a | unregister_personality |
REGISTER_RESTRICTIONS | 11 | R | 3f | IoUringBuilder::restrict (§3.3) |
REGISTER_ENABLE_RINGS | 12 | R | 3f | IoUringBuilder::enable |
REGISTER_FILES2 | 13 | R | 3a | FixedFdTable::register |
REGISTER_FILES_UPDATE2 | 14 | R | 3a | FixedFdTable::update |
REGISTER_BUFFERS2 | 15 | R | 3a | RegisteredBuffers::register |
REGISTER_BUFFERS_UPDATE | 16 | R | 3a | RegisteredBuffers::update |
REGISTER_IOWQ_AFF | 17 | R | 3a | IoUring::set_work_queue_affinity |
UNREGISTER_IOWQ_AFF | 18 | R | 3a | clear_work_queue_affinity |
REGISTER_IOWQ_MAX_WORKERS | 19 | R | 3a | set_work_queue_max_workers |
REGISTER_RING_FDS | 20 | R | 3a | IoUring::register_ring_fd |
UNREGISTER_RING_FDS | 21 | R | 3a | unregister_ring_fd |
REGISTER_PBUF_RING | 22 | R | 3b | ProvidedBufferRing::register |
UNREGISTER_PBUF_RING | 23 | R | 3b | ProvidedBufferRing::unregister |
REGISTER_SYNC_CANCEL | 24 | R | 1/2c | IoUring::sync_cancel (used by S2) |
REGISTER_FILE_ALLOC_RANGE | 25 | R | 3a | FixedFdTable::set_alloc_range |
REGISTER_PBUF_STATUS | 26 | R | 3b | ProvidedBufferRing::status |
REGISTER_NAPI | 27 | R | 3a | IoUring::register_napi |
UNREGISTER_NAPI | 28 | R | 3a | unregister_napi |
REGISTER_CLOCK | 29 | R | 3a | IoUring::register_clock |
REGISTER_CLONE_BUFFERS | 30 | R | 3a | RegisteredBuffers::clone_from |
USE_REGISTERED_RING (flag 1<<31) | — | R | 1 | applied transparently when the ring fd is registered. |
Register summary: 28 retained, 3 dropped (REGISTER_BUFFERS,
REGISTER_FILES, REGISTER_FILES_UPDATE).
5.4 Axis D — Per-SQE Flags IOSQE_* (7)
| Flag | Decision | Façade symbol |
|---|---|---|
FIXED_FILE | R | implicit via the “fixed” variants (registered FD). |
IO_DRAIN | R | SubmitOptions::drain(). |
IO_LINK | R | LinkedChainBuilder (Stage 3c). |
IO_HARDLINK | R | LinkedChainBuilder::add_hard_link. |
ASYNC | R | SubmitOptions::force_async(). |
BUFFER_SELECT | R | implicit via ProvidedBufferRing (Stage 3b). |
CQE_SKIP_SUCCESS | R | SubmitOptions::skip_cqe_on_success() (FEAT_CQE_SKIP). |
5.5 Axis E — CQE Flags IORING_CQE_F_* (5 in 6.12)
| Flag | Decision | Exposure |
|---|---|---|
BUFFER | R | Completion::buffer_id() -> Option<u16>. |
MORE | R | Completion::has_more() (multishot, Stage 3d). |
SOCK_NONEMPTY | R | Completion::socket_has_pending_data(). |
NOTIF | R | Completion::is_notif() (zero-copy send, Stage 2b). |
BUF_MORE | R | incremental consumption (IOU_PBUF_RING_INC, Stage 3b). |
5.6 Axis F — io_uring_enter Flags (6 in 6.12)
Managed internally by the façade (not exposed raw):
| Flag | Decision | Internal usage |
|---|---|---|
GETEVENTS | R | submit_and_wait, wait_completion. |
SQ_WAKEUP | R | wakeup of the SQPOLL thread (Stage 3e). |
SQ_WAIT | R | wait for the SQ to drain (SQPOLL). |
EXT_ARG | R | wait timeout (getevents_arg), wait_completion_timeout. |
REGISTERED_RING | R | applied if ring fd is registered (transparent). |
ABS_TIMER | R | absolute timeout (FEAT_MIN_TIMEOUT). |
5.7 Axis G — Features IORING_FEAT_* (16 in 6.12)
Read from io_uring_params.features after setup, exposed via
IoUringCapabilities:
SINGLE_MMAP, NODROP, SUBMIT_STABLE, RW_CUR_POS, CUR_PERSONALITY,
FAST_POLL, POLL_32BITS, SQPOLL_NONFIXED, EXT_ARG, NATIVE_WORKERS,
RSRC_TAGS, CQE_SKIP, LINKED_FILE, REG_REG_RING, RECVSEND_BUNDLE,
MIN_TIMEOUT. → all R, mapped to IoUringCapabilities::* predicates.
5.8 Axis H — Raw Ring Protocol (Stage 4)
Kernel structures exposed as #[repr(C)] in ::raw, plus ring flags:
io_uring_sqe(64 b / 128 b),io_uring_cqe(16 b / 32 b).io_sqring_offsets,io_cqring_offsets,io_uring_params.- SQ ring flags:
SQ_NEED_WAKEUP,SQ_CQ_OVERFLOW,SQ_TASKRUN. - CQ ring flag:
CQ_EVENTFD_DISABLED. - mmap offsets:
OFF_SQ_RING,OFF_CQ_RING,OFF_SQES,OFF_PBUF_RING. - Register argument structures:
io_uring_rsrc_register,io_uring_rsrc_update(2),io_uring_buf(_ring|_reg|_status),io_uring_napi,io_uring_clock_register,io_uring_clone_buffers,io_uring_sync_cancel_reg,io_uring_file_index_range,io_uring_probe(_op),io_uring_restriction,io_uring_getevents_arg,io_uring_recvmsg_out. - Enums:
io_uring_op,io_uring_register_op,io_uring_register_restriction_op,io_uring_msg_ring_flags,io_uring_socket_op,io_uring_register_pbuf_ring_flags,io_wq_type.
6. Revised Stage Breakdown
The breakdown from ADR-022 is retained and extended (additions: 2d
uring_cmd, separate 3b provided-buffers-ring, 3f restrictions/sandbox):
| Stage | Sub-module | Scope | Symbols |
|---|---|---|---|
| 1 | (root) | Ring lifecycle, setup flags, slab (S1), Drop/shutdown (S2), submission/completion, Completion, probe/capabilities. | ~20 |
| 2a | (root) | Filesystem (read/write/sync/open/stat/dir/perm/xattr/splice/ftruncate…). | 26 |
| 2b | (root) | Network (accept/connect/send/recv/msg/shutdown/socket/bind/listen + zero-copy send/sendmsg). | 12 |
| 2c | (root) | Async-specific (nop/timeout/cancel/poll/futex/waitid/msg_ring/epoll_ctl/files_update/fixed_fd_install). | 15 |
| 2d | ::cmd | URING_CMD: passthrough + socket commands (SIOCINQ/OUTQ, get/setsockopt). | 3–5 |
| 3a | ::registration | Modern fixed resources: files2/buffers2/update, ring_fds, eventfd, personality, iowq aff/workers, napi, clock, clone_buffers, alloc_range. | ~25 |
| 3b | ::provided | Ring-mapped provided buffers (PBUF_RING/STATUS, incremental). | 6–8 |
| 3c | ::linked | Linked chains (soft/hard link, link_timeout). | 5–7 |
| 3d | ::multishot | Multishot (accept/recv/poll/read_multishot). | 5–7 |
| 3e | ::shared | Multi-thread: LockedIoUring, RingPool, SqpollIoUring. | 10–12 |
| 3f | ::sandbox | Restrictions + R_DISABLED + ENABLE_RINGS (capability, §3.3). | 6–8 |
| 4 | ::raw | Raw protocol (SQE/CQE, rings, register structs). | 8–12 |
7. Core Rustdoc Skeleton (Stage 1) — To Validate Before Implementation
This skeleton is the contract that Claude Code will implement (bodies =
todo!()). The exhaustive per-opcodesubmit_*functions (Stages 2a–2d) and the sub-modules (3a–3f, 4) are produced in their dedicated specs on the same model. Layer-0 conventions (ADR-021) applied:Result<_, Errno>, EINTR propagated to caller,Option<T>instead of sentinels, FDs asOwnedFd/BorrowedFd, no heap allocation in the happy path (S1).
#![allow(unused)]
fn main() {
//! Module `air-sys-syscall::io_uring` — typed io_uring façade (target 6.12).
//!
//! Abstraction level 2 (ADR-022, Decision 1): typed submission/completion.
//! Level 1 (raw rings) lives in [`raw`]. Buffers follow the ownership transfer
//! model (ADR-022, Decision 3), parked in a pre-allocated slab (S1); teardown
//! is safe via quiescent `Drop` + [`IoUring::shutdown`] (S2).
use crate::Errno;
use core::num::NonZeroU32;
use std::os::fd::{BorrowedFd, OwnedFd};
// ---------------------------------------------------------------------------
// Construction & lifecycle
// ---------------------------------------------------------------------------
/// io_uring ring. `Send` but not `Sync` (ADR-022, Decision 6): one reactor
/// per thread. For multi-thread use, see [`shared`].
pub struct IoUring {
/* fd: OwnedFd, SQ/CQ/SQE mmaps, in-flight op slab (S1), capabilities */
}
/// Builds an [`IoUring`], applying setup flags and restrictions (S3) before
/// activation.
pub struct IoUringBuilder { /* ... */ }
impl IoUringBuilder {
/// Starts a builder for `entries` SQEs (rounded up to the next power of two).
pub fn new(entries: NonZeroU32) -> Self { todo!() }
/// Explicit CQ size (`SETUP_CQSIZE`).
pub fn with_completion_queue_entries(self, entries: NonZeroU32) -> Self { todo!() }
/// Enables setup flags (see [`SetupFlags`]).
pub fn with_flags(self, flags: SetupFlags) -> Self { todo!() }
/// Shares the io-wq pool of an existing ring (`SETUP_ATTACH_WQ`).
pub fn attach_work_queue(self, other: &IoUring) -> Self { todo!() }
/// Creates the ring in disabled state (`R_DISABLED`) and applies
/// restrictions (S3). Must be followed by [`IoUringBuilder::enable`].
pub fn restrict(self, restrictions: &[Restriction]) -> Self { todo!() }
/// Finalizes: `io_uring_setup(2)`. If `restrict` was used, the ring is
/// created disabled and must be activated via [`IoUring::enable`].
pub fn build(self) -> Result<IoUring, Errno> { todo!() }
}
impl IoUring {
/// Shortcut: `IoUringBuilder::new(entries).build()`.
pub fn new(entries: NonZeroU32) -> Result<Self, Errno> { todo!() }
/// Activates a ring created with `R_DISABLED` (`REGISTER_ENABLE_RINGS`).
pub fn enable(&mut self) -> Result<(), Errno> { todo!() }
/// Clean teardown (S2): cancels in-flight ops, drains, closes the FD.
/// Prefer over an implicit `drop` on the hot path.
pub fn shutdown(self) -> Result<(), Errno> { todo!() }
}
impl Drop for IoUring {
/// Safety net (S2): if operations are still in flight, quiesces (cancels +
/// drains, **blocking**) before releasing memory. Cost is acknowledged.
fn drop(&mut self) { todo!() }
}
// ---------------------------------------------------------------------------
// Submission & completion
// ---------------------------------------------------------------------------
impl IoUring {
/// Submits pending SQEs (`io_uring_enter`, without waiting). Returns the
/// number submitted. EINTR propagated to caller (ADR-021, conv. 2).
pub fn submit(&mut self) -> Result<u32, Errno> { todo!() }
/// Submits then waits for at least `want` completions.
pub fn submit_and_wait(&mut self, want: u32) -> Result<u32, Errno> { todo!() }
/// Waits (blocking) for the next completion and consumes it.
pub fn wait_completion(&mut self) -> Result<Completion, Errno> { todo!() }
/// Waits for a completion with a maximum deadline (`EXT_ARG`/`ABS_TIMER`).
pub fn wait_completion_timeout(
&mut self,
timeout: core::time::Duration,
) -> Result<Option<Completion>, Errno> { todo!() }
/// Returns a completion if one is available, without blocking.
pub fn try_completion(&mut self) -> Option<Completion> { todo!() }
/// Iterates over available completions in the CQ.
pub fn completions(&mut self) -> CompletionIter<'_> { todo!() }
/// Per-operation options (link, drain, async, skip-cqe) applied to the
/// next submission.
pub fn with(&mut self, opts: SubmitOptions) -> &mut Self { todo!() }
}
/// Opaque token linking a submission to its completion. Encapsulates a slab
/// slot index + generation (compteur de génération anti-réutilisation) — *not* a raw `user_data` (S1).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SubmissionToken { /* slot: u32, gen: u32 */ }
/// Token for a multishot operation (multiple completions). See [`multishot`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MultishotToken { /* ... */ }
/// Submission options (`IOSQE_*` flags exposed safely).
#[derive(Debug, Clone, Copy, Default)]
pub struct SubmitOptions { /* ... */ }
impl SubmitOptions {
pub fn drain(self) -> Self { todo!() }
pub fn force_async(self) -> Self { todo!() }
pub fn skip_cqe_on_success(self) -> Self { todo!() }
}
// ---------------------------------------------------------------------------
// Completion
// ---------------------------------------------------------------------------
/// A typed completion (CQE). Interpretation methods depend on the submitted op
/// (ADR-022, Decision 9); on failure they return `Err(Errno)`.
pub struct Completion { /* decoded user_data, res, flags */ }
impl Completion {
/// Token of the operation that produced this completion.
pub fn token(&self) -> SubmissionToken { todo!() }
/// Raw kernel result (semantics are op-dependent).
pub fn raw_result(&self) -> i32 { todo!() }
/// Completion flags (see [`CompletionFlags`]).
pub fn flags(&self) -> CompletionFlags { todo!() }
/// `IORING_CQE_F_MORE`: more completions will follow (multishot).
pub fn has_more(&self) -> bool { todo!() }
/// `IORING_CQE_F_NOTIF`: zero-copy notification completion.
pub fn is_notif(&self) -> bool { todo!() }
/// ID of the consumed provided buffer (`IORING_CQE_F_BUFFER`).
pub fn buffer_id(&self) -> Option<u16> { todo!() }
/// `IORING_CQE_F_SOCK_NONEMPTY`: data remaining after a recv.
pub fn socket_has_pending_data(&self) -> bool { todo!() }
// Typed interpretations (sample; completed by Stages 2a–2d):
/// Bytes read + return of the transferred buffer (S1).
pub fn into_read_result(self) -> Result<(Vec<u8>, usize), Errno> { todo!() }
/// Bytes written + return of the buffer.
pub fn into_write_result(self) -> Result<(Vec<u8>, usize), Errno> { todo!() }
/// Accepted FD (`submit_accept`), CLOEXEC by default.
pub fn accepted_fd(self) -> Result<OwnedFd, Errno> { todo!() }
/// Opened FD (`submit_openat2`).
pub fn opened_fd(self) -> Result<OwnedFd, Errno> { todo!() }
/// Success with no return value (close, fsync, …).
pub fn completed(&self) -> Result<(), Errno> { todo!() }
}
/// Iterator over available completions.
pub struct CompletionIter<'ring> { /* ... */ }
// ---------------------------------------------------------------------------
// Capabilities & introspection
// ---------------------------------------------------------------------------
impl IoUring {
/// True if the current kernel supports `op` (`REGISTER_PROBE`, Decision 8).
pub fn supports_op(&self, op: IoUringOpcode) -> bool { todo!() }
/// Features negotiated at setup (axis G).
pub fn capabilities(&self) -> IoUringCapabilities { todo!() }
/// Global or targeted synchronous cancellation (`REGISTER_SYNC_CANCEL`),
/// building block of [`IoUring::shutdown`] (S2).
pub fn sync_cancel(&mut self, target: CancelTarget) -> Result<u32, Errno> { todo!() }
}
/// io_uring features of the current kernel (axis G). Stable predicates.
#[derive(Debug, Clone, Copy)]
pub struct IoUringCapabilities { /* feature bits */ }
impl IoUringCapabilities {
pub fn single_mmap(&self) -> bool { todo!() }
pub fn nodrop(&self) -> bool { todo!() }
pub fn cqe_skip(&self) -> bool { todo!() }
pub fn min_timeout(&self) -> bool { todo!() }
pub fn recvsend_bundle(&self) -> bool { todo!() }
pub fn reg_reg_ring(&self) -> bool { todo!() }
/* … one predicate per feature from axis G … */
}
/// Enumeration of operations (axis B) for probe and restrictions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum IoUringOpcode { Nop, Readv, Writev, /* … 55 retained variants … */ }
/// Cancellation target for [`IoUring::sync_cancel`].
#[derive(Debug, Clone, Copy)]
pub enum CancelTarget {
Token(SubmissionToken),
Fd(BorrowedFd<'static>),
Op(IoUringOpcode),
Any,
}
// ---------------------------------------------------------------------------
// Bitflags & sandbox (Stage 3f, detailed in ::sandbox)
// ---------------------------------------------------------------------------
bitflags::bitflags! {
/// `io_uring_setup` flags (axis A, 17 flags in 6.12).
pub struct SetupFlags: u32 {
const IOPOLL = 1 << 0;
const SQPOLL = 1 << 1;
const SQ_AFF = 1 << 2;
const CLAMP = 1 << 4;
const R_DISABLED = 1 << 6;
const SUBMIT_ALL = 1 << 7;
const COOP_TASKRUN = 1 << 8;
const TASKRUN_FLAG = 1 << 9;
const SQE128 = 1 << 10;
const CQE32 = 1 << 11;
const SINGLE_ISSUER = 1 << 12;
const DEFER_TASKRUN = 1 << 13;
const NO_MMAP = 1 << 14;
const REGISTERED_FD_ONLY = 1 << 15;
const NO_SQARRAY = 1 << 16;
/* CQSIZE / ATTACH_WQ exposed via builder methods */
}
/// Completion flags (axis E, 5 flags in 6.12).
pub struct CompletionFlags: u32 {
const BUFFER = 1 << 0;
const MORE = 1 << 1;
const SOCK_NONEMPTY = 1 << 2;
const NOTIF = 1 << 3;
const BUF_MORE = 1 << 4;
}
}
/// A restriction applied before activation (S3; `io_uring_restriction`).
#[derive(Debug, Clone, Copy)]
pub enum Restriction {
/// Allow only this submission opcode.
AllowOp(IoUringOpcode),
/// Allow only this register op.
AllowRegister(u8),
/// SQE flags in the allowlist.
SqeFlagsAllowed(SubmitOptions),
/// SQE flags required on every submission.
SqeFlagsRequired(SubmitOptions),
}
}
8. Prospective Cross-cutting Traits (To Be Decided in Stage 1)
To validate: is a common trait for submittable operations needed to factor out linked/multishot handling, or do inherent methods suffice (verbosity in service of clarity, Principle 7)?
trait Submittable— an operation that can be submitted (used byLinkedChainBuilder). Candidate; risk of premature abstraction.trait FixedResource— a registerable resource (FD / buffer). Candidate.
Recommendation: do not introduce these traits in Stage 1. Add them only if Stages 3a/3c reveal the need through measured repetition.
9. Next Steps (Detailed Per-Stage Specs)
Produce, at the same level of detail as the other families (full signature,
underlying syscall, preconditions/# Safety, behavior, errors, performance,
tests, examples), the files: io-uring-1-core-en.md,
io-uring-2a-filesystem-en.md,
io-uring-2b-network-en.md,
io-uring-2c-async-en.md,
io-uring-2d-cmd-en.md,
io-uring-3a-registration-en.md,
io-uring-3b-provided-en.md,
io-uring-3c-linked-en.md,
io-uring-3d-multishot-en.md,
io-uring-3e-shared-en.md,
io-uring-3f-sandbox-en.md,
io-uring-4-raw-en.md.
Then: the English version of this master document.
Document license: MPL 2.0
Status: Master inventory document for the air-sys-syscall::io_uring module, kernel target 6.12 LTS. To be validated before producing the detailed per-Stage specs.
Layer 0 Spec — io_uring Module, Stage 1: API Core
Technical specification — Version 1.0 (target kernel: Linux 6.12 LTS)
Position. This document specifies Stage 1 of the
air-sys-syscall::io_uringmodule: the core on which all other phases build. It derives from the master inventory document (io-uring-0-inventaire-en.md) and from ADR-022. Soundness decisions S1 (pre-allocated slab), S2 (safe teardown), and S3 (sandbox) are made operational here. Business operations (submit_read, etc.) belong to Stages 2a–2d and 3a–3f; this document specifies the ring, submission, completion, and their safety.Layer 0 conventions (ADR-021) applied without exception:
Result<_, Errno>everywhere, EINTR propagated to the caller (never automatic retry),Option<T>instead of kernel sentinels, typed FDs (OwnedFd/BorrowedFd), no heap allocation in the happy path (S1),// SAFETY:on everyunsafeblock.
1. Scope of Stage 1
Stage 1 covers:
- The memory model: the three mmapped regions (SQ, CQ, SQE) and the publication protocol via head/tail with acquire/release ordering (§3). This is the correctness foundation for the entire module.
- Ring construction:
IoUringBuilder,IoUring::new,io_uring_setup(2), feature negotiation, application of setup flags (§5). - The in-flight operation slab (S1): allocation-free storage of transferred
buffers and metadata,
SubmissionTokenslot+generation encoding, back-pressure (§4). - Submission:
submit,submit_and_wait,with(SubmitOptions),io_uring_enter(2)(§6). - Completion:
wait_completion,wait_completion_timeout,try_completion,completions(), and theCompletiontype with its generic typed interpretations (§7). - Safe teardown (S2): explicit
shutdown()+ quiescentDrop, built onsync_cancel(§8). - Introspection:
supports_op(probe) andcapabilities(§9).
The three underlying syscalls (identical numbers on x86_64 / ARM64):
| Syscall | x86_64 no. | ARM64 no. | Stage 1 usage |
|---|---|---|---|
io_uring_setup | 425 | 425 | construction (§5) |
io_uring_enter | 426 | 426 | submission + wait (§6, §7) |
io_uring_register | 427 | 427 | probe, sync_cancel, eventfd… (§8, §9) |
2. Fundamental types introduced in Stage 1
| Type | Role | air-sys-types? |
|---|---|---|
IoUring | the ring (FD + mmaps + slab + capabilities). Send, !Sync. | yes |
IoUringBuilder | configured construction (flags, sizes, restrictions). | yes |
SetupFlags | bitflags for io_uring_setup flags (axis A). | yes |
IoUringParams | typed mirror of io_uring_params (setup inputs/outputs). | yes |
IoUringCapabilities | negotiated features (axis G), stable predicates. | yes |
IoUringOpcode | enumeration of the 55 retained opcodes (probe, restrictions). | yes |
SubmissionToken | opaque slot+generation token (S1). | yes |
SubmitOptions | per-op options (drain, async, skip-cqe, link — exposed in Stage 3c). | yes |
Completion | a typed completion (decoded CQE). | yes |
CompletionFlags | bitflags for CQE flags (axis E). | yes |
CompletionIter<'ring> | iterator over available completions. | no (borrow) |
CancelTarget | cancellation target (token / fd / op / any). | yes |
3. Memory model and ring protocol
Stage 1 implements this protocol but does not expose it raw (the raw layer is in Stage 4,
::raw). This section establishes the correctness contract.
3.1 The three mmapped regions
io_uring_setup returns an FD and fills io_uring_params with two sets
of offsets (io_sqring_offsets, io_cqring_offsets). We mmap:
- SQ ring at offset
IORING_OFF_SQ_RING: containshead,tail,ring_mask,ring_entries,flags,dropped, and (unlessNO_SQARRAY) the index arrayarray. - SQE array at offset
IORING_OFF_SQES:ring_entriesentries of 64 bytes (or 128 bytes ifSQE128). - CQ ring at offset
IORING_OFF_CQ_RING:head,tail,ring_mask,ring_entries,overflow,flags, and thecqesarray (16 bytes, or 32 bytes ifCQE32).
With IORING_FEAT_SINGLE_MMAP (always present in 6.12), SQ and CQ share
a single mmap; this is detected and handled accordingly (two mmaps otherwise). With NO_MMAP,
the caller provides the memory — not covered in Stage 1 (advanced option, gated).
3.2 Publication protocol — memory ordering
The head/tail values are monotonic 32-bit counters shared with the kernel;
the real index = counter & ring_mask. Ordering is non-negotiable:
Submission (userspace as SQ producer):
- Write the complete SQE into
sqes[tail & mask]. - (Unless
NO_SQARRAY) writetail & maskintoarray[tail & mask]. - Publish the new
tailwith a store release (AtomicU32::store(.., Release)) — guarantees the kernel sees SQE writes before seeing the tail advance. - The SQ
headis read with a load acquire to calculate available space.
Completion (userspace as CQ consumer):
- Read the CQ
tailwith a load acquire — guarantees that CQEs written by the kernel are visible. - Read the CQEs between
headandtail. - Publish the new
headwith a store release to return entries to the kernel.
Every implementation must respect this protocol; it is model-tested
(loom, §11) in addition to functional tests. Reference: io_uring(7) and
io_uring_setup(2).
3.3 Wakeup and ring flags
IORING_SQ_NEED_WAKEUP(SQ flags): inSQPOLLmode, if set, the kernel thread must be woken viaio_uring_enter(.., SQ_WAKEUP)(handled in Stage 3e; in Stage 1 we read/expose the flag).IORING_SQ_CQ_OVERFLOW: the CQ has overflowed; withIORING_FEAT_NODROP(present in 6.12) the kernel retains completions and re-delivers them, but signals the condition. Stage 1 surfaces this (IoUring::completion_queue_overflowed()).IORING_SQ_TASKRUN: task-work is pending (withTASKRUN_FLAG); indicates that a kernel entry is useful.
4. The in-flight operation slab (Decision S1)
4.1 Problem and shape
Between a submission and its completion, the kernel potentially holds a
buffer (ownership transfer model, ADR-022 Decision 3) and the façade
must retrieve, at completion time, which operation and which buffer
correspond to the CQE. The link is user_data (u64, placed in the SQE, returned
in the CQE).
A dynamic table would allocate per operation — forbidden (CLAUDE.md). We use a pre-allocated slab:
struct InflightSlab {
slots: Box<[Slot]>, // allocated ONCE at construction
free_head: Option<u32>,
in_flight: u32,
}
struct Slot {
generation: u32, // incremented at each reuse (compteur de génération anti-réutilisation)
state: SlotState, // Free | Inflight { kind, buffer } | Multishot { .. }
}
- Capacity = maximum number of simultaneously in-flight operations, defaulting to
cq_entries(the kernel cannot have more pending completions than the CQ, modulo overflow handled by NODROP). Configurable via the builder. - The transferred buffer is moved into the slot: no copy, no reallocation in the happy path.
4.2 SubmissionToken encoding
SubmissionToken encapsulates { slot: u32, generation: u32 }. The kernel
user_data = ((generation as u64) << 32) | (slot as u64). At completion:
- decode
user_data→(generation, slot); - if
slots[slot].generation != generation→ stale completion (op already cancelled/recycled, e.g. a late multishot CQE): silently ignored; - otherwise, consume the slot and return the buffer to the caller.
4.3 Back-pressure
submit_* reserves a slot before writing the SQE. Slab full (in_flight == capacity) ⇒ returns Err(Errno::EBUSY) without touching the kernel. This is
Air’s structural back-pressure: we refuse politely rather than overflow.
4.4 Multishot
A multishot operation (Stage 3d) occupies one slot but produces multiple
CQEs. The slot stays alive as long as completions carry
IORING_CQE_F_MORE; it is freed on the final completion (without F_MORE) or on
cancellation. The generation guards against CQEs arriving after cancellation.
5. Ring construction
5.1 IoUringBuilder
#![allow(unused)]
fn main() {
pub struct IoUringBuilder { /* ... */ }
impl IoUringBuilder {
pub fn new(entries: NonZeroU32) -> Self;
pub fn with_completion_queue_entries(self, entries: NonZeroU32) -> Self; // SETUP_CQSIZE
pub fn max_inflight(self, n: NonZeroU32) -> Self; // slab capacity
pub fn with_flags(self, flags: SetupFlags) -> Self;
pub fn with_sqpoll_idle(self, dur: Duration) -> Self; // SQPOLL (Stage 3e)
pub fn with_sqpoll_cpu(self, cpu: u32) -> Self; // SQ_AFF
pub fn attach_work_queue(self, other: &IoUring) -> Self; // ATTACH_WQ
pub fn restrict(self, restrictions: &[Restriction]) -> Self; // S3 (Stage 3f)
pub fn build(self) -> Result<IoUring, Errno>;
}
}
build — behavior.
- Translates the configuration into
io_uring_params, callsio_uring_setup(2). entriesis rounded up by the kernel to the next power of two; the effective sizes are read back (sq_entries,cq_entries).- mmaps SQ/CQ/SQE per §3.1 (RAII: the mmaps are owned by
IoUring). - allocates the slab (§4) once, sized to
max_inflightorcq_entries. - reads
params.features→IoUringCapabilities. - if
REGISTERED_FD_ONLY/REG_REG_RING: registers the ring fd and transparently switches to registered-fd mode. - if
restrict(..)is non-empty: setsR_DISABLED, appliesREGISTER_RESTRICTIONS, leaves the ring disabled (caller invokesenable(); see Stage 3f).
Preconditions. entries ≤ kernel limit (otherwise EINVAL). Incompatible
flag combinations are rejected (e.g. IOPOLL + SQPOLL depending on context):
the kernel’s EINVAL is propagated without masking.
Errors. EINVAL (invalid params), ENOMEM (memory), EPERM
(SQPOLL/affinity without privilege depending on config), EFAULT, ENOSYS (io_uring
unavailable — sandbox/container: see §10).
Performance. Cost dominated by io_uring_setup + mmaps + single slab
allocation: ~a few tens of µs. Done once, off the hot path.
Example.
#![allow(unused)]
fn main() {
use core::num::NonZeroU32;
let ring = IoUringBuilder::new(NonZeroU32::new(256).unwrap())
.with_flags(SetupFlags::SINGLE_ISSUER | SetupFlags::DEFER_TASKRUN)
.build()?;
}
Tests. unit (rounded sizes, feature reading), property (arbitrary entries →
size invariants), integration (real creation on 6.12),
syscall simulator to force ENOSYS/ENOMEM, fuzzing of flag combinations.
5.2 IoUring::new and enable
#![allow(unused)]
fn main() {
impl IoUring {
pub fn new(entries: NonZeroU32) -> Result<Self, Errno>; // = builder.build()
pub fn enable(&mut self) -> Result<(), Errno>; // REGISTER_ENABLE_RINGS
pub fn completion_queue_overflowed(&self) -> bool; // reads SQ_CQ_OVERFLOW
pub fn submission_queue_space_left(&self) -> u32;
pub fn in_flight(&self) -> u32; // occupied slots (S1)
}
}
enable is only useful for a ring created in disabled state (via restrict); on an
already-active ring it returns Err(EINVAL) (propagated as-is).
6. Submission
6.1 submit and submit_and_wait
#![allow(unused)]
fn main() {
impl IoUring {
pub fn submit(&mut self) -> Result<u32, Errno>;
pub fn submit_and_wait(&mut self, want: u32) -> Result<u32, Errno>;
}
}
Behavior. submit publishes the SQ tail (§3.2) then calls
io_uring_enter(fd, to_submit, 0, 0, ..); returns the number of SQEs consumed
by the kernel. submit_and_wait(want) adds IORING_ENTER_GETEVENTS and
min_complete = want.
In SQPOLL mode, if the kernel thread is running, submit may require no
syscall (just the release publication); it wakes the thread only if
SQ_NEED_WAKEUP.
EINTR. Propagated as-is (ADR-021 conv. 2). The caller who wants to retry writes its own loop.
Errors. EINTR, EAGAIN (transient resources), EBUSY (CQ full and
not drained, depending on context), EINVAL, EFAULT, EBADF.
Performance. The io_uring gain is here: we batch N operations for a
single io_uring_enter. A submit without wait on a hot SQPOLL ring ≈ cost
of an atomic store.
6.2 Per-operation options
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, Default)]
pub struct SubmitOptions { /* ... */ }
impl SubmitOptions {
pub fn drain(self) -> Self; // IOSQE_IO_DRAIN
pub fn force_async(self) -> Self; // IOSQE_ASYNC
pub fn skip_cqe_on_success(self) -> Self; // IOSQE_CQE_SKIP_SUCCESS (FEAT_CQE_SKIP)
// link / hardlink exposed via LinkedChainBuilder (Stage 3c)
}
impl IoUring {
pub fn with(&mut self, opts: SubmitOptions) -> &mut Self;
}
}
with applies options to the next submit_* call. Note:
skip_cqe_on_success frees the S1 slot at submission time (no CQE expected on
success) — the slot spec documents this special release case.
7. Completion
7.1 Retrieval
#![allow(unused)]
fn main() {
impl IoUring {
pub fn wait_completion(&mut self) -> Result<Completion, Errno>;
pub fn wait_completion_timeout(&mut self, timeout: Duration)
-> Result<Option<Completion>, Errno>;
pub fn try_completion(&mut self) -> Option<Completion>;
pub fn completions(&mut self) -> CompletionIter<'_>;
}
}
wait_completion: blocks until at least one completion (io_uring_enter(.., GETEVENTS, min_complete=1)), decodes it, and consumes it.wait_completion_timeout: usesIORING_ENTER_EXT_ARG(io_uring_getevents_arg+__kernel_timespec) orABS_TIMER(FEAT_MIN_TIMEOUT);Ok(None)on expiry.try_completion: non-blocking, reads the CQ without a syscall if CQEs are present (load acquire of the tail).completions(): drains what is available, advanceshead(store release) at end of iteration or in batches.
Stale completion. If the decoded user_data points to a slot whose generation
does not match (§4.2), the completion is filtered out: these functions
skip it and move to the next without returning it to the caller.
7.2 The Completion type
#![allow(unused)]
fn main() {
pub struct Completion { /* token, res, flags, slot access for buffer retrieval */ }
impl Completion {
pub fn token(&self) -> SubmissionToken;
pub fn raw_result(&self) -> i32; // raw, semantics depend on op
pub fn flags(&self) -> CompletionFlags;
pub fn has_more(&self) -> bool; // CQE_F_MORE (multishot)
pub fn is_notif(&self) -> bool; // CQE_F_NOTIF (zero-copy)
pub fn buffer_id(&self) -> Option<u16>; // CQE_F_BUFFER
pub fn socket_has_pending_data(&self) -> bool; // CQE_F_SOCK_NONEMPTY
// Generic interpretation (rich typed variants are in Stages 2x):
pub fn into_result(self) -> Result<i32, Errno>; // res<0 => Err(-res)
pub fn into_buffer_result(self) -> Result<(Vec<u8>, usize), Errno>; // S1 buffer retrieval
pub fn completed(&self) -> Result<(), Errno>; // success with no value
}
}
Result convention. A negative res is a -errno; the into_* methods
convert it to Err(Errno). A res ≥ 0 is the useful value
(bytes, fd, etc.), interpreted by the appropriate method — the developer knows
which operation they submitted (ADR-022 Decision 9).
Buffer retrieval. into_buffer_result takes the buffer moved out of
the slot (S1) and returns it to the caller along with the byte count — zero copy.
7.3 CompletionFlags
bitflags for the 5 flags on axis E: BUFFER, MORE, SOCK_NONEMPTY, NOTIF,
BUF_MORE.
8. Safe teardown (Decision S2)
8.1 sync_cancel (building block)
#![allow(unused)]
fn main() {
pub enum CancelTarget {
Token(SubmissionToken),
Fd(BorrowedFd<'_>),
Op(IoUringOpcode),
Any,
}
impl IoUring {
pub fn sync_cancel(&mut self, target: CancelTarget) -> Result<u32, Errno>;
}
}
Relies on IORING_REGISTER_SYNC_CANCEL (io_uring_sync_cancel_reg, with
timeout). Maps CancelTarget to IORING_ASYNC_CANCEL_* flags
(USERDATA / FD / OP / ANY). Returns the number of cancelled operations.
8.2 shutdown and Drop
#![allow(unused)]
fn main() {
impl IoUring {
pub fn shutdown(self) -> Result<(), Errno>;
}
impl Drop for IoUring { fn drop(&mut self); }
}
shutdown (clean path).
sync_cancel(Any)with a bounded timeout.- Drain the CQ until
in_flight() == 0(slot buffers are cleanly released as they come). munmapthe rings, close the FD (RAII), release the slab.- Returns
Errif draining fails/expires (caller decides).
Drop (safety net). If in_flight() > 0, executes the same quiesce sequence
in a blocking best-effort manner (bounded loop). The cost
(potential blocking) is documented: on the hot path, prefer shutdown().
Rationale: “over-secure then trim after measurement” (Principle 5) — a Drop that
allows the kernel to write into freed memory is an unacceptable soundness defect in
layer 0.
Safety invariant. As long as an operation is in flight, its associated buffer lives in its slot (S1) and cannot be freed by the caller; the ring cannot be destroyed without quiescence. These two invariants guarantee that no kernel write lands on freed memory.
9. Introspection: probe and capabilities
#![allow(unused)]
fn main() {
impl IoUring {
pub fn supports_op(&self, op: IoUringOpcode) -> bool;
pub fn capabilities(&self) -> IoUringCapabilities;
}
}
supports_op: queriesIORING_REGISTER_PROBE(cached at construction). Enables fallback to synchronous syscalls when an op is not supported by the current kernel (ADR-022 Decision 8).capabilities: exposes the 16 features on axis G via stable predicates (single_mmap,nodrop,cqe_skip,min_timeout,recvsend_bundle,reg_reg_ring, …).
Fallback example.
#![allow(unused)]
fn main() {
if ring.supports_op(IoUringOpcode::Openat2) {
let tok = ring.submit_openat2(/* ... */)?; // Stage 2a
} else {
// fall back to synchronous air-sys-syscall::fs::openat2
}
}
10. io_uring unavailability (ADR-022 Decision 10)
If the kernel does not support io_uring or if the environment has disabled it
(seccomp, sandbox, hardened container), build() returns Err(Errno::ENOSYS)
(or EPERM depending on the filter). No hidden automatic fallback: the caller
chooses to switch to synchronous mode or to refuse to start.
11. Test strategy (layer 0 — 100% lines + branches)
- Unit:
SubmissionTokenencoding/decoding (slot+generation), head/tail arithmetic (masking, wrap), back-pressureEBUSY, stale completion filtering. - Property-based (proptest): for any sequence of submit/complete, the
invariants
in_flight ≤ capacity,head ≤ tail, no doubly-freed slot, no lost buffer. - Concurrency model (loom): the acquire/release protocol from §3.2 on a producer(userspace)/consumer(simulated kernel) model; detects any missing ordering.
- Integration: on a real 6.12 kernel — creation, nop, submit/wait, timeout, clean shutdown, Drop with in-flight ops, CQ overflow (NODROP).
- Syscall simulator: harness injecting
EINTR,EAGAIN,ENOSYS,EFAULTatenter/setup/registerboundaries to cover error branches without depending on the kernel. - Fuzzing (cargo-fuzz): decoding of CQEs and returned
io_uring_params(any data coming from the kernel is treated as external input, Principle 3). - Drop tests: Miri/valgrind to confirm absence of use-after-free on quiescence paths.
Non-coverable branches (e.g. kernel errors impossible to trigger):
recorded in docs/COVERAGE-EXCEPTIONS.md with justification.
12. Error summary (Stage 1)
| Function | Notable errors |
|---|---|
build / new | EINVAL, ENOMEM, EPERM, ENOSYS, EFAULT |
enable | EINVAL, EBADF |
submit / submit_and_wait | EINTR, EAGAIN, EBUSY, EINVAL, EBADF, EFAULT |
wait_completion* | EINTR, ETIME (internal timeout, per mapping), EBADF |
submit_* (slot reservation) | EBUSY (slab full) before any syscall |
sync_cancel | EINTR, EALREADY, ENOENT, EINVAL |
shutdown | propagates drain/cancel errors |
13. Types added to air-sys-types (Stage 1)
IoUring, IoUringBuilder, IoUringParams, SetupFlags,
IoUringCapabilities, IoUringOpcode, SubmissionToken, SubmitOptions,
Completion, CompletionFlags, CancelTarget. Approximately 11 public types (the
slab and mmap wrappers are internal, not exposed).
14. Core decisions emerging from Stage 1
SubmissionToken= slot+generation, not rawuser_data. The API never lets the caller manipulate an arbitraryuser_data: the generation guards against ABA and stale completions (multishot/cancel).- Slab sized by default to
cq_entries. Aligns application-level back-pressure with the kernel’s actual completion capacity. - Blocking
Dropaccepted. Soundness over performance, reversible after measurement (but never in the dangerous direction). - No
Submittabletrait in Stage 1. Inherent methods; the abstraction is introduced only if Stages 3c/3d prove the need for it (Principle 7). - Timeouts via
EXT_ARG/ABS_TIMER, not via a linkedTIMEOUToperation — that remains available in Stage 2c for explicit cases.
15. Next steps
Following specs, on this model: io-uring-2a-filesystem-en.md (the 26 FS
operations built on the core above), then 2b, 2c, 2d, 3a–3f, 4. The global
English translation is produced once the French documents are validated.
Document license: MPL 2.0
Status: Technical specification of Stage 1 (core) of the air-sys-syscall::io_uring module, target kernel 6.12 LTS.
Layer 0 spec — Module io_uring, Stage 2a: filesystem operations
Technical specification — Version 1.0 (target kernel: Linux 6.12 LTS)
Position. Stage 2a specifies the 26 filesystem operations exposed via io_uring in 6.12 (see master document
io-uring-0-inventaire-en.md, axis B). All of them reuse as-is the core from Stage 1 (io-uring-1-core-en.md): submission, slab S1, completion, teardown S2. This document does not re-specify the ring or the slab — it specifies the operations.
1. Cross-cutting conventions for Stage 2a
1.1 Cross-reference to synchronous families (ADR-022 Decision 2)
Each io_uring operation has a synchronous syscall equivalent already specified
in family-fs.md / family-mem.md. To avoid duplication (and the risk of
introducing divergences), this document:
- identifies the op by its opcode
IORING_OP_*and its number (source of truth: uapi header v6.12); - names the equivalent syscall and cross-references the family for its x86_64/ARM64 number, flags, and fine-grained semantics;
- reuses the shared types (
OpenHow,StatxFlags,StatxMask,Statx,RenameFlags,Mode,DirFd,Advice,SpliceFlags,FallocateMode,FsyncFlags,XattrFlags…) defined alongside the families — a developer familiar with the synchronous API will find the same types here.
The “real underlying syscall” of every io_uring op is io_uring_enter(2)
(no. 426); the names below refer to the semantic equivalent.
1.2 Buffer model (recap ADR-022 Decision 3 + S1)
Three mechanisms, ownership transfer by default:
- Ownership (default): the buffer (
Vec<u8>,Box<Statx>,CString…) is moved into slot S1 on submission, and returned on completion. Safe by construction: it cannot be touched while the kernel holds it. - Registered buffer (
READ_FIXED/WRITE_FIXED): references a pre-registered buffer by index → avoids address translation. TypeRegisteredBufferSlicedefined at Stage 3a; only thesubmit_read_fixedform is exposed here. - Raw unsafe: raw pointer, validity guaranteed by the caller. Stage 4.
1.3 Offset: Option<u64> rather than a sentinel (ADR-021 conv. 1)
Operations that take an offset (read, write, readv, writev, read_fixed,
write_fixed) accept offset: Option<u64>:
Some(n): absolute offsetn;None: “current position of the file” (transmitted as-1to the kernel, requiresIORING_FEAT_RW_CUR_POS, present in 6.12).
The magic -1 is never exposed: the typed None replaces it.
1.4 Directory descriptors
Operations using *at take dirfd: DirFd (newtype shared with family-fs),
where DirFd::CWD materializes AT_FDCWD — yet another kernel sentinel replaced
by a typed constructor.
1.5 Paths
Paths are CString values (NUL-terminated bytes, not necessarily UTF-8;
consistent with Principle 3 and the use of Path/OsStr at layer 1). The path
buffer is moved into the slot (the kernel reads it asynchronously; it must
remain valid until completion — S1 guarantees this).
2. Read / write
2.1 submit_read / submit_write
#![allow(unused)]
fn main() {
impl IoUring {
pub fn submit_read(&mut self, fd: BorrowedFd<'_>, buffer: Vec<u8>, offset: Option<u64>)
-> Result<SubmissionToken, Errno>;
pub fn submit_write(&mut self, fd: BorrowedFd<'_>, buffer: Vec<u8>, offset: Option<u64>)
-> Result<SubmissionToken, Errno>;
}
}
- Opcode:
IORING_OP_READ(22) /IORING_OP_WRITE(23). - Equivalent:
pread/pwrite(offsetSome) orread/write(offsetNone). Seefamily-fs.md. - Buffer:
bufis moved into the slot. Forread, its length bounds the number of bytes read (len = buf.len()— the logical length of theVecis used). Forwrite,buf.len()bytes are written. - Completion:
completion.into_buffer_result() -> (Vec<u8>, usize)returns the buffer and the number of bytes transferred.res < 0⇒Err(Errno). - Preconditions:
bufnon-empty for a useful read (a zero-byte read is legal and completes at 0).fdopened in the correct mode. - Errors:
EBUSY(slab full, before syscall), then at completionEBADF,EINVAL,EFAULT,EIO,EAGAIN(non-blocking FD with no data). - Performance: zero copy on the facade side (move). On a hot SQPOLL ring, submission ≈ one atomic store.
Example.
#![allow(unused)]
fn main() {
let buf = vec![0u8; 4096];
let tok = ring.submit_read(file.as_fd(), buf, Some(0))?;
ring.submit_and_wait(1)?;
let cmp = ring.wait_completion()?;
let (buf, n) = cmp.into_buffer_result()?; // buffer recovered, n bytes read
}
2.2 submit_readv / submit_writev
#![allow(unused)]
fn main() {
impl IoUring {
pub fn submit_readv(&mut self, fd: BorrowedFd<'_>, buffers: Vec<Vec<u8>>, offset: Option<u64>)
-> Result<SubmissionToken, Errno>;
pub fn submit_writev(&mut self, fd: BorrowedFd<'_>, buffers: Vec<Vec<u8>>, offset: Option<u64>)
-> Result<SubmissionToken, Errno>;
}
}
- Opcode:
IORING_OP_READV(1) /IORING_OP_WRITEV(2). - Equivalent:
preadv/pwritev. Seefamily-fs.md. - Buffers: we own a vector of owned buffers (
Vec<Vec<u8>>) to remain safe (ownership transfer). Theiovecarray transmitted to the kernel is built internally and stored in the slot; it does not outlive the caller. (The vectorized high-performance path without copying belongs to Stage 4.) - Completion:
completion.into_vectored_result() -> (Vec<Vec<u8>>, usize). - Errors: same as read/write +
EINVALif the number of iovecs >IOV_MAX.
2.3 submit_read_fixed / submit_write_fixed
#![allow(unused)]
fn main() {
impl IoUring {
pub fn submit_read_fixed(&mut self, fd: BorrowedFd<'_>, buf: RegisteredBufferSlice, offset: Option<u64>)
-> Result<SubmissionToken, Errno>;
pub fn submit_write_fixed(&mut self, fd: BorrowedFd<'_>, buf: RegisteredBufferSlice, offset: Option<u64>)
-> Result<SubmissionToken, Errno>;
}
}
- Opcode:
IORING_OP_READ_FIXED(4) /IORING_OP_WRITE_FIXED(5). - Buffer:
RegisteredBufferSlicedesignates a slice of a pre-registered buffer (IORING_REGISTER_BUFFERS2, Stage 3a). No per-operation ownership transfer: the buffer belongs to the registration. - Benefit: the kernel does not need to pin/translate pages on each op (already done at registration time). Hot path (AirCom data plane).
- Completion:
completion.into_result() -> i32(bytes); the buffer remains owned by the registration. - Cross-reference: registration semantics are detailed at Stage 3a.
3. Synchronization, allocation, truncation
3.1 submit_fsync
#![allow(unused)]
fn main() {
pub fn submit_fsync(&mut self, fd: BorrowedFd<'_>, flags: FsyncFlags)
-> Result<SubmissionToken, Errno>;
}
- Opcode:
IORING_OP_FSYNC(3). Equivalent:fsync/fdatasync(FsyncFlags::DATASYNC⇒IORING_FSYNC_DATASYNC). - Completion:
completed() -> Result<(), Errno>. - Errors:
EBADF,EIO,EINVAL.
3.2 submit_sync_file_range
#![allow(unused)]
fn main() {
pub fn submit_sync_file_range(&mut self, fd: BorrowedFd<'_>, offset: u64, nbytes: u64, flags: SyncFileRangeFlags)
-> Result<SubmissionToken, Errno>;
}
- Opcode:
IORING_OP_SYNC_FILE_RANGE(8). Equivalent:sync_file_range. Seefamily-fs.mdfor flags. - Completion:
completed().
3.3 submit_fallocate
#![allow(unused)]
fn main() {
pub fn submit_fallocate(&mut self, fd: BorrowedFd<'_>, mode: FallocateMode, offset: i64, length: i64)
-> Result<SubmissionToken, Errno>;
}
- Opcode:
IORING_OP_FALLOCATE(17). Equivalent:fallocate. - Note:
offset/lenasi64in conformance with the ABI; upstream validation (len > 0, no overflow) is performed before submission (Principle 4). - Completion:
completed(). Errors:EBADF,EFBIG,ENOSPC,EINVAL.
3.4 submit_ftruncate
#![allow(unused)]
fn main() {
pub fn submit_ftruncate(&mut self, fd: BorrowedFd<'_>, length: u64)
-> Result<SubmissionToken, Errno>;
}
- Opcode:
IORING_OP_FTRUNCATE(55). Equivalent:ftruncate. - Completion:
completed(). Errors:EBADF,EINVAL,EFBIG,EPERM.
4. Open / close
4.1 submit_openat2
#![allow(unused)]
fn main() {
pub fn submit_openat2(&mut self, dirfd: DirFd, path: CString, how: OpenHow)
-> Result<SubmissionToken, Errno>;
}
- Opcode:
IORING_OP_OPENAT2(28). Equivalent:openat2(no. 437 x86_64/ARM64). Seefamily-fs.mdforOpenHow(flags, mode, resolve). - Note:
IORING_OP_OPENAT(18) is excluded (UNSUPPORTED.md) in favour ofopenat2, which is a superset (see master document §4). - Completion:
completion.opened_fd() -> Result<OwnedFd, Errno>(CLOEXEC according tohow). The direct descriptor variant (result placed in a slot of the registered FD table viafile_index) belongs to Stage 3a;submit_openat2_directwill be exposed there. - Errors:
ENOENT,EACCES,ELOOP,ENFILE,EINVAL…
4.2 submit_close
#![allow(unused)]
fn main() {
pub fn submit_close(&mut self, fd: OwnedFd) -> Result<SubmissionToken, Errno>;
}
- Opcode:
IORING_OP_CLOSE(19). Equivalent:close. - Ownership:
submit_closeconsumes theOwnedFd(moved into the slot) — it can no longer be reused, guaranteeing the absence of a double close. The FD is only actually closed upon kernel execution. - Completion:
completed(). Error:EBADF(unlikely, FD is owned). - Fixed variant: closing a slot in the registered FD table → Stage 3a.
5. Metadata
5.1 submit_statx
#![allow(unused)]
fn main() {
pub fn submit_statx(&mut self, dirfd: DirFd, path: CString, flags: StatxFlags, mask: StatxMask, out: Box<Statx>)
-> Result<SubmissionToken, Errno>;
}
- Opcode:
IORING_OP_STATX(21). Equivalent:statx(no. 332 x86_64 / 291 ARM64). Seefamily-fs.mdforStatxFlags,StatxMask,Statx. - Output buffer: the kernel writes the structure into
out; the ownership ofBox<Statx>is transferred into the slot (it must remain valid until completion — S1). - Completion:
completion.into_statx() -> Result<Box<Statx>, Errno>. - Errors:
ENOENT,EACCES,EINVAL.
6. Access advice (advise)
6.1 submit_fadvise
#![allow(unused)]
fn main() {
pub fn submit_fadvise(&mut self, fd: BorrowedFd<'_>, offset: u64, length: u64, advice: Advice)
-> Result<SubmissionToken, Errno>;
}
- Opcode:
IORING_OP_FADVISE(24). Equivalent:posix_fadvise. - Completion:
completed().
6.2 submit_madvise — implemented (coordinated family-mem PR)
#![allow(unused)]
fn main() {
pub fn submit_madvise(&mut self, region: &MmapRegion, range: Range<usize>, advice: MadviseAdvice)
-> Result<SubmissionToken, Errno>;
}
- Opcode:
IORING_OP_MADVISE(25). Equivalent:madvise. - Safety (settled decision, realized): no raw range (
addr/len) orunsafeAPI.submit_madvisetakes a shareableMmapRegion(type fromfamily-mem, seefamily-mem-mmap-region.md) and a sub-rangevalidated against the region’s bounds (Principle 4;range.end ≤ region.len()andrange.start ≤ range.endotherwiseErr(EINVAL)before submission). The region must remain mapped until completion: slot S1 retains anMmapRegionLiveness(clone of the innerArc,region.liveness_handle()), so thatmunmapcannot happen while amadviseop is in flight — safe by construction (no use-after-unmap, no leak:munmapat last drop), proven with Miri + loom. The same handle is reused byfutex(Stage 2c §6.1) and will be by the memory registration in Stage 3a. - Completion:
completed().
7. Zero-copy between FDs
7.1 submit_splice
#![allow(unused)]
fn main() {
pub fn submit_splice(&mut self, fd_in: BorrowedFd<'_>, offset_in: Option<u64>,
fd_out: BorrowedFd<'_>, offset_out: Option<u64>,
length: u32, flags: SpliceFlags)
-> Result<SubmissionToken, Errno>;
}
- Opcode:
IORING_OP_SPLICE(30). Equivalent:splice. Seefamily-ipc.md(theipcfamily already documentssplice/tee). - Precondition: at least one of the two FDs is a pipe (as with
splice(2)).off_in/off_outasOption<u64>(None = current position / non-seekable FD). - Completion:
completion.into_result() -> i32(bytes transferred). - Note:
SPLICE_F_FD_IN_FIXED(bit 31) allows using a registered FD as input → exposed cleanly whenfd_inis a fixed FD (Stage 3a).
7.2 submit_tee
#![allow(unused)]
fn main() {
pub fn submit_tee(&mut self, fd_in: BorrowedFd<'_>, fd_out: BorrowedFd<'_>, length: u32, flags: SpliceFlags)
-> Result<SubmissionToken, Errno>;
}
- Opcode:
IORING_OP_TEE(33). Equivalent:tee. Both FDs are pipes; duplicates data without consuming the source. Completion:into_result.
8. Directories and links
Uniform operations: paths as CString moved into the slot, dirfd: DirFd, completion completed(). Equivalents and flags: see family-fs.md.
#![allow(unused)]
fn main() {
impl IoUring {
pub fn submit_mkdirat(&mut self, dirfd: DirFd, path: CString, mode: Mode)
-> Result<SubmissionToken, Errno>; // OP 37
pub fn submit_unlinkat(&mut self, dirfd: DirFd, path: CString, flags: UnlinkFlags)
-> Result<SubmissionToken, Errno>; // OP 36
pub fn submit_renameat(&mut self, old_dirfd: DirFd, old_path: CString,
new_dirfd: DirFd, new_path: CString, flags: RenameFlags)
-> Result<SubmissionToken, Errno>; // OP 35 (renameat2 semantics)
pub fn submit_linkat(&mut self, old_dirfd: DirFd, old_path: CString,
new_dirfd: DirFd, new_path: CString, flags: LinkFlags)
-> Result<SubmissionToken, Errno>; // OP 39
pub fn submit_symlinkat(&mut self, target: CString, new_dirfd: DirFd, link_path: CString)
-> Result<SubmissionToken, Errno>; // OP 38
}
}
renameatcarriesRenameFlags(RENAME_NOREPLACE,RENAME_EXCHANGE,RENAME_WHITEOUT) —renameat2semantics.- Typical errors:
ENOENT,EEXIST,ENOTEMPTY,EACCES,EXDEV(linkat cross-device),EINVAL(flags). - Note: each rename/link op carries two
CStringvalues → two buffers moved into the slot; slot S1 stores a composite state, documented accordingly.
9. Extended attributes (xattr)
#![allow(unused)]
fn main() {
impl IoUring {
pub fn submit_setxattr(&mut self, path: CString, name: CString, value: Vec<u8>, flags: XattrFlags)
-> Result<SubmissionToken, Errno>; // OP 42
pub fn submit_getxattr(&mut self, path: CString, name: CString, value: Vec<u8>)
-> Result<SubmissionToken, Errno>; // OP 44
pub fn submit_fsetxattr(&mut self, fd: BorrowedFd<'_>, name: CString, value: Vec<u8>, flags: XattrFlags)
-> Result<SubmissionToken, Errno>; // OP 41
pub fn submit_fgetxattr(&mut self, fd: BorrowedFd<'_>, name: CString, value: Vec<u8>)
-> Result<SubmissionToken, Errno>; // OP 43
}
}
- Equivalents:
setxattr/getxattr/fsetxattr/fgetxattr. Seefamily-fs.md. - Buffers:
name(CString) andvalue(Vec<u8>, raw bytes — Principle 3) are moved into the slot. Forget*,valueserves as an output buffer; completioncompletion.into_xattr_result() -> (Vec<u8>, usize)(the returned size allows the buffer to be resized). - Errors:
ENODATA,ERANGE(buffer too short),ENOTSUP,EACCES.
10. Summary of the 26 operations
| # opcode | Opcode | Facade | Completion |
|---|---|---|---|
| 1 | READV | submit_readv | into_vectored_result |
| 2 | WRITEV | submit_writev | into_vectored_result |
| 3 | FSYNC | submit_fsync | completed |
| 4 | READ_FIXED | submit_read_fixed | into_result |
| 5 | WRITE_FIXED | submit_write_fixed | into_result |
| 8 | SYNC_FILE_RANGE | submit_sync_file_range | completed |
| 17 | FALLOCATE | submit_fallocate | completed |
| 19 | CLOSE | submit_close | completed |
| 21 | STATX | submit_statx | into_statx |
| 22 | READ | submit_read | into_buffer_result |
| 23 | WRITE | submit_write | into_buffer_result |
| 24 | FADVISE | submit_fadvise | completed |
| 25 | MADVISE | submit_madvise | completed |
| 28 | OPENAT2 | submit_openat2 | opened_fd |
| 30 | SPLICE | submit_splice | into_result |
| 33 | TEE | submit_tee | into_result |
| 35 | RENAMEAT | submit_renameat | completed |
| 36 | UNLINKAT | submit_unlinkat | completed |
| 37 | MKDIRAT | submit_mkdirat | completed |
| 38 | SYMLINKAT | submit_symlinkat | completed |
| 39 | LINKAT | submit_linkat | completed |
| 41 | FSETXATTR | submit_fsetxattr | completed |
| 42 | SETXATTR | submit_setxattr | completed |
| 43 | FGETXATTR | submit_fgetxattr | into_xattr_result |
| 44 | GETXATTR | submit_getxattr | into_xattr_result |
| 55 | FTRUNCATE | submit_ftruncate | completed |
Intentionally absent (pseudo-opcodes from a former overview, non-existent
in the io_uring 6.12 ABI): copy_file_range, fchmodat, fchownat. Permission
and ownership changes remain synchronous (family-fs).
11. Added / shared types
No new ring types; reuses the core (Stage 1). Business types shared with
the families: OpenHow, Statx, StatxFlags, StatxMask,
Mode, DirFd, RenameFlags, UnlinkFlags, LinkFlags, FsyncFlags,
SyncFileRangeFlags, FallocateMode, Advice, SpliceFlags, XattrFlags.
New at Stage 2a: RegisteredBufferSlice (shape; defined at Stage 3a).
madvise reuses MmapRegion from family-mem (shared liveness handle,
specified jointly). New Completion methods:
into_vectored_result, into_statx, into_xattr_result.
12. Test strategy
- Integration (kernel 6.12, tmpfs/ext4): read/write round-trips with
content verification, offset
Some/None, multi-buffer readv, openat2 + read + close chained, statx, rename/link/unlink, xattr set/get round-trip, file→pipe splice. - Property-based: for read/write,
bytes transferred ≤ lenand faithful buffer restitution; for paths, robustness to non-UTF-8 bytes. - Errors via simulator:
ENOENT,EACCES,ERANGE(xattr),ENOSPC(fallocate),EXDEV(linkat). - Soundness: Miri on buffer transfer/return paths; verify that an in-flight buffer can be neither freed nor reused (the type makes it impossible).
- Fuzzing: decoding of
statx/getxattrresults (data coming from the kernel = external data). - Coverage: 100% lines + branches; legitimate exceptions in
COVERAGE-EXCEPTIONS.md.
13. Key decisions that emerged at Stage 2a
offset: Option<u64>— direct application of ADR-021 conv. 1 (None = current position, no magic-1).readv/writevwith ownedVec<Vec<u8>>— safety first; the zero-copy vectorized path is at Stage 4, not a shortcut here.submit_close(OwnedFd)consumes the FD — double close is prevented by the type system.- No
copy_file_range/fchmodat/fchownat— these opcodes do not exist in io_uring 6.12; no fake facade is built. The corresponding operations remain synchronous. - “Direct descriptor” and “fixed buffer” variants deferred to Stage 3a — registration is not introduced here in order to keep 2a focused on operations with simple ownership.
14. Next steps
Next spec: io-uring-2b-network-en.md (12 network operations, including the
zero-copy variants send_zc/sendmsg_zc and bind/listen). The global
English translation is produced once the French documents have been validated.
Document license: MPL 2.0
Status: Technical specification for Stage 2a (filesystem) of the air-sys-syscall::io_uring module, target kernel 6.12 LTS.
Spec layer 0 — Module io_uring, Stage 2b: network operations
Technical specification — Version 1.0 (target kernel: Linux 6.12 LTS)
Position. Stage 2b specifies the 12 network operations of io_uring in 6.12 (master doc
io-uring-0-inventaire-en.md, axis B). They reuse the core from Stage 1 and the conventions of Stage 2a (references to families, buffer model S1, typed offsets/sentinels). This Stage is strategic for AirCom (ADR-001): this is where zero-copy (send_zc,sendmsg_zc) for the data plane and FD passing (capabilities) viasendmsg/recvmsgcontrol messages live.
1. Cross-cutting conventions for Stage 2b
- Types shared with
family-net(ADR-022 D2):SocketAddr,SocketDomain,SocketType,MessageFlags,ShutdownMode,AcceptFlags,SendMessageRequest,ReceiveMessageRequest,ControlMessages. Seefamily-net.mdfor their definitions and equivalent syscall numbers. MSG_NOSIGNALby default on all sends: noSIGPIPEon a closed socket (the error surfaces asEPIPEin the completion). This is an Air invariant, explicitly overridable.SOCK_CLOEXECby default on created FDs (accept,socket): consistent with thenetfamily and the other layer 0 families.- Owned addresses: a
SocketAddrto be transmitted is serialized into an ownedsockaddrstorage, moved into slot S1 (the kernel reads it asynchronously — it must survive until completion). - Direct descriptor variants (result stored in a registered FD table
rather than an ordinary FD) and multishot: deferred to Stage 3a
and Stage 3d respectively.
bind/listenexist precisely to operate on these direct FDs without an ordinary FD.
2. Connection establishment
2.1 submit_accept
#![allow(unused)]
fn main() {
impl IoUring {
pub fn submit_accept(&mut self, listener: BorrowedFd<'_>, flags: AcceptFlags)
-> Result<SubmissionToken, Errno>;
pub fn submit_accept_with_peer(&mut self, listener: BorrowedFd<'_>, flags: AcceptFlags)
-> Result<SubmissionToken, Errno>;
}
}
- Opcode:
IORING_OP_ACCEPT(13). Equivalent:accept4. - Completion:
completion.accepted_fd() -> Result<OwnedFd, Errno>(CLOEXEC by default). The_with_peervariant captures the peer address in an owned storage in the slot ⇒completion.into_accept_result() -> (OwnedFd, SocketAddr). - Multishot:
submit_accept_multishotat Stage 3d (a single SQE accepts continuously). Direct fd:_directvariant at Stage 3a. - Errors:
EAGAIN(nothing to accept, non-blocking FD),EMFILE/ENFILE,ECONNABORTED,EINVAL.
2.2 submit_connect
#![allow(unused)]
fn main() {
pub fn submit_connect(&mut self, sock: BorrowedFd<'_>, address: SocketAddr)
-> Result<SubmissionToken, Errno>;
}
- Opcode:
IORING_OP_CONNECT(16). Equivalent:connect. - Completion:
completed(). Errors:ECONNREFUSED,ETIMEDOUT,EINPROGRESS(should not surface in io_uring: the op completes at the end),EALREADY,EISCONN.
2.3 submit_socket
#![allow(unused)]
fn main() {
pub fn submit_socket(&mut self, domain: SocketDomain, ty: SocketType, protocol: i32)
-> Result<SubmissionToken, Errno>;
}
- Opcode:
IORING_OP_SOCKET(45). Equivalent:socket. - Completion:
completion.into_socket_fd() -> Result<OwnedFd, Errno>(CLOEXEC by default). Direct fd:_directvariant at Stage 3a. - Motivation: create a socket inside the ring, chainable (Stage 3c)
with
connect/bindwithout a synchronous round-trip.
2.4 submit_bind / submit_listen
#![allow(unused)]
fn main() {
pub fn submit_bind(&mut self, sock: BorrowedFd<'_>, address: SocketAddr)
-> Result<SubmissionToken, Errno>; // OP 56
pub fn submit_listen(&mut self, sock: BorrowedFd<'_>, backlog: u32)
-> Result<SubmissionToken, Errno>; // OP 57
}
- Opcodes:
IORING_OP_BIND(56) /IORING_OP_LISTEN(57) — added in 6.11. Equivalents:bind/listen. - Motivation: allow
bind/listenon a socket created as a direct descriptor (submit_socket_direct), which has no ordinary FD usable by the classicbind(2)/listen(2)calls. - Completion:
completed(). Errors:EADDRINUSE,EACCES,EINVAL.
3. Data transfer (one-shot)
3.1 submit_send / submit_receive
#![allow(unused)]
fn main() {
pub fn submit_send(&mut self, sock: BorrowedFd<'_>, buffer: Vec<u8>, flags: MessageFlags)
-> Result<SubmissionToken, Errno>; // OP 26
pub fn submit_receive(&mut self, sock: BorrowedFd<'_>, buffer: Vec<u8>, flags: MessageFlags)
-> Result<SubmissionToken, Errno>; // OP 27
}
- Equivalents:
send/recv.MSG_NOSIGNALadded by default tosend. - Buffer: ownership transfer (S1). Completion:
into_buffer_result() -> (Vec<u8>, usize). recv:completion.socket_has_pending_data()(CQE_F_SOCK_NONEMPTY) indicates there is more data to read — useful for deciding whether to issue anotherrecv.- Multishot recv and provided buffers (
BUFFER_SELECT): Stage 3d/3b. - Errors:
ECONNRESET,EPIPE(send on closed socket, thanks to NOSIGNAL),EAGAIN,EMSGSIZE.
3.2 submit_send_message / submit_receive_message
#![allow(unused)]
fn main() {
pub fn submit_send_message(&mut self, sock: BorrowedFd<'_>, request: SendMessageRequest)
-> Result<SubmissionToken, Errno>; // OP 9
pub fn submit_receive_message(&mut self, sock: BorrowedFd<'_>, request: ReceiveMessageRequest)
-> Result<SubmissionToken, Errno>; // OP 10
}
- Equivalents:
sendmsg/recvmsg. SendMessageRequest(owned, moved into the slot) aggregates: data buffers (Vec<Vec<u8>>), an optional destination address, and control messages (ControlMessages) — includingSCM_RIGHTSfor FD passing. This is the mechanism by which AirCom transports a capability (an unforgeable FD) between two peers (ADR-001). The façade builds thestruct msghdr+cmsginternally, stored in the slot.ReceiveMessageRequest(owned): receive buffers + a control buffer pre-sized for incoming cmsgs (received FDs). Completion:completion.into_receive_message_result() -> Result<(ReceiveMessageRequest, ReceiveMessageMeta), Errno>whereReceiveMessageMeta(backed byio_uring_recvmsg_out) providesnamelen,controllen,payloadlen,flags(includingMSG_TRUNC/MSG_CTRUNCto check). Received FDs are extracted from the cmsgs asOwnedFd(CLOEXEC).- FD passing safety: received FDs are materialized as
OwnedFd; a truncated cmsg (MSG_CTRUNC) is reported and partial FDs are closed cleanly (no FD leak). - Errors: same as send/recv +
EINVAL(malformed cmsg on the send side).
4. Zero-copy (AirCom data plane)
4.1 Two-completion lifecycle — the key point
send_zc/sendmsg_zc avoid the kernel copy of the payload: the kernel
references the userspace pages directly. This has a major impact on
ownership:
A zero-copy submission produces two completions:
- Result completion — carries the number of bytes sent (
res ≥ 0) and the flagCQE_F_MORE(a notification will follow).- Notification completion — carries
CQE_F_NOTIF; it signals that the kernel no longer references the buffer, which may now be reused or freed.
The buffer must remain alive until the NOTIF completion, not just until the result. The façade manages this via slot S1:
- the slot retains the buffer until
CQE_F_NOTIFis received; - the result completion (
F_MORE) exposes the byte count viainto_result()without returning the buffer; - the NOTIF completion (
is_notif()) returns the buffer viainto_zero_copy_buffer()and releases the slot.
This is the only case in the module where a slot outlives its first completion; it is specified explicitly here and tested (result→NOTIF sequencing, NOTIF without a preceding result on early failure).
4.2 submit_send_zero_copy / submit_send_message_zero_copy
#![allow(unused)]
fn main() {
pub fn submit_send_zero_copy(&mut self, sock: BorrowedFd<'_>, buffer: Vec<u8>, flags: MessageFlags, zero_copy: ZeroCopyFlags)
-> Result<SubmissionToken, Errno>; // OP 47
pub fn submit_send_message_zero_copy(&mut self, sock: BorrowedFd<'_>, request: SendMessageRequest, zero_copy: ZeroCopyFlags)
-> Result<SubmissionToken, Errno>; // OP 48
}
- Equivalents: zero-copy variants of
send/sendmsg. ZeroCopyFlags: exposesREPORT_USAGE(IORING_SEND_ZC_REPORT_USAGE→ the NOTIF completion indicates whether zero-copy actually took place or whether the kernel had to copy; readable viaCompletion::zero_copy_copied()). Bundle (RECVSEND_BUNDLE,FEAT_RECVSEND_BUNDLE) with provided buffers: Stage 3b.- When to use it: large payloads (≳ 10 KB, measure first); for small
messages, ordinary
sendis simpler and often faster (zero-copy has a fixed notification-management overhead). Recommendation aligned with Principle 5 (measure). - Completion: see §4.1. Early failure ⇒ NOTIF without a preceding
F_MORE: the façade then returns the buffer and propagates the error.
5. Teardown
5.1 submit_shutdown
#![allow(unused)]
fn main() {
pub fn submit_shutdown(&mut self, sock: BorrowedFd<'_>, how: ShutdownMode)
-> Result<SubmissionToken, Errno>; // OP 34
}
- Equivalent:
shutdown.ShutdownMode:Read/Write/Both. - Completion:
completed(). Errors:ENOTCONN,EINVAL. - Note: closing the FD itself is done via
submit_close(Stage 2a) or the RAII drop of theOwnedFd.
6. Summary of the 12 operations
| # opcode | Opcode | Façade | Completion |
|---|---|---|---|
| 9 | SENDMSG | submit_send_message | into_result |
| 10 | RECVMSG | submit_receive_message | into_receive_message_result |
| 13 | ACCEPT | submit_accept / _with_peer | accepted_fd / into_accept_result |
| 16 | CONNECT | submit_connect | completed |
| 26 | SEND | submit_send | into_buffer_result |
| 27 | RECV | submit_receive | into_buffer_result |
| 34 | SHUTDOWN | submit_shutdown | completed |
| 45 | SOCKET | submit_socket | into_socket_fd |
| 47 | SEND_ZC | submit_send_zero_copy | into_result + NOTIF into_zero_copy_buffer |
| 48 | SENDMSG_ZC | submit_send_message_zero_copy | into_result + NOTIF into_zero_copy_buffer |
| 56 | BIND | submit_bind | completed |
| 57 | LISTEN | submit_listen | completed |
7. Added / shared types
Shared with family-net: SocketAddr, SocketDomain, SocketType,
MessageFlags, ShutdownMode, AcceptFlags, SendMessageRequest, ReceiveMessageRequest,
ControlMessages. New at Stage 2b: ZeroCopyFlags, ReceiveMessageMeta (backed by
io_uring_recvmsg_out). New Completion methods:
into_socket_fd, into_accept_result, into_receive_message_result, into_zero_copy_buffer,
zero_copy_copied.
8. Test strategy
- Integration (local sockets AF_UNIX + AF_INET loopback): accept/connect, send/recv round-trip, sendmsg/recvmsg with FD passing (send an FD, receive it, verify it points to the same object — core AirCom test), shutdown, socket+bind+listen chained (Stage 3c), accept of a direct fd.
- Zero-copy: verify the sequence result (
F_MORE) → NOTIF (F_NOTIF), that the buffer is not returned before NOTIF, that it is returned at NOTIF, thezero_copy_copiedreport, and the early-failure case (NOTIF only). - FD passing safety:
MSG_CTRUNC(control buffer too small) ⇒ no FD leak; received FDs correctly wrapped as CLOEXECOwnedFd. - Property-based: bytes sent/received ≤ buffer size; robustness of
recvmsg_outparsing (kernel data = external input, Principle 3). - Errors via simulator:
ECONNRESET,EPIPE,EAGAIN,EADDRINUSE. - Soundness: Miri on the zero-copy two-completion lifecycle (the buffer must neither be freed nor read between submission and NOTIF).
- Coverage 100% lines + branches.
9. Key decisions that emerged at Stage 2b
MSG_NOSIGNALandSOCK_CLOEXECby default — Air invariants, consistent across layer 0; explicit override possible.- Explicit zero-copy two-completion lifecycle (extended S1) — a slot
can outlive its first completion until
CQE_F_NOTIF. The only such case in the module; documented and tested. - First-class FD passing via
sendmsg/recvmsg— this is the kernel materialization of AirCom capabilities (ADR-001). Received FDs areOwnedFd, never raw integers. bind/listenexposed even though “obvious” — indispensable for sockets as direct descriptors (without an ordinary FD).- Direct/multishot/bundle variants deferred to Stages 3a/3d/3b — 2b remains focused on one-shot operations with clear ownership.
10. Next steps
Next spec: io-uring-2c-async-en.md (15 operations with no direct
filesystem/network equivalent: nop, timeout, cancel, poll, futex, waitid,
msg_ring, epoll_ctl, files_update, fixed_fd_install). Global English
translation after validation of the French documents.
Document license: MPL 2.0
Status: Technical specification for Stage 2b (network) of the air-sys-syscall::io_uring module, target kernel 6.12 LTS.
Spec layer 0 — Module io_uring, Stage 2c: async-specific operations
Technical specification — Version 1.0 (target kernel: Linux 6.12 LTS)
Position. Stage 2c specifies the 15 io_uring operations that have no direct filesystem/network equivalent: ring control, timers/timeouts, cancellation, event monitoring, synchronization, and inter-ring communication (master doc
io-uring-0-inventaire-en.md, axis B). They reuse the core from Stage 1. Several form the foundation of APIs refined in Stage 3c (linked), 3d (multishot), or 3a (fixed resources) — cross-references are indicated.
1. Cross-cutting conventions for Stage 2c
poll32_events: only the 32-bit variant is exposed (PollEvents= bitflags onu32); the legacypoll_eventsfield (u16) is dropped (master doc §4).- Bounded-wait timeouts vs
TIMEOUToperations: bounded wait for a completion goes throughwait_completion_timeout(EXT_ARG/ABS_TIMER, Stage 1 decision, §14.5). TheTIMEOUTop in this Stage is for in-flow timers (standalone deadlines, completion counting, link-timeout) — explicitly distinct usages. - Shared memory (futex): a futex word lives in memory that must remain valid
until completion; we reuse the liveness handle backed by
MmapRegion(family-mem) introduced formadvise(Stage 2a §6.2). - Shared types:
WaitTarget/WaitOptions/SignalInfo(family-process),EpollOp/EpollEvent(family-*),PollEvents. Refer to the family specs for equivalent syscall numbers.
2. Control: nop
#![allow(unused)]
fn main() {
pub fn submit_nop(&mut self) -> Result<SubmissionToken, Errno>; // OP 0
pub fn submit_nop_with_result(&mut self, injected: i32)
-> Result<SubmissionToken, Errno>; // NOP_INJECT_RESULT
}
- Opcode:
IORING_OP_NOP(0). No action; completes immediately. - Usage: ring priming/benchmarking, milestone in a chain (Stage 3c),
testing (the
_with_resultvariant injects aresviaIORING_NOP_INJECT_RESULT— valuable for testing error paths without triggering a real kernel error). - Completion:
completed()/into_result().
3. Timers/Timeouts
3.1 submit_timeout
#![allow(unused)]
fn main() {
pub fn submit_timeout(&mut self, spec: TimeoutSpec, flags: TimeoutFlags)
-> Result<SubmissionToken, Errno>; // OP 11
}
- Opcode:
IORING_OP_TIMEOUT(11). TimeoutSpec: duration (__kernel_timespec) and/or a completion threshold (off= number of CQEs after which the timeout fires). A timeout can therefore be “after 10 ms” and/or “after N completions”.TimeoutFlags:ABS(absolute deadline),BOOTTIME/REALTIME(clock selection),ETIME_SUCCESS(expiry is a success, not an error),MULTISHOT(repeated firings → multishot variant, Stage 3d).- Completion:
completed();res == -ETIMEon expiry (or success ifETIME_SUCCESS).
3.2 submit_timeout_remove / submit_timeout_update
#![allow(unused)]
fn main() {
pub fn submit_timeout_remove(&mut self, target: SubmissionToken)
-> Result<SubmissionToken, Errno>; // OP 12
pub fn submit_timeout_update(&mut self, target: SubmissionToken, spec: TimeoutSpec, flags: TimeoutFlags)
-> Result<SubmissionToken, Errno>; // OP 12 + TIMEOUT_UPDATE
}
- Opcode:
IORING_OP_TIMEOUT_REMOVE(12); the update carriesIORING_TIMEOUT_UPDATE. Cancels or re-arms an in-flight timeout. - Errors:
ENOENT(unknown/already-fired target),EBUSY.
3.3 submit_link_timeout
#![allow(unused)]
fn main() {
pub fn submit_link_timeout(&mut self, spec: TimeoutSpec, flags: TimeoutFlags)
-> Result<SubmissionToken, Errno>; // OP 15
}
- Opcode:
IORING_OP_LINK_TIMEOUT(15). Semantics: bounds in time the preceding operation in a linked chain; if the timeout expires first, the linked op is cancelled (ECANCELED), and vice versa. - Constraint: only meaningful when attached to an op via
IOSQE_IO_LINK. The safe facade exposes it through theLinkedChainBuilderfrom Stage 3c (.with_link_timeout(spec)), not as a standalone op — emitting it alone returns an upstream validation error (Principle 4).
4. Cancellation: cancel (asynchronous)
#![allow(unused)]
fn main() {
pub fn submit_cancel(&mut self, target: CancelTarget, flags: CancelFlags)
-> Result<SubmissionToken, Errno>; // OP 14
}
- Opcode:
IORING_OP_ASYNC_CANCEL(14). Asynchronous variant of thesync_cancelfrom Stage 1 (which goes throughregisterand is blocking). CancelTarget/CancelFlags: byToken(user_data), byFd,FdFixed, byOp, orAny(IORING_ASYNC_CANCEL_*).ALL= cancel all matching operations.- Completion:
into_result()= number of cancelled operations;-ENOENTif nothing matches,-EALREADYif cancellation is already in progress. - Slab interaction: cancelling an op causes its completion to arrive with
-ECANCELED; slot S1 (and its buffer) is returned normally. For multishot, the final completion (withoutF_MORE) releases the slot.
5. Event monitoring
5.1 submit_poll_add / submit_poll_remove / submit_poll_update
#![allow(unused)]
fn main() {
pub fn submit_poll_add(&mut self, fd: BorrowedFd<'_>, events: PollEvents)
-> Result<SubmissionToken, Errno>; // OP 6
pub fn submit_poll_remove(&mut self, target: SubmissionToken)
-> Result<SubmissionToken, Errno>; // OP 7
pub fn submit_poll_update(&mut self, target: SubmissionToken, events: PollEvents)
-> Result<SubmissionToken, Errno>; // OP 7 + POLL_UPDATE
}
- Opcodes:
POLL_ADD(6),POLL_REMOVE(7). Monitors an FD for events (read/write/error), likepoll. - Completion (poll_add):
into_poll_result() -> PollEvents(ready events). Single-shot here; multishot (IORING_POLL_ADD_MULTI, multiple notifications) and level-triggered (POLL_ADD_LEVEL) at Stage 3d. poll_update: modifies the monitored events or the user_data of an in-flight poll (POLL_UPDATE_EVENTS/POLL_UPDATE_USER_DATA).
5.2 submit_epoll_ctl
#![allow(unused)]
fn main() {
pub fn submit_epoll_ctl(&mut self, epfd: BorrowedFd<'_>, op: EpollOp, fd: BorrowedFd<'_>, event: EpollEvent)
-> Result<SubmissionToken, Errno>; // OP 29
}
- Opcode:
IORING_OP_EPOLL_CTL(29). Equivalent:epoll_ctl(add/mod/del on an epoll set), executed asynchronously. - Completion:
completed(). Note: in 6.12, this is the only io_uring epoll integration (EPOLL_WAITonly arrives after 6.12, out of scope). Useful for driving an existing epoll set without a synchronous syscall.
6. Synchronization
6.1 submit_futex_wait / submit_futex_wake / submit_futex_waitv — implemented
Implemented (coordinated
family-memPR, 2026-06-12). The historical&AtomicU32signature (unsound in async: the borrow does not outlive the facade’s return) has been replaced by a reference into anMmapRegion+ offset (seefamily-mem-mmap-region.md §3): the futex word lives in a shareable region kept alive by slot S1 (anMmapRegionLiveness), forbiddingmunmapwhile the op is in flight — no UAF, no leak (proven with Miri + loom).
#![allow(unused)]
fn main() {
pub fn submit_futex_wait(&mut self, region: &MmapRegion, offset: usize,
expected: u64, mask: u64, flags: FutexFlags)
-> Result<SubmissionToken, Errno>; // OP 51
pub fn submit_futex_wake(&mut self, region: &MmapRegion, offset: usize,
nr: u64, mask: u64, flags: FutexFlags)
-> Result<SubmissionToken, Errno>; // OP 52
pub fn submit_futex_waitv(&mut self, waiters: Vec<FutexWaiter>, flags: FutexFlags)
-> Result<SubmissionToken, Errno>; // OP 53
pub struct FutexWaiter {
pub region: MmapRegion, // owned (clone) ⇒ natural liveness guard
pub offset: usize, // bounded, 4-aligned (else EINVAL)
pub expected: u64, // no mask: the kernel futex_waitv carries none
}
}
- Opcodes:
FUTEX_WAIT(51),FUTEX_WAKE(52),FUTEX_WAITV(53). - Value: an io_uring reactor can wait on a futex without blocking its
thread — integrating userspace synchronization into the same mechanism as
I/O.
futex_waitvwaits on multiple futexes at once. offsetlocates the (u32) futex word within the region: validated up front viaMmapRegion::futex_word— region writable (WRITE, since the returned ref is mutable), bounds + 4-alignment —EINVALotherwise, before submission (Principle 4). TheFUTEX2_SIZE_U32size is enforced (anMmapRegionword is anAtomicU32); onlyPRIVATEis exposed byFutexFlags.mask(forwait/wake) = futex bitset (addr3).- Safety: slot S1 retains the
MmapRegionLivenessguard(s) (see §1) ⇒ the region stays mapped until completion. - ABI note: the kernel
struct futex_waitvcarries no per-waiter mask ({ val, uaddr, flags, __reserved });FutexWaitertherefore exposes no mask (a silently-ignored field would be a footgun, ADR-032). For a masked wait, usesubmit_futex_wait(single-wait). - Completion:
wait/waitvcomplete when woken up (or-EAGAINif the value differs fromexpectedat arm time) viacompleted();wakereturns the number of woken waiters viainto_result().
6.2 submit_waitid
#![allow(unused)]
fn main() {
pub fn submit_waitid(&mut self, target: WaitTarget, options: WaitOptions, info: Box<SignalInfo>)
-> Result<SubmissionToken, Errno>; // OP 50
}
- Opcode:
IORING_OP_WAITID(50). Equivalent:waitid(seefamily-process.md; layer 0 convention:waitidpreferred overwait/waitpid). WaitTarget: pid / pgid / pidfd / all — typed (no raw integer).- Output: the kernel fills
info(SignalInfo), owned buffer moved into the slot. Completion:into_waitid_result() -> Result<Box<SignalInfo>, Errno>. - Value: reaping a child process without blocking the reactor — a
building block for a supervisor (relevant for
air-launchd, layer 5).
7. Inter-ring and resources
7.1 submit_msg_ring
#![allow(unused)]
fn main() {
pub fn submit_message_ring_data(&mut self, target: &IoUring, res: i32, user_data: u64, flags: MessageRingFlags)
-> Result<SubmissionToken, Errno>; // OP 40 (MSG_DATA)
pub fn submit_message_ring_fd(&mut self, target: &IoUring, src_slot: u32, dst_slot: FixedSlotTarget, flags: MessageRingFlags)
-> Result<SubmissionToken, Errno>; // OP 40 (MSG_SEND_FD)
}
- Opcode:
IORING_OP_MSG_RING(40). Sends a message from one ring to another:MSG_DATA: posts a CQE into the target ring (arbitraryres+user_data). A very lightweight inter-thread/inter-service notification mechanism — relevant for waking up a peer reactor (AirCom link).MSG_SEND_FD: transfers a registered direct descriptor to another ring’s FD table, without going throughsendmsg/socket.
MessageRingFlags:CQE_SKIP(no CQE on the target side),FLAGS_PASS(propagate flags to the target CQE).- Completion (sender side):
completed(). Target side: a CQE appears in its CQ. - Safety: the target ring is borrowed (
&IoUring); in practice a registered ring fd (Stage 3a) is often passed to decouple lifetimes — detailed in Stage 3a/3e.
7.2 submit_files_update
#![allow(unused)]
fn main() {
pub fn submit_files_update(&mut self, offset: u32, fds: Vec<RawFd>)
-> Result<SubmissionToken, Errno>; // OP 20
}
- Opcode:
IORING_OP_FILES_UPDATE(20). Updates slots in the registered FD table asynchronously (op equivalent ofREGISTER_FILES_UPDATE2, but in the submission flow). - Cross-reference: the fixed FD table (
FixedFdTable) is defined in Stage 3a. - Completion:
into_result()= number of updated slots.
7.3 submit_fixed_fd_install
#![allow(unused)]
fn main() {
pub fn submit_fixed_fd_install(&mut self, fixed_slot: u32, flags: InstallFdFlags)
-> Result<SubmissionToken, Errno>; // OP 54
}
- Opcode:
IORING_OP_FIXED_FD_INSTALL(54). Converts a direct descriptor (which has no ordinary FD) into an ordinary FD, returned in the completion. InstallFdFlags:NO_CLOEXEC(by default the installed FD is CLOEXEC).- Completion:
completion.installed_fd() -> Result<OwnedFd, Errno>. - Usage: handing a real FD to third-party code from a direct descriptor
obtained via direct
accept/socket/openat2(Stage 3a).
8. Summary of the 15 operations
| # opcode | Opcode | Facade | Completion |
|---|---|---|---|
| 0 | NOP | submit_nop / _with_result | completed / into_result |
| 6 | POLL_ADD | submit_poll_add | into_poll_result |
| 7 | POLL_REMOVE | submit_poll_remove / submit_poll_update | completed |
| 11 | TIMEOUT | submit_timeout | completed (-ETIME) |
| 12 | TIMEOUT_REMOVE | submit_timeout_remove / _update | completed |
| 14 | ASYNC_CANCEL | submit_cancel | into_result |
| 15 | LINK_TIMEOUT | (via LinkedChainBuilder, Stage 3c) | — |
| 20 | FILES_UPDATE | submit_files_update | into_result |
| 29 | EPOLL_CTL | submit_epoll_ctl | completed |
| 40 | MSG_RING | submit_message_ring_data / _fd | completed |
| 50 | WAITID | submit_waitid | into_waitid_result |
| 51 | FUTEX_WAIT | submit_futex_wait | completed |
| 52 | FUTEX_WAKE | submit_futex_wake | into_result |
| 53 | FUTEX_WAITV | submit_futex_waitv | completed |
| 54 | FIXED_FD_INSTALL | submit_fixed_fd_install | installed_fd |
9. Added / shared types
New in Stage 2c: TimeoutSpec, TimeoutFlags, PollEvents, CancelFlags,
FutexFlags, FutexWaiter, MessageRingFlags, InstallFdFlags,
FixedSlotTarget. Shared: WaitTarget, WaitOptions, SignalInfo
(family-process), EpollOp, EpollEvent. New Completion methods:
into_poll_result, into_waitid_result, installed_fd. FixedFdTable and
the registered ring fd: Stage 3a.
10. Test strategy
- Integration: nop (+ result injection), timeout (expiry +
ETIME_SUCCESS+ completion counting), cancel of an in-flight op (-ECANCELEDreceived, slot returned), poll_add on a pipe then write, epoll_ctl add/mod/del, futex_wait woken by futex_wake, futex_waitv multi-wait, waitid on a terminating child, msg_ring data between two rings (CQE appears on the target side), fixed_fd_install of a direct fd. - Property-based: idempotent cancel (
ENOENT/EALREADY), consistent poll events. - Safety: Miri/liveness handle for futexes (the futex word memory outlives
the op); no FD leak on
msg_ring_fd/fixed_fd_install. - Errors via simulator:
ETIME,ECANCELED,ENOENT,EAGAIN(futex). - Coverage: 100% lines + branches.
11. Key decisions that emerged in Stage 2c
link_timeoutnever as a standalone op — exposed only through theLinkedChainBuilder(Stage 3c); standalone emission is rejected upstream.- async
cancelvssync_cancel— two deliberate paths: asynchronous in-flow (here) and synchronous blocking (Stage 1, basis for teardown S2). msg_ringrecognized as an inter-reactor primitive — notification and FD transfer between rings; a potential building block for waking AirCom peers. Fine-grained inter-ring lifetime management is detailed in Stage 3a/3e.- Async futex backed by the
MmapRegionliveness handle — same memory safety mechanism asmadvise(Stage 2a), no exposedunsafe. nop_with_result— exposed primarily for test tooling (covering error branches without a real kernel error).
12. Next steps
Next spec: io-uring-2d-cmd-en.md (URING_CMD: generic passthrough +
socket commands SIOCINQ/SIOCOUTQ/get/setsockopt). Global English
translation after validation of the French documents.
Document license: MPL 2.0
Status: Technical specification for Stage 2c (async-specific operations) of the air-sys-syscall::io_uring module, target kernel 6.12 LTS.
Spec layer 0 — Module io_uring, Stage 2d: URING_CMD (passthrough)
Technical specification — Version 1.0 (target kernel: Linux 6.12 LTS)
Position. Stage 2d specifies the
IORING_OP_URING_CMDoperation (46), a passthrough sub-protocol through which io_uring forwards an opaque command to theuring_cmdhandler of the subsystem that owns the target file (socket, NVMe, ublk…). Dedicated submoduleair-sys-syscall::io_uring::cmd. This is the most “raw” operation among the Stage 2x operations: its semantics depend on the target subsystem, not on io_uring. Reuses the core of Stage 1.
1. Nature and constraints
1.1 The mechanism
URING_CMD carries two things in the SQE:
cmd_op(__u32): the operation specific to the subsystem.- a command area
cmd[]: 16 bytes in a standard SQE (theaddr3/padding area), or 80 bytes if the ring is created withIORING_SETUP_SQE128.
The contents of cmd[] are opaque to io_uring: it is the file’s
f_op->uring_cmd handler that interprets them. Consequence: the facade can
only guarantee safety for the commands it knows in typed form (socket);
everything else is a constrained generic mechanism.
1.2 Stage 2d conventions
SQE128detection: if a command requires more than 16 bytes and the ring does not haveSetupFlags::SQE128, submission fails upfront (Err(EINVAL), Principle 4) — no silent truncation.FIXEDflag:IORING_URING_CMD_FIXED(bit 0) allows using a registered buffer (index inbuf_index) as the data area associated with the command. The only flag available in 6.12.- No multishot:
IORING_URING_CMD_MULTISHOTis later than 6.12 → out of scope (master doc §1.2).
2. Socket commands (safe typed layer)
io_uring 6.12 exposes, via URING_CMD on a socket FD, four commands
(enum io_uring_socket_op). The facade wraps them as typed and safe:
#![allow(unused)]
fn main() {
impl IoUring {
/// SIOCINQ: bytes available to read. cmd_op = SOCKET_URING_OP_SIOCINQ (0).
pub fn submit_socket_inq(&mut self, sock: BorrowedFd<'_>)
-> Result<SubmissionToken, Errno>;
/// SIOCOUTQ: bytes pending transmission. cmd_op = SOCKET_URING_OP_SIOCOUTQ (1).
pub fn submit_socket_outq(&mut self, sock: BorrowedFd<'_>)
-> Result<SubmissionToken, Errno>;
/// getsockopt. cmd_op = SOCKET_URING_OP_GETSOCKOPT (2).
pub fn submit_getsockopt(&mut self, sock: BorrowedFd<'_>, level: SocketOptionLevel,
optname: i32, value: Vec<u8>)
-> Result<SubmissionToken, Errno>;
/// setsockopt. cmd_op = SOCKET_URING_OP_SETSOCKOPT (3).
pub fn submit_setsockopt(&mut self, sock: BorrowedFd<'_>, level: SocketOptionLevel,
optname: i32, value: Vec<u8>)
-> Result<SubmissionToken, Errno>;
}
}
inq/outq: completioninto_result() -> i32(byte count). Equivalents:ioctl(SIOCINQ/SIOCOUTQ), but inside the ring, without a synchronousioctl— useful for driving socket flow in a reactor.getsockopt:valueis the output buffer (ownership transferred, S1); the SQE carrieslevel/optname/optlen/optval. Completioninto_buffer_result() -> (Vec<u8>, usize)(value + effective length).setsockopt:value(bytes, Principle 3) moved into the slot. Completioncompleted().SocketOptionLevel: type shared withfamily-net(SOL_SOCKET, IPPROTO_TCP…).- Errors:
ENOPROTOOPT,EINVAL,ENOTSOCK,EFAULT.
These four commands advantageously replace synchronous
ioctl/getsockoptcalls on an asynchronous path, without leaving the reactor.
3. Generic mechanism (arbitrary subsystems)
For subsystems the facade does not know in typed form (NVMe passthrough, ublk, future handlers), two levels are provided.
3.1 Typed level via the UringCommand trait
#![allow(unused)]
fn main() {
/// A passthrough command from a subsystem, serializable into the cmd[] area.
///
/// # Safety
/// The implementor guarantees that `encode` produces a valid command for the
/// `uring_cmd` handler of the targeted `fd`, and that `cmd_op` and any
/// associated buffers satisfy the subsystem's contract.
pub unsafe trait UringCommand {
/// Operation specific to the subsystem (cmd_op).
fn cmd_op(&self) -> u32;
/// Required size of the command area (≤ 16, or ≤ 80 if SQE128).
fn cmd_len(&self) -> usize;
/// Serializes the command into `out` (length `cmd_len`).
fn encode(&self, out: &mut [u8]);
/// Typed interpretation of the completion.
type Output;
fn interpret(&self, completion: &Completion) -> Result<Self::Output, Errno>;
}
impl IoUring {
pub fn submit_uring_cmd<C: UringCommand>(&mut self, fd: BorrowedFd<'_>, cmd: C)
-> Result<SubmissionToken, Errno>;
}
}
- The facade checks
cmd.cmd_len() ≤ 16(or≤ 80ifSQE128), otherwiseErr(EINVAL). Thetraitisunsafebecause the validity of the command depends on the subsystem, outside io_uring’s control. - Example consumer: a storage crate (upper layer) defines a
NvmePassthroughCmd: UringCommand(struct ~72 bytes ⇒ requiresSQE128). Air layer 0 provides the mechanism, not the NVMe-specific structures (those live where they are used).
3.2 Raw unsafe level (escape hatch)
#![allow(unused)]
fn main() {
impl IoUring {
/// # Safety
/// `cmd_data` must be a valid command for the uring_cmd handler of `fd`.
/// Any buffer referenced by the command must remain valid until completion.
/// `cmd_data.len()` ≤ 16 (or ≤ 80 if SQE128).
pub unsafe fn submit_uring_cmd_raw(&mut self, fd: BorrowedFd<'_>,
cmd_op: u32, cmd_data: &[u8], flags: UringCmdFlags)
-> Result<SubmissionToken, Errno>;
}
}
- Last resort for subsystems not yet typed. Exhaustive
# Safetydocumentation (layer 0 convention).UringCmdFlags:FIXED.
4. Summary
| # opcode | Opcode | Facade (safe) | Facade (generic) |
|---|---|---|---|
| 46 | URING_CMD | submit_socket_inq/outq, submit_getsockopt/setsockopt | submit_uring_cmd<C> / submit_uring_cmd_raw (unsafe) |
Socket cmd_op covered (6.12): SIOCINQ (0), SIOCOUTQ (1), GETSOCKOPT
(2), SETSOCKOPT (3). TX_TIMESTAMP/GETSOCKNAME = post-6.12, out of scope.
5. Types added / shared
New: UringCmdFlags (bitflags, FIXED), trait UringCommand. Shared:
SocketOptionLevel (family-net). Completion methods reused:
into_result/into_buffer_result/completed.
6. Test strategy
- Integration:
inq/outqon a loopback socket with known data queued;getsockopt(SO_RCVBUF)/setsockopt(SO_REUSEADDR)round-trip; comparison with synchronousgetsockopt/ioctl(family-net). - SQE128: command > 16 bytes rejected without
SQE128(EINVAL); accepted with it. - Safety: Miri on the
submit_uring_cmd<C>path (serialization into boundedcmd[]); test thatcmd_len> capacity is rejected. - Fuzzing: decoding of
getsockoptcompletions (external kernel data). - Coverage: 100% lines + branches; the
unsafesubmit_uring_cmd_rawtested via a fake handler in a harness, or recorded inCOVERAGE-EXCEPTIONS.mdif not provokable.
7. Key decisions that emerged at Stage 2d
- Two levels of exposure: safe typed socket commands on one side;
trait UringCommand(unsafe trait) + raw formunsafefor arbitrary subsystems on the other. We do not claim to type what only the subsystem knows. - Upfront
SQE128detection — outright rejection rather than silent truncation. - NVMe structures outside layer 0 — Air provides the passthrough mechanism; specific commands (NVMe, ublk) are defined by their consumers.
- No multishot uring_cmd — later than 6.12.
8. Next steps
Next spec: io-uring-3a-registration-en.md (modern fixed resources:
FixedFdTable, RegisteredBuffers, registered ring fd, eventfd, personality,
io-wq affinities, napi, clock, clone_buffers, alloc_range). Global English
translation after validation of the French documents.
Document license: MPL 2.0
Status: Technical specification for Stage 2d (URING_CMD) of the air-sys-syscall::io_uring module, target kernel 6.12 LTS.
Layer 0 spec — Module io_uring, Stage 3a: registration (fixed resources)
Technical specification — Version 1.0 (target kernel: Linux 6.12 LTS)
Position. Stage 3a specifies resource registration with the kernel (
io_uring_register(2), no. 427): fixed descriptor tables, registered buffers, registered ring fd, eventfd, personality, io-wq pool tuning, NAPI, clock. Sub-moduleair-sys-syscall::io_uring::registration. This Stage unlocks the “fixed”/“direct” variants referenced from Stages 2a–2c. Reuses the core of Stage 1.Scope: 21 register opcodes (master doc, axis C — Stage 3a). Legacy variants (
REGISTER_BUFFERS/FILES/FILES_UPDATE) are dropped in favour of the*2-tagged variants (master doc §4).
1. Cross-cutting conventions for Stage 3a
- Explicit registration (ADR-022 D4): Air never registers automatically. The application that wants the benefit (fewer address translations, direct FDs) requests it explicitly.
- Ownership of registered resources: a registered resource (buffer, FD)
must remain valid as long as it is registered. The types
RegisteredBuffers/FixedFdTableown the resource; usage references (RegisteredBufferSlice,FixedSlot) are tied by lifetime, and are therefore unusable after unregistration (safety by construction). - Link with teardown S2: a ring cannot be destroyed while resources are
still registered without releasing them;
shutdown()/Dropcleanly unregisters (owned buffers are returned). - Tags and sparse: the
*2variants support sparse registration (IORING_RSRC_REGISTER_SPARSE: empty slots to be filled later) and tags (FEAT_RSRC_TAGS: a CQE notification signals when a replaced resource is no longer referenced). - Registered ring fd: if the ring has registered its own fd (Stage 1,
REG_REG_RING), allregister/entercalls use it transparently (USE_REGISTERED_RING).
2. Fixed descriptor table — FixedFdTable
#![allow(unused)]
fn main() {
pub struct FixedFdTable { /* owns the OwnedFd of filled slots */ }
impl FixedFdTable {
/// REGISTER_FILES2 (13). `capacity` slots, sparse by default (empty slots).
pub fn register(ring: &mut IoUring, capacity: NonZeroU32) -> Result<Self, Errno>;
/// REGISTER_FILES2 with an initial set of FDs.
pub fn register_with(ring: &mut IoUring, fds: Vec<OwnedFd>) -> Result<Self, Errno>;
/// REGISTER_FILES_UPDATE2 (14): places/replaces an FD in a slot.
pub fn set(&mut self, ring: &mut IoUring, slot: u32, fd: OwnedFd) -> Result<(), Errno>;
/// Clears a slot (FD returned to the caller).
pub fn clear(&mut self, ring: &mut IoUring, slot: u32) -> Result<Option<OwnedFd>, Errno>;
/// REGISTER_FILE_ALLOC_RANGE (25): bounds the auto-allocation range.
pub fn set_alloc_range(&mut self, ring: &mut IoUring, range: Range<u32>) -> Result<(), Errno>;
/// UNREGISTER_FILES (3): unregisters everything, returns remaining FDs.
pub fn unregister(self, ring: &mut IoUring) -> Result<Vec<OwnedFd>, Errno>;
/// Borrows a slot for use in an operation (IOSQE_FIXED_FILE).
pub fn slot(&self, slot: u32) -> Option<FixedSlot<'_>>;
}
/// Borrowed reference to a filled slot; tied to the table by lifetime.
pub struct FixedSlot<'t> { /* index + borrow */ }
}
2.1 What FixedFdTable unlocks
- Fixed-FD operations:
read/write/send/recv… accept aFixedSlotinstead of aBorrowedFd(flagIOSQE_FIXED_FILE) → the kernel avoids FD resolution on every op. - “Direct descriptor” variants from Stages 2a/2b — the result is stored in a slot instead of being returned as an ordinary FD:
#![allow(unused)]
fn main() {
impl IoUring {
pub fn submit_openat2_direct(&mut self, dirfd: DirFd, path: CString, how: OpenHow,
slot: FixedSlotTarget) -> Result<SubmissionToken, Errno>;
pub fn submit_accept_direct(&mut self, listener: BorrowedFd<'_>, flags: AcceptFlags,
slot: FixedSlotTarget) -> Result<SubmissionToken, Errno>;
pub fn submit_socket_direct(&mut self, domain: SocketDomain, ty: SocketType, protocol: i32,
slot: FixedSlotTarget) -> Result<SubmissionToken, Errno>;
}
/// Target slot: a precise index, or auto-allocation (IORING_FILE_INDEX_ALLOC).
pub enum FixedSlotTarget { Index(u32), Alloc }
}
FixedSlotTarget::Alloc⇒ the kernel picks a free slot (within the range set byset_alloc_range) and returns it incqe->res; otherwise-ENFILE.- To convert back to an ordinary FD:
submit_fixed_fd_install(Stage 2c). - Asynchronous update in the stream:
submit_files_update(Stage 2c, op 20).
Air use case: services with a high connection rate (compositor, AirCom) keep their sockets as direct descriptors — measurable gain, and confinement (a direct FD is not visible as an ordinary process FD).
3. Registered buffers — RegisteredBuffers
#![allow(unused)]
fn main() {
pub struct RegisteredBuffers { /* owns the pinned buffers */ }
impl RegisteredBuffers {
/// REGISTER_BUFFERS2 (15). Pins the buffers; sparse/tags supported.
pub fn register(ring: &mut IoUring, buffers: Vec<Vec<u8>>) -> Result<Self, Errno>;
/// Variant backed by owned MmapRegion instances (data plane).
pub fn register_mmap(ring: &mut IoUring, regions: Vec<MmapRegion>) -> Result<Self, Errno>;
/// REGISTER_BUFFERS_UPDATE (16): replaces a buffer (tag → notification when
/// the old one is no longer referenced).
pub fn update(&mut self, ring: &mut IoUring, index: u32, buffer: Vec<u8>) -> Result<(), Errno>;
/// CLONE_BUFFERS (30): clones the registered buffers from another ring.
pub fn clone_from(ring: &mut IoUring, src: &IoUring) -> Result<Self, Errno>;
/// UNREGISTER_BUFFERS (1): returns the buffers.
pub fn unregister(self, ring: &mut IoUring) -> Result<Vec<Vec<u8>>, Errno>;
/// Slice of a registered buffer, for read_fixed/write_fixed (Stage 2a).
pub fn slice(&self, index: u32, range: Range<usize>) -> Option<RegisteredBufferSlice<'_>>;
}
/// Reference to a slice of a registered buffer; tied by lifetime.
pub struct RegisteredBufferSlice<'b> { /* index + range + borrow */ }
}
- Gain: pinning and address translation are done once at registration →
read_fixed/write_fixed(Stage 2a) andsend_zc/recv fixed (Stage 2b) avoid this per-op cost. Hot path of the AirCom data plane (register_mmapon a sharedmemfd). clone_from: shares buffers between rings within the same process (thread-per-core) without re-pinning.- Ownership:
RegisteredBufferSlicecannot outliveunregister(lifetime) —read_fixedon a stale slice does not compile.
4. Registered ring fd
#![allow(unused)]
fn main() {
impl IoUring {
pub fn register_ring_fd(&mut self) -> Result<(), Errno>; // RING_FDS (20)
pub fn unregister_ring_fd(&mut self) -> Result<(), Errno>; // UNREGISTER_RING_FDS (21)
}
}
- Registers the ring’s fd in an internal table → subsequent
io_uring_entercalls useIORING_ENTER_REGISTERED_RING(no FD resolution). Net gain on paths with frequententercalls. Requires/activatesFEAT_REG_REG_RING. - Also used to designate a target ring in
msg_ring(Stage 2c) in a decoupled manner.
5. eventfd notification
#![allow(unused)]
fn main() {
impl IoUring {
pub fn register_eventfd(&mut self, efd: BorrowedFd<'_>) -> Result<(), Errno>; // EVENTFD (4)
pub fn register_eventfd_async(&mut self, efd: BorrowedFd<'_>) -> Result<(), Errno>; // EVENTFD_ASYNC (7)
pub fn unregister_eventfd(&mut self) -> Result<(), Errno>; // UNREGISTER_EVENTFD (5)
}
}
- Binds the ring to an
eventfd(familyipc): the kernel writes to it on every posted completion → a reactor can wait for completions via the eventfd (epoll integration, or cross-thread wake-up). register_eventfd_asynconly notifies for completions processed asynchronously (filters out the noise from inline completions). Temporary deactivation via theIORING_CQ_EVENTFD_DISABLEDflag (CQ ring, Stage 1).
6. Personality (identities)
#![allow(unused)]
fn main() {
pub struct Personality(/* id */);
impl IoUring {
/// REGISTER_PERSONALITY (9): registers the current credentials.
pub fn register_personality(&mut self) -> Result<Personality, Errno>;
/// UNREGISTER_PERSONALITY (10).
pub fn unregister_personality(&mut self, p: Personality) -> Result<(), Errno>;
}
}
- Registers the calling process’s credentials and returns an id; an
operation can then execute with those credentials by placing the id in
sqe->personality. Allows a privileged service to perform an operation on behalf of a less-privileged identity (and vice versa) in a controlled manner. - Security link: combined with restrictions (Stage 3f) and the entitlements model (ADR-010), this is a least-privilege building block — exposed here as a primitive; policy-level use belongs to the upper layers.
7. io-wq pool tuning
#![allow(unused)]
fn main() {
impl IoUring {
/// IOWQ_AFF (17): CPU affinity of io-wq workers.
pub fn set_work_queue_affinity(&mut self, cpus: &CpuSet) -> Result<(), Errno>;
/// UNREGISTER_IOWQ_AFF (18).
pub fn clear_work_queue_affinity(&mut self) -> Result<(), Errno>;
/// IOWQ_MAX_WORKERS (19): caps/limits for bounded/unbounded workers.
pub fn set_work_queue_max_workers(&mut self, bounded: u32, unbounded: u32)
-> Result<WorkQueueWorkerLimits, Errno>;
}
}
- The io-wq pool executes operations that cannot be done inline (e.g. blocking
I/O).
set_work_queue_max_workerscaps the thread count (categoriesIO_WQ_BOUND/IO_WQ_UNBOUND) — important on modest hardware (Charter principle 4: control the footprint on Raspberry Pi 4). CpuSetshared withfamily-system(affinities).
8. NAPI busy-poll
#![allow(unused)]
fn main() {
impl IoUring {
pub fn register_napi(&mut self, cfg: NapiConfig) -> Result<NapiConfig, Errno>; // NAPI (27)
pub fn unregister_napi(&mut self) -> Result<(), Errno>; // UNREGISTER_NAPI (28)
}
pub struct NapiConfig { /* busy_poll_to, prefer_busy_poll */ }
}
- Enables network busy-polling (NAPI) to reduce latency on very high-throughput sockets, at the cost of CPU. Reserve for profiles where latency is paramount (measure before enabling, Principle 5).
9. Clock source
#![allow(unused)]
fn main() {
impl IoUring {
pub fn register_clock(&mut self, clock: ClockSource) -> Result<(), Errno>; // CLOCK (29)
}
pub enum ClockSource { Monotonic, Boottime, Realtime }
}
- Sets the clock used by the ring’s timeouts (consistent with the
TimeoutFlagsof Stage 2c andfamily-time).
10. Summary of register opcodes (Stage 3a)
| # | Register op | Facade |
|---|---|---|
| 1 | UNREGISTER_BUFFERS | RegisteredBuffers::unregister |
| 3 | UNREGISTER_FILES | FixedFdTable::unregister |
| 4 | REGISTER_EVENTFD | register_eventfd |
| 5 | UNREGISTER_EVENTFD | unregister_eventfd |
| 7 | REGISTER_EVENTFD_ASYNC | register_eventfd_async |
| 9 | REGISTER_PERSONALITY | register_personality |
| 10 | UNREGISTER_PERSONALITY | unregister_personality |
| 13 | REGISTER_FILES2 | FixedFdTable::register(_with) |
| 14 | REGISTER_FILES_UPDATE2 | FixedFdTable::set / clear |
| 15 | REGISTER_BUFFERS2 | RegisteredBuffers::register(_mmap) |
| 16 | REGISTER_BUFFERS_UPDATE | RegisteredBuffers::update |
| 17 | REGISTER_IOWQ_AFF | set_work_queue_affinity |
| 18 | UNREGISTER_IOWQ_AFF | clear_work_queue_affinity |
| 19 | REGISTER_IOWQ_MAX_WORKERS | set_work_queue_max_workers |
| 20 | REGISTER_RING_FDS | register_ring_fd |
| 21 | UNREGISTER_RING_FDS | unregister_ring_fd |
| 25 | REGISTER_FILE_ALLOC_RANGE | FixedFdTable::set_alloc_range |
| 27 | REGISTER_NAPI | register_napi |
| 28 | UNREGISTER_NAPI | unregister_napi |
| 29 | REGISTER_CLOCK | register_clock |
| 30 | REGISTER_CLONE_BUFFERS | RegisteredBuffers::clone_from |
Total: 21 register opcodes. (PROBE → Stage 1; SYNC_CANCEL → Stage 1/2c;
RESTRICTIONS/ENABLE_RINGS → Stage 3f; PBUF_RING/PBUF_STATUS → Stage 3b.)
11. New / shared types
New: FixedFdTable, FixedSlot<'t>, FixedSlotTarget, RegisteredBuffers,
RegisteredBufferSlice<'b>, Personality, WorkQueueWorkerLimits, NapiConfig,
ClockSource. Shared: OpenHow, AcceptFlags, SocketDomain/Type (Stages
2a/2b), CpuSet (family-system), MmapRegion (family-mem).
12. Test strategy
- Integration: register a
FixedFdTable, perform anaccept_direct/openat2_direct(slotAllocthenIndex),read/writeviaFixedSlot,fixed_fd_installback to an ordinary FD;RegisteredBuffers+read_fixed/write_fixedround-trip;clone_frombetween two rings;register_eventfdthen verify the notification;register_personality+ op with personality;set_work_queue_max_workers(read returned values);register_napi;register_clock. - Safety (compile-fail + Miri): a
RegisteredBufferSlice/FixedSlotmust not outlive unregistration (trybuildcompile-fail test); no FD/buffer leak onunregisteror teardown S2. - Property-based: sparse registration + progressive fill, tag consistency (notification when the old buffer is no longer referenced).
- Errors via simulator:
ENFILE(Alloc slot full),EINVAL,EBUSY,EFAULT. - Coverage: 100% lines + branches.
13. Key decisions that emerged at Stage 3a
- Legacy variants dropped — only the
*2(tagged) variants are exposed (master doc §4); this simplifies the API and makes tagging benefits mandatory. - Owned resources + lifetime-tied references — safety by construction: it is impossible to use an unregistered resource.
FixedSlotTarget::{Index, Alloc}— auto-allocation is a typed enum, not the rawIORING_FILE_INDEX_ALLOCsentinel (~0U) exposed directly.register_mmapfor the data plane — registered buffers backed by ownedMmapRegioninstances (shared AirCom memfd), reusing the liveness handle fromfamily-mem.- io-wq caps/limits exposed early — to control CPU footprint on modest hardware, in accordance with the Charter.
14. Next steps
Next spec: io-uring-3b-provided-en.md (ring-mapped provided buffers:
PBUF_RING/PBUF_STATUS, incremental consumption, automatic buffer selection
for multishot recv/read). Global English translation after French document
validation.
Document license: MPL 2.0
Status: Technical specification for Stage 3a (registration) of the air-sys-syscall::io_uring module, target kernel 6.12 LTS.
Spec layer 0 — Module io_uring, Stage 3b: provided buffers (ring-mapped)
Technical specification — Version 1.0 (target kernel: Linux 6.12 LTS)
Position. Stage 3b specifies ring-mapped provided buffers (
IORING_REGISTER_PBUF_RING), which replace the legacy op-based mechanismPROVIDE_BUFFERS/REMOVE_BUFFERS(retired, master doc §4). Sub-moduleair-sys-syscall::io_uring::provided. Reuses the core from Stage 1 and the registration from Stage 3a.Scope: register opcodes
PBUF_RING(22),UNREGISTER_PBUF_RING(23),PBUF_STATUS(26); submission flagIOSQE_BUFFER_SELECT; completion flagsCQE_F_BUFFERandCQE_F_BUF_MORE.
1. The problem solved
For a classic recv, a buffer must be pre-committed per operation — therefore
per pending connection. A server with 10,000 idle connections wastes 10,000
pinned buffers. Provided buffers invert this model:
The application registers a buffer group; it submits a
recv/readwithIOSQE_BUFFER_SELECTwithout an attached buffer; the kernel picks a buffer from the group when data arrives, and returns its identifier in the CQE (CQE_F_BUFFER, id in the upper 16 bits ofcqe->flags).
Buffers are consumed only by connections that are actually active. Combined with multishot recv (Stage 3d), a single SQE serves an entire stream, with each datagram landing in a kernel-chosen buffer.
2. The buffer group — ProvidedBufferRing
#![allow(unused)]
fn main() {
pub struct ProvidedBufferRing { /* bgid, ring memory, backing buffers */ }
pub struct ProvidedBufferRingOptions {
/// Ring memory allocated by the kernel (IOU_PBUF_RING_MMAP) then
/// mmapped by the facade, instead of being supplied by the application.
pub kernel_mmap: bool,
/// Incremental consumption (IOU_PBUF_RING_INC): a buffer can serve
/// multiple completions, consumed incrementally (see §5).
pub incremental: bool,
}
impl ProvidedBufferRing {
/// PBUF_RING (22). Registers a group `group_id` of `count` buffers of
/// `buf_size` bytes (count = power of two). `count` also bounds
/// the buffer ring.
pub fn register(ring: &mut IoUring, group_id: u16, count: NonZeroU16,
buf_size: NonZeroU32, opts: ProvidedBufferRingOptions)
-> Result<Self, Errno>;
/// UNREGISTER_PBUF_RING (23). Returns the buffer memory.
pub fn unregister(self, ring: &mut IoUring) -> Result<(), Errno>;
/// PBUF_STATUS (26). Current head of the group (diagnostics / flow control).
pub fn status(&self, ring: &IoUring) -> Result<ProvidedBufferRingStatus, Errno>;
pub fn group_id(&self) -> u16;
/// Number of buffers currently available (not checked out).
pub fn available(&self) -> u16;
}
pub struct ProvidedBufferRingStatus { pub head: u32 }
}
- Two memory modes: buffers supplied by the application (default, on an
owned
MmapRegion— data plane); or ring allocated by the kernel (kernel_mmap) then mmapped atIORING_OFF_PBUF_RING | (group_id << IORING_OFF_PBUF_SHIFT). - Ownership:
ProvidedBufferRingowns the buffer memory for as long as the group is registered;unregisterreleases it. Teardown link S2: unregistration before the ring is destroyed. - Replenishment: the application returns consumed buffers to the group (advances the tail of the buffer ring) — see §4.
3. Submission with automatic selection
#![allow(unused)]
fn main() {
impl IoUring {
/// recv with automatic buffer selection from `group` (IOSQE_BUFFER_SELECT).
pub fn submit_receive_provided(&mut self, sock: BorrowedFd<'_>, group: &ProvidedBufferRing, flags: MessageFlags)
-> Result<SubmissionToken, Errno>;
/// read with automatic buffer selection.
pub fn submit_read_provided(&mut self, fd: BorrowedFd<'_>, group: &ProvidedBufferRing, length: u32, offset: Option<u64>)
-> Result<SubmissionToken, Errno>;
}
}
- No buffer is attached to the submission: only the
group_idis passed, along withIOSQE_BUFFER_SELECT. Slot S1 therefore does not hold a buffer (the buffer belongs to the group). - Multishot variants (
recv_multishot/read_multishot): Stage 3d — they rely on this same group. - Bundle (
IORING_RECVSEND_BUNDLE,FEAT_RECVSEND_BUNDLE): a single recv/send can consume multiple contiguous buffers from the group at once (referenced in Stage 2b) — exposed via abundle: boolparameter here. - Shortage/starvation: if the group is empty when data arrives, the completion
carries
-ENOBUFS; the application must replenish and resubmit.
4. Consumption: the ProvidedBuffer RAII guard
#![allow(unused)]
fn main() {
impl Completion {
/// Retrieves the buffer chosen by the kernel for this completion.
/// `None` if the completion did not use a provided buffer.
pub fn into_provided_buffer<'r>(self, group: &'r mut ProvidedBufferRing)
-> Option<ProvidedBuffer<'r>>;
}
/// RAII access to data received in a provided buffer. Returns the buffer to the
/// group (replenishment) when the guard is dropped.
pub struct ProvidedBuffer<'r> { /* id, useful length, borrow of the group */ }
impl<'r> ProvidedBuffer<'r> {
pub fn id(&self) -> u16;
pub fn data(&self) -> &[u8]; // length = bytes received (cqe->res)
pub fn data_mut(&mut self) -> &mut [u8];
}
impl Drop for ProvidedBuffer<'_> { /* replenishes the group */ }
}
- Checkout → process → return cycle, made safe by RAII: the kernel has
“checked out” a buffer (id in the CQE);
into_provided_buffermaterialises bounded access to the received bytes; dropping the guard returns the buffer to the group (advances the ring tail). Forgetting to return = buffer lost to the group → the guard returns it automatically. - The
&'r mut ProvidedBufferRingborrow guarantees that the group cannot be unregistered while a buffer is being processed.
5. Incremental consumption (incremental)
With ProvidedBufferRingOptions::incremental (IOU_PBUF_RING_INC):
- A large buffer can be consumed partially across multiple
completions. The CQE then carries
CQE_F_BUF_MORE(Completion::flags()), signalling that the same buffer will receive further completions and is therefore not returned automatically. - The
ProvidedBufferguard reflects this: ifCQE_F_BUF_MOREis set, dropping it does not replenish (the kernel continues using the buffer); replenishment only occurs on the final completion (withoutBUF_MORE). The application and the facade jointly track the consumption index (documented contract). - Benefit: registering large ranges (a single large
memfd) and consuming only what is needed per receive — memory-efficient for the AirCom data plane.
6. Summary
| # | Element | Facade |
|---|---|---|
| reg 22 | PBUF_RING | ProvidedBufferRing::register |
| reg 23 | UNREGISTER_PBUF_RING | ProvidedBufferRing::unregister |
| reg 26 | PBUF_STATUS | ProvidedBufferRing::status |
| SQE flag | IOSQE_BUFFER_SELECT | submit_receive_provided / submit_read_provided |
| CQE flag | CQE_F_BUFFER | ProvidedBuffer::id (via into_provided_buffer) |
| CQE flag | CQE_F_BUF_MORE | incremental consumption (§5) |
Replaces IORING_OP_PROVIDE_BUFFERS (31) and REMOVE_BUFFERS (32),
retired to UNSUPPORTED.md.
7. Added / shared types
New: ProvidedBufferRing, ProvidedBufferRingOptions, ProvidedBufferRingStatus,
ProvidedBuffer<'r>. New method on Completion: into_provided_buffer.
Shared: MmapRegion (family-mem), MessageFlags (Stage 2b).
8. Test strategy
- Integration: register a group,
recv_providedon a loopback socket, verify the returned buffer id and received bytes, drop the guard, verify replenishment (available()rises); buffer shortage (-ENOBUFS) followed by replenishment;kernel_mmapmode; multi-buffer bundle;status(). - Incremental: large buffer consumed across multiple recvs, sequence of
CQE_F_BUF_MOREthen final completion, replenishment only at the end. - Safety (compile-fail + Miri): impossible to unregister the group while
a
ProvidedBufferis live (mut borrow); no double return of a buffer; no read beyond theresreceived bytes. - Property-based: for any checkout/return sequence,
available ≤ count, no id returned twice, no buffer lost. - Coverage 100% lines + branches.
9. Key decisions that emerged at Stage 3b
- Ring-mapped mechanism only — the classic op-based approach is abandoned; the API is simpler and requires no syscall per buffer batch.
ProvidedBufferRAII guard — replenishment is automatic on drop; it is impossible to “forget” to return a buffer.&mutborrow of the group during processing — prevents unregistration from being called underneath a buffer that is being read.- Incremental consumption handled explicitly —
CQE_F_BUF_MOREchanges the return semantics of the guard; documented and tested, not left implicit. MmapRegionbacking — provided buffers for the data plane live on a shared owned memfd (reuses the liveness handle fromfamily-mem).
10. Next steps
Next spec: io-uring-3c-linked-en.md (linked operation chains:
soft/hard link, LinkedChainBuilder, integration of the link_timeout from
Stage 2c).
Document license: MPL 2.0
Status: Technical specification for Stage 3b (ring-mapped provided buffers) of the air-sys-syscall::io_uring module, target kernel 6.12 LTS.
Spec layer 0 — Module io_uring, Stage 3c: linked operations
Technical specification — Version 1.0 (target kernel: Linux 6.12 LTS)
Position. Stage 3c specifies chains of linked operations: expressing that an operation only starts after the success (or completion) of the previous one, without a userspace round-trip between the two. Sub-module
air-sys-syscall::io_uring::linked. Relies on the SQE flagsIOSQE_IO_LINK/IOSQE_IO_HARDLINK(axis D) and integrates theLINK_TIMEOUTop (15, Stage 2c). Reuses the core of Stage 1. No register opcode.
⚠️ Correction to
io-uring-overview.md. The old overview describes the soft/hard link semantics backwards. The correct semantics (verified against theio_uring_enter(2)man page) are fixed in §2 below. The overview will be corrected during its reconciliation (its inventory role is already covered by the master document).
1. Principle
Several SQEs submitted together can form a chain: each link (chain element) only starts after the completion of the previous one. Marking is done via a flag on the predecessor link:
IOSQE_IO_LINKon op A ⇒ op B (the next SQE) is linked to A.- The tail of the chain is the first SQE without a link flag.
Without a chain, SQEs submitted together may execute in an arbitrary order and
in parallel. The chain enforces ordering and dependency — essential for, e.g.,
socket → connect → send or openat2 → read → close in a single submission.
2. Soft vs hard link semantics (critical point)
The two link types differ in their error propagation, not in ordering (ordering is guaranteed in both cases):
2.1 Soft link — IOSQE_IO_LINK
If a link (chain element) completes with an error, the chain is severed: all remaining links that have not yet started are cancelled and completed with
-ECANCELED.
Broad definition of “error”: io_uring treats any unexpected result as
an error that severs the chain — including a read/recv short read
(fewer bytes than requested). This is the classic pitfall: a chain
read(N) → process is severed if the read returns fewer than N bytes. This
must be prominently documented in the facade.
2.2 Hard link — IOSQE_IO_HARDLINK
A hard link does not sever the chain on a completion error result: the following links execute regardless.
Nuances:
- A hard link implies a soft link (ordering is still guaranteed).
- It only protects against completion errors: if the submission of the parent fails, the chain is severed anyway.
2.3 Table
| Ordering guaranteed | Completion error on a link | Submission failure of parent | |
|---|---|---|---|
soft (IO_LINK) | yes | severs the chain (-ECANCELED on subsequent links); short read = error | severs |
hard (IO_HARDLINK) | yes | does not interrupt (subsequent links execute) | severs |
3. API: LinkedChainBuilder
#![allow(unused)]
fn main() {
impl IoUring {
/// Starts building a linked chain.
pub fn link_chain(&mut self) -> LinkedChainBuilder<'_>;
}
pub struct LinkedChainBuilder<'ring> { /* &mut IoUring in staging mode */ }
impl<'ring> LinkedChainBuilder<'ring> {
/// First link. `op` calls a normal `submit_*`, held in staging
/// instead of being published.
pub fn first<F>(self, op: F) -> Result<Self, Errno>
where F: FnOnce(&mut IoUring) -> Result<SubmissionToken, Errno>;
/// **Soft-linked** link to the previous one (sets `IO_LINK` on the previous).
pub fn then<F>(self, op: F) -> Result<Self, Errno>
where F: FnOnce(&mut IoUring) -> Result<SubmissionToken, Errno>;
/// **Hard-linked** link to the previous one (sets `IO_HARDLINK` on the previous).
pub fn then_hard<F>(self, op: F) -> Result<Self, Errno>
where F: FnOnce(&mut IoUring) -> Result<SubmissionToken, Errno>;
/// Time-bounds the **previous link** via a `LINK_TIMEOUT`
/// (op 15). Must immediately follow the op to be bounded.
pub fn with_link_timeout(self, spec: TimeoutSpec, flags: TimeoutFlags)
-> Result<Self, Errno>;
/// Publishes the **entire chain, atomically**. Returns the tokens in order.
pub fn submit(self) -> Result<ChainTokens, Errno>;
}
/// Tokens for the links in a chain, in submission order.
pub struct ChainTokens { /* Vec<SubmissionToken> */ }
}
3.1 Staging mode (reuses the 55 submit_* methods without duplication)
For the lifetime of the builder, the &mut IoUring is in staging mode: a
submit_* called inside the closure reserves a slot S1 and writes an SQE
(with the appropriate link flag set on the predecessor), without publishing
the queue. Publication only happens at the final submit(), which advances
the SQ queue a single time (§3.3). This avoids duplicating 55 prepare_*
methods: the same submit_* surface serves both direct mode and chain mode
(consistent with Principle 7 — usage is explicitly delimited by link_chain()).
3.2 Atomic slot reservation
submit() only publishes if all of the chain’s slots could be reserved
(S1). If the slab cannot hold the entire chain, the builder returns
Err(EBUSY) before writing anything — no partial chain.
3.3 Publication
All the chain’s SQEs are written, then the SQ queue is advanced by a single store release (§3.2 of Stage 1) → the kernel sees the complete chain at once, guaranteeing that links are respected.
3.4 Example
#![allow(unused)]
fn main() {
// socket → connect → send, in a single linked submission.
let tokens = ring.link_chain()
.first(|r| r.submit_socket(SocketDomain::Inet, SocketType::Stream, 0))?
.then(|r| r.submit_connect(sock, addr))? // starts after the socket
.then(|r| r.submit_send(sock, buf, MessageFlags::empty()))?
.submit()?;
}
4. Completions of a chain
- One completion per link, in order. Each link has its slot S1 and returns its buffer normally.
- Cancelled link (soft link severed): completion with
res == -ECANCELED; slot/buffer returned. - With
IOSQE_CQE_SKIP_SUCCESS(Stage 1,SubmitOptions) on intermediate links, you can wait only for the final completion (intermediate successes do not post a CQE) — useful for a chain where only the final result matters. ChainTokensallows correlating each completion to its link.
5. link_timeout (integration of op 15)
with_link_timeout(spec, flags) inserts a LINK_TIMEOUT that bounds the
previous link:
- if the timeout expires before the link finishes ⇒ the link is cancelled
(
-ECANCELED) and the chain is severed (the timeout itself completes); - if the link finishes before ⇒ the timeout is cancelled automatically.
This is the only safe way to emit a LINK_TIMEOUT in Air (emitting it as an
isolated op is rejected, Stage 2c §3.3). Example: bounding a connect to 2 s.
#![allow(unused)]
fn main() {
let tokens = ring.link_chain()
.first(|r| r.submit_connect(sock, addr))?
.with_link_timeout(TimeoutSpec::after(Duration::from_secs(2)), TimeoutFlags::empty())?
.submit()?;
}
6. Types added / shared
New: LinkedChainBuilder<'ring>, ChainTokens. Reuses:
SubmissionToken, SubmitOptions (drain, skip-cqe), TimeoutSpec,
TimeoutFlags (Stage 2c). No new kernel types.
7. Testing strategy
- Integration: successful
socket→connect→sendchain (observed ordering); soft chain with an error in the middle ⇒ subsequent links-ECANCELED; short read severing a soft chain (explicit test of the pitfall); hard chain where an error does not interrupt;link_timeoutexpiring (link cancelled) and not expiring (timeout cancelled);skip_cqe_on_successon intermediates (only the final completion arrives). - Property-based: for any chain, order of completions = submission order; CQE count consistent with skips; no slot/buffer lost even on severance.
- Atomicity: insufficient slab ⇒
EBUSYwith no partial publication (no SQE published). - Safety: Miri on buffer return for cancelled links.
- Coverage: 100% lines + branches.
8. Key decisions that emerged at Stage 3c
- Correct semantics frozen — soft = severs on error (short read included); hard = does not sever on completion error. (Corrects the overview.)
- Staging mode on
submit_*— noprepare_*duplication; usage explicitly delimited bylink_chain(). - Atomic slot reservation + single publication — never a partial chain;
EBUSYupfront if the slab is insufficient. link_timeoutexclusively via the builder — isolated emission rejected (consistent with Stage 2c).skip_cqe_on_successfor intermediates — wait only for the final result when that is the only useful one.
9. Next steps
Next spec: io-uring-3d-multishot-en.md (multishot operations: accept/recv/
poll/read multishot, MultishotToken token, CQE_F_MORE lifecycle,
interaction with the provided buffers from Stage 3b). Global English translation
after validation of the French documents.
Document license: MPL 2.0
Status: Technical specification for Stage 3c (linked operations) of the air-sys-syscall::io_uring module, target kernel 6.12 LTS.
Spec layer 0 — Module io_uring, Stage 3d: multishot operations
Technical specification — Version 1.0 (target kernel: Linux 6.12 LTS)
Position. Stage 3d specifies multishot operations: a single submission produces multiple completions. Sub-module
air-sys-syscall::io_uring::multishot. Relies on the completion flagCQE_F_MORE(axis E), on the provided buffers from Stage 3b (for recv/read), and on the direct descriptors from Stage 3a (for accept). Reuses the core from Stage 1. No registered opcode, one dedicated opcode (READ_MULTISHOT, 49) and op flags (ACCEPT_MULTISHOT,RECV_MULTISHOT,POLL_ADD_MULTI,TIMEOUT_MULTISHOT).
1. Principle and lifecycle
A one-shot operation submits one SQE, receives one CQE, and releases its slot. A multishot operation submits one SQE and receives a stream of CQEs:
- each intermediate completion carries
CQE_F_MORE: “more completions will follow for this same SQE”; - the final (terminal) completion does not have
CQE_F_MORE: the multishot is done (error, buffer starvation, or cancellation).
Interaction with slab S1. The slot stays alive as long as completions
carry CQE_F_MORE; it is released only at the completion without F_MORE.
This is the second case (after the zero-copy NOTIF from Stage 2b) where a slot
outlives its first completion — the generation of the SubmissionToken
guards against late CQEs after cancellation (Stage 1 §4.2).
Token. A multishot operation returns a MultishotToken (distinct from the
one-shot SubmissionToken). All its completions carry it:
#![allow(unused)]
fn main() {
impl Completion {
/// Multishot token if this completion comes from a multishot.
pub fn multishot_token(&self) -> Option<MultishotToken>;
// has_more() (Stage 1) = CQE_F_MORE : true as long as the stream continues.
}
}
Re-arming. When a multishot ends (has_more() == false), the application
resubmits if it wants to continue. The facade makes termination explicit;
no hidden re-arming.
2. Accept multishot
#![allow(unused)]
fn main() {
impl IoUring {
pub fn submit_accept_multishot(&mut self, listener: BorrowedFd<'_>, flags: AcceptFlags)
-> Result<MultishotToken, Errno>;
/// Direct-descriptor variant: each connection lands in an auto-allocated slot
/// in the FixedFdTable (Stage 3a).
pub fn submit_accept_multishot_direct(&mut self, listener: BorrowedFd<'_>, flags: AcceptFlags)
-> Result<MultishotToken, Errno>;
}
}
- Op:
ACCEPT(13) +IORING_ACCEPT_MULTISHOT. A single SQE accepts continuously: each incoming connection produces a completion carrying the accepted FD (accepted_fd()),CQE_F_MOREmaintained. - Direct variant: each connection goes into a direct slot (auto-alloc,
FixedSlotTarget::Alloc) — ideal for a very high connection-rate server (compositor, AirCom): no ordinary FD, no userspace table to manage. Links to Stage 3a. - Termination: on error (e.g. listener closed) ⇒ completion without
F_MORE. Use case: a server makes one submission at startup and consumes connections as they arrive.
3. Recv / Read multishot (with provided buffers)
#![allow(unused)]
fn main() {
impl IoUring {
pub fn submit_receive_multishot(&mut self, sock: BorrowedFd<'_>, group: &ProvidedBufferRing, flags: MessageFlags)
-> Result<MultishotToken, Errno>;
pub fn submit_read_multishot(&mut self, fd: BorrowedFd<'_>, group: &ProvidedBufferRing, offset: Option<u64>)
-> Result<MultishotToken, Errno>;
}
}
- Ops:
RECV(27) +IORING_RECV_MULTISHOT;READ_MULTISHOT(49, dedicated opcode). Require provided buffers (Stage 3b,IOSQE_BUFFER_SELECT): each data arrival takes a buffer from the group and produces a completion. - Completion:
into_provided_buffer(group)returns the chosen buffer (id + bytes), a RAII guard that replenishes on drop (Stage 3b §4). Incremental consumption (CQE_F_BUF_MORE, Stage 3b §5) composes with multishot. - Buffer starvation: if the group is empty when data arrives, the completion
carries
-ENOBUFSand terminates the multishot (noF_MORE). The application replenishes the group then resubmits. The facade signals this case distinctly (termination due to starvation vs. network error). - Major benefit for AirCom / servers: a single SQE per connection serves its entire incoming stream, without a buffer pre-committed per idle connection.
4. Poll multishot
#![allow(unused)]
fn main() {
impl IoUring {
pub fn submit_poll_multishot(&mut self, fd: BorrowedFd<'_>, events: PollEvents)
-> Result<MultishotToken, Errno>;
}
}
- Op:
POLL_ADD(6) +IORING_POLL_ADD_MULTI. Each state transition of the FD toward the monitoredeventsproduces a completion (into_poll_result() -> PollEvents),F_MOREmaintained. - Level-triggered:
IORING_POLL_ADD_LEVELexposed via an option (PollEvents+ level). Otherwise, edge-triggered. - Cancellation:
submit_poll_remove/cancel_multishot.
5. Timeout multishot
#![allow(unused)]
fn main() {
impl IoUring {
pub fn submit_timeout_multishot(&mut self, interval: Duration, flags: TimeoutFlags)
-> Result<MultishotToken, Errno>;
}
}
- Op:
TIMEOUT(11) +IORING_TIMEOUT_MULTISHOT. Emits a completion at a regular interval (repeating timer) until cancelled — useful for a periodic heartbeat in the reactor without resubmitting on every tick. - Termination:
cancel_multishotor error.
6. Cancellation
#![allow(unused)]
fn main() {
impl IoUring {
pub fn cancel_multishot(&mut self, token: MultishotToken) -> Result<(), Errno>;
}
}
- Cancels an in-flight multishot (via
ASYNC_CANCEL, Stage 2c, targeting the token). The terminal completion (withoutF_MORE, often-ECANCELED) releases the slot; any late CQEs are filtered by generation (S1). submit_cancel(CancelTarget::…)(Stage 2c) remains usable for grouped cancellations (by FD, by op,Any).
7. Summary
| Op / flag | Facade | Completions |
|---|---|---|
ACCEPT + ACCEPT_MULTISHOT | submit_accept_multishot[_direct] | stream of accepted FDs |
RECV + RECV_MULTISHOT | submit_receive_multishot | stream of provided buffers |
READ_MULTISHOT (49) | submit_read_multishot | stream of provided buffers |
POLL_ADD + POLL_ADD_MULTI | submit_poll_multishot | stream of events |
TIMEOUT + TIMEOUT_MULTISHOT | submit_timeout_multishot | stream of ticks |
ASYNC_CANCEL | cancel_multishot | termination |
All share: MultishotToken, the CQE_F_MORE invariant, slot release at the
terminal completion.
8. Types added / shared
New: MultishotToken (already declared in Stage 1, semantics fixed here).
Reuses: AcceptFlags, MessageFlags, PollEvents, TimeoutFlags,
ProvidedBufferRing/ProvidedBuffer (Stage 3b), FixedSlotTarget (Stage 3a).
Completion method: multishot_token.
9. Test strategy
- Integration:
accept_multishoton a listener, N connections ⇒ N completions withF_MORE, listener closed ⇒ terminal completion; direct variant (FD in slots);recv_multishot+ buffer group, stream of datagrams, starvation ⇒-ENOBUFSterminating the multishot, replenish + resubmit;poll_multishoton a pipe written multiple times;timeout_multishot(N ticks then cancel);cancel_multishot(termination, late CQE filtered). - S1 lifecycle: slot remains occupied while
F_MORE, released at terminal completion; generation filters post-cancellation CQEs (property-based). - Safety: Miri on multishot slot restitution; no double-free; provided buffers correctly returned in multishot.
- Coverage: 100% lines + branches.
10. Key decisions that emerged at Stage 3d
- Distinct
MultishotToken— a stream of completions, not a one-shot op; the type prevents confusing the two lifecycles. - Slot alive until the terminal completion (
!F_MORE) — an assumed extension of S1 (like zero-copy NOTIF); generation guards against late CQEs. - Explicit termination, explicit re-arming — no hidden resubmission
(Principle 7);
-ENOBUFSsignalled distinctly from network error. accept_multishot_directpreferred for high connection-rate servers — connections as direct descriptors (links Stage 3a), relevant for AirCom/compositor.- recv/read multishot backed by provided buffers — no buffer pre-committed per connection (links Stage 3b).
11. Next steps
Next spec: io-uring-3e-shared-en.md (multi-thread usage: LockedIoUring,
RingPool thread-per-core, SqpollIoUring).
Document license: MPL 2.0
Status: Technical specification for Stage 3d (multishot) of the air-sys-syscall::io_uring module, target kernel 6.12 LTS.
Spec layer 0 — Module io_uring, Stage 3e: multi-threaded usage (shared)
Technical specification — Version 1.0 (target kernel: Linux 6.12 LTS)
Position. Stage 3e specifies the multi-threaded models for the ring. Sub-module
air-sys-syscall::io_uring::shared. Builds on the invariant from ADR-022 Decision 6 (IoUringisSendbut notSync), on theIORING_SETUP_SQPOLLflag (Stage 1),ATTACH_WQ(Stage 3a) andmsg_ring(Stage 2c). Reuses the core from Stage 1. No register opcode of its own.
1. The starting point: Send but not Sync
IoUring can be moved between threads (Send) but not shared by
reference (!Sync). Reason: the ordering protocol from Stage 1 (§3.2)
synchronises userspace ↔ kernel, not userspace ↔ userspace. The
SQ/CQ heads/tails and the S1 slab are not protected against concurrent
userspace accesses. Three responses, in order of Air preference.
2. Recommended model: thread-per-core (one ring per thread)
This is Air’s default model. Each thread (ideally pinned to a core) owns its own
IoUring, created withSINGLE_ISSUER | DEFER_TASKRUN(Stage 1 §5.1). No sharing on the hot path, therefore no lock, low and predictable latency.
SINGLE_ISSUERinforms the kernel that a single thread submits → internal optimisations.DEFER_TASKRUNdefers task-work to the wait point → fewer interruptions.- Communication between rings: the ring is not shared; messages are passed
via
msg_ring(Stage 2c,MSG_DATAto wake up a peer,MSG_SEND_FDto transfer a file descriptor to it). This is the reactor-level counterpart of AirCom’s peer-to-peer model. - Safety: trivial — no concurrent access to the same ring. The
!SynconIoUringforbids by typing accidental sharing.
This model introduces no new type: it is the direct use of IoUring,
one per thread. Sections 3–5 cover cases where that is not sufficient.
3. LockedIoUring — simple sharing via a lock
#![allow(unused)]
fn main() {
pub struct LockedIoUring { /* IoUring + internal lock */ }
impl LockedIoUring {
pub fn new(entries: NonZeroU32) -> Result<Self, Errno>;
pub fn from_builder(builder: IoUringBuilder) -> Result<Self, Errno>;
// Mirror API of IoUring, but taking &self (internal lock):
pub fn submit_read(&self, fd: BorrowedFd<'_>, buffer: Vec<u8>, offset: Option<u64>)
-> Result<SubmissionToken, Errno>;
pub fn submit_and_wait(&self, want: u32) -> Result<u32, Errno>;
pub fn wait_completion(&self) -> Result<Completion, Errno>;
// … (all submit_*/completions, taking &self) …
}
// LockedIoUring: Send + Sync.
}
- A single ring shared between threads, protected by an internal lock
(serialises userspace accesses to the SQ/CQ/slab). Becomes
Sync. - Warning: the lock is a contention point — under heavy multi-threaded load it serialises everything. Reserve for simple or lightly used cases. For performance, prefer thread-per-core (§2).
- Granularity: a single lock to start with (clarity, Principle 7); refinement (separate locks for submission/completion) is only considered after measurement (Principle 5), never upfront.
4. RingPool — thread-per-core helper
#![allow(unused)]
fn main() {
pub struct RingPool { /* N rings, shared io-wq, registered ring fds */ }
impl RingPool {
/// Creates `workers` rings: the first creates the io-wq pool, the subsequent
/// ones attach to it (ATTACH_WQ) → total number of worker threads bounded.
pub fn new(workers: NonZeroUsize, entries: NonZeroU32) -> Result<Self, Errno>;
/// Distributes the rings: one owned `IoUring` per worker (to be moved into
/// each thread). Ring fds are mutually registered for msg_ring.
pub fn into_rings(self) -> Vec<IoUring>;
/// Routing handle to a peer ring (for msg_ring).
pub fn handle(&self, worker: usize) -> Option<RingHandle>;
}
/// Reference to a peer ring, usable as a msg_ring target (Stage 2c).
pub struct RingHandle { /* registered ring fd of the peer */ }
}
- Role: create N coherent rings for the §2 model, by:
- sharing the io-wq pool (
ATTACH_WQ, Stage 3a) ⇒ the total number of kernel worker threads is bounded regardless of N — crucial on modest hardware (Pi 4, Charter principle 4); - registering ring fds mutually (Stage 3a) ⇒
msg_ringcan route to a peer in a decoupled manner (wakeup, FD transfer).
- sharing the io-wq pool (
- Each returned
IoUringremainsSend/!Sync: it is moved into its thread, never shared.RingPoolcreates no sharing — it organises.
5. SqpollIoUring — kernel thread polling the SQ
#![allow(unused)]
fn main() {
pub struct SqpollIoUring { /* IoUring with SETUP_SQPOLL */ }
impl SqpollIoUring {
/// `idle`: inactivity delay before the kernel thread goes to sleep
/// (NEED_WAKEUP). `cpu`: optional thread pinning (SQ_AFF).
pub fn new(entries: NonZeroU32, idle: Duration, cpu: Option<u32>)
-> Result<Self, Errno>;
// Usual submit_* methods; submission may require NO syscall at all.
}
}
- With
IORING_SETUP_SQPOLL, a kernel thread polls the SQ: in steady state, submission makes noio_uring_entercall (just the release publication). Minimal latency. - Wakeup: if the thread has gone to sleep (after
idle), theIORING_SQ_NEED_WAKEUPflag is set; the façade detects it and issuesio_uring_enter(.., SQ_WAKEUP)automatically on submission (this is the only wakeup case handled on the caller’s behalf — documented). SQ_AFF: pins the kernel thread to a core (cpu).- Cost: one kernel core dedicated to polling. Excellent for latency under heavy load, poor choice on Pi 4 where cores are scarce. Reserve for profiles where latency dominates and CPU is abundant (measurement, Principle 5).
SQPOLL_NONFIXED(FEAT_SQPOLL_NONFIXED, present in 6.12) lifts the old constraint requiring exclusive use of registered FDs.
6. Send / Sync matrix
| Type | Send | Sync | Model |
|---|---|---|---|
IoUring | yes | no | one ring per thread (§2) — recommended |
LockedIoUring | yes | yes | shared ring, lock (§3) — simple, contention |
RingPool | yes | no (distributes) | organises N thread-per-core rings (§4) |
SqpollIoUring | yes | no | kernel SQ poll (§5) — latency vs CPU |
7. Safety
- The
!Syncinvariant onIoUringprevents by typing the sharing of an unprotected ring between threads — no data race possible on the SQ/CQ/slab. LockedIoUringadds the missing userspace↔userspace synchronisation; the userspace↔kernel ordering protocol from Stage 1 remains unchanged underneath.RingPool/thread-per-core: no sharing ⇒ no synchronisation required; all exchanges go throughmsg_ring(the kernel serialises writes to the target CQ).- Teardown S2 applies per ring (each ring quiesces its own).
8. Added / shared types
New: LockedIoUring, RingPool, RingHandle, SqpollIoUring. Reuses:
IoUring, IoUringBuilder, SetupFlags (SQPOLL/SQ_AFF/SINGLE_ISSUER/
DEFER_TASKRUN/ATTACH_WQ), msg_ring (Stage 2c), registered ring fd (Stage 3a).
9. Test strategy
- Thread-per-core: N threads, one ring each, independent load; hand-off
via
msg_ring(one thread wakes up / transfers to a peer, verified). - LockedIoUring: concurrent access from multiple threads, completion consistency; loom on the lock + the ring protocol (no data race, no deadlock).
- RingPool:
ATTACH_WQcorrectly bounds the number of worker threads (observed via/procor a counter); correct inter-ring routing. - SqpollIoUring: syscall-free submission in steady state; correct wakeup
after
idle(NEED_WAKEUP);SQ_AFFpinning. - Safety: compile-fail confirming that
IoUringdoes not cross a&between threads (!Sync); Miri/loom forLockedIoUring. - Coverage 100% lines + branches.
10. Key decisions that emerged at Stage 3e
- Thread-per-core as the default model — one ring per thread,
SINGLE_ISSUER + DEFER_TASKRUN, no lock; communication viamsg_ring. Aligns the async runtime (ADR-023) with AirCom’s peer-to-peer model. !Syncassumed onIoUring— multi-threaded safety is an explicit choice of the caller (LockedIoUring or RingPool), not a cost imposed on everyone.RingPoolorganises, does not share —ATTACH_WQbounds worker threads (Pi 4); rings remain owned per thread.SqpollIoUringreserved — real latency gain but costs one core; discouraged on modest hardware. NEED_WAKEUP wakeup handled for the caller (the only exception to “no hidden magic”, as it is indispensable and documented).- Single lock first in
LockedIoUring— refinement only after measurement (Principle 5).
11. Next steps
Next spec: io-uring-3f-sandbox-en.md (confinement: R_DISABLED +
REGISTER_RESTRICTIONS + ENABLE_RINGS, capabilities link ADR-010/AirCom).
Global English translation after validation of the French documents.
Document license: MPL 2.0
Status: Technical specification for Stage 3e (multi-threaded) of the air-sys-syscall::io_uring module, target kernel 6.12 LTS.
Spec layer 0 — Module io_uring, Stage 3f: confinement (sandbox)
Technical specification — Version 1.0 (target kernel: Linux 6.12 LTS)
Position. Stage 3f makes soundness decision S3 from the master document operational: confine a ring so that it can only submit operations that have been explicitly authorized. Sub-module
air-sys-syscall::io_uring::sandbox. Builds on theIORING_SETUP_R_DISABLEDflag (Stage 1), and the register opcodesREGISTER_RESTRICTIONS(11) andENABLE_RINGS(12). This is the io_uring brick of Air’s capabilities model (ADR-001 AirCom, ADR-010 signed entitlements), in defense in depth withfamily-security(seccomp/Landlock).
1. Why confine a ring?
io_uring executes operations (read, openat2, connect…) that, when submitted
through the ring, do not go through the classic syscall interface. A
seccomp filter that blocks the openat2 syscall does not prevent an
IORING_OP_OPENAT2 submitted via the ring. Historically, this has been a bypass
path for sandboxes. The proper response: restrict the ring itself.
A confined ring can only submit the opcodes, register-ops, and SQE flags explicitly placed on the allowlist, and the kernel enforces this. Even a compromised service cannot expand what its ring permits.
This is the materialization, at the io_uring level, of the entitlements
declared in the signed manifest of an .airservice/.airapp (ADR-010), and
the kernel-side counterpart of AirCom capabilities (ADR-001).
2. The mechanism in three stages
- Create disabled: the ring is created with
IORING_SETUP_R_DISABLED— it exists but cannot submit anything. - Restrict:
REGISTER_RESTRICTIONSapplies a list of restrictions (allowlist). Possible only while the ring is disabled. - Enable:
REGISTER_ENABLE_RINGSactivates the ring. After activation, restrictions are immutable — they can no longer be relaxed.
The order is kernel-enforced and reflected by the API: a ring that is already active cannot be restricted, and submission is not possible before activation.
3. API
3.1 RestrictionSet
#![allow(unused)]
fn main() {
/// Allowlist of what a confined ring will be permitted to do. Default-deny: anything
/// not explicitly authorized is refused by the kernel.
#[derive(Debug, Clone, Default)]
pub struct RestrictionSet { /* ... */ }
impl RestrictionSet {
pub fn new() -> Self;
/// Authorizes a submission opcode (RESTRICTION_SQE_OP).
pub fn allow_op(self, op: IoUringOpcode) -> Self;
/// Authorizes a register opcode (RESTRICTION_REGISTER_OP).
pub fn allow_register(self, op: RegisterOp) -> Self;
/// Authorized SQE flags (RESTRICTION_SQE_FLAGS_ALLOWED).
pub fn allow_sqe_flags(self, flags: SqeFlagSet) -> Self;
/// SQE flags required on EVERY submission (RESTRICTION_SQE_FLAGS_REQUIRED).
pub fn require_sqe_flags(self, flags: SqeFlagSet) -> Self;
/// Conversion from an entitlement set (upper layer).
/// Provided as an integration point; policy lives in layer 5.
pub fn from_entitlements(/* … */) -> Self;
}
}
3.2 Integration with the builder (Stage 1)
#![allow(unused)]
fn main() {
// IoUringBuilder::restrict(&[Restriction]) → R_DISABLED + REGISTER_RESTRICTIONS.
let mut ring = IoUringBuilder::new(entries)
.restrict(restrictions.as_slice()) // ring created disabled
.build()?;
ring.enable()?; // REGISTER_ENABLE_RINGS; immutable thereafter
// From this point, any submission outside the allowlist fails (-EACCES/-EINVAL).
}
build()with a non-emptyrestrictleaves the ring disabled;enable()(Stage 1 §5.2) activates it. Any attempt to callsubmit_*beforeenable()⇒ error. Any attempt to re-restrict afterenable()⇒ error.- Default-deny semantics: if
RestrictionSetcontains at least oneallow_op, only the listed opcodes are permitted (the rest are refused by the kernel). Same for register-ops.
4. The four restriction types
Type (io_uring_register_restriction_op) | RestrictionSet | Effect |
|---|---|---|
RESTRICTION_SQE_OP (1) | allow_op | allowlist of submittable opcodes |
RESTRICTION_REGISTER_OP (0) | allow_register | allowlist of register-ops |
RESTRICTION_SQE_FLAGS_ALLOWED (2) | allow_sqe_flags | authorized SQE flags |
RESTRICTION_SQE_FLAGS_REQUIRED (3) | require_sqe_flags | flags required on each SQE |
Example — a “network only” AirCom service:
#![allow(unused)]
fn main() {
let r = RestrictionSet::new()
.allow_op(IoUringOpcode::Socket)
.allow_op(IoUringOpcode::Connect)
.allow_op(IoUringOpcode::Send)
.allow_op(IoUringOpcode::Receive)
.allow_op(IoUringOpcode::SendZeroCopy)
.allow_op(IoUringOpcode::Close);
// The ring can never emit openat2, unlinkat, etc. — kernel-guaranteed.
}
5. Relationship with Air’s security model
- ADR-010 (signed entitlements): the manifest of an
.airservicedeclares its entitlements; a layer 5 component (air-launchd/air-trust) translates those entitlements into aRestrictionSetand provides the service with an already-confined ring. Layer 0 provides the mechanism (RestrictionSet,restrict,enable); policy lives in the upper layer — a clean separation. - ADR-001 (AirCom): a service receives only the capabilities (FDs) from its manifest, and its ring can only submit the corresponding operations — a double lock (unforgeable FDs + restricted ring).
family-security(seccomp/Landlock): io_uring restrictions complement seccomp/Landlock; they do not replace them. seccomp filters syscalls, Landlock filters FS access, io_uring restrictions filter ring operations — defense in depth. This is precisely what closes the bypass path described in §1.- Personality (Stage 3a): combined with restrictions, allows ops to be executed under a controlled identity — least privilege.
Out-of-scope note. Even finer-grained filtering via a BPF program (
IORING_REGISTER_BPF_FILTER) is post-6.12; it may enrich this confinement later, through runtime detection, without amendment.
6. Summary
| Element | Facade |
|---|---|
SETUP_R_DISABLED | IoUringBuilder::restrict (creates disabled) |
REGISTER_RESTRICTIONS (11) | IoUringBuilder::restrict (applies the list) |
REGISTER_ENABLE_RINGS (12) | IoUring::enable |
4 restriction_op | RestrictionSet::{allow_op, allow_register, allow_sqe_flags, require_sqe_flags} |
7. Added / shared types
New: RestrictionSet, RegisterOp (enumeration of authorizable register-ops),
SqeFlagSet. Reused: Restriction and IoUringOpcode (declared in Stage 1),
IoUringBuilder::restrict, IoUring::enable. Integration point
from_entitlements (entitlement type defined in the upper layer).
8. Test strategy
- Integration: create an
R_DISABLEDring, apply an allowlist,enable(), verify that an authorized opcode passes and that a non-authorized opcode fails (-EACCES/-EINVAL); same for register-ops;require_sqe_flagsenforced (a submission without the required flag fails); submission beforeenablerejected; re-restriction afterenablerejected (immutability). - Security: test demonstrating that an
openat2submitted via a “network only” ring is refused even when the process has the right to open the file (proof of defense in depth vs seccomp). - Property-based: for any subset of authorized opcodes, exactly those opcodes pass.
- Coverage: 100% lines + branches.
9. Key decisions that emerged at Stage 3f
- Confinement = layer 0 mechanism, layer 5 policy —
RestrictionSetand therestrict → enableflow are neutral; the translation of signed entitlements (ADR-010) lives in the upper layer. - Default-deny — as soon as an opcode allowlist is set, everything else is refused by the kernel; no half-measures.
- Immutability after
enable— guaranteed by the kernel and reflected by the API; a confined ring cannot be relaxed. - Explicit defense in depth — io_uring restrictions complement seccomp/Landlock and close the syscall filter bypass path.
- BPF filtering noted but out of scope (post-6.12).
10. Next steps
Next and final spec in the module: io-uring-4-raw-en.md (level-1 access to the
ring protocol: RawSubmissionQueueEntry/RawCompletionQueueEntry, direct SQE/CQE manipulation, not-yet-wrapped
operations, extensive # Safety). Global English translation after validation of
the French documents.
Document license: MPL 2.0
Status: Technical specification for Stage 3f (confinement) of the air-sys-syscall::io_uring module, target kernel 6.12 LTS.
Layer 0 spec — io_uring module, Stage 4: raw access (level 1)
Technical specification — Version 1.0 (kernel target: Linux 6.12 LTS)
Position. Stage 4 specifies level 1 access to the ring protocol: direct manipulation of raw SQE/CQE slots, for cases that the typed API (levels Stages 1–3f) does not cover. Sub-module
air-sys-syscall::io_uring::raw. This is the safety valve of ADR-022 (Decision 1): almost everything goes through level 2; level 1 remains available, governed by an exhaustive# Safetycontract.
1. When (not) to use the raw level
Legitimate use cases:
- Operations not yet wrapped: a kernel opcode introduced after the facade (reminder: scope is frozen at 6.12; a 6.13+ opcode detected by probe can be driven at the raw level while its level-2 wrapper is pending).
- Advanced optimisations: aggressive batching, SQE layouts that the typed API cannot express.
- Tooling: debugging, monitoring, ring state inspection.
Avoid otherwise. The raw level bypasses slab S1 (and therefore safe buffer ownership) and exposes fields where a mistake corrupts the ring. The Air rule: stay at level 2 unless there is a measured necessity (Principle 5).
2. Raw types
2.1 RawSubmissionQueueEntry / RawCompletionQueueEntry
#![allow(unused)]
fn main() {
/// Exact mirror of `struct io_uring_sqe` (64 bytes; 128 if SETUP_SQE128).
#[repr(C)]
pub struct RawSubmissionQueueEntry { /* opcode, flags, ioprio, fd, off/addr2, addr, len,
op_flags, user_data, buf_index, personality,
splice_fd_in/file_index, addr3/cmd… */ }
/// Exact mirror of `struct io_uring_cqe` (16 bytes; 32 if SETUP_CQE32).
#[repr(C)]
pub struct RawCompletionQueueEntry {
pub user_data: u64,
pub res: i32,
pub flags: u32,
// big_cqe[] (16 bytes) if CQE32
}
/// Raw opcode (also covers opcodes outside `IoUringOpcode`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RawOpcode(pub u8);
}
#[repr(C)]and layout bit-for-bit identical to the uapi v6.12 header; verified by size/offset assertions (static) — any drift breaks compilation.SQE128/CQE32variants are accounted for: accessors respect the configured ring size (a 64-byteRawSubmissionQueueEntryon a non-SQE128ring; the 80-bytecmd[]area only exists underSQE128).- Typed constructors/accessors for filling an SQE without manual offset
arithmetic:
RawSubmissionQueueEntry::nop(),.set_fd(),.set_addr(),.set_len(),.set_user_data(),.set_flags(), etc.
3. Raw submission
#![allow(unused)]
fn main() {
impl IoUring {
/// Reserves a free SQE slot and returns it for manual filling.
/// `None` if the SQ is full.
///
/// # Safety
/// The caller must write a **valid** SQE: supported opcode, consistent
/// `fd`/`addr`/`len`, and **every buffer referenced by `addr` must remain
/// valid until completion** (slab S1 does NOT manage that buffer). The
/// `user_data` must comply with the coexistence rule (§5).
pub unsafe fn raw_get_submission_queue_entry(&mut self) -> Option<&mut RawSubmissionQueueEntry>;
/// Total SQ capacity (effective entries).
pub fn submission_queue_capacity(&self) -> u32; // safe
/// Available SQ slots.
pub fn submission_queue_available(&self) -> u32; // safe
/// Total CQ capacity.
pub fn completion_queue_capacity(&self) -> u32; // safe
}
}
- Publishing =
submit()(Stage 1), which remains safe. The caller fills one or moreRawSubmissionQueueEntryentries viaraw_get_submission_queue_entry, then callssubmit()/submit_and_wait(): it is the facade that performs the store release on the queue (Stage 1 §3.2). The ring protocol ordering is therefore managed by the facade; theunsafecovers only the content of the SQE and the memory validity of buffers, not the ring protocol.
4. Raw completion
#![allow(unused)]
fn main() {
impl IoUring {
/// Inspects the next raw completion without consuming it. `None` if the CQ
/// is empty. (Safe: read-only via internal load acquire.)
pub fn raw_peek_completion_queue_entry(&self) -> Option<&RawCompletionQueueEntry>;
/// Consumes `n` completions from the CQ (advances the head, store release).
pub fn raw_advance_completion_queue(&mut self, n: u32);
}
}
- Raw read/advance for monitoring tools and operations submitted at the raw
level.
raw_peek_completion_queue_entryis safe (read-only);raw_advance_completion_queueis safe (the facade handles the ordering), but interpretingres/flagsis the caller’s responsibility (based on the opcode it submitted).
5. Raw / level-2 coexistence: the user_data coexistence rule (essential)
Slab S1 (Stage 1 §4.2) encodes the user_data of level-2 operations as
(generation << 32) | slot, with slot < capacity. A raw operation sets its
user_data freely — creating a risk of collision with the slab encoding.
Frozen rule: a raw operation must set the most-significant bit
(RAW_USER_DATA_TAG = 1 << 63) in its user_data. The facade’s completion loop
then routes:
user_data & RAW_USER_DATA_TAG == 0⇒ managed completion: decoded via the slab, delivered as aCompletion(Stage 1);user_data & RAW_USER_DATA_TAG != 0⇒ raw completion: delivered as-is (RawCompletionQueueEntry) to the caller, without touching the slab.
#![allow(unused)]
fn main() {
pub const RAW_USER_DATA_TAG: u64 = 1 << 63;
}
Consequence: the slab never uses bit 63 (slot + generation fit in 63 bits —
more than sufficient). Raw and level 2 coexist on the same ring without
collision. The facade rejects (debug_assert + error) a raw_get_submission_queue_entry
whose user_data does not carry the tag, in test builds.
6. # Safety contract (summary)
The caller of the raw level guarantees:
- Valid opcode, supported by the current kernel (verify via
supports_op/ probe). - Field consistency of the SQE (
fd,addr,len, flags) for the opcode. - Memory validity: every buffer pointed to by
addr/addr2/addr3remains valid and unpinned until completion (slab S1 does not protect it). user_datatag (§5) set on every raw operation.- No double consumption of a completion (
raw_advance_completion_queueconsistent withraw_peek_completion_queue_entrycalls).
The facade guarantees in return: ring ordering (publish/consume store release / load acquire) and non-corruption of internal structures (the raw level does not expose naked mmap pointers, only SQE/CQE slots accessed through bounded offsets).
7. Added / shared types
New: RawSubmissionQueueEntry, RawCompletionQueueEntry, RawOpcode, constant RAW_USER_DATA_TAG. Reuses the ring
and its ordering (Stage 1). The raw register argument structures
(io_uring_rsrc_register, io_uring_buf_reg, etc.) are exposed as #[repr(C)]
here for tooling purposes, but their normal usage goes through the typed types of
Stages 3a/3b.
8. Test strategy
- Layout: static size/offset assertions for
RawSubmissionQueueEntry/RawCompletionQueueEntryagainst the v6.12 header (64/128 and 16/32 bytes); compilation failure on any drift. - Integration: submit a
NOPat the raw level (tagged user_data), observe it returned viaraw_peek_completion_queue_entry/raw_advance_completion_queue; implement a simple operation at the raw level and compare against the equivalent level-2 wrapper. - Coexistence: mix level-2 (slab) operations and raw (tagged) operations on the same ring; verify correct routing of completions, no collision.
- Safety:
debug_asserton missing tag; Miri onraw_get_submission_queue_entry/advance (slot bounds); fuzzing ofRawCompletionQueueEntrydecoding (external kernel data, Principle 3). - Coverage: the raw
unsafelevel is tested via NOP and simple opcodes; branches that cannot be provoked are documented inCOVERAGE-EXCEPTIONS.mdwith justification.
9. Key decisions that emerged at Stage 4
- Ordering always managed by the facade — even at the raw level,
submit()/raw_advance_completion_queueperform the store release / load acquire barriers; theunsafecovers only the content of the SQE and memory validity of buffers, not the ring protocol. Naked queue head/tail pointers are never exposed. user_datatag (bit 63) — raw / level-2 coexistence without collision on the same ring; the slab never uses this bit.- No naked mmap pointers exposed — the raw level goes through bounded SQE/CQE slots, not through the naked rings; reduces the error surface.
- Exhaustive and localised
# Safety— consistent with layer 0 conventions; everyunsafefunction documents its preconditions. - Safety valve, not the main path — the raw level exists for the 5 % of cases that level 2 does not cover; the documentation discourages its use by default.
10. End of the io_uring module
With this Stage 4, the inventory of the master document is fully covered:
- Stage 1 (core); 2a (fs); 2b (network); 2c (async); 2d (uring_cmd); 3a (registration); 3b (provided buffers); 3c (linked); 3d (multishot); 3e (multi-thread); 3f (confinement); 4 (raw).
All io_uring features for the 6.12 target are specified as a Rust facade,
excluding obsolete interfaces (moved to UNSUPPORTED.md).
Next steps: global English translation of the module documents (after
validation of the French versions), then hand off the body implementations
(todo!()) from the validated rustdoc skeletons.
Document license: MPL 2.0
Status: Technical specification for Stage 4 (raw access) of the air-sys-syscall::io_uring module, kernel target 6.12 LTS. Closes the module specification.
Air’s layer 0 — introduction (developers)
Overview document, to get up to speed on layer 0 quickly and know where to find the
reference. The ADRs (docs/adrs/) are project-internal documents; this document is
aimed at the developers who consume or study layer 0.
What layer 0 is
Layer 0 is Air’s syscall foundation: a typed, safe Rust API on top of the Linux system calls, without libc. Two crates:
air-sys-types— the pure types (no syscall): typed identifiers (Pid,Tid,Uid/Gid,WatchDescriptor…),Errno, flags/bitflags, mirror types (#[repr(C)]) of the kernel structures.air-sys-syscall— the wrappers that call the kernel viacore::arch::asm!and returnResult<T, Errno>.
What you find here:
- 11 syscall families:
process,fs,mem,signal,time,net,ipc,security,system,device(uevent/evdev),ebpf(bpf()+perf_event_open); - the complete
io_uringmodule (12 “Temps”: core, files, network, async,uring_cmd, registration, provided buffers, linked operations, multishot, multi-thread, confinement, raw access) — the foundation of Air’s asynchronous I/O; - extensions:
fs::inotify, CPU affinity (sched_set/getaffinity),mem::MmapRegion, the privsep extension ofprocess(setgroups/setres*id).
Why this architecture
- No libc, no
rustix. Layer 0 talks to the kernel directly inasm!. Goal: total control of the behavior, no C dependency, and ano_std-compatible target. (ADR-004: Linux tier-1.) - Safety through typing.
Option<T>instead of the kernel sentinels (-1/0),Result<T, Errno>instead of the global errno,OwnedFd/BorrowedFd(RAII, no FD leak), newtypes per identifier. (Conventions carved in stone: ADR-021.) - io_uring as the async foundation. Air’s asynchronous runtime (ADR-023) is built on io_uring (ADR-022/028), not on epoll nor Tokio. A safe, proven “slab S1” ownership model (Miri/loom).
- Modern variants preferred:
clone3(notclone),pidfd_*(not the raw PID),waitid(notwait/waitpid),signalfd(ADR-020), io_uring’s*2register-ops… - Foundational-layer rigor: 100% coverage of lines + branches (excluding
documented exceptions), property-testing, fuzzing on every parser of external
data, Miri/loom on
unsafe/concurrent code. (Principle 1.)
Why a 6.12 kernel minimum
6.12 is an LTS kernel (long-term support, consistent with Air’s durability horizon),
and it is the version where the io_uring surface Air depends on is available (down to
the recent operations and register-ops), alongside Landlock, seccomp-bpf, eBPF, etc.
Targeting a single stable version avoids the hell of compatibility #ifdefs and
keeps layer 0 simple and auditable. The syscall numbers and layouts are verified
per architecture (x86_64 and aarch64) against the 6.12 uapi.
What you will not find here (and why)
- No libc, no
rustix— by design (see above). - The legacy syscalls deliberately set aside, listed and justified in
UNSUPPORTED.md: the old variants superseded by a modern variant (clone,wait/waitpid, raw PIDs…), and the obsolete io_uring mechanisms (PROVIDE_BUFFERS/REMOVE_BUFFERSop-based, untagged v1 register-ops…). - Deferred by decision (present nowhere, and that’s intentional): epoll (the
readiness need is covered by io_uring’s
poll/multishot; only speculativeEpoll*types exist), fanotify (privileged primitive, for a later security service), synchronous futex (will come withair-thread’s in-house futex implementation).
UNSUPPORTED.md is the reference list of these choices.
External dependencies
None in production. Layer 0 is pure Rust + asm! — no C, no
libc, no rustix. The only dependencies are test-only (property-testing,
loom, cargo-fuzz), isolated and subject to deny.toml/cargo audit (carve-out
ADR-030). This is unusual and assumed: the foundation depends on nothing above
the kernel.
For the C/C++ developer
Layer 0 does not expose a C ABI, and that is intentional: all its value lives in
the Rust type system (RAII, lifetimes, Option/Result, io_uring ownership)
— which does not cross a C boundary. If you are in C/C++:
- raw syscalls → use your libc (glibc/musl); io_uring in C → liburing;
- Air features → the C ABI of layers 1+ (
air-base-lib, thenair-runtime/air-aircom), where Air is polyglot by design (ADR-027); - building C tools on top of the Air stack is the subject of a
libc-compattrack (exploration,docs/notes/), not of layer 0.
Reference — to go further
- Per-family specs:
docs/specs/layer-0/family-*.md. - io_uring module: master inventory
io-uring-0-inventaire.md+ one document per Temps (io-uring-1-core.md…io-uring-4-raw.md). - Decisions: ADR-021 (layer 0 conventions), ADR-019 (errors), ADR-020 (signals), ADR-022/028 (io_uring), ADR-029 (naming), ADR-024/030/034 (dependencies), ADR-031/035 (coverage & exception taxonomy).
- Reference lists:
UNSUPPORTED.md(legacy set aside),COVERAGE-EXCEPTIONS.md(justified coverage exceptions). - API: the generated rustdoc reference (
/api/).
Document license: MPL 2.0.
Layer 0 Spec — Crate air-sys-types
Technical specification — Version 0.1 (partial, to be completed)
⚠️ Document status
This spec covers the fundamental types of the
air-sys-typescrate, identified throughout the specifications of the syscall families. The types are grouped by their family of origin. The estimated total is roughly ~187 public types in the complete crate.This document lists the types and their signatures. A complete spec would detail, for each type, its methods, invariants, conversions, and tests. Work to resume: produce the exhaustive spec type by type, in parallel with the actual implementation.
Crate overview
The air-sys-types crate is the absolute foundation of the Air stack: every other module depends on it. It exposes:
- The fundamental newtypes:
Errno,Pid,Tid, etc. - The RAII wrappers for kernel resources:
OwnedFd,BorrowedFd,Mapping, etc. - The bitflags for kernel flags:
OpenFlags,CloneFlags, etc. - The typed enums for kernel semantics:
Signal,Capability, etc. - The data structures mirroring the kernel:
StatxResult,SignalFdInfo, etc.
Crate principles.
-
No allocation by default. The fundamental types (Errno, Pid, etc.) are newtypes over primitive types, without allocation.
-
#[repr(transparent)]or#[repr(C)]as needed. Types that must have a kernel-compatible layout use#[repr(C)]. Simple newtypes use#[repr(transparent)]. -
Explicit conversions. Conversions between types (for example,
Pid::as_raw() -> i32) are explicit via named methods, not via implicitFrom/Into. -
Validation at construction. Types that have invariants (for example,
Pid, which isNonZeroI32) validate at construction and exposetry_newmethods that returnOptionorResult. -
Extensive documentation. Each type has a docstring that explains its role, its invariants, and its relationship with the kernel.
Fundamental types (universal)
Errno
#![allow(unused)]
fn main() {
#[repr(transparent)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Errno(NonZeroI32);
impl Errno {
// ~140 constants corresponding to the standard errno codes
pub const EPERM: Self;
pub const ENOENT: Self;
pub const ESRCH: Self;
pub const EINTR: Self;
pub const EIO: Self;
// ... etc.
pub const fn as_raw(self) -> i32;
pub const fn name(self) -> &'static str;
pub const fn description(self) -> &'static str;
}
impl core::fmt::Display for Errno { /* ... */ }
impl core::error::Error for Errno {}
}
See ADR-019 for the context.
File descriptors
#![allow(unused)]
fn main() {
pub struct OwnedFd { /* ... */ }
pub struct BorrowedFd<'fd> { /* ... */ }
pub type RawFd = i32;
impl OwnedFd {
pub fn as_fd(&self) -> BorrowedFd<'_>;
pub fn into_raw_fd(self) -> RawFd;
pub unsafe fn from_raw_fd(fd: RawFd) -> Self;
}
impl<'fd> BorrowedFd<'fd> {
pub fn as_raw_fd(&self) -> RawFd;
pub unsafe fn borrow_raw(fd: RawFd) -> Self;
}
impl Drop for OwnedFd {
fn drop(&mut self) { /* calls close(fd), ignores error */ }
}
}
Discipline: every FD managed by Air goes through OwnedFd/BorrowedFd. No bare RawFd except in the raw APIs.
DirFd
#![allow(unused)]
fn main() {
pub enum DirFd<'fd> {
Cwd,
Fd(BorrowedFd<'fd>),
}
}
Basis for the *at operations (see ADR-021 convention 1).
Types of the process family
#![allow(unused)]
fn main() {
pub struct Pid(NonZeroI32);
pub struct Tid(NonZeroI32);
pub struct PidFd { /* internal OwnedFd */ }
pub struct PidFdReceiver { /* ... */ }
pub struct TidReceiver { /* ... */ }
pub enum WaitTarget<'fd> {
AnyChild,
Pid(Pid),
ProcessGroup(Pid),
AnyProcessGroup,
PidFd(BorrowedFd<'fd>),
}
pub struct WaitOptions(i32); // bitflags
pub struct WaitStatus {
pub pid: Pid,
pub uid: u32,
pub event: WaitEvent,
}
pub enum WaitEvent {
Exited { code: i32 },
Killed { signal: Signal, core_dumped: bool },
Stopped { signal: Signal },
Continued,
Trapped { signal: Signal },
}
pub struct CloneFlags(u64); // bitflags
pub struct CloneArgs { /* ... */ }
pub enum CloneResult { /* ... */ }
pub struct Signal(NonZeroI32);
pub struct SignalMask { /* ... */ }
pub enum Capability { /* enum of the Linux capabilities */ }
pub struct CapabilityMask(u64);
pub struct CapabilitySet { /* ... */ }
pub enum CapabilityTarget { /* ... */ }
pub enum Resource { /* enum of the Linux limits */ }
pub struct Rlimit { /* ... */ }
pub enum RlimitValue { Finite(u64), Infinity }
pub enum DumpableMode { /* ... */ }
pub struct ExecveatFlags(i32); // bitflags
pub struct PidFdOpenFlags(u32); // bitflags
}
~20 types for this family.
Types of the fs family
#![allow(unused)]
fn main() {
pub struct OpenFlags(u64); // bitflags
pub type Mode = u32;
pub struct OpenHow { /* ... */ }
pub struct ResolveFlags(u64); // bitflags
pub struct StatxResult { /* ... */ }
pub struct StatxMask(u32); // bitflags
pub struct StatxFlags(u32); // bitflags
pub struct StatxTimestamp { /* ... */ }
pub struct AccessMode(u32); // bitflags
pub struct AccessFlags(i32); // bitflags
pub struct RenameFlags(u32); // bitflags
pub struct FallocateMode(i32); // bitflags
pub enum SeekWhence { Set, Current, End, Data, Hole }
pub struct DirEntry { /* ... */ } // name: Vec<u8> (octets) — ADR-048
pub enum DirEntryType { /* ... */ }
pub struct DirEntryIter<'buf> { /* ... */ }
pub enum UtimeValue { Now, Omit, Time(StatxTimestamp) }
pub struct FdFlags(i32); // bitflags
pub struct StatusFlags(i32); // bitflags
pub struct FileLock { /* ... */ }
pub struct Seals(u32); // bitflags
pub struct FileHandle { /* ... */ }
pub struct NameToHandleFlags(i32); // bitflags
pub struct IoSlice<'data> { /* ... */ }
pub struct IoSliceMut<'data> { /* ... */ }
}
~25 types for this family.
Types of the mem family
#![allow(unused)]
fn main() {
pub struct Mapping {
addr: NonNull<u8>,
length: usize,
}
pub struct MappingPointer { /* ... */ }
pub struct ProtectionFlags(i32); // bitflags
pub struct MapFlags(i32); // bitflags
pub struct MremapFlags(i32); // bitflags
pub enum MadviseAdvice { /* ~25 variants */ }
pub struct MlockFlags(u32); // bitflags
pub struct MlockallFlags(i32); // bitflags
pub struct MemfdFlags(u32); // bitflags
pub struct MsyncFlags(i32); // bitflags
pub struct RemoteIoSlice { /* ... */ }
}
~10 types for this family.
Types of the io_uring module
Type placement (ADR-022 / ADR-028). This document specifies the
air-sys-typescrate. Forio_uring, only the pure-data types (with no behavior, no FD/mmap, no lifetime tied to a ring) live here; the stateful / coupled types (ring, builders, tokens, completions,#[repr(C)]mirrors, RAII guards) live inair-sys-syscall::io_uringby encapsulation. The rule is decided Temps by Temps, as the implementation progresses.Note (naming, ADR-029). Explicit facade names, without abbreviation. The old abbreviated names (
IoUringOp,RawSqe,RawCqe,RawOp,FdPool,RegisteredBuffer,ProvidedBuffers) are replaced; the kernel mirrors keep their short inherited names.
#![allow(unused)]
fn main() {
// PLACEMENT (ADR-022/028). Only `SetupFlags`, `CompletionFlags` and
// `IoUringOpcode` live in `air-sys-types` (pure types, delivered in Temps 1).
// ALL the other types below live in `air-sys-syscall::io_uring`
// (stateful / coupled — encapsulation); listed here for reference, specified in
// the `io-uring-*.md` specs. The later Temps will place their types according to
// the same rule (pure here, coupled in air-sys-syscall).
pub struct IoUring { /* ... */ }
pub struct IoUringBuilder { /* ... */ }
pub struct IoUringParams { /* ... */ }
pub struct IoUringCapabilities { /* ... */ }
pub struct SetupFlags(u32); // bitflags
pub struct Completion { /* ... */ }
pub struct CompletionFlags(u32); // bitflags
pub struct CompletionIter<'ring> { /* ... */ }
pub struct SubmitOptions { /* ... */ }
pub struct SubmissionToken(/* opaque: slot index + generation */);
pub struct MultishotToken(/* opaque */);
pub enum IoUringOpcode { /* enum of the operations */ }
pub enum CancelTarget { /* token / fd / op / any */ }
// Fixed resources (Temps 3a)
pub struct FixedFdTable { /* ... */ }
pub struct FixedSlot<'t> { /* ... */ }
pub enum FixedSlotTarget { /* Index(u32) | Alloc */ }
pub struct RegisteredBuffers { /* ... */ }
pub struct RegisteredBufferSlice<'b> { /* ... */ }
pub struct Personality(/* id */);
pub struct WorkQueueWorkerLimits { /* ... */ }
pub struct NapiConfig { /* ... */ }
pub enum ClockSource { /* Monotonic | Boottime | Realtime */ }
// Ring-mapped provided buffers (Temps 3b)
pub struct ProvidedBufferRing { /* ... */ }
pub struct ProvidedBuffer<'r> { /* RAII guard */ }
pub struct ProvidedBufferRingOptions { /* ... */ }
pub struct ProvidedBufferRingStatus { /* ... */ }
// Linked / multishot / zero-copy network
pub struct LinkedChainBuilder<'ring> { /* ... */ }
pub struct ChainTokens { /* ... */ }
pub struct ZeroCopyFlags(u32); // bitflags
// Multi-thread (Temps 3e)
pub struct LockedIoUring { /* ... */ }
pub struct RingPool { /* ... */ }
pub struct RingHandle { /* ... */ }
pub struct SqpollIoUring { /* ... */ }
// Confinement (Temps 3f)
pub struct RestrictionSet { /* ... */ }
pub enum Restriction { /* ... */ }
pub enum RegisterOp { /* ... */ }
pub struct SqeFlagSet { /* ... */ }
// Raw access level 1 (Temps 4) — verbose type, fields = kernel names
#[repr(C)]
pub struct RawSubmissionQueueEntry { /* 64 bytes; fields user_data, res… */ }
#[repr(C)]
pub struct RawCompletionQueueEntry { /* 16 or 32 bytes */ }
pub struct RawOpcode(pub u8);
pub const RAW_USER_DATA_TAG: u64 = 1 << 63;
pub struct TimeoutFlags(u32); // bitflags
pub struct PollEvents(u32); // bitflags
pub enum EpollOp { /* ... */ }
pub struct EpollEvent { /* ... */ }
}
In air-sys-types: 3 types (Temps 1 — SetupFlags, CompletionFlags,
IoUringOpcode). The others live in air-sys-syscall::io_uring or are not
yet implemented.
Types of the signal family
#![allow(unused)]
fn main() {
pub struct SignalFdInfo { /* ... */ }
pub struct SignalFdFlags(i32); // bitflags
pub struct SignalQueueInfo { /* ... */ }
pub enum SignalValue { Integer(i32), Pointer(u64) }
// synchronous_handler submodule
pub enum FatalSignal { Segv, Bus, Fpe, Ill }
pub struct SignalInfo { /* ... */ }
pub struct PreviousHandler { /* opaque */ }
}
~6 types for this family.
Types of the time family
#![allow(unused)]
fn main() {
pub enum Clock {
Realtime, Monotonic, ProcessCpuTime, ThreadCpuTime,
MonotonicRaw, RealtimeCoarse, MonotonicCoarse,
Boottime, RealtimeAlarm, BoottimeAlarm, Tai,
}
pub struct Instant {
clock: Clock,
seconds: i64,
nanoseconds: u32,
}
pub struct Timespec { /* internal */ }
pub struct TimerFd { /* internal OwnedFd */ }
pub struct TimerFdSpecification {
pub initial: Duration,
pub interval: Duration,
}
pub struct TimerFdFlags(i32); // bitflags
pub struct TimerSetFlags(i32); // bitflags
pub enum SleepDeadline {
Relative(Duration),
AbsoluteInstant(Instant),
}
pub enum SleepError {
Interrupted { remaining: Duration },
Other(Errno),
}
}
~9 types for this family.
Types of the net family
#![allow(unused)]
fn main() {
pub enum SocketDomain { /* ... */ }
pub enum SocketType { /* ... */ }
pub enum SocketAddr {
Unix(UnixSocketAddr),
Inet(InetSocketAddr),
Inet6(Inet6SocketAddr),
// ...
}
pub struct UnixSocketAddr { /* ... */ }
pub struct InetSocketAddr { /* ... */ }
pub struct Inet6SocketAddr { /* ... */ }
pub struct MessageFlags(i32); // bitflags
pub struct AcceptFlags(i32); // bitflags
pub struct AcceptResult { /* ... */ }
pub struct SendMessageRequest { /* ... */ }
pub struct ReceiveMessageRequest { /* ... */ }
pub struct SendMessageResult { /* ... */ }
pub struct ReceiveMessageResult { /* ... */ }
pub enum ShutdownMode { Read, Write, Both }
pub enum AncillarySent { /* ... */ }
pub enum AncillaryReceived { /* ... */ }
pub enum SocketOptionLevel { /* ... */ }
pub enum LingerOption { Disabled, Enabled(Duration) }
pub struct UnixCredentials { /* ... */ }
}
~20 types for this family.
Types of the ipc family
#![allow(unused)]
fn main() {
pub struct EventFd { /* internal OwnedFd */ }
pub struct EventFdFlags(i32); // bitflags
pub struct PipeFlags(i32); // bitflags
pub struct SpliceFlags(u32); // bitflags
}
~4 types for this family.
Types of the security family
#![allow(unused)]
fn main() {
pub struct SeccompFilter { /* ... */ }
pub struct SeccompRule { /* ... */ }
pub enum SeccompAction { /* ... */ }
pub enum SeccompMode { /* ... */ }
pub struct SeccompFilterFlags(u32); // bitflags
pub struct SyscallNumber(i32);
pub struct SyscallArgumentCondition { /* ... */ }
pub enum ConditionOp { /* ... */ }
pub struct LandlockRuleset { /* ... */ }
pub struct LandlockAccessFs(u64); // bitflags
}
~10 types for this family.
Types of the system family
#![allow(unused)]
fn main() {
pub struct UtsName {
pub sysname: CString,
pub nodename: CString,
pub release: CString,
pub version: CString,
pub machine: CString,
pub domainname: CString,
}
pub struct SystemInfo { /* ... */ }
pub struct GetrandomFlags(u32); // bitflags
}
3 types for this family.
Global summary
| Family | Approximate number of types |
|---|---|
| Fundamental | 5 (Errno, OwnedFd, BorrowedFd, RawFd, DirFd) |
process | 20 |
fs | 25 |
mem | 10 |
io_uring (in air-sys-types) | 3 (Temps 1) |
signal | 6 |
time | 9 |
net | 20 |
ipc | 4 |
security | 10 |
system | 3 |
Estimated total: ~140-160 public types.
The final spec would perhaps have a few more types depending on the needs that emerge during implementation. The initial estimate of ~187 types remains reasonable.
Work to resume
TODO: produce an exhaustive spec of
air-sys-types:For each type, document:
- Internal representation (
#[repr(...)]).- Constructors (
new,try_new, etc.) and invariants.- Main methods (
as_raw,from_raw, conversions).- Trait implementations (
Debug,Display,Error,Clone,Copy, etc.).- Tests to plan for (constructions, invariants, conversions).
- Relationship with the equivalent kernel types.
This exhaustive spec is probably to be produced in parallel with the actual implementation of the crate, rather than ahead of it, because adjustments will emerge naturally.
Document license: MPL 2.0
Status: Partial spec of the air-sys-types crate. To be completed in parallel with the implementation.
Layer 0 Spec — process Family
Technical specification — Version 1.0
Family overview
The air-sys-syscall::process module exposes the kernel primitives for managing processes, threads, and child processes. It is one of the foundational families of layer 0: everything concerning a process lifecycle (creation, waiting, termination), identity (PIDs, TIDs), capabilities, and rlimits.
Family scope.
Eighteen wrapped syscalls, organized into sub-categories:
- Identity:
getpid,gettid. - Process creation:
clone3,execve,execveat. - Waiting and termination:
waitid,exit_group,pidfd_open,pidfd_send_signal,pidfd_getfd. - Groups and sessions:
setpgid,getpgid,setsid,getsid. - Process control:
prctl(operations exposed individually). - Resource limits:
getrlimit,setrlimit,prlimit. - Capabilities:
capget,capset.
Cross-cutting characteristics of the family.
-
Preference for modern variants.
clone3rather thanclone,pidfd_openrather than using bare PIDs for post-fork operations. The legacy syscalls are listed inUNSUPPORTED.mdwith justification. -
Systematic newtypes for identifiers.
Pid,Tid,PidFdare typed newtypes. No barei32for a PID. -
Clear ownership semantics.
PidFdis RAII: it automatically closes the FD on destruction. FDs received by operations are always returned asOwnedFd. -
Systematic upstream validation. In accordance with Engineering Principle 4, every public function validates its parameters at the start and returns
ResultorOptionon violation.
Subsection 1: Identity
getpid
Signature.
#![allow(unused)]
fn main() {
pub fn getpid() -> Pid;
}
Underlying syscall. getpid (x86_64 #39, ARM64 #172). Man page getpid(2).
Behavior.
Returns the PID of the calling process. Cannot fail. POSIX guarantees that a returned PID is strictly positive.
Return type.
Pid is a newtype over NonZeroI32. The function is total (no Result).
Performance. Negligible (~30-50 ns via vDSO on recent kernels).
Tests.
- Nominal test:
getpid()returns a valid Pid. - Consistency test: two successive calls return the same value.
gettid
Signature.
#![allow(unused)]
fn main() {
pub fn gettid() -> Tid;
}
Underlying syscall. gettid (x86_64 #186, ARM64 #178). Man page gettid(2).
Behavior.
Returns the TID of the calling thread. On Linux, each thread has a distinct TID; the main thread of a process has a TID equal to the process PID.
Return type.
Tid is a newtype distinct from Pid to prevent confusion by type.
Performance. ~30-50 ns.
Tests.
- Main thread test:
gettid()equalsgetpid()for the main thread. - Multi-thread test: two threads of the same process have distinct TIDs.
Subsection 2: Process creation
clone3
Signature.
#![allow(unused)]
fn main() {
pub unsafe fn clone3(args: &CloneArgs) -> Result<CloneResult, Errno>;
pub enum CloneResult {
Parent { child_pid: Pid, child_pidfd: Option<PidFd> },
Child,
}
#[derive(Debug, Default)]
pub struct CloneArgs {
pub flags: CloneFlags,
pub pidfd: Option<PidFdReceiver>,
pub child_tid: Option<TidReceiver>,
pub parent_tid: Option<TidReceiver>,
pub exit_signal: Option<Signal>,
pub stack: Option<StackSpecification>,
pub tls: Option<u64>,
pub set_tid: Option<&[Pid]>,
pub cgroup: Option<RawFd>,
}
bitflags! {
pub struct CloneFlags: u64 {
const VM = 0x00000100;
const FS = 0x00000200;
const FILES = 0x00000400;
const SIGHAND = 0x00000800;
const PIDFD = 0x00001000;
const PTRACE = 0x00002000;
const VFORK = 0x00004000;
const PARENT = 0x00008000;
const THREAD = 0x00010000;
const NEWNS = 0x00020000;
const SYSVSEM = 0x00040000;
const SETTLS = 0x00080000;
const PARENT_SETTID = 0x00100000;
const CHILD_CLEARTID = 0x00200000;
const DETACHED = 0x00400000;
const UNTRACED = 0x00800000;
const CHILD_SETTID = 0x01000000;
const NEWCGROUP = 0x02000000;
const NEWUTS = 0x04000000;
const NEWIPC = 0x08000000;
const NEWUSER = 0x10000000;
const NEWPID = 0x20000000;
const NEWNET = 0x40000000;
const IO = 0x80000000;
const CLEAR_SIGHAND = 0x100000000;
const INTO_CGROUP = 0x200000000;
const NEWTIME = 0x400000000;
}
}
pub struct StackSpecification {
pub addr: *mut u8,
pub size: usize,
}
}
Underlying syscall. clone3 (x86_64 #435, ARM64 #435). Man page clone(2). Available since Linux 5.3 (September 2019).
Preconditions (Safety).
The API is unsafe because clone3 can create a thread sharing memory with the parent (CLONE_VM), which requires careful handling of memory aliasing that Rust cannot verify.
The # Safety documentation requires:
-
For “classic fork” uses (creation of an independent child process), no particular precondition. The Air wrapper may provide a safe
fork_processhelper that calls clone3 with the appropriate flags. -
For uses with
CLONE_VM, the caller must guarantee that the shared memory is manipulated in a thread-safe manner by both sides. -
For uses with
CLONE_NEWUSER,CLONE_NEWNS, etc. (namespaces), the caller must have the necessary capabilities.
Behavior.
clone3 is the modern unified operation for creating processes, threads, and any combination of the two. The precise behavior depends on the flags:
- Without special flags: equivalent to
fork(). Creates an independent child process. - With
CLONE_VM | CLONE_THREAD | CLONE_SIGHAND: creates a thread in the current process. - With
CLONE_NEW*: creates the process in new namespaces. - With
CLONE_PIDFD: also returns a pidfd for the new process, avoiding races on the recycled PID.
The Air wrapper returns a CloneResult that clearly distinguishes the parent side from the child side. For the parent, the child’s PID is returned, plus optionally a PidFd if CLONE_PIDFD was requested.
Recommended pattern.
#![allow(unused)]
fn main() {
let mut args = CloneArgs::default();
args.flags = CloneFlags::PIDFD;
args.exit_signal = Some(Signal::SIGCHLD);
// SAFETY: no shared memory, classic fork with pidfd
let result = unsafe { clone3(&args)? };
match result {
CloneResult::Parent { child_pid, child_pidfd } => {
let pidfd = child_pidfd.expect("requested PIDFD");
// Wait for the child via waitid on the pidfd
}
CloneResult::Child => {
// Child code
// Probably execve here
unreachable!("execve should not return");
}
}
}
Errors.
EAGAIN: system limits reached (maximum number of processes).EINVAL: invalid flag combination.ENOMEM: insufficient kernel memory.EPERM: insufficient capabilities for the requested flags.EUSERS: too many user namespaces.
Performance.
clone3 with classic fork: ~50-100 µs. The main cost is the duplication of page tables (copy-on-write).
clone3 for thread creation: ~20-50 µs.
clone3 with namespaces: variable, ~100-500 µs depending on the requested namespaces.
Portability.
Strictly Linux. No direct equivalent on other Unix systems (which have separate fork/vfork/pthread_create).
Tests.
- Classic fork test: create a child process that exits immediately, waitid, verify exit code.
- Pidfd test: create with CLONE_PIDFD, verify that the pidfd is valid.
- Exec-in-child test: fork then execve, verify that the external binary is executed.
- Namespaces test: create in a new PID namespace, verify isolation.
- Capabilities test: attempt without privileges, verify EPERM.
execve
Signature.
#![allow(unused)]
fn main() {
pub fn execve(
path: &CStr,
argv: &[&CStr],
envp: &[&CStr],
) -> Result<Infallible, Errno>;
}
Underlying syscall. execve (x86_64 #59, ARM64 #221). Man page execve(2).
Preconditions.
argv and envp are arrays of C-compatible strings. The wrapper converts to *const *const c_char internally.
Behavior.
Replaces the memory image of the current process with that of the binary at path. On success, never returns (the process now runs the new binary). On failure, returns with an Errno.
Return type.
Result<Infallible, Errno>: Infallible is the Rust idiom for “never happens on success”. The user can write let _: Infallible = execve(...)?; and then have an unreachable!().
Errors.
E2BIG: argv/envp too large.EACCES: insufficient permissions to executepath.ENOENT:pathdoes not exist.ENOEXEC:pathis not a valid executable.ENOMEM: insufficient memory.ETXTBSY: the binary is open for writing by another process.
Performance.
Variable depending on the binary size and the complexity of the loader. Typically ~1-5 ms for a simple binary, more for dynamically linked binaries with many libraries.
Tests.
- Nominal test: fork then execve of a simple binary (
/bin/true), waitid. - ENOENT test: execve on a nonexistent path.
- EACCES test: execve on a non-executable file.
execveat
Signature.
#![allow(unused)]
fn main() {
pub fn execveat(
dirfd: DirFd,
path: &CStr,
argv: &[&CStr],
envp: &[&CStr],
flags: ExecveatFlags,
) -> Result<Infallible, Errno>;
bitflags! {
pub struct ExecveatFlags: i32 {
const EMPTY_PATH = 0x1000;
const SYMLINK_NOFOLLOW = 0x100;
}
}
}
Underlying syscall. execveat (x86_64 #322, ARM64 #281). Available since Linux 3.19.
Behavior.
Variant of execve that takes a directory FD (for relative path resolution) or a direct FD to the binary (with EMPTY_PATH).
Main use case: execute a binary referenced by a previously opened FD, without a race on the filesystem path.
#![allow(unused)]
fn main() {
let bin_fd = openat(DirFd::Cwd, c"/usr/bin/myapp", OpenFlags::PATH | OpenFlags::CLOEXEC, Mode::empty())?;
// Later, after possible checks:
execveat(DirFd::Fd(bin_fd.as_fd()), c"", &argv, &envp, ExecveatFlags::EMPTY_PATH)?;
}
Tests.
- EMPTY_PATH test: openat on binary, execveat with FD.
- Relative path test: execveat with dirfd + path.
Subsection 3: Waiting and termination
waitid
Signature.
#![allow(unused)]
fn main() {
pub fn waitid(
target: WaitTarget,
options: WaitOptions,
) -> Result<Option<WaitStatus>, Errno>;
pub enum WaitTarget {
AnyChild,
Pid(Pid),
ProcessGroup(Pid),
AnyProcessGroup,
PidFd(BorrowedFd<'_>),
}
bitflags! {
pub struct WaitOptions: i32 {
const EXITED = 4;
const STOPPED = 2;
const CONTINUED = 8;
const NOHANG = 1;
const NOWAIT = 0x01000000;
}
}
pub struct WaitStatus {
pub pid: Pid,
pub uid: u32,
pub event: WaitEvent,
}
pub enum WaitEvent {
Exited { code: i32 },
Killed { signal: Signal, core_dumped: bool },
Stopped { signal: Signal },
Continued,
Trapped { signal: Signal },
}
}
Underlying syscall. waitid (x86_64 #247, ARM64 #95). Man page waitid(2).
Preconditions.
WaitOptions must contain at least one of EXITED, STOPPED, or CONTINUED (otherwise waitid does not know which events to report).
Behavior.
Waits for an event to occur on the targeted process or group. More powerful than wait or waitpid because it can wait on a pidfd directly (avoiding races) and discriminate event types.
The wrapper returns Result<Option<WaitStatus>, Errno>. Ok(None) indicates that NOHANG was requested and that no event was available.
Pattern with pidfd.
#![allow(unused)]
fn main() {
let mut args = CloneArgs::default();
args.flags = CloneFlags::PIDFD;
// SAFETY: classic fork with pidfd
let result = unsafe { clone3(&args)? };
if let CloneResult::Parent { child_pidfd: Some(pidfd), .. } = result {
let status = waitid(WaitTarget::PidFd(pidfd.as_fd()), WaitOptions::EXITED)?;
match status {
Some(WaitStatus { event: WaitEvent::Exited { code }, .. }) => {
println!("Child exited with code {}", code);
}
_ => { /* other cases */ }
}
}
}
Errors.
ECHILD: no child to wait for.EINTR: interrupted by a signal (in accordance with convention 2 of ADR-021, propagated to the caller).EINVAL: invalid options.
Performance.
Variable. With NOHANG, immediate return ~1 µs. Without NOHANG, blocking until an event.
exit_group
Signature.
#![allow(unused)]
fn main() {
pub fn exit_group(status: i32) -> !;
}
Underlying syscall. exit_group (x86_64 #231, ARM64 #94). Man page exit_group(2).
Behavior.
Terminates all threads of the calling process with the specified exit code. Never returns.
Return type.
! (never type): the function never returns.
Performance. Negligible on the caller side (the process disappears).
pidfd_open
Signature.
#![allow(unused)]
fn main() {
pub fn pidfd_open(pid: Pid, flags: PidFdOpenFlags) -> Result<PidFd, Errno>;
bitflags! {
pub struct PidFdOpenFlags: u32 {
const NONBLOCK = 0x800;
}
}
pub struct PidFd { /* owns an internal OwnedFd */ }
impl PidFd {
pub fn as_fd(&self) -> BorrowedFd<'_>;
pub fn into_fd(self) -> OwnedFd;
}
}
Underlying syscall. pidfd_open (x86_64 #434, ARM64 #434). Available since Linux 5.3.
Behavior.
Opens an FD that references a process by PID. The FD remains valid even if the PID is recycled after the death of the original process (the FD does not “transfer” to a new process).
The pidfd can then be used for:
waitid(wait for the process to finish).pidfd_send_signal(send a signal without a race on the PID).pidfd_getfd(retrieve an FD from the target process).- Monitoring via poll/epoll (the FD becomes readable when the process terminates).
Errors.
EINVAL: invalid flags.ESRCH: nonexistent process.ENOMEM: insufficient kernel memory.
Performance. ~5-10 µs.
Tests.
- Nominal test: fork child, pidfd_open on its PID, waitid on the pidfd.
- ESRCH test: pidfd_open on a nonexistent PID.
- Post-mortem test: create pidfd, kill process, verify that the pidfd remains valid until waitid.
pidfd_send_signal
Signature.
#![allow(unused)]
fn main() {
pub fn pidfd_send_signal(
pidfd: BorrowedFd<'_>,
signal: Signal,
info: Option<&SignalInfo>,
) -> Result<(), Errno>;
}
Underlying syscall. pidfd_send_signal (x86_64 #424, ARM64 #424). Available since Linux 5.1.
Behavior.
Sends a signal to the process referenced by the pidfd. No race on the recycled PID: if the original process is dead, the signal is not delivered to a possible new process with the same PID.
To be preferred over kill(pid, sig) when a pidfd is available.
Errors.
EBADF: invalid pidfd.EINVAL: invalid signal.EPERM: insufficient permissions.ESRCH: the referenced process no longer exists.
pidfd_getfd
Signature.
#![allow(unused)]
fn main() {
pub fn pidfd_getfd(
pidfd: BorrowedFd<'_>,
target_fd: RawFd,
flags: u32,
) -> Result<OwnedFd, Errno>;
}
Underlying syscall. pidfd_getfd (x86_64 #438, ARM64 #438). Available since Linux 5.6.
Behavior.
Retrieves a duplicate of an FD open in another process. Requires CAP_SYS_PTRACE or being the same user as the target process.
Specialized use case: debuggers, monitoring tools, resource transfer between cooperating processes.
Subsection 4: Groups and sessions
setpgid, getpgid, setsid, getsid
Signatures.
#![allow(unused)]
fn main() {
pub fn setpgid(pid: Option<Pid>, pgid: Option<Pid>) -> Result<(), Errno>;
pub fn getpgid(pid: Option<Pid>) -> Result<Pid, Errno>;
pub fn setsid() -> Result<Pid, Errno>;
pub fn getsid(pid: Option<Pid>) -> Result<Pid, Errno>;
}
Underlying syscalls. setpgid (x86_64 #109, ARM64 #154), getpgid (#121, #155), setsid (#112, #157), getsid (#124, #156).
Preconditions.
Option<Pid> allows expressing “the current process” via None (instead of the kernel sentinel 0). Consistent with convention 1 of ADR-021.
Behavior.
Management of Unix process groups and sessions. Rarely used in modern applications but necessary for shells, daemons, and certain terminal applications.
Performance. Negligible (~1-2 µs).
Subsection 5: prctl (operations exposed individually)
prctl is a multiplexed syscall: 60+ distinct operations depending on the first argument. In accordance with convention 3 of ADR-021, Air does not wrap prctl generically but exposes each operation as a dedicated typed function.
Exposed operations
#![allow(unused)]
fn main() {
// PR_SET_PDEATHSIG / PR_GET_PDEATHSIG
pub fn set_parent_death_signal(signal: Option<Signal>) -> Result<(), Errno>;
pub fn get_parent_death_signal() -> Result<Option<Signal>, Errno>;
// PR_SET_NO_NEW_PRIVS / PR_GET_NO_NEW_PRIVS
pub fn set_no_new_privs() -> Result<(), Errno>;
pub fn get_no_new_privs() -> Result<bool, Errno>;
// PR_SET_NAME / PR_GET_NAME (thread name, max 16 bytes)
pub fn set_thread_name(name: &CStr) -> Result<(), Errno>;
pub fn get_thread_name() -> Result<CString, Errno>;
// PR_SET_DUMPABLE / PR_GET_DUMPABLE
pub fn set_dumpable(dumpable: DumpableMode) -> Result<(), Errno>;
pub fn get_dumpable() -> Result<DumpableMode, Errno>;
pub enum DumpableMode {
NotDumpable,
Dumpable,
SuidDumpable,
}
// PR_SET_KEEPCAPS / PR_GET_KEEPCAPS
pub fn set_keep_caps(keep: bool) -> Result<(), Errno>;
pub fn get_keep_caps() -> Result<bool, Errno>;
// PR_SET_TIMERSLACK
pub fn set_timer_slack(slack_ns: u64) -> Result<(), Errno>;
// PR_CAP_AMBIENT (with sub-operations)
pub fn cap_ambient_raise(cap: Capability) -> Result<(), Errno>;
pub fn cap_ambient_lower(cap: Capability) -> Result<(), Errno>;
pub fn cap_ambient_clear_all() -> Result<(), Errno>;
pub fn cap_ambient_is_set(cap: Capability) -> Result<bool, Errno>;
// PR_SET_SECCOMP_MODE_FILTER (alternative to the direct seccomp syscall)
// Not exposed here, prefer seccomp_load_filter in the security family.
}
This list covers the most-used prctl operations. Rare operations (PR_TASK_PERF_EVENTS, PR_GET_TID_ADDRESS, etc.) are listed in UNSUPPORTED.md and can be added on a case-by-case basis if Air needs them.
Subsection 6: Resource limits
getrlimit, setrlimit, prlimit
Signatures.
#![allow(unused)]
fn main() {
pub fn getrlimit(resource: Resource) -> Result<Rlimit, Errno>;
pub fn setrlimit(resource: Resource, limit: Rlimit) -> Result<(), Errno>;
pub fn prlimit(
pid: Option<Pid>,
resource: Resource,
new_limit: Option<Rlimit>,
) -> Result<Rlimit, Errno>;
pub enum Resource {
Cpu,
FileSize,
Data,
Stack,
Core,
Rss,
NProc,
NoFile,
MemLock,
As,
Locks,
SigPending,
MsgQueue,
Nice,
RtPrio,
RtTime,
}
pub struct Rlimit {
pub soft: RlimitValue,
pub hard: RlimitValue,
}
pub enum RlimitValue {
Finite(u64),
Infinity,
}
}
Underlying syscalls. getrlimit (x86_64 #97, ARM64 #163), setrlimit (#160, #164), prlimit64 (#302, #261).
Behavior.
Reads and modifies the resource limits of a process. prlimit is more powerful: it allows operating on another process (with appropriate privileges) and performs both operations (reading the old value + writing the new one) in a single atomic call.
Air recommendation. Prefer prlimit everywhere. getrlimit and setrlimit are exposed for compatibility but are essentially special cases of prlimit.
Errors.
EFAULT: should not happen via the safe API.EINVAL: invalid resource or invalid limit.EPERM: attempt to raise the hard limit without privileges.ESRCH: nonexistent process (for prlimit).
Subsection 7: Capabilities
capget, capset
Signatures.
#![allow(unused)]
fn main() {
pub fn capget(target: CapabilityTarget) -> Result<CapabilitySet, Errno>;
pub fn capset(target: CapabilityTarget, set: &CapabilitySet) -> Result<(), Errno>;
pub enum CapabilityTarget {
CurrentThread,
Thread(Tid),
Process(Pid),
}
pub struct CapabilitySet {
pub effective: CapabilityMask,
pub permitted: CapabilityMask,
pub inheritable: CapabilityMask,
}
#[derive(Debug, Clone, Copy)]
pub struct CapabilityMask(u64);
#[derive(Debug, Clone, Copy)]
pub enum Capability {
Chown,
DacOverride,
DacReadSearch,
Fowner,
Fsetid,
Kill,
Setgid,
Setuid,
SysAdmin,
SysBoot,
SysChroot,
SysModule,
SysNice,
SysPtrace,
SysRawio,
SysResource,
SysTime,
NetAdmin,
NetBindService,
NetRaw,
// ... other Linux capabilities
}
}
Underlying syscalls. capget (x86_64 #125, ARM64 #90), capset (#126, #91).
Behavior.
Management of Linux capabilities: fine-grained permissions replacing the binary root/non-root model.
Three sets per thread:
- Effective: capabilities currently usable.
- Permitted: capabilities the thread can enable.
- Inheritable: capabilities transmitted across
execve.
Performance. ~1-2 µs.
Tests.
- Current capget test: read the capabilities, verify consistency.
- capset drop test: remove a capability, verify that it disappears.
- EPERM test: attempt to raise a non-permitted capability, verify the error.
Process family summary
Exposed functions:
| Category | Main functions |
|---|---|
| Identity | getpid, gettid |
| Creation | clone3, execve, execveat |
| Waiting | waitid, exit_group |
| Pidfd | pidfd_open, pidfd_send_signal, pidfd_getfd |
| Groups | setpgid, getpgid, setsid, getsid |
| prctl | ~12 individual operations |
| Limits | getrlimit, setrlimit, prlimit |
| Capabilities | capget, capset |
Total: 18 wrapped syscalls, plus the individual prctl operations.
Non-wrapped syscalls (listed in UNSUPPORTED.md):
fork,vfork: replaced byclone3.clone(without the 3): replaced byclone3.wait,wait3,wait4,waitpid: replaced bywaitid.kill: exposed in thesignalfamily, not inprocess.tkill: deprecated, replaced bytgkill(in the signal family)._exit: replaced byexit_group.- Rare
prctloperations not listed above.
Types added to air-sys-types
Pid,TidPidFd,PidFdReceiver,TidReceiverWaitTarget,WaitOptions,WaitStatus,WaitEventCloneFlags,CloneArgs,CloneResultSignal,SignalMask(also used in the signal family)Capability,CapabilityMask,CapabilitySet,CapabilityTargetResource,Rlimit,RlimitValueDumpableModeExecveatFlags,PidFdOpenFlags
That is ~20 types for this family.
Underlying decisions that emerged in the process family
1. Pid and Tid as distinct newtypes.
Prevents, by typing, confusion between a process PID and a thread TID. Direct application of Principle 7.
2. Option<Pid> for “current process”.
Instead of the kernel sentinel 0 which means “the calling process”, Air uses None. Clearer, safer, application of convention 1 of ADR-021.
3. PidFd RAII with clear ownership.
The pidfd is encapsulated in a type that automatically closes the FD on destruction. No leak possible.
4. clone3 unsafe but with a safe helper to come.
The low-level API remains unsafe because CLONE_VM can violate Rust invariants. A safe fork_process helper covering the common case (classic fork without shared VM) will be exposed in layer 1.
5. prctl exposed operation by operation.
Refusal of the generic multiplexed form. Each prctl operation is a dedicated typed function. The cost in code volume is offset by the clarity.
Document license: MPL 2.0
Status: Technical specification of the air-sys-syscall::process module (layer 0).
Layer 0 Spec — fs Family
Technical specification — Version 1.0
Family overview
The air-sys-syscall::fs module exposes the kernel primitives for filesystem management: opening and closing files, reading and writing, metadata, directory operations, path manipulation, special operations (truncate, fsync, fallocate, copy_file_range, splice, etc.).
It is the largest layer 0 family by volume, with roughly 35 wrapped syscalls.
Family scope.
Operation categories:
- Opening and closing:
openat2,openat,close. - Synchronous I/O:
read,write,pread,pwrite,readv,writev,preadv,pwritev. - Positioning:
lseek. - Metadata:
statx,faccessat. - Directories:
mkdirat,getdents64,unlinkat,renameat2,linkat,symlinkat,readlinkat,mknodat. - Permissions:
fchmodat,fchownat,utimensat. - Special operations:
ftruncate,truncate,fsync,fdatasync,sync_file_range,fallocate,copy_file_range,splice,tee,vmsplice,flock,statfs,fstatfs. - fcntl (operations exposed individually, see ADR-021 convention 3).
- Persistent handles:
name_to_handle_at,open_by_handle_at.
Cross-cutting characteristics of the family.
-
*atvariants preferred systematically.openat2rather thanopen,mkdiratrather thanmkdir,unlinkatrather thanunlink, etc. The*atvariants take a directory FD as their base, which eliminates path-resolution races. -
statxpreferred overfstat/fstatat/lstat/stat.statxis the modern unified API that covers every case and lets you select the fields to retrieve. -
openat2preferred overopenat.openat2(Linux 5.6+) allows stricter path resolution via theresolvefield ofOpenHow. The Air wrapper detects at runtime whetheropenat2is available and falls back toopenatotherwise. -
Universal CLOEXEC by default. Every FD opened through the Air wrapper has
O_CLOEXECenabled by default. For the rare cases where you want to disable CLOEXEC, the option must be passed explicitly. -
DirFdtype for the*atcalls. Instead of usingAT_FDCWD(the kernel sentinel-100), Air exposes aDirFdtype:
#![allow(unused)]
fn main() {
pub enum DirFd<'fd> {
Cwd,
Fd(BorrowedFd<'fd>),
}
}
Consistent with convention 1 of ADR-021.
- No support for relative paths without
*at. The wrapper functions require an explicit base (DirFd::Cwdor an FD); no magic around the implicit cwd.
Cross-cutting types used.
OwnedFd,BorrowedFd: FD ownership.DirFd: base for*atoperations.OpenFlags,Mode: open flags and permissions.OpenHow: complete structure foropenat2.StatxResult,StatxMask:statxresults and masks.Errno: errors.
Subsection 1: Opening and closing
openat2
Signature.
#![allow(unused)]
fn main() {
pub fn openat2(
dirfd: DirFd<'_>,
path: &CStr,
how: OpenHow,
) -> Result<OwnedFd, Errno>;
#[derive(Debug, Clone, Copy, Default)]
pub struct OpenHow {
pub flags: OpenFlags,
pub mode: Mode,
pub resolve: ResolveFlags,
}
bitflags! {
pub struct OpenFlags: u64 {
const RDONLY = 0;
const WRONLY = 1;
const RDWR = 2;
const CREAT = 0o100;
const EXCL = 0o200;
const NOCTTY = 0o400;
const TRUNC = 0o1000;
const APPEND = 0o2000;
const NONBLOCK = 0o4000;
const DSYNC = 0o10000;
const ASYNC = 0o20000;
const DIRECT = 0o40000;
const LARGEFILE = 0o100000;
const DIRECTORY = 0o200000;
const NOFOLLOW = 0o400000;
const NOATIME = 0o1000000;
const CLOEXEC = 0o2000000;
const PATH = 0o10000000;
const TMPFILE = 0o20200000;
}
}
bitflags! {
pub struct ResolveFlags: u64 {
const NO_XDEV = 0x01;
const NO_MAGICLINKS = 0x02;
const NO_SYMLINKS = 0x04;
const BENEATH = 0x08;
const IN_ROOT = 0x10;
const CACHED = 0x20;
}
}
pub type Mode = u32;
}
Underlying syscall. openat2 (x86_64 #437, ARM64 #437). Available since Linux 5.6 (March 2020).
Preconditions.
path is a C-compatible string (NUL-terminated). For opens against a direct FD (no relative path), pass c"".
The Air wrapper systematically adds OpenFlags::CLOEXEC to the flags, unless the caller has explicitly used a raw mode that disables this discipline.
Behavior.
Opens a file (or a directory with DIRECTORY, or a path-only FD with PATH). Path resolution is controlled by resolve:
ResolveFlags::BENEATH: refuses to resolve beyonddirfd. Prevents “../../” attacks.ResolveFlags::NO_SYMLINKS: refuses to follow symbolic links.ResolveFlags::NO_MAGICLINKS: refuses magic links (/proc/self/fd/X).ResolveFlags::IN_ROOT: treatsdirfdas a new root.
These flags enable a strict resolution discipline, useful for sandboxed contexts.
Recommended pattern.
#![allow(unused)]
fn main() {
let how = OpenHow {
flags: OpenFlags::RDONLY,
mode: 0,
resolve: ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS,
};
let fd = openat2(DirFd::Cwd, c"./config.toml", how)?;
}
Errors.
EACCES: insufficient permissions.EEXIST: file exists andEXCLwas requested.EISDIR: attempt to open a directory for writing withoutDIRECTORY.ENOENT: nonexistent path.ENOTDIR: a path component is not a directory.EXDEV:NO_XDEVviolation.EAGAIN:BENEATHorNO_SYMLINKSviolation (resolution refused).
Performance. ~5-20 µs depending on path complexity.
Tests.
- Read-open test: existing file, verify valid FD and CLOEXEC.
- ENOENT test: nonexistent path.
- BENEATH test: attempt to traverse upward with
../, verify EAGAIN.
openat (fallback)
Signature.
#![allow(unused)]
fn main() {
pub fn openat(
dirfd: DirFd<'_>,
path: &CStr,
flags: OpenFlags,
mode: Mode,
) -> Result<OwnedFd, Errno>;
}
Behavior.
Version without resolve. For kernels that do not support openat2 (< 5.6). The Air wrapper uses openat automatically if openat2 returns ENOSYS.
close
Signature.
#![allow(unused)]
fn main() {
pub fn close(fd: OwnedFd) -> Result<(), Errno>;
}
Behavior.
Closes an FD. The wrapper consumes the OwnedFd (takes ownership), guaranteeing that it cannot be reused after close.
Note. The automatic Drop of OwnedFd calls close silently (ignoring errors). The explicit close function lets you recover a potential error (rare but possible: EIO, EINTR on some systems).
Subsection 2: Synchronous I/O
read, write
Signatures.
#![allow(unused)]
fn main() {
pub fn read(fd: BorrowedFd<'_>, buf: &mut [u8]) -> Result<usize, Errno>;
pub fn write(fd: BorrowedFd<'_>, buf: &[u8]) -> Result<usize, Errno>;
}
Underlying syscalls. read (x86_64 #0, ARM64 #63), write (x86_64 #1, ARM64 #64).
Behavior.
Sequential read/write on the FD. Returns the number of bytes actually read/written, which may be fewer than buf.len() (partial read for sockets, pipes, files at EOF).
Common errors.
EAGAIN: non-blocking FD and no data available (or pipe full).EINTR: interrupted by a signal (see ADR-021 convention 2).EIO: low-level I/O error.EBADF: invalid FD.
pread, pwrite
Signatures.
#![allow(unused)]
fn main() {
pub fn pread(fd: BorrowedFd<'_>, buf: &mut [u8], offset: u64) -> Result<usize, Errno>;
pub fn pwrite(fd: BorrowedFd<'_>, buf: &[u8], offset: u64) -> Result<usize, Errno>;
}
Behavior.
Positioned variants: read/write at a specific offset without modifying the FD’s current position. Safe for concurrent use from multiple threads.
readv, writev, preadv, pwritev
Signatures.
#![allow(unused)]
fn main() {
pub fn readv(fd: BorrowedFd<'_>, iov: &mut [IoSliceMut<'_>]) -> Result<usize, Errno>;
pub fn writev(fd: BorrowedFd<'_>, iov: &[IoSlice<'_>]) -> Result<usize, Errno>;
pub fn preadv(fd: BorrowedFd<'_>, iov: &mut [IoSliceMut<'_>], offset: u64) -> Result<usize, Errno>;
pub fn pwritev(fd: BorrowedFd<'_>, iov: &[IoSlice<'_>], offset: u64) -> Result<usize, Errno>;
}
Behavior.
Scatter-gather variants: read/write to/from multiple buffers in a single syscall. Useful for protocols with header + payload.
Subsection 3: Positioning
lseek
Signature.
#![allow(unused)]
fn main() {
pub fn lseek(fd: BorrowedFd<'_>, offset: i64, whence: SeekWhence) -> Result<u64, Errno>;
pub enum SeekWhence {
Set,
Current,
End,
Data, // Linux-specific: next data after offset
Hole, // Linux-specific: next hole after offset
}
}
Behavior.
Modifies the current position of a seekable FD. Returns the new absolute position.
Subsection 4: Metadata
statx
Signature.
#![allow(unused)]
fn main() {
pub fn statx(
dirfd: DirFd<'_>,
path: &CStr,
flags: StatxFlags,
mask: StatxMask,
) -> Result<StatxResult, Errno>;
bitflags! {
pub struct StatxFlags: u32 {
const EMPTY_PATH = 0x1000;
const NO_AUTOMOUNT = 0x800;
const SYMLINK_NOFOLLOW = 0x100;
}
}
bitflags! {
pub struct StatxMask: u32 {
const TYPE = 0x0001;
const MODE = 0x0002;
const NLINK = 0x0004;
const UID = 0x0008;
const GID = 0x0010;
const ATIME = 0x0020;
const MTIME = 0x0040;
const CTIME = 0x0080;
const INO = 0x0100;
const SIZE = 0x0200;
const BLOCKS = 0x0400;
const BTIME = 0x0800;
const MNT_ID = 0x1000;
const ALL = 0xfff;
}
}
pub struct StatxResult {
pub mask: StatxMask,
pub blksize: u32,
pub attributes: u64,
pub nlink: u32,
pub uid: u32,
pub gid: u32,
pub mode: u16,
pub ino: u64,
pub size: u64,
pub blocks: u64,
pub atime: StatxTimestamp,
pub btime: StatxTimestamp,
pub ctime: StatxTimestamp,
pub mtime: StatxTimestamp,
pub rdev_major: u32,
pub rdev_minor: u32,
pub dev_major: u32,
pub dev_minor: u32,
pub mount_id: u64,
}
pub struct StatxTimestamp {
pub seconds: i64,
pub nanoseconds: u32,
}
}
Underlying syscall. statx (x86_64 #332, ARM64 #291). Available since Linux 4.11.
Behavior.
Reads a file’s metadata. mask selects the desired fields (performance: the kernel reads only what is requested). The result’s mask field indicates the fields actually filled in (it may be a subset of the requested mask if some information is not available).
Pattern.
#![allow(unused)]
fn main() {
let result = statx(
DirFd::Cwd,
c"./config.toml",
StatxFlags::empty(),
StatxMask::SIZE | StatxMask::MTIME,
)?;
if result.mask.contains(StatxMask::SIZE) {
println!("Size: {}", result.size);
}
}
faccessat
Signature.
#![allow(unused)]
fn main() {
pub fn faccessat(
dirfd: DirFd<'_>,
path: &CStr,
mode: AccessMode,
flags: AccessFlags,
) -> Result<(), Errno>;
bitflags! {
pub struct AccessMode: u32 {
const F_OK = 0;
const R_OK = 4;
const W_OK = 2;
const X_OK = 1;
}
}
bitflags! {
pub struct AccessFlags: i32 {
const EACCESS = 0x200;
const SYMLINK_NOFOLLOW = 0x100;
}
}
}
Behavior.
Tests access permissions on a file without opening it. Subject to races (the result may be stale by the time you act on it): prefer to open directly and handle the error.
Subsection 5: Directories
mkdirat
#![allow(unused)]
fn main() {
pub fn mkdirat(dirfd: DirFd<'_>, path: &CStr, mode: Mode) -> Result<(), Errno>;
}
getdents64
#![allow(unused)]
fn main() {
pub fn getdents64(fd: BorrowedFd<'_>, buf: &mut [u8]) -> Result<usize, Errno>;
pub struct DirEntry {
pub inode: u64,
pub offset: i64,
pub file_type: DirEntryType,
pub name: Vec<u8>, // raw bytes (without NUL) — see std-free note below
}
pub enum DirEntryType {
Unknown,
Fifo,
Character,
Directory,
Block,
Regular,
Symlink,
Socket,
}
pub struct DirEntryIter<'buf> { /* ... */ }
impl<'buf> Iterator for DirEntryIter<'buf> {
type Item = DirEntry<'buf>;
// ...
}
pub fn parse_dirents<'buf>(buf: &'buf [u8], length: usize) -> DirEntryIter<'buf>;
}
Behavior.
Reads the entries of a directory opened with DIRECTORY. The kernel binary format is complex; the wrapper provides a typed iterator.
std-free note (ADR-048, re-seal layer-0-v1.6).DirEntry.nameis an owned copy of raw bytes (alloc::vec::Vec<u8>), without the trailingNUL. The initial design (borrowed&'buf CStr) assumedcore::ffi::CStrand a borrow on the read buffer; the implementation keeps an owned copy so the entry is decoupled from the read buffer (reused acrossgetdents64calls). The bytes type (notOsString/String) honors the “paths = bytes” doctrine: on Unix a file name is an opaque byte string, possibly non-UTF-8 (Principle 3, no encoding assumption).parse_direntsyieldsimpl Iterator<Item = DirEntry>(owned entries, no lifetime parameter onDirEntry).
unlinkat, renameat2, linkat, symlinkat, readlinkat, mknodat
Conventional signatures, following the *at convention. Standard details.
renameat2
#![allow(unused)]
fn main() {
pub fn renameat2(
old_dirfd: DirFd<'_>,
old_path: &CStr,
new_dirfd: DirFd<'_>,
new_path: &CStr,
flags: RenameFlags,
) -> Result<(), Errno>;
bitflags! {
pub struct RenameFlags: u32 {
const NOREPLACE = 1;
const EXCHANGE = 2;
const WHITEOUT = 4;
}
}
}
RenameFlags::EXCHANGE is particularly useful for atomic updates (swapping two files atomically).
Subsection 6: Permissions
fchmodat, fchownat, utimensat
Conventional signatures. utimensat can take UTIME_NOW or UTIME_OMIT as special values for timestamps, expressed via a typed enum:
#![allow(unused)]
fn main() {
pub enum UtimeValue {
Now,
Omit,
Time(StatxTimestamp),
}
}
Subsection 7: Special operations
ftruncate, truncate, fsync, fdatasync
Standard signatures.
fallocate
#![allow(unused)]
fn main() {
pub fn fallocate(
fd: BorrowedFd<'_>,
mode: FallocateMode,
offset: i64,
length: i64,
) -> Result<(), Errno>;
bitflags! {
pub struct FallocateMode: i32 {
const KEEP_SIZE = 0x01;
const PUNCH_HOLE = 0x02;
const COLLAPSE_RANGE = 0x08;
const ZERO_RANGE = 0x10;
const INSERT_RANGE = 0x20;
const UNSHARE_RANGE = 0x40;
}
}
}
copy_file_range
#![allow(unused)]
fn main() {
pub fn copy_file_range(
fd_in: BorrowedFd<'_>,
off_in: Option<&mut u64>,
fd_out: BorrowedFd<'_>,
off_out: Option<&mut u64>,
length: usize,
flags: u32,
) -> Result<usize, Errno>;
}
Zero-copy copy between files (on the same filesystem when possible).
splice, tee, vmsplice
Covered in the ipc family but referenced here because they are also FS operations.
flock, statfs, fstatfs
Standard signatures.
Subsection 8: fcntl (individual operations)
Like prctl, fcntl is a multiplexed syscall. Air exposes it operation by operation, in keeping with convention 3 of ADR-021:
#![allow(unused)]
fn main() {
// F_DUPFD, F_DUPFD_CLOEXEC
pub fn dup_fd(fd: BorrowedFd<'_>, min_fd: RawFd) -> Result<OwnedFd, Errno>;
// F_GETFD, F_SETFD
pub fn get_fd_flags(fd: BorrowedFd<'_>) -> Result<FdFlags, Errno>;
pub fn set_fd_flags(fd: BorrowedFd<'_>, flags: FdFlags) -> Result<(), Errno>;
// F_GETFL, F_SETFL
pub fn get_status_flags(fd: BorrowedFd<'_>) -> Result<StatusFlags, Errno>;
pub fn set_status_flags(fd: BorrowedFd<'_>, flags: StatusFlags) -> Result<(), Errno>;
// F_SETPIPE_SZ, F_GETPIPE_SZ
pub fn set_pipe_size(fd: BorrowedFd<'_>, size: usize) -> Result<usize, Errno>;
pub fn get_pipe_size(fd: BorrowedFd<'_>) -> Result<usize, Errno>;
// F_SETLK, F_SETLKW, F_GETLK
pub fn try_lock(fd: BorrowedFd<'_>, lock: &FileLock) -> Result<bool, Errno>;
pub fn lock(fd: BorrowedFd<'_>, lock: &FileLock) -> Result<(), Errno>;
// F_ADD_SEALS, F_GET_SEALS (for memfd)
pub fn add_seals(fd: BorrowedFd<'_>, seals: Seals) -> Result<(), Errno>;
pub fn get_seals(fd: BorrowedFd<'_>) -> Result<Seals, Errno>;
// ... other operations as Air needs them
}
Subsection 9: Persistent handles
name_to_handle_at, open_by_handle_at
#![allow(unused)]
fn main() {
pub fn name_to_handle_at(
dirfd: DirFd<'_>,
path: &CStr,
flags: NameToHandleFlags,
) -> Result<FileHandle, Errno>;
pub fn open_by_handle_at(
mount_fd: BorrowedFd<'_>,
handle: &FileHandle,
flags: OpenFlags,
) -> Result<OwnedFd, Errno>;
}
Allows referencing a file by an opaque persistent handle (even after a path change). Advanced use case.
fs family summary
Exposed functions: ~35 main syscalls + individual fcntl operations.
| Category | Main functions |
|---|---|
| Opening | openat2, openat, close |
| Sync I/O | read, write, pread, pwrite, readv, writev, preadv, pwritev |
| Position | lseek |
| Metadata | statx, faccessat |
| Directories | mkdirat, getdents64, unlinkat, renameat2, linkat, symlinkat, readlinkat, mknodat |
| Permissions | fchmodat, fchownat, utimensat |
| Special | ftruncate, truncate, fsync, fdatasync, fallocate, copy_file_range, flock, statfs, fstatfs |
| fcntl | ~10 individual operations |
| Handles | name_to_handle_at, open_by_handle_at |
Non-wrapped syscalls (listed in UNSUPPORTED.md):
open(withoutat): replaced byopenat2/openat.stat,fstat,lstat,fstatat: replaced bystatx.mkdir(withoutat): replaced bymkdirat.unlink,rmdir(withoutat): replaced byunlinkat.rename,renameat: replaced byrenameat2.link,symlink,readlink,mknod,access(withoutat): replaced byatvariants.chmod,chown,utimes,lchown: replaced byatvariants.getdents(without 64): deprecated, replaced bygetdents64.chdir,fchdir: Air does not expose manipulation of the implicit cwd (see convention discussion).
Types added to air-sys-types
DirFdOpenFlags,Mode,OpenHow,ResolveFlagsStatxResult,StatxMask,StatxFlags,StatxTimestampAccessMode,AccessFlagsRenameFlags,FallocateModeSeekWhenceDirEntry,DirEntryType,DirEntryIterUtimeValueFdFlags,StatusFlags,FileLock,SealsFileHandleNameToHandleFlagsIoSlice,IoSliceMut
That is ~25 types for this family, bringing the air-sys-types total to ~45 types after the process and fs families.
Underlying decisions that emerged in the fs family
1. Exclusive *at variants.
No wrapper for the non-at variants. Every filesystem operation takes an explicit base (DirFd::Cwd or FD). Consistent with security and convention 1 of ADR-021.
2. statx as the sole metadata function.
stat, fstat, lstat, fstatat are not wrapped. statx covers them all, and is more performant thanks to the mask.
3. openat2 preferred, transparent openat fallback.
The wrapper switches automatically if openat2 returns ENOSYS. No friction on the caller’s side.
4. Universal CLOEXEC.
Every FD opened through Air has CLOEXEC. For the rare exceptions (intentional inheritance across a fork), a raw API exists.
5. No cwd manipulation.
chdir/fchdir not exposed. The cwd is a process-global variable, a source of bugs in concurrent code. Air prefers that relative paths start from an explicit DirFd.
Document license: MPL 2.0
Status: Technical specification of the air-sys-syscall::fs module (layer 0).
Layer 0 spec — mem family
Technical specification — Version 1.0
Family overview
The air-sys-syscall::mem module exposes the kernel memory-management primitives: mappings (mmap/munmap), protection (mprotect), kernel advice (madvise), in-memory locking (mlock), memfd, and inter-process memory access (process_vm_readv/process_vm_writev).
This is a medium-sized family (13 syscalls) but a critical one: memory management is at the heart of performance and security, particularly for components such as the AirCom data plane (which uses memfd + mmap for zero-copy).
Scope of the family.
Operation categories:
- Mappings:
mmap,munmap,mremap. - Protection:
mprotect. - Kernel advice:
madvise. - Locking:
mlock,mlock2,munlock,mlockall,munlockall. - memfd:
memfd_create,memfd_secret. - Synchronization:
msync. - Inter-process access:
process_vm_readv,process_vm_writev.
Cross-cutting characteristics of the family.
-
RAII
Mappingtype. Mappings returned bymmapare wrapped in aMappingtype that callsmunmapon destruction. No leak of mapped memory is possible. -
Distinction between anonymous and file mappings. The typed API distinguishes the two cases via separate functions (
mmap_anonymous,mmap_file). -
Dynamic page size on ARM64. Raspberry Pi and other ARM64 devices may have different page sizes (4 KB, 16 KB, 64 KB depending on the kernel). The Air wrapper detects the page size at initialization via
sysconf(_SC_PAGESIZE)and exposes it as a runtime constant (notconst). -
Checked alignment. Operations that require page-size alignment are checked up front. No opaque kernel
EINVAL. -
memfd at the heart of AirCom.
memfd_createis the primitive on which the AirCom IPC data plane is built (ADR-001). Detailed spec.
Subsection 1: Mappings
mmap (typed)
Rather than a single wrapper over mmap (which is multiplexed depending on the flags), Air exposes functions typed by use case.
mmap_anonymous
Signature.
#![allow(unused)]
fn main() {
pub fn mmap_anonymous(
length: usize,
prot: ProtectionFlags,
flags: MapFlags,
) -> Result<Mapping, Errno>;
pub struct Mapping {
addr: NonNull<u8>,
length: usize,
// Drop calls munmap automatically
}
impl Mapping {
pub fn as_ptr(&self) -> *const u8;
pub fn as_mut_ptr(&mut self) -> *mut u8;
pub fn len(&self) -> usize;
pub fn as_slice(&self) -> &[u8];
pub unsafe fn as_mut_slice(&mut self) -> &mut [u8];
}
bitflags! {
pub struct ProtectionFlags: i32 {
const NONE = 0;
const READ = 1;
const WRITE = 2;
const EXEC = 4;
}
}
bitflags! {
pub struct MapFlags: i32 {
const SHARED = 0x01;
const PRIVATE = 0x02;
const FIXED = 0x10;
const ANONYMOUS = 0x20;
const GROWSDOWN = 0x0100;
const DENYWRITE = 0x0800;
const EXECUTABLE = 0x1000;
const LOCKED = 0x2000;
const NORESERVE = 0x4000;
const POPULATE = 0x8000;
const NONBLOCK = 0x10000;
const STACK = 0x20000;
const HUGETLB = 0x40000;
const SYNC = 0x80000;
const FIXED_NOREPLACE = 0x100000;
}
}
}
Underlying syscall. mmap (x86_64 no. 9, ARM64 no. 222).
Behavior.
Creates an anonymous mapping (no underlying file). The wrapper adds MAP_ANONYMOUS | MAP_PRIVATE to the flags by default.
len is rounded up to the page size by the kernel. The Air wrapper does not perform the rounding explicitly (the caller can do it if it wants to know the effective size).
Pattern.
#![allow(unused)]
fn main() {
let mapping = mmap_anonymous(
4096,
ProtectionFlags::READ | ProtectionFlags::WRITE,
MapFlags::PRIVATE, // ANONYMOUS implicit
)?;
// Use the mapping
// munmap happens automatically on drop
}
Errors.
EINVAL: invalid size or invalid flags.ENOMEM: no memory available.EACCES: insufficient permissions.
Performance. Highly variable. ~10-100 µs depending on size and system memory.
mmap_file
Signature.
#![allow(unused)]
fn main() {
pub fn mmap_file(
fd: BorrowedFd<'_>,
length: usize,
offset: u64,
prot: ProtectionFlags,
flags: MapFlags,
) -> Result<Mapping, Errno>;
}
Behavior.
Creates a file-backed mapping. offset must be page-size aligned.
Typical use case.
#![allow(unused)]
fn main() {
let fd = openat2(DirFd::Cwd, c"./data.bin", OpenHow::read_only())?;
let stat = statx(DirFd::Fd(fd.as_fd()), c"", StatxFlags::EMPTY_PATH, StatxMask::SIZE)?;
let mapping = mmap_file(
fd.as_fd(),
stat.size as usize,
0,
ProtectionFlags::READ,
MapFlags::PRIVATE,
)?;
let data: &[u8] = mapping.as_slice();
}
mmap_fixed
Signature.
#![allow(unused)]
fn main() {
pub unsafe fn mmap_fixed(
addr: NonNull<u8>,
length: usize,
prot: ProtectionFlags,
flags: MapFlags,
fd: Option<BorrowedFd<'_>>,
offset: u64,
) -> Result<MappingPointer, Errno>;
}
Behavior.
Maps at a specific address. unsafe because it can overwrite existing mappings if MAP_FIXED_NOREPLACE is not used.
Rare and advanced use case: runtime implementation, custom allocator, JIT.
munmap
Signature.
#![allow(unused)]
fn main() {
pub fn munmap(mapping: Mapping) -> Result<(), Errno>;
}
Behavior.
Explicitly unmaps. Consumes the Mapping. The automatic Drop also calls munmap (ignoring the error); the explicit function lets you recover a possible error.
mremap
Signature.
#![allow(unused)]
fn main() {
pub fn mremap(
mapping: Mapping,
new_length: usize,
flags: MremapFlags,
) -> Result<Mapping, Errno>;
bitflags! {
pub struct MremapFlags: i32 {
const MAYMOVE = 1;
const FIXED = 2;
const DONTUNMAP = 4;
}
}
}
Behavior.
Resizes a mapping. With MAYMOVE, the kernel may relocate the mapping in memory if in-place resizing is impossible.
Subsection 2: Protection
mprotect
Signature.
#![allow(unused)]
fn main() {
pub fn mprotect(
addr: NonNull<u8>,
length: usize,
prot: ProtectionFlags,
) -> Result<(), Errno>;
}
Behavior.
Changes the permissions of a mapped memory range. Typical pattern: allocate as READ+WRITE, initialize, then switch to READ only for constant data.
Errors.
EINVAL: invalid or unaligned range.EACCES: attempt to gain a forbidden permission (typically EXEC on memory mapped from a no-exec FS).ENOMEM: insufficient kernel resources.
Subsection 3: Kernel advice
madvise
Signature.
#![allow(unused)]
fn main() {
pub fn madvise(
addr: NonNull<u8>,
length: usize,
advice: MadviseAdvice,
) -> Result<(), Errno>;
#[derive(Debug, Clone, Copy)]
pub enum MadviseAdvice {
Normal,
Random,
Sequential,
WillNeed,
DontNeed,
Free,
Remove,
DontFork,
DoFork,
Mergeable,
Unmergeable,
HugePage,
NoHugePage,
DontDump,
DoDump,
WipeOnFork,
KeepOnFork,
Cold,
PageOut,
PopulateRead,
PopulateWrite,
DontNeedLocked,
Collapse,
}
}
Behavior.
Informs the kernel about the expected access pattern for a memory range. The kernel can then optimize (prefetch, swapping, transparent huge pages, etc.).
Common cases:
Sequential+WillNeedfor intensive sequential reads.Randomfor random access (avoids counterproductive prefetch).DontNeedto free pages that will no longer be needed without unmapping.
Subsection 4: Locking
mlock, mlock2, munlock, mlockall, munlockall
#![allow(unused)]
fn main() {
pub fn mlock(addr: NonNull<u8>, length: usize) -> Result<(), Errno>;
pub fn mlock2(addr: NonNull<u8>, length: usize, flags: MlockFlags) -> Result<(), Errno>;
pub fn munlock(addr: NonNull<u8>, length: usize) -> Result<(), Errno>;
pub fn mlockall(flags: MlockallFlags) -> Result<(), Errno>;
pub fn munlockall() -> Result<(), Errno>;
bitflags! {
pub struct MlockFlags: u32 {
const ONFAULT = 1;
}
}
bitflags! {
pub struct MlockallFlags: i32 {
const CURRENT = 1;
const FUTURE = 2;
const ONFAULT = 4;
}
}
}
Behavior.
Locks memory pages to prevent them from being swapped out. Use cases: security (sensitive data that must not end up on the swap disk), real time (predictable latencies).
Limits: RLIMIT_MEMLOCK bounds what an unprivileged process can lock.
Subsection 5: memfd
memfd_create
Signature.
#![allow(unused)]
fn main() {
pub fn memfd_create(name: &CStr, flags: MemfdFlags) -> Result<OwnedFd, Errno>;
bitflags! {
pub struct MemfdFlags: u32 {
const CLOEXEC = 1;
const ALLOW_SEALING = 2;
const HUGETLB = 4;
const NOEXEC_SEAL = 8;
const EXEC = 0x10;
}
}
}
Underlying syscall. memfd_create (x86_64 no. 319, ARM64 no. 279). Available since Linux 3.17.
Behavior.
Creates an FD to an anonymous memory region (a “memory file”). The FD can be:
- Extended with
ftruncate. - Mmapped for sharing between processes.
- Passed between processes via SCM_RIGHTS over a Unix socket.
- Sealed with
fcntl(F_ADD_SEALS)to forbid certain operations.
This is the central primitive for modern shared memory on Linux. At the heart of the AirCom data plane (ADR-001).
Pattern: sharing data between processes.
#![allow(unused)]
fn main() {
// Process A: create a memfd, fill it, share it
let fd = memfd_create(c"shared-data", MemfdFlags::CLOEXEC | MemfdFlags::ALLOW_SEALING)?;
ftruncate(fd.as_fd(), 4096)?;
let mapping = mmap_file(fd.as_fd(), 4096, 0, ProtectionFlags::READ | ProtectionFlags::WRITE, MapFlags::SHARED)?;
// ... fill the mapping ...
// Seal to forbid further modifications
add_seals(fd.as_fd(), Seals::SEAL_WRITE | Seals::SEAL_SHRINK | Seals::SEAL_GROW)?;
// Send the FD over a Unix socket
sendmsg(socket, &[fd.as_fd()], ...)?;
// Process B: receive the FD, mmap it for reading
let received_fd: OwnedFd = recvmsg(socket, ...)?;
let mapping = mmap_file(received_fd.as_fd(), 4096, 0, ProtectionFlags::READ, MapFlags::SHARED)?;
// Read the shared data
}
memfd_secret
Signature.
#![allow(unused)]
fn main() {
pub fn memfd_secret(flags: u32) -> Result<OwnedFd, Errno>;
}
Underlying syscall. memfd_secret (x86_64 no. 447, ARM64 no. 447). Available since Linux 5.14.
Behavior.
A variant of memfd_create that creates a memory region not accessible to the kernel after the mapping. Used for high-value cryptographic secrets.
Subsection 6: Synchronization
msync
#![allow(unused)]
fn main() {
pub fn msync(
addr: NonNull<u8>,
length: usize,
flags: MsyncFlags,
) -> Result<(), Errno>;
bitflags! {
pub struct MsyncFlags: i32 {
const ASYNC = 1;
const SYNC = 4;
const INVALIDATE = 2;
}
}
}
Behavior.
Forces synchronization of a mapping’s modifications to the underlying file. For MAP_SHARED file-backed mappings.
Subsection 7: Inter-process access
process_vm_readv, process_vm_writev
#![allow(unused)]
fn main() {
pub fn process_vm_readv(
pid: Pid,
local_iov: &mut [IoSliceMut<'_>],
remote_iov: &[RemoteIoSlice],
flags: u32,
) -> Result<usize, Errno>;
pub fn process_vm_writev(
pid: Pid,
local_iov: &[IoSlice<'_>],
remote_iov: &[RemoteIoSlice],
flags: u32,
) -> Result<usize, Errno>;
pub struct RemoteIoSlice {
pub addr: u64,
pub len: usize,
}
}
Behavior.
Allows reading/writing the memory of another process without ptrace. Requires permissions (typically CAP_SYS_PTRACE or the same user as the target).
Specialized use case: debuggers, monitoring tools, buffer transfer between cooperating processes without copying.
mem family summary
Exposed functions:
| Category | Main functions |
|---|---|
| Mappings | mmap_anonymous, mmap_file, mmap_fixed, munmap, mremap |
| Protection | mprotect |
| Advice | madvise |
| Locking | mlock, mlock2, munlock, mlockall, munlockall |
| memfd | memfd_create, memfd_secret |
| Sync | msync |
| Inter-process | process_vm_readv, process_vm_writev |
Total: 13 wrapped syscalls.
Non-wrapped syscalls (listed in UNSUPPORTED.md):
brk,sbrk: legacy heap management, handled by the allocator, not exposed.pkey_alloc,pkey_free,pkey_mprotect: Memory Protection Keys, marginal in practice.mbind,migrate_pages,move_pages: NUMA-specific, to be evaluated later if Air needs fine-grained NUMA support.set_mempolicy,get_mempolicy: likewise NUMA.
Distribution of types between the two crates
Types in air-sys-types (pure, no syscall)
MappingPointer— pointer + length of a mapping, without ownership semantics (frees nothing on drop). This is the return type ofmmap_fixed, where the caller manages the lifecycle itself.ProtectionFlags,MapFlags,MremapFlagsMadviseAdviceMlockFlags,MlockallFlagsMemfdFlagsMsyncFlagsRemoteIoSlice
RAII type in air-sys-syscall::mem (and not air-sys-types)
Mapping— see core decision no. 2 below.
Architecture decision (Q1, validated on 2026-05-31). The initial drafting of this spec placed
Mappinginair-sys-types. This is incorrect: theDropofMappingmust callmunmap, which is a syscall. Butair-sys-typescontains only pure types and never calls a syscall (onlyair-sys-syscalldoes, viacore::arch::asm!). MakingMappinglive inair-sys-typeswould force that crate to embedasm!, breaking the separation of responsibilities between the two layer 0 crates. As a result,Mapping(a RAII type whoseDropcalls a syscall) is defined inair-sys-syscall::mem, alongside the functions that produce it (mmap_anonymous,mmap_file,mremap). Only the types without a syscall (MappingPointerand thebitflags/enums above) remain inair-sys-types. The same rule applies toLandlockRuleseton thesecurityfamily side (cf.family-security.md).
Core decisions that emerged in the mem family
1. mmap split into typed functions.
Rather than a single wrapper that would take all the flags and arguments, splitting into mmap_anonymous, mmap_file, mmap_fixed. Clearer for the developer, fewer errors (forgetting MAP_ANONYMOUS when there is no FD, for example).
2. Strict RAII Mapping — and defined in air-sys-syscall::mem.
A Mapping cannot be copied, only moved. The automatic munmap on
drop avoids leaks. For cases where you want a “long-lived” mapping that survives
several scopes, you move ownership explicitly.
Because its Drop calls the munmap syscall, Mapping lives in
air-sys-syscall::mem, not in air-sys-types (cf. the “Distribution of
types” section above). General layer 0 rule: a RAII type whose
destruction triggers a syscall lives in the wrappers crate (air-sys-syscall),
never in the pure-types crate (air-sys-types). The MappingPointer type
(bare pointer, without ownership, returned by mmap_fixed) on the other hand stays in
air-sys-types since it frees nothing.
3. Page size detected at runtime.
On Raspberry Pi 4, the page size can be 4 KB or 16 KB depending on the kernel configuration. Air detects it at init and exposes it as a static. No compile-time constant.
4. memfd_create central to AirCom.
In accordance with ADR-001, the AirCom data plane uses memfd + mmap shared. The memfd spec here is deliberately detailed because it determines IPC performance.
5. No Air allocator in layer 0.
Air does not expose a custom allocator at the syscall level. The system global allocator (often jemalloc or mimalloc) is used by default. The allocator can be configured at the application level, outside the layer 0 scope.
Document license: MPL 2.0
Status: Technical specification of the air-sys-syscall::mem module (layer 0).
Layer 0 Spec — signal Family
Technical specification — Version 1.0
Family overview
The air-sys-syscall::signal module exposes the Unix signal management primitives. In accordance with ADR-020, Air’s approach is:
-
signalfd by default for all deferrable signals. The mechanism turns signals into events readable on an FD, which integrates naturally with io_uring and with the unified event loop.
-
Restricted sigaction in a
synchronous_handlersubmodule for the four synchronous non-deferrable signals (SIGSEGV,SIGBUS,SIGFPE,SIGILL).
This discipline structurally eliminates an entire class of bugs (non-async-signal-safe signal handlers), while preserving the ability to handle the cases where sigaction is unavoidable.
Cross-cutting characteristics of the family.
-
No generic handler exposed. The API does not allow installing a sigaction handler on an arbitrary signal. The path is barred by construction.
-
All created FDs are CLOEXEC by default. Consistent with the universal discipline of layer 0.
-
Thread-safe management. Signal mask operations are thread-safe (each thread has its own mask). Handler operations are per process.
-
Natural io_uring integration. A signalfd FD can be read via io_uring, which allows waiting for signals within the same mechanism as the other asynchronous events.
Cross-cutting types used.
Signal: typed representation of a signal.SignalMask: set of signals as a bitmask.SignalFd: signalfd FD.SignalFdInfo: structure returned by reading a signalfd.FatalSignal: restricted enum of the 4 synchronous signals acceptable for sigaction.SignalAction: type for handlers (used only in the synchronous_handler submodule).
Subsection 1: signalfd and signal masks
signalfd_create
Signature.
#![allow(unused)]
fn main() {
pub fn signalfd_create(
mask: &SignalMask,
flags: SignalFdFlags,
) -> Result<SignalFd, Errno>;
pub struct SignalFd { /* opaque, owns internal OwnedFd */ }
impl SignalFd {
pub fn as_fd(&self) -> BorrowedFd<'_>;
pub fn into_fd(self) -> OwnedFd;
pub fn read(&self) -> Result<SignalFdInfo, Errno>;
pub fn update_mask(&mut self, mask: &SignalMask) -> Result<(), Errno>;
}
bitflags! {
pub struct SignalFdFlags: i32 {
const NONBLOCK = 0x800;
const CLOEXEC = 0x80000;
}
}
#[repr(C)]
pub struct SignalFdInfo {
pub signal: Signal,
pub errno: i32,
pub code: i32,
pub pid: Pid,
pub uid: u32,
pub fd: i32,
pub timer_id: u32,
pub band: u32,
pub overrun: u32,
pub trap_no: u32,
pub status: i32,
pub int: i32,
pub ptr: u64,
pub utime: u64,
pub stime: u64,
pub addr: u64,
}
}
Underlying syscall. signalfd4 (x86_64 #289, ARM64 #74). Man page signalfd(2). Available since Linux 2.6.27.
Preconditions.
mask indicates the signals that this signalfd must catch. Signals not listed in the mask continue to be delivered according to their default disposition.
SignalFdFlags::CLOEXEC is always enabled by default by the wrapper.
Behavior.
Creates an FD that turns the signals in the mask into readable events. When one of the listed signals is delivered to the thread, instead of invoking a handler, it becomes “readable” on the signalfd FD.
Important: the mask alone is not enough.
Creating a signalfd for a signal S is not enough to prevent the normal delivery of S (handler or default behavior). You also need to block S in the thread’s signal mask via block_signals. The correct pattern:
#![allow(unused)]
fn main() {
let mask = SignalMask::from_signals(&[Signal::SIGTERM, Signal::SIGINT]);
block_signals(&mask)?;
let sfd = signalfd_create(&mask, SignalFdFlags::empty())?;
// now SIGTERM and SIGINT will arrive only on sfd
}
Air provides a helper that combines the two steps:
#![allow(unused)]
fn main() {
pub fn signalfd_create_blocking(
mask: &SignalMask,
flags: SignalFdFlags,
) -> Result<SignalFd, Errno>;
}
Errors.
EINVAL: invalid mask or invalid flags.EMFILE,ENFILE: FD limits reached.ENODEV,ENOMEM: insufficient kernel resources.
Performance.
Creation: ~5-10 µs. Read via read(): ~1-2 µs if a signal is immediately available.
block_signals and unblock_signals
Signature.
#![allow(unused)]
fn main() {
pub fn block_signals(mask: &SignalMask) -> Result<SignalMask, Errno>;
pub fn unblock_signals(mask: &SignalMask) -> Result<SignalMask, Errno>;
pub fn set_signal_mask(mask: &SignalMask) -> Result<SignalMask, Errno>;
pub fn current_signal_mask() -> Result<SignalMask, Errno>;
}
Underlying syscall. rt_sigprocmask (x86_64 #14, ARM64 #135).
Behavior.
block_signals adds the signals from the mask to the thread’s current mask. Returns the old mask (before modification).
unblock_signals removes the signals from the mask.
set_signal_mask replaces the mask entirely.
current_signal_mask reads the current mask without modification.
Special cases.
SIGKILL and SIGSTOP cannot be blocked. An attempt to include them in the mask is silently ignored by the kernel.
The mask is inherited across fork. execve resets the mask.
Performance. ~100-200 ns. Very fast.
wait_for_signal helper
Signature.
#![allow(unused)]
fn main() {
pub fn wait_for_signal(
mask: &SignalMask,
timeout: Option<Duration>,
) -> Result<SignalFdInfo, Errno>;
}
Behavior.
Helper that creates a temporary signalfd, waits for a signal from the mask to arrive, and returns the info. Convenient for simple patterns.
Subsection 2: Sending signals
kill and tgkill
#![allow(unused)]
fn main() {
pub fn kill(pid: Pid, signal: Option<Signal>) -> Result<(), Errno>;
pub fn tgkill(tgid: Pid, tid: Tid, signal: Option<Signal>) -> Result<(), Errno>;
}
Underlying syscalls. kill (x86_64 #62, ARM64 #129), tgkill (x86_64 #234, ARM64 #131).
Preconditions.
Option<Signal> enables the “signal 0” pattern (existence test without actually sending) via None. Consistent with convention 1 of ADR-021.
Behavior.
kill(pid, sig) sends a signal to the process identified by pid.
tgkill(tgid, tid, sig) sends a signal to a specific thread of a process.
Air recommendation.
For child processes, prefer pidfd_send_signal (process family) over kill: no race on the recycled PID.
Errors.
EINVAL: invalid signal.EPERM: insufficient permissions.ESRCH: nonexistent process.
Performance. ~1-2 µs.
rt_sigqueueinfo
#![allow(unused)]
fn main() {
pub fn rt_sigqueueinfo(
pid: Pid,
signal: Signal,
info: SignalQueueInfo,
) -> Result<(), Errno>;
pub struct SignalQueueInfo {
pub code: i32,
pub value: SignalValue,
}
pub enum SignalValue {
Integer(i32),
Pointer(u64),
}
}
Sending a signal with an attached “value”. Rare use case.
Subsection 3: Restricted sigaction
In accordance with ADR-020, the air-sys-syscall::signal::synchronous_handler submodule exposes sigaction only for the 4 fatal synchronous signals.
FatalSignal type
#![allow(unused)]
fn main() {
pub mod synchronous_handler {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FatalSignal {
Segv,
Bus,
Fpe,
Ill,
}
impl FatalSignal {
pub const fn as_signal(self) -> Signal;
}
}
}
The enum is restricted to these 4 signals. There is no way to pass another signal to this API.
install_fatal_handler
#![allow(unused)]
fn main() {
pub mod synchronous_handler {
pub unsafe fn install_fatal_handler(
signal: FatalSignal,
handler: FatalHandler,
) -> Result<PreviousHandler, Errno>;
pub unsafe fn restore_handler(
signal: FatalSignal,
previous: PreviousHandler,
) -> Result<(), Errno>;
pub type FatalHandler = unsafe extern "C" fn(
signum: c_int,
info: *mut SignalInfo,
context: *mut c_void,
);
pub struct PreviousHandler {
// Opaque
}
#[repr(C)]
pub struct SignalInfo {
pub signal: c_int,
pub errno: c_int,
pub code: c_int,
pub addr: u64,
// ...
}
}
}
Preconditions (Safety).
The API is unsafe. The # Safety documentation requires the handler to be async-signal-safe.
Legitimate use cases.
- Crash reporter: captures the process state at the moment of a crash, writes a mini-dump, then terminates.
- Stack guards: stack overflow detection via a signal handler on an alternate stack.
Tricky tests.
The tests run in isolated subprocesses via a subprocess harness.
signal family summary
Exposed functions:
| Category | Main functions |
|---|---|
| signalfd | signalfd_create, signalfd_create_blocking, SignalFd::read, SignalFd::update_mask |
| Masks | block_signals, unblock_signals, set_signal_mask, current_signal_mask |
| Helpers | wait_for_signal |
| Sending | kill, tgkill, rt_sigqueueinfo |
| Handlers (restricted) | install_fatal_handler, restore_handler |
Total: ~12 main public functions.
Non-wrapped syscalls (listed in UNSUPPORTED.md):
signal(BSD-style, deprecated).tkill(deprecated, replaced by tgkill).pause,alarm: legacy, replaced by signalfd + timerfd.sigsuspend,rt_sigsuspend: obsolete pattern, replaced by signalfd.sigaltstack: to be added later if stack guards are needed.
Types added to air-sys-types
SignalFdInfoSignalFdFlagsSignalQueueInfo,SignalValueFatalSignal(synchronous_handler submodule)SignalInfo(synchronous_handler submodule)PreviousHandler(synchronous_handler submodule)
That is ~6 additional types.
Substantive decisions that emerged in the signal family
1. signalfd_create_blocking as the canonical helper.
The “create signalfd + block signals” pattern is so universal that a combined helper simplifies usage and avoids the trap of forgetting a step.
2. kill via Option<Signal> for signal 0.
Application of convention 1 of ADR-021.
3. install_fatal_handler returns an opaque PreviousHandler.
Hides the kernel details of the sigaction struct.
4. No wrapper for sigaltstack.
Specialized case, to be added later without breaking the API.
Document license: MPL 2.0
Status: Technical specification of the air-sys-syscall::signal module (layer 0).
Layer 0 spec — time family
Technical specification — Version 1.0
Family overview
The air-sys-syscall::time module exposes time-management primitives: system clocks, timer FDs, precise sleeps. It is a small family (~8 syscalls) but essential for all patterns that need to measure or wait for durations.
Cross-cutting characteristics of the family.
-
Multiple Linux clocks. The kernel exposes several clocks with distinct semantics:
CLOCK_REALTIME(wall-clock time, adjustable),CLOCK_MONOTONIC(monotonic since boot),CLOCK_BOOTTIME(includes suspend), and others. The Air wrapper exposes them as a typed enum to prevent confusion between incompatible clocks. -
timerfd integrates with io_uring. Like signalfd, timerfd is an FD that becomes readable when the timer expires. This integrates naturally with io_uring.
-
Nanosecond precision. The kernel’s
Timespecstructures express time with nanosecond resolution. -
EINTR handling.
clock_nanosleepand blocking reads on timerfd can returnEINTR. In accordance with convention 2 of ADR-021, it is propagated to the caller. -
Dual representations. Air exposes both
Duration(Rust standard) for durations andInstant(Air custom) for points in time.
Subsection 1: Clocks and instants
Clock type
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(i32)]
pub enum Clock {
Realtime = 0,
Monotonic = 1,
ProcessCpuTime = 2,
ThreadCpuTime = 3,
MonotonicRaw = 4,
RealtimeCoarse = 5,
MonotonicCoarse = 6,
Boottime = 7,
RealtimeAlarm = 8,
BoottimeAlarm = 9,
Tai = 11,
}
impl Clock {
pub const fn as_raw(self) -> i32;
pub fn try_from_raw(value: i32) -> Option<Self>;
pub fn name(self) -> &'static str;
}
}
Clock semantics.
Realtime: wall-clock UTC time. Adjustable by NTP. Discontinuous.Monotonic: counter since an arbitrary point. Strictly increasing. For measuring durations.ProcessCpuTime: CPU time consumed by the process.ThreadCpuTime: CPU time consumed by the thread.MonotonicRaw: like Monotonic but not affected by NTP slewing.RealtimeCoarse,MonotonicCoarse: reduced-resolution versions, faster.Boottime: like Monotonic but includes suspend.RealtimeAlarm,BoottimeAlarm: can wake the system from suspend.Tai: International Atomic Time.
Usage recommendations.
- Measure a duration:
Monotonic. - Timer or timeout:
MonotonicorBoottime(if suspend-aware). - User timestamp:
Realtime. - Wake the machine:
BoottimeAlarm(with privileges).
clock_gettime
#![allow(unused)]
fn main() {
pub fn clock_gettime(clock: Clock) -> Result<Instant, Errno>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Instant {
clock: Clock,
seconds: i64,
nanoseconds: u32,
}
impl Instant {
pub fn clock(&self) -> Clock;
pub fn seconds(&self) -> i64;
pub fn nanoseconds(&self) -> u32;
pub fn as_duration_since_epoch(&self) -> Duration;
pub fn checked_duration_since(&self, earlier: Instant) -> Option<Duration>;
pub fn saturating_duration_since(&self, earlier: Instant) -> Duration;
}
}
Behavior.
Reads the current time for the specified clock. Returns an Instant that includes the clock used.
No cross-clock Instant arithmetic.
The type prevents, by construction, operations between Instant values of different clocks.
Performance. ~30-100 ns via vDSO for the main clocks.
clock_settime and clock_getres
#![allow(unused)]
fn main() {
pub fn clock_settime(clock: Clock, instant: Instant) -> Result<(), Errno>;
pub fn clock_getres(clock: Clock) -> Result<Duration, Errno>;
}
clock_settime requires CAP_SYS_TIME. Rarely used.
clock_getres returns the clock’s resolution.
Subsection 2: Sleep
clock_nanosleep
#![allow(unused)]
fn main() {
pub fn clock_nanosleep(
clock: Clock,
deadline: SleepDeadline,
) -> Result<(), SleepError>;
pub enum SleepDeadline {
Relative(Duration),
AbsoluteInstant(Instant),
}
pub enum SleepError {
Interrupted { remaining: Duration },
Other(Errno),
}
}
Behavior.
Blocks the thread until the specified instant (absolute mode) or for the specified duration (relative mode). Absolute mode is generally preferred to avoid drift.
Special EINTR handling.
On EINTR in relative mode, the kernel returns the time remaining to sleep. The Air wrapper retrieves it and passes it via SleepError::Interrupted { remaining }.
Subsection 3: timerfd
timerfd_create
#![allow(unused)]
fn main() {
pub fn timerfd_create(
clock: Clock,
flags: TimerFdFlags,
) -> Result<TimerFd, Errno>;
pub struct TimerFd { /* owns an internal OwnedFd */ }
impl TimerFd {
pub fn as_fd(&self) -> BorrowedFd<'_>;
pub fn into_fd(self) -> OwnedFd;
pub fn arm(&self, spec: &TimerFdSpecification, flags: TimerSetFlags) -> Result<TimerFdSpecification, Errno>;
pub fn disarm(&self) -> Result<TimerFdSpecification, Errno>;
pub fn current(&self) -> Result<TimerFdSpecification, Errno>;
pub fn read(&self) -> Result<u64, Errno>;
}
bitflags! {
pub struct TimerFdFlags: i32 {
const NONBLOCK = 0x800;
const CLOEXEC = 0x80000;
}
}
bitflags! {
pub struct TimerSetFlags: i32 {
const ABSTIME = 1;
const CANCEL_ON_SET = 2;
}
}
#[derive(Debug, Clone, Copy)]
pub struct TimerFdSpecification {
pub initial: Duration,
pub interval: Duration,
}
}
Underlying syscall. timerfd_create (x86_64 #283, ARM64 #85).
Behavior.
Creates a timerfd FD. The application arms it via arm() with a specification.
On each expiration, the FD becomes readable. read() returns the number of expirations that have occurred.
Spec modes.
- One-shot:
initial = duration,interval = ZERO. - Periodic:
initial = duration,interval = period. - Disarmed:
initial = ZERO,interval = ZERO.
ABSTIME and CANCEL_ON_SET.
ABSTIME: initial is an absolute instant.
CANCEL_ON_SET: if the Realtime clock is adjusted, the timer is cancelled.
Usage pattern.
#![allow(unused)]
fn main() {
let tfd = timerfd_create(Clock::Monotonic, TimerFdFlags::empty())?;
let spec = TimerFdSpecification {
initial: Duration::from_secs(5),
interval: Duration::ZERO,
};
tfd.arm(&spec, TimerSetFlags::empty())?;
// Wait for expiration
let expirations = tfd.read()?;
// Or via io_uring
ring.submit_read(tfd.as_fd(), buf, 0)?;
}
time family summary
Exposed functions:
| Category | Main functions |
|---|---|
| Clocks | clock_gettime, clock_settime, clock_getres |
| Sleep | clock_nanosleep |
| timerfd | timerfd_create, TimerFd::arm, TimerFd::disarm, TimerFd::current, TimerFd::read |
Total: ~8 main public functions.
Non-wrapped syscalls (listed in UNSUPPORTED.md):
getitimer,setitimer(legacy)times(covered by clock_gettime CpuTime)time,gettimeofday(legacy)adjtimex,clock_adjtime(deferred to phase 0)nanosleep(replaced by clock_nanosleep)
Types added to air-sys-types
ClockInstantTimespec(internal)TimerFd,TimerFdSpecification,TimerFdFlags,TimerSetFlagsSleepDeadline,SleepError
That is ~9 additional types.
Underlying decisions that emerged in the time family
1. Instant typed by clock.
Prevents, by construction, clock-mixing bugs.
2. SleepError distinct from Errno.
Dedicated error type to expose the “remaining” information for the EINTR case.
3. No direct std::time::Instant API.
The Air Instant type is distinct from std::time::Instant. Conversion is provided but not implicit.
4. TimerFd::read() returns the number of expirations.
Important for real-time patterns.
Document license: MPL 2.0
Status: Technical specification of the air-sys-syscall::time module (layer 0).
Layer 0 Spec — net Family
Technical specification — Version 1.0
Family overview
The air-sys-syscall::net module exposes the synchronous socket primitives. In accordance with Decision 2 of ADR-022, the operations exist alongside their io_uring equivalents, with shared types and conventions.
Family scope.
Three categories of operations:
-
Socket setup:
socket,bind,listen,getsockopt,setsockopt. No io_uring equivalent on Linux 5.15. -
Synchronous connected operations:
connect,accept4,send,recv,sendmsg,recvmsg,shutdown. All have their io_uring equivalent exposed in Phase 2b. -
Utility operations:
getsockname,getpeername,socketpair.
Cross-cutting characteristics of the family.
-
Universal CLOEXEC. All socket FDs created have CLOEXEC by default.
-
NOSIGNAL by default on send. Synchronous sends disable SIGPIPE by default.
-
Types shared with io_uring.
SocketAddr,MessageFlags,AcceptFlags, etc. are the same types as in Phase 2b. -
Family validation. The functions validate the consistency between the socket family and the address used.
Subsection 1: Socket setup
socket
#![allow(unused)]
fn main() {
pub fn socket(
domain: SocketDomain,
ty: SocketType,
protocol: i32,
) -> Result<OwnedFd, Errno>;
}
The Air wrapper uses SOCK_CLOEXEC systematically.
bind
#![allow(unused)]
fn main() {
pub fn bind(sock: BorrowedFd<'_>, address: &SocketAddr) -> Result<(), Errno>;
}
For path-based Unix sockets, the correct pattern is to attempt unlinkat before bind, ignoring ENOENT. An Air helper in layer 1 (bind_unix_socket_replacing) will make this pattern easier.
listen
#![allow(unused)]
fn main() {
pub fn listen(sock: BorrowedFd<'_>, backlog: u32) -> Result<(), Errno>;
}
getsockopt and setsockopt
API typed per option (cf. convention 3 of ADR-021). Rather than a generic wrapper, Air exposes one function per common option:
#![allow(unused)]
fn main() {
pub fn get_so_keepalive(sock: BorrowedFd<'_>) -> Result<bool, Errno>;
pub fn set_so_keepalive(sock: BorrowedFd<'_>, enable: bool) -> Result<(), Errno>;
pub fn get_so_reuseaddr(sock: BorrowedFd<'_>) -> Result<bool, Errno>;
pub fn set_so_reuseaddr(sock: BorrowedFd<'_>, enable: bool) -> Result<(), Errno>;
pub fn get_so_reuseport(sock: BorrowedFd<'_>) -> Result<bool, Errno>;
pub fn set_so_reuseport(sock: BorrowedFd<'_>, enable: bool) -> Result<(), Errno>;
pub fn get_tcp_nodelay(sock: BorrowedFd<'_>) -> Result<bool, Errno>;
pub fn set_tcp_nodelay(sock: BorrowedFd<'_>, enable: bool) -> Result<(), Errno>;
pub fn get_so_rcvbuf(sock: BorrowedFd<'_>) -> Result<u32, Errno>;
pub fn set_so_rcvbuf(sock: BorrowedFd<'_>, size: u32) -> Result<(), Errno>;
pub fn get_so_sndbuf(sock: BorrowedFd<'_>) -> Result<u32, Errno>;
pub fn set_so_sndbuf(sock: BorrowedFd<'_>, size: u32) -> Result<(), Errno>;
pub fn get_so_error(sock: BorrowedFd<'_>) -> Result<Option<Errno>, Errno>;
pub fn get_so_peercred(sock: BorrowedFd<'_>) -> Result<UnixCredentials, Errno>;
pub fn get_so_linger(sock: BorrowedFd<'_>) -> Result<LingerOption, Errno>;
pub fn set_so_linger(sock: BorrowedFd<'_>, linger: LingerOption) -> Result<(), Errno>;
pub enum LingerOption {
Disabled,
Enabled(Duration),
}
pub fn set_so_bindtodevice(sock: BorrowedFd<'_>, ifname: &CStr) -> Result<(), Errno>;
pub fn get_so_bindtodevice(sock: BorrowedFd<'_>) -> Result<CString, Errno>;
// TCP-specific
pub fn set_tcp_keepidle(sock: BorrowedFd<'_>, seconds: u32) -> Result<(), Errno>;
pub fn set_tcp_keepintvl(sock: BorrowedFd<'_>, seconds: u32) -> Result<(), Errno>;
pub fn set_tcp_keepcnt(sock: BorrowedFd<'_>, count: u32) -> Result<(), Errno>;
// IP-specific
pub fn set_ip_ttl(sock: BorrowedFd<'_>, ttl: u8) -> Result<(), Errno>;
pub fn set_ipv6_v6only(sock: BorrowedFd<'_>, v6only: bool) -> Result<(), Errno>;
}
If a developer needs an option not covered, there are two paths: an RFC to add the wrapper function, or the raw API in air-sys-syscall::net::raw.
Subsection 2: Synchronous operations
connect
#![allow(unused)]
fn main() {
pub fn connect(sock: BorrowedFd<'_>, address: &SocketAddr) -> Result<(), Errno>;
}
For non-blocking sockets, returns EINPROGRESS immediately.
accept4
#![allow(unused)]
fn main() {
pub fn accept4(
listener: BorrowedFd<'_>,
flags: AcceptFlags,
) -> Result<AcceptResult, Errno>;
}
AcceptFlags::CLOEXEC enabled by default. accept4 is preferred over accept because it allows passing CLOEXEC atomically.
send, recv, sendmsg, recvmsg, shutdown
#![allow(unused)]
fn main() {
pub fn send(
sock: BorrowedFd<'_>,
buf: &[u8],
flags: MessageFlags,
) -> Result<usize, Errno>;
pub fn recv(
sock: BorrowedFd<'_>,
buf: &mut [u8],
flags: MessageFlags,
) -> Result<usize, Errno>;
pub fn sendmsg(
sock: BorrowedFd<'_>,
request: &SendMessageRequest,
) -> Result<SendMessageResult, Errno>;
pub fn recvmsg(
sock: BorrowedFd<'_>,
request: &mut ReceiveMessageRequest,
) -> Result<ReceiveMessageResult, Errno>;
pub fn shutdown(
sock: BorrowedFd<'_>,
mode: ShutdownMode,
) -> Result<(), Errno>;
}
Semantic difference with io_uring.
The synchronous versions take references (&[u8], &mut [u8]) rather than owned values. No ownership transfer is needed since the operation is synchronous.
NOSIGNAL by default. As in io_uring, MessageFlags::NOSIGNAL is enabled by default in the synchronous send wrapper.
FD passing via sendmsg/recvmsg. Identical to Phase 2b: received FDs auto-wrapped into OwnedFd, typed ancillary data.
Subsection 3: Utility operations
getsockname and getpeername
#![allow(unused)]
fn main() {
pub fn getsockname(sock: BorrowedFd<'_>) -> Result<SocketAddr, Errno>;
pub fn getpeername(sock: BorrowedFd<'_>) -> Result<SocketAddr, Errno>;
}
socketpair
#![allow(unused)]
fn main() {
pub fn socketpair(
domain: SocketDomain,
ty: SocketType,
protocol: i32,
) -> Result<(OwnedFd, OwnedFd), Errno>;
}
Only Unix sockets support socketpair on Linux. CLOEXEC enabled by default on both FDs.
net family summary
Functions exposed:
| Category | Main functions |
|---|---|
| Setup | socket, bind, listen |
| Options | typed getsockopt/setsockopt (~20 options exposed) |
| Sync connection | connect, accept4 |
| Sync I/O | send, recv, sendmsg, recvmsg, shutdown |
| Utilities | getsockname, getpeername, socketpair |
Total: ~30 main public functions.
Non-wrapped syscalls (listed in UNSUPPORTED.md):
accept(without the 4): replaced by accept4.sendto,recvfrom: covered via sendmsg/recvmsg.sendmmsg,recvmmsg: batched operations, to be evaluated for future addition.
Types added to air-sys-types
Mainly reuse of types already introduced in Phase 2b (SocketAddr, MessageFlags, etc.). A few additions:
SocketOptionLevelLingerOption- Various constants for the options
That is ~3 additional types.
Underlying decisions that emerged in the net family
1. API typed per option for setsockopt/getsockopt.
Strict application of convention 3 of ADR-021. The cost in code volume is offset by the safety and per-option documentation.
2. No non-accept4 accept wrapper.
accept4 is strictly superior.
3. NOSIGNAL by default.
Consistent with io_uring. SIGPIPE is almost always undesirable.
4. socketpair returns a tuple.
The idiomatic Rust choice.
Document license: MPL 2.0
Status: Technical specification of the air-sys-syscall::net module (layer 0).
Layer 0 Spec — ipc Family
Technical specification — Version 1.0
Family Overview
The air-sys-syscall::ipc module exposes the modern kernel IPC primitives: eventfd, pipe, and the zero-copy operations between FDs (splice, tee, vmsplice). It is a small family that rounds out the inter-thread and inter-process communication toolkit.
Scope of the family.
Three distinct mechanisms:
-
eventfd: a kernel counter exposed as an FD, readable when non-zero. Lightweight inter-thread or inter-process notifications. -
pipe: classic Unix pipe, unidirectional communication. -
Zero-copy operations:
splice,tee,vmsplice. Moving data between FDs without going through userspace.
Position relative to the other families.
- Unix sockets (the
netfamily) are Air’s primary IPC, notably for AirCom. - Shared memory via
memfd(thememfamily) is used for the data plane. - This
ipcfamily covers the simpler cases: notifications, pipes, zero-copy optimizations.
Cross-cutting characteristics.
- Universal CLOEXEC.
- Modern variants.
eventfd2andpipe2preferred. - No System V IPC.
shmget,semget,msggetnot wrapped. Listed inUNSUPPORTED.md. - No POSIX message queues.
mq_openetc. not wrapped.
Subsection 1: eventfd
eventfd2
#![allow(unused)]
fn main() {
pub fn eventfd2(
initial: u64,
flags: EventFdFlags,
) -> Result<EventFd, Errno>;
pub struct EventFd { /* owns an internal OwnedFd */ }
impl EventFd {
pub fn as_fd(&self) -> BorrowedFd<'_>;
pub fn into_fd(self) -> OwnedFd;
pub fn read(&self) -> Result<u64, Errno>;
pub fn write(&self, value: u64) -> Result<(), Errno>;
}
bitflags! {
pub struct EventFdFlags: i32 {
const CLOEXEC = 0x80000;
const NONBLOCK = 0x800;
const SEMAPHORE = 1;
}
}
}
Underlying syscall. eventfd2 (x86_64 no. 290, ARM64 no. 19). Available since Linux 2.6.27.
Behavior.
Creates an FD that wraps a 64-bit kernel counter. Two modes depending on the SEMAPHORE flag:
- Normal mode:
read()returns the current value and resets it to zero. Blocks if the counter is zero. - Semaphore mode:
read()returns 1 and decrements. Blocks if zero.
Typical use case: inter-thread notification.
#![allow(unused)]
fn main() {
let efd = eventfd2(0, EventFdFlags::empty())?;
// Thread A: wait for a notification
loop {
let count = efd.read()?;
process_events(count);
}
// Thread B: send a notification
efd.write(1)?;
}
Integration with io_uring.
#![allow(unused)]
fn main() {
let efd = eventfd2(0, EventFdFlags::empty())?;
let mut buf = [0u8; 8];
let token = ring.submit_read(efd.as_fd(), buf.to_vec(), 0)?;
ring.submit()?;
// From another thread:
efd.write(1)?;
// The ring receives the completion
let completion = ring.wait_completion()?;
}
This is the canonical pattern for letting an io_uring reactor thread interact with other threads.
Performance. Creation ~5-10 µs. Read/write ~500 ns to 1 µs.
Subsection 2: pipe
pipe2
#![allow(unused)]
fn main() {
pub fn pipe2(flags: PipeFlags) -> Result<(OwnedFd, OwnedFd), Errno>;
bitflags! {
pub struct PipeFlags: i32 {
const CLOEXEC = 0x80000;
const DIRECT = 0x4000;
const NONBLOCK = 0x800;
}
}
}
Behavior.
Creates a unidirectional pipe. Returns (read_end, write_end).
Pipes have a kernel buffer size (typically 64 KB, adjustable via fcntl).
PipeFlags::DIRECT: “packet” mode where each write produces a distinct packet on the read side.
Typical use case.
- Parent-child communication after fork.
- Simple notification where eventfd would be overkill.
- Pipeline implementation.
Subsection 3: Zero-copy operations
splice
#![allow(unused)]
fn main() {
pub fn splice(
fd_in: BorrowedFd<'_>,
off_in: Option<&mut u64>,
fd_out: BorrowedFd<'_>,
off_out: Option<&mut u64>,
length: usize,
flags: SpliceFlags,
) -> Result<usize, Errno>;
bitflags! {
pub struct SpliceFlags: u32 {
const MOVE = 1;
const NONBLOCK = 2;
const MORE = 4;
const GIFT = 8;
}
}
}
Preconditions.
At least one of the two FDs must be a pipe.
Behavior.
Transfers up to len bytes from fd_in to fd_out without going through user space.
Typical use cases.
- Serving a file over a socket: transfer from a file FD to a socket FD without copying.
- Pipeline between processes.
- High-performance logging.
Performance. Main gain: elimination of the kernel → user → kernel copies. Throughput 2-3 times higher than read/write for large transfers.
tee
#![allow(unused)]
fn main() {
pub fn tee(
fd_in: BorrowedFd<'_>,
fd_out: BorrowedFd<'_>,
length: usize,
flags: SpliceFlags,
) -> Result<usize, Errno>;
}
Both FDs must be pipes. Duplicates the contents of one pipe into another without consuming the source pipe.
vmsplice
#![allow(unused)]
fn main() {
pub fn vmsplice(
fd: BorrowedFd<'_>,
iov: &[IoSlice<'_>],
flags: SpliceFlags,
) -> Result<usize, Errno>;
}
fd must be a pipe. Transfers data from userspace buffers into a pipe.
Gifting (the optimized mode) is dangerous: the donated pages must no longer be modified. Air documents the constraints heavily.
ipc Family Summary
Exposed functions:
| Category | Main functions |
|---|---|
| eventfd | eventfd2, EventFd::read, EventFd::write |
| pipe | pipe2 |
| zero-copy | splice, tee, vmsplice |
Total: ~6 main public functions.
Non-wrapped syscalls (listed in UNSUPPORTED.md):
eventfd(without the 2): replaced by eventfd2.pipe(without the 2): replaced by pipe2.shmget,shmat,shmdt,shmctl: System V shared memory, deprecated.semget,semop,semctl: System V semaphores, deprecated.msgget,msgsnd,msgrcv,msgctl: System V message queues, deprecated.mq_open,mq_send,mq_receive,mq_close,mq_unlink,mq_notify,mq_setattr: POSIX message queues, marginal.
Types added to air-sys-types
EventFd,EventFdFlagsPipeFlagsSpliceFlags
That is ~4 additional types.
Underlying decisions that emerged in the ipc family
1. No System V IPC.
Clear decision: we do not expose the legacy mechanisms that have known semantic and security problems.
2. No POSIX message queues.
Marginal usage, supplanted by sockets and memfd.
3. EventFd::read returns u64, not Vec<u8>.
Air exposes the counter directly as u64. The conversion is internal to the wrapper.
4. vmsplice gifting documented but lightly tested.
A powerful but dangerous mode. An advanced case in phase 0.
Document license: MPL 2.0
Status: Technical specification of the air-sys-syscall::ipc module (layer 0).
Layer 0 Spec — security Family
Technical specification — Version 1.0
Family overview
The air-sys-syscall::security module exposes the kernel sandboxing primitives: seccomp-BPF (syscall filtering) and Landlock (filesystem access filtering). Both mechanisms are at the core of the Air security model (ADR-010: entitlements and capability-based sandbox).
Family scope.
Three distinct subsystems:
- seccomp-BPF: filtering the syscalls a thread is allowed to execute.
- Landlock: filesystem access filtering.
- Capabilities: already covered in the
processfamily (capget,capset).
Position in the Air security model.
The Air launcher (layer 5, ADR-010) applies the following at the startup of each application:
- Capabilities according to the entitlements.
- Landlock filters to restrict filesystem access.
- seccomp filters to restrict syscalls.
- Isolation via namespaces.
Layer 0 provides the primitives; layer 5 orchestrates them.
Cross-cutting characteristics.
-
Privileged or constrained operations. Many require
CAP_SYS_ADMINor prior activation ofno_new_privs. -
Irreversibility. Security restrictions are strictly monotonic: once applied, they cannot be weakened.
-
Layer 0 = primitives only. For seccomp-BPF, layer 0 exposes no declarative API and no filter compiler: it loads an already-formed BPF program (cf. Q4, subsection 1). The declarative
SeccompFilterAPI and its compilation live in layer 1. Likewise, the high-level Landlock helpers (combining create/add/restrict, ABI masking) belong to layer 1; layer 0 exposes only the Landlock primitives. -
No wrapper for AppArmor / SELinux. These mechanisms are managed at the distribution level.
Subsection 1: seccomp
Layer 0 scope: the primitive only, not the filter compiler
Architecture decision (Q4, validated on 2026-05-31). The initial draft of this spec placed a declarative API (
SeccompFilter,SeccompRule,SeccompAction…) compiled to BPF internally in layer 0. That is too much for layer 0: compiling a declarative filter into BPF bytecode is logic (encoding ofsock_filterinstructions, computation of jump offsets, taking the architecture into account), not a syscall wrapper. In keeping with the raison d’être of layer 0 (“abstract without hiding”, a thin facade over the kernel), layer 0 exposes only the primitive: loading into the kernel an already-compiled BPF program, supplied by the caller. The declarative API and its compiler (SeccompFilter,SeccompRule,SeccompAction,SyscallArgumentCondition,ConditionOp,SyscallNumber) are specified and implemented in layer 1, and fall outside the scope of layer 0.
Boundary (Q4, decided — confirmed by the delivered code, 2026-06-14). Layer 0 exposes only the primitive
seccomp_set_mode_filter(&SockFprog, SeccompFilterFlags)— whereSockFprogborrows a slice ofsock_filteralready compiled by the caller. Declarative compilation (filter rules → cBPFsock_filterbytecode) is logic → layer 1 (consistent with ADR-021: layer 0 does no logic). Nobpf_compiler/declarativeSeccompFiltertype in layer 0. (Closes question Q4 ofQUESTIONS-implementation.md: the merged code implements exactly this primitive.)
Types: an already-formed BPF program
These types are pure (no syscall) and live in air-sys-types::security.
#![allow(unused)]
fn main() {
/// A classic BPF filter instruction — exact layout of `struct sock_filter`.
#[repr(C)]
pub struct SockFilter {
pub code: u16,
pub jt: u8,
pub jf: u8,
pub k: u32,
}
/// Complete BPF program to load — equivalent of `struct sock_fprog`.
/// Borrows a slice of instructions owned by the caller (zero alloc).
pub struct SockFprog<'a> { /* len: u16, filter: *const SockFilter, 'a marker */ }
impl<'a> SockFprog<'a> {
/// Builds a program from a slice of instructions.
///
/// # Errors
/// `EINVAL` if the slice is empty or exceeds `BPF_MAXINSNS` (4096).
pub fn new(instructions: &'a [SockFilter]) -> Result<Self, Errno>;
}
}
The BPF program itself is produced elsewhere (layer 1 declarative compiler, or any other means) and passed here as a slice. Layer 0 does not inspect it and does not transform it: it loads it as-is.
seccomp_set_mode_filter
#![allow(unused)]
fn main() {
pub unsafe fn seccomp_set_mode_filter(
prog: &SockFprog<'_>,
flags: SeccompFilterFlags,
) -> Result<(), Errno>;
bitflags! {
pub struct SeccompFilterFlags: u32 {
const TSYNC = 1;
const LOG = 2;
const SPEC_ALLOW = 4;
const NEW_LISTENER = 8;
const TSYNC_ESRCH = 16;
const WAIT_KILLABLE_RECV = 32;
}
}
}
Loads the BPF program prog into the kernel for the current thread
(seccomp(SECCOMP_SET_MODE_FILTER, flags, prog)).
unsafe — preconditions (# Safety):
- the caller must have called
set_no_new_privs()(processfamily) or holdCAP_SYS_ADMIN; - the BPF program must allow all the syscalls actually used by the Rust runtime and by the code executed after loading, failing which the thread/process will be killed;
- the operation is irreversible and strictly monotonic (one can only tighten).
TSYNC applies the filter to all threads of the process. NEW_LISTENER returns
a notification descriptor (seccomp_unotify) — leveraged in layer 1.
EINTR propagated as-is, never any automatic retry (ADR-021 conv. 2).
seccomp_set_mode_strict
#![allow(unused)]
fn main() {
pub fn seccomp_set_mode_strict() -> Result<(), Errno>;
}
Enables strict mode (SECCOMP_SET_MODE_STRICT): only read, write,
_exit/exit_group and sigreturn remain allowed. No program to supply,
no compilation — hence the absence of unsafe. Irreversible.
Performance. Loading a filter: ~50-200 µs. Per-syscall cost afterwards: ~50-100 ns for a simple filter.
Tricky tests. A seccomp violation typically kills the thread/process; the
tests run in isolated subprocesses (fork + observing the status via
waitid), with an explicit skip if the environment does not allow no_new_privs.
prctl_no_new_privs prerequisite
Covered by the process family (prctl), recalled here as a prerequisite to seccomp
without privilege. Irreversible.
Subsection 2: Landlock
Landlock is a modern Linux mechanism for restricting filesystem access at the path level, without privileges. Available since Linux 5.13.
The API is designed around “rulesets”: sets of rules that define which paths are accessible with which permissions.
landlock_create_ruleset
#![allow(unused)]
fn main() {
pub fn landlock_create_ruleset(
handled_access: LandlockAccessFs,
) -> Result<LandlockRuleset, Errno>;
pub struct LandlockRuleset { /* owns an internal OwnedFd */ }
impl LandlockRuleset {
pub fn add_rule_path_beneath(
&mut self,
path: BorrowedFd<'_>,
allowed_access: LandlockAccessFs,
) -> Result<(), Errno>;
pub fn restrict_self(&self) -> Result<(), Errno>;
pub fn as_fd(&self) -> BorrowedFd<'_>;
}
bitflags! {
pub struct LandlockAccessFs: u64 {
const EXECUTE = 1 << 0;
const WRITE_FILE = 1 << 1;
const READ_FILE = 1 << 2;
const READ_DIR = 1 << 3;
const REMOVE_DIR = 1 << 4;
const REMOVE_FILE = 1 << 5;
const MAKE_CHAR = 1 << 6;
const MAKE_DIR = 1 << 7;
const MAKE_REG = 1 << 8;
const MAKE_SOCK = 1 << 9;
const MAKE_FIFO = 1 << 10;
const MAKE_BLOCK = 1 << 11;
const MAKE_SYM = 1 << 12;
const REFER = 1 << 13; // Landlock v2 (Linux 5.19)
const TRUNCATE = 1 << 14; // Landlock v3 (Linux 6.2)
const IOCTL_DEV = 1 << 15; // Landlock v5 (Linux 6.10)
}
}
}
Placement (Q1, validated on 2026-05-31).
LandlockRulesetis a RAII type whose methods (add_rule_path_beneath,restrict_self) andDropcall Landlock syscalls. It therefore resides inair-sys-syscall::security, and not inair-sys-types— the same rule asMappingon thememfamily side: a type that calls a syscall lives in the wrappers crate, never in the pure-types crate. Only the syscall-free types (LandlockAccessFs) remain inair-sys-types.
Preconditions.
handled_access indicates which permissions this ruleset will manage.
The available permissions depend on the kernel’s Landlock ABI version.
Decision (Q6, validated on 2026-05-31): no automatic masking in layer 0.
landlock_supported_abi()merely reports the ABI version supported by the kernel.landlock_create_ruleset/add_rule_path_beneath/restrict_selfdo not silently mask unsupported access bits: if the caller passes a bit the kernel does not know, the kernel error (EINVAL) is propagated as-is. Quietly masking would be “magic” behavior, contrary to the “abstract without hiding” principle. It is up to the caller — or to a layer 1 helper (landlock_restrict_to_paths) — to querylandlock_supported_abi()and then mask the bits according to the target ABI.
Typical usage pattern.
#![allow(unused)]
fn main() {
let mut ruleset = landlock_create_ruleset(
LandlockAccessFs::READ_FILE | LandlockAccessFs::READ_DIR | LandlockAccessFs::EXECUTE,
)?;
let usr_fd = openat(DirFd::Cwd, c"/usr", OpenFlags::PATH | OpenFlags::DIRECTORY, Mode::empty())?;
ruleset.add_rule_path_beneath(
usr_fd.as_fd(),
LandlockAccessFs::READ_FILE | LandlockAccessFs::EXECUTE,
)?;
let home_fd = openat(DirFd::Cwd, c"/home/user", OpenFlags::PATH | OpenFlags::DIRECTORY, Mode::empty())?;
ruleset.add_rule_path_beneath(
home_fd.as_fd(),
LandlockAccessFs::READ_FILE | LandlockAccessFs::READ_DIR,
)?;
set_no_new_privs()?;
ruleset.restrict_self()?;
}
Particularities.
- Irreversibility.
restrict_selfis definitive. - Stacking rulesets. Several successive rulesets add restrictions.
- NO_NEW_PRIVS prerequisite unless
CAP_SYS_ADMIN. - “path_beneath” semantics. A rule applies to a path and all its descendants.
- ABI versions. Detect via
landlock_supported_abi().
Performance.
Creation: ~10-50 µs. Adding a rule: ~5-10 µs. restrict_self: ~50-100 µs. Per filesystem-access cost afterwards: ~100-500 ns.
High-level helpers
In layer 0, only the ABI version query is exposed — a pure read primitive, without masking (cf. Q6):
#![allow(unused)]
fn main() {
/// Reports the Landlock ABI version supported by the current kernel.
pub fn landlock_supported_abi() -> Result<u32, Errno>;
}
Moved to layer 1 (Q4/Q6). The
landlock_restrict_to_paths(paths)helper — which combinescreate_ruleset+ severaladd_rule_path_beneath+restrict_selfin a single call, and which can mask the bits according to the ABI — is convenience logic, not a syscall primitive. It is therefore specified in layer 1, alongside the declarative seccomp API. Layer 0 exposes only the building blocks (landlock_create_ruleset,add_rule_path_beneath,restrict_self,landlock_supported_abi); it is typically the Air launcher, via this layer 1 helper, that will apply an application’s filesystem entitlements.
security family summary
Exposed functions:
| Category | Main functions |
|---|---|
| seccomp | seccomp_set_mode_filter, seccomp_set_mode_strict |
| Landlock | landlock_create_ruleset, LandlockRuleset::add_rule_path_beneath, LandlockRuleset::restrict_self, LandlockRuleset::as_fd, landlock_supported_abi |
Total: ~6 main public functions in layer 0. Deferred to layer 1 (outside this scope): the declarative seccomp API (SeccompFilter / SeccompRule / SeccompAction / … + BPF compiler) and the landlock_restrict_to_paths helper.
Syscalls listed but not wrapped in phase 0:
bpf: generic API for BPF programs. Advanced.perf_event_open: profiling and monitoring. Outside phase 0.keyctl,add_key,request_key: kernel keyring. To be evaluated.pkey_alloc,pkey_free,pkey_mprotect: memory protection keys. Marginal.
Distribution of types between the two crates
In air-sys-types (pure, no syscall)
SockFilter,SockFprog— already-formed BPF program.SeccompFilterFlags— seccomp loading flags.LandlockAccessFs— filesystem access bits.
In air-sys-syscall::security (RAII calling syscalls)
LandlockRuleset— owns anOwnedFd; its methods and itsDropcall syscalls. Therefore does not live inair-sys-types(decision Q1 above; same rule asMappingon thememside).
Deferred to layer 1 (outside layer 0, decision Q4)
- Declarative seccomp API:
SeccompFilter,SeccompRule,SeccompAction,SyscallArgumentCondition,ConditionOp,SyscallNumber, and the declarative compiler → BPF.
Substantive decisions that emerged in the security family
1. Layer 0 = seccomp primitive only (Q4).
Layer 0 exposes neither the construction nor the compilation of filters: it loads an already-formed BPF program (seccomp_set_mode_filter) or enables strict mode (seccomp_set_mode_strict). The convenient declarative API (which “avoids common bugs”) is real and useful — but it is logic, hence specified in layer 1, on top of this primitive.
2. High-level helpers → layer 1 (Q4/Q6).
landlock_restrict_to_paths (combining create/add/restrict + optional ABI masking) covers ~90 % of use cases, but remains convenience built on the primitives: it lives in layer 1. Layer 0 provides only the Landlock building blocks and the landlock_supported_abi query (without masking, Q6).
2 bis. Syscall-calling RAII types outside air-sys-types (Q1).
LandlockRuleset (like Mapping on the mem side) calls syscalls in its methods and its Drop; it resides in air-sys-syscall::security, never in the pure-types crate.
3. No AppArmor / SELinux API.
These frameworks are configured at the distribution level.
4. Runtime detection of the Landlock ABI version.
Allows applications to adapt without recompilation.
Document license: MPL 2.0
Status: Technical specification of the air-sys-syscall::security module (layer 0).
Layer 0 Spec — system Family
Technical specification — Version 1.0
Family overview
The air-sys-syscall::system module exposes miscellaneous system primitives: system information, machine identity, cryptographic entropy generation. It is the smallest family in layer 0, but it contains essential functions such as getrandom.
Family scope.
Four categories:
- System information:
uname,sysinfo. - Machine identity:
gethostname,sethostname. - Cryptographic entropy:
getrandom. - Privileged system actions:
reboot(mentioned but not exposed).
Cross-cutting characteristics.
- Heterogeneity. The functions have little in common semantically.
- A few privileged operations.
sethostname,rebootrequireCAP_SYS_ADMIN. getrandomis the most important element. Cryptography, identifier generation.
Subsection 1: System information
uname
#![allow(unused)]
fn main() {
pub fn uname() -> Result<UtsName, Errno>;
#[derive(Debug, Clone)]
pub struct UtsName {
pub sysname: CString,
pub nodename: CString,
pub release: CString,
pub version: CString,
pub machine: CString,
pub domainname: CString,
}
}
Behavior.
Returns information about the system:
sysname: OS name (“Linux”).nodename: machine name.release: kernel version (“5.15.0-91-generic”).version: kernel build information.machine: hardware architecture (“x86_64”, “aarch64”).domainname: NIS domain name (Linux-specific).
Performance. ~1-2 µs.
sysinfo
#![allow(unused)]
fn main() {
pub fn sysinfo() -> Result<SystemInfo, Errno>;
#[derive(Debug, Clone)]
pub struct SystemInfo {
pub uptime: Duration,
pub load_average: [f64; 3], // 1, 5, 15 minutes
pub total_ram: u64,
pub free_ram: u64,
pub shared_ram: u64,
pub buffer_ram: u64,
pub total_swap: u64,
pub free_swap: u64,
pub processes: u16,
pub total_high: u64,
pub free_high: u64,
pub mem_unit: u32,
}
}
Behavior.
Returns global statistics about the system. Useful for monitoring tools.
Notes on the wrapper.
The kernel struct uses mem_unit. The Air wrapper performs the multiplication internally and exposes values in bytes directly.
The load_average values are converted to f64 by the wrapper.
Precision limits.
sysinfo is fast but imprecise. For detailed stats, read /proc/meminfo etc.
Performance. ~1-2 µs.
Subsection 2: Machine identity
gethostname and sethostname
#![allow(unused)]
fn main() {
pub fn gethostname() -> Result<CString, Errno>;
pub fn sethostname(name: &CStr) -> Result<(), Errno>;
}
Preconditions.
For sethostname: CAP_SYS_ADMIN required. name must have a length ≤ HOST_NAME_MAX (typically 64 bytes).
Behavior.
gethostname is implemented via uname() and extracts the nodename field.
sethostname changes the hostname. A privileged action, rarely used by applications.
Subsection 3: Cryptographic entropy
getrandom
#![allow(unused)]
fn main() {
pub fn getrandom(
buf: &mut [u8],
flags: GetrandomFlags,
) -> Result<usize, Errno>;
bitflags! {
pub struct GetrandomFlags: u32 {
const NONBLOCK = 1;
const RANDOM = 2;
const INSECURE = 4;
}
}
}
Underlying syscall. getrandom (x86_64 #318, ARM64 #278). Available since Linux 3.17.
Behavior.
Fills the buffer with cryptographically secure random bytes. The modern and preferred mechanism for obtaining entropy on Linux.
Flag semantics.
- No flag (default): uses the
urandompool. Blocks if not yet initialized. NONBLOCK: returnsEAGAINinstead of blocking.RANDOM: uses therandompool. Avoid except in special cases (cryptographically equivalent to urandom on modern Linux).INSECURE: returns immediately even if not initialized, but with reduced quality. Strictly reserved for non-cryptographic cases.
Air recommendation.
For 99% of uses: GetrandomFlags::empty().
Behavior with large buffers.
getrandom may return fewer bytes than requested for large buffers. Loop if necessary:
#![allow(unused)]
fn main() {
fn fill_random(buf: &mut [u8]) -> Result<(), Errno> {
let mut offset = 0;
while offset < buf.len() {
let n = getrandom(&mut buf[offset..], GetrandomFlags::empty())?;
offset += n;
}
Ok(())
}
}
Air may provide a getrandom_exact helper in layer 1.
Performance. ~1-2 µs for small buffers. For large ones, throughput is limited by the kernel CSPRNG.
Tests.
- Nominal test: fill a 32-byte buffer, verify that it is not all zeros.
- Variation test: two successive calls, verify that the buffers are different.
- Large buffer test: 4 KB with a loop.
Subsection 4: Privileged operations not exposed
reboot
reboot(2) allows rebooting or shutting down the machine. Not exposed in the public Air API. System lifecycle control is the responsibility of systemd.
Listed in UNSUPPORTED.md with justification.
system family summary
Exposed functions:
| Category | Main functions |
|---|---|
| Information | uname, sysinfo |
| Identity | gethostname, sethostname |
| Entropy | getrandom |
Total: 5 main public functions.
Non-wrapped syscalls (listed in UNSUPPORTED.md):
reboot: system action reserved for init.syslog(reading the kernel ring buffer): marginal.setdomainname: legacy NIS, marginal.iopl,ioperm: direct I/O port access, privileged and rare.personality: changing process personality, out of scope for phase 0.
Types added to air-sys-types
UtsNameSystemInfoGetrandomFlags
That is 3 additional types.
Foundational decisions that emerged in the system family
1. gethostname implemented via uname.
Rather than a separate syscall. Saves one theoretical syscall.
2. getrandom is deliberately minimalist.
No sophisticated “generate N cryptographic bytes” helper in layer 0. Higher layers will provide those helpers.
3. INSECURE is exposed but discouraged.
The flag exists in the kernel; Air does not hide it. But the documentation insists that it is reserved for non-cryptographic cases.
4. reboot not exposed.
Not the role of a normal Air application.
Overall layer 0 assessment
With the system family, layer 0 is fully specified.
Summary by family.
| Family | Main functions |
|---|---|
process | 18 syscalls |
fs | ~35 syscalls |
mem | 13 syscalls |
io_uring (Phases 1-4) | ~80 functions |
signal | ~12 functions |
time | ~8 functions |
net | ~30 functions |
ipc | ~6 functions |
security | ~6 functions |
system | 5 functions |
Approximate total: ~213 public wrapper functions in layer 0.
air-sys-types crate: ~187 public types.
Document license: MPL 2.0
Status: Technical specification of the air-sys-syscall::system module (layer 0).
Layer 0 Spec — device family
Technical specification — Version 1.0
Family overview
The air-sys-syscall::device module exposes the primitives for interacting with
the hardware devices exposed by the kernel: receiving uevent
notifications (hardware appearance/disappearance, via a netlink socket),
access to evdev input devices (/dev/input/eventX: keyboards, mice,
gamepads, touchscreens), and the articulation with sysfs (/sys/...) for
reading/writing device attributes.
This family is the system foundation for everything in the upper layers that builds a device model: hardware enumeration, hotplug, input handling for the Wayland compositor (ADR-003), layer 5 system services.
Scope of the family.
Three distinct subsystems:
-
uevent(netlink): aNETLINK_KOBJECT_UEVENTsocket receives from the kernel the device appearance/disappearance messages. Layer 0 opens the socket, reads the messages, and decodes the wire format (action@devpathheader +KEY=VALUEpairs separated by\0) via a borrowed, zero-allocation iterator. -
evdev(input): input devices are char devices opened through thefsfamily (openat). Layer 0 provides the typed reading ofstruct input_eventrecords and the dedicated ioctls (EVIOC*) as typed functions (never a generic ioctl, cf. ADR-021 convention 3). -
sysfs: a pseudo file system; its attributes are read and written with thefsfamily. Layer 0 adds no specific wrapper (cf. subsection 3) — path construction and attribute parsing are layer 1 logic.
Cross-cutting characteristics of the family.
-
Universal CLOEXEC. Every FD created by the family (uevent socket) carries
CLOEXECby default. Theevdevchar devices are opened byfs::openat, which already applies the same discipline. -
Decoding = mirror of the kernel format, not logic. The decoding of
ueventmessages (key=valuepairs) and ofinput_eventrecords reflects stable kernel ABI wire formats. This is the same category asSignalFdInfo(thesignalfamily), which layer 0 already decodes. Conversely, building a rich device model (typed subsystem enums, device tree, uevent ↔ sysfs correlation) is logic: layer 1. -
Borrowed parsers, zero allocation. The
uevent/input_eventdecoders write into (or borrow from) a buffer provided by the caller. No heap allocation in the happy path (ADR-021 convention 4). -
No generic ioctl. Each
EVIOC*operation is a dedicated, typed function (ADR-021 convention 3). Noioctl(fd, request, ...)function is exposed. -
Kernel sentinels →
Option<T>/ typed enums (ADR-021 convention 1).EVIOCGRAB(argument1vs null pointer) becomes two distinct functionsevdev_grab/evdev_release; the timestamp clockid becomes an enum.
Subsection 1: uevent — device notifications (netlink)
The kernel broadcasts, on a netlink socket of the NETLINK_KOBJECT_UEVENT
protocol, a message at each device lifecycle event (add, remove, change,
bind, unbind, move, online, offline). This is the modern hotplug
mechanism, replacing the old /sbin/hotplug.
The socket: UEventSocket
#![allow(unused)]
fn main() {
pub struct UEventSocket { /* opaque, owns an internal OwnedFd */ }
pub fn uevent_socket_open(
groups: UEventGroups,
flags: UEventSocketFlags,
) -> Result<UEventSocket, Errno>;
bitflags! {
/// Netlink multicast groups to listen to.
pub struct UEventGroups: u32 {
/// Raw messages generated by the kernel (netlink group 1).
const KERNEL = 1 << 0;
/// Messages re-broadcast by the userspace device manager
/// (netlink group 2, the "libudev monitor").
const USERSPACE = 1 << 1;
}
}
bitflags! {
pub struct UEventSocketFlags: i32 {
const NONBLOCK = 0x800; // SOCK_NONBLOCK
const CLOEXEC = 0x80000; // SOCK_CLOEXEC (always enabled by the wrapper)
}
}
impl UEventSocket {
pub fn as_fd(&self) -> BorrowedFd<'_>;
pub fn into_fd(self) -> OwnedFd;
/// Reads a message and decodes it in place into `buffer`.
///
/// The returned `UEventMessage` **borrows** `buffer`: it stays valid as long
/// as the buffer is not reused.
pub fn read<'b>(
&self,
buffer: &'b mut [u8],
) -> Result<UEventMessage<'b>, Errno>;
}
}
Underlying syscalls. socket (AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC,
NETLINK_KOBJECT_UEVENT = 15) then bind on a struct sockaddr_nl
(nl_family = AF_NETLINK, nl_pid = 0 — the kernel assigns, nl_groups
according to UEventGroups). Reading uses recvmsg (and not recv) in order to
retrieve the source address and verify authenticity (cf. below). Numbers:
socket (x86_64 #41, ARM64 #198), bind (x86_64 #49, ARM64 #200),
recvmsg (x86_64 #47, ARM64 #212).
Preconditions.
groupscannot be empty (EINVALotherwise — nothing to listen to).- Listening to the
KERNELgroup requires no privilege. Messages from theKERNELgroup always come from the kernel (nl_pid == 0). CLOEXECis always enabled by the wrapper.
Behavior.
Creates and binds the socket in a single operation. On each read, the kernel
delivers one complete uevent message. If the buffer is too small, the
message is truncated and the error EMSGSIZE/ENOBUFS may be reported depending
on the mode — the caller therefore sizes the buffer generously (cf. “Sizing”).
Authenticity — anti-spoofing verification.
Any process holding CAP_NET_ADMIN can emit to a netlink multicast group. To
avoid mistaking a real kernel uevent for a forged message, the wrapper
systematically verifies, via recvmsg, that the source address has
nl_pid == 0 (kernel) and that the SCM_CREDENTIALS credential, if the
USERSPACE mode is used, corresponds to uid == 0. A message that fails this
verification triggers Errno::EPERM (the message is consumed and rejected).
Decision (layer 0 “abstract without hiding”). The wrapper validates the source because it is a safety precondition of the mechanism itself (without it, the API would be a trap). It goes no further: it does not filter by subsystem, does not deduplicate, does not correlate with
sysfs. These conveniences are layer 1 logic.
Buffer sizing.
A kernel uevent message almost always fits under 2 KiB, but can reach ~16 KiB in
extreme cases (long property lists). Recommendation: a 8192-byte buffer. A
constant UEVENT_RECOMMENDED_BUFFER_SIZE = 8192 is exposed as a guideline.
Errors.
EINVAL:groupsempty or invalid flags.EPERM: message rejected for authenticity reasons (non-kernel source).EMSGSIZE/ENOBUFS: buffer too small or receive queue saturated.EAGAIN:NONBLOCKsocket with no message available.EMFILE,ENFILE,ENOMEM: resource limits.
Performance.
Opening: ~10-20 µs. Reading an available message: ~2-5 µs. The event rate is intrinsically low (hotplug), hence not critical.
The decoded message: UEventMessage
#![allow(unused)]
fn main() {
pub struct UEventMessage<'b> { /* borrows &'b [u8] */ }
impl<'b> UEventMessage<'b> {
/// The action (header before the first `\0`), e.g. `add`, `remove`.
/// A typed subset for the known actions, raw otherwise.
pub fn action(&self) -> UEventAction;
/// The `DEVPATH` relative to `/sys` (extracted from the header or the properties).
pub fn device_path(&self) -> Option<&'b [u8]>;
/// The subsystem (`SUBSYSTEM=...`), e.g. `usb`, `input`, `block`.
pub fn subsystem(&self) -> Option<&'b [u8]>;
/// The value of an arbitrary property by key.
pub fn property(&self, key: &[u8]) -> Option<&'b [u8]>;
/// Iterates over all `(key, value)` pairs without allocating.
pub fn properties(&self) -> UEventProperties<'b>;
/// The raw bytes of the message (for diagnostics / passthrough).
pub fn as_bytes(&self) -> &'b [u8];
}
pub struct UEventProperties<'b> { /* cursor over the buffer */ }
impl<'b> Iterator for UEventProperties<'b> {
type Item = (&'b [u8], &'b [u8]); // (key, value), borrowed slices
fn next(&mut self) -> Option<Self::Item>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UEventAction {
Add,
Remove,
Change,
Move,
Online,
Offline,
Bind,
Unbind,
/// Unrecognized action; the slice is the raw header.
Other,
}
}
Decoded wire format.
A kernel uevent message has the form:
add@/devices/pci0000:00/.../input/input12\0ACTION=add\0DEVPATH=/devices/.../input12\0SUBSYSTEM=input\0...\0
- Header: everything before the first
\0—action@devpath. - Properties: a sequence of
KEY=VALUEterminated by\0.
The decoder makes no copy: action(), subsystem(), property() and the
properties() iterator return slices that point into the caller’s buffer. Keys
and values are bytes (&[u8]), not str: the kernel does not guarantee
UTF-8 (ADR — layer 0, “zero assumption about encodings”, Principle 3).
Conversion to str is left to the caller.
Note on the USERSPACE (libudev) format.
Messages from the USERSPACE group are prefixed with a binary libudev header
(magic 0xfeedcafe, offsets). The decoder detects it and exposes the properties
the same way; the binary header is hidden behind the API. If the magic is absent
or corrupted, read returns EBADMSG.
Tests.
- Decoding a synthetic
addmessage: verifyaction,subsystem, complete iteration of the properties, absence of allocation (via a test allocator that panics onalloc). - Truncated / headerless message → clean error, no panic or OOB (slicing via
get, never direct indexing — Principle 3). - End-to-end test (privileged, marked “ignore” by default): trigger a real
uevent (e.g.
modprobe/rmmodof a dummy module, or a write to/sys/.../uevent) and read the message. - Property-based (proptest): for any byte buffer, the decoder never panics and the iterator terminates.
- Fuzzing (
cargo-fuzz): the uevent decoder accepts external data (the kernel buffer) → mandatory fuzz harness onUEventMessage::parse.
Subsection 2: evdev — input devices
Linux input devices are exposed under /dev/input/eventX. They are opened
with the fs family (openat for reading, possibly OpenFlags::NONBLOCK) — the
device family has no dedicated opening function, an evdev being an ordinary
char device. What layer 0 adds: the typed reading of events and the
EVIOC* ioctls for querying and control.
Reading events: InputEvent
#![allow(unused)]
fn main() {
/// `#[repr(C)]` mirror of `struct input_event` (24 bytes on LP64).
/// Fields keep the kernel names (ADR-029, "mirror type" nuance).
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct InputEvent {
/// Seconds of the timestamp (`struct timeval::tv_sec`, `time_t`).
/// **Signed**: `time_t`/`suseconds_t` are `long` on LP64 (our 2 targets).
pub sec: i64,
/// Microseconds of the timestamp (`struct timeval::tv_usec`, `suseconds_t`).
pub usec: i64,
/// Event type (`EV_KEY`, `EV_REL`, `EV_ABS`, `EV_SYN`...).
pub event_type: u16,
/// Code (key, axis, button) depending on the type.
pub code: u16,
/// Value (1/0 for a key, delta for `EV_REL`, absolute for `EV_ABS`).
pub value: i32,
}
/// Reads a batch of events into `events`, without allocation.
/// Returns the number of **complete** events read.
pub fn evdev_read_events(
device: BorrowedFd<'_>,
events: &mut [InputEvent],
) -> Result<usize, Errno>;
}
Underlying syscall. read (x86_64 #0, ARM64 #63) on the evdev FD. The
kernel delivers an integer multiple of size_of::<input_event>() (24 bytes on
both of Air’s LP64 targets, x86_64 and aarch64 — no y2038 splitting since long
is 64 bits).
Behavior.
evdev_read_events reads into the events slice reinterpreted as bytes, then
returns the number of complete events. If the kernel returns a byte count that is
not a multiple of 24 (should never happen), the wrapper reports EPROTO rather
than exposing a partial event. On a NONBLOCK FD with no data: EAGAIN.
Why sec/usec as u64 rather than an Instant.
The evdev timestamp comes from a configurable clock (CLOCK_REALTIME by
default, or CLOCK_MONOTONIC via evdev_set_clock). Layer 0 reflects the two
kernel fields as-is; correlating with Instant/Duration (the time
family) is layer 1 logic, which knows which clock was chosen.
Pure decoder (no syscall)
#![allow(unused)]
fn main() {
impl InputEvent {
/// Reinterprets a byte buffer as a slice of `InputEvent` (zero copy).
/// `None` if the length is not a multiple of `size_of::<InputEvent>()`
/// or if the alignment is not respected.
pub fn slice_from_bytes(bytes: &[u8]) -> Option<&[InputEvent]>;
}
}
Useful when the bytes come from elsewhere (io_uring, mmap). A pure type, it
lives in air-sys-types.
Device identity and description
#![allow(unused)]
fn main() {
/// `EVIOCGVERSION` — evdev protocol version of the driver.
pub fn evdev_driver_version(device: BorrowedFd<'_>) -> Result<u32, Errno>;
/// `EVIOCGID` — bus/vendor/product/version identifier.
pub fn evdev_device_id(device: BorrowedFd<'_>) -> Result<InputId, Errno>;
/// `EVIOCGNAME(len)` — device name, written into `buffer`.
/// Returns the slice actually filled (bytes, possibly non-UTF-8).
pub fn evdev_name<'b>(
device: BorrowedFd<'_>,
buffer: &'b mut [u8],
) -> Result<&'b [u8], Errno>;
/// `EVIOCGPHYS(len)` — physical location (topology), e.g. `usb-0000:00:14.0-1/input0`.
pub fn evdev_physical_location<'b>(
device: BorrowedFd<'_>,
buffer: &'b mut [u8],
) -> Result<&'b [u8], Errno>;
/// `EVIOCGUNIQ(len)` — unique identifier (often empty).
pub fn evdev_unique_id<'b>(
device: BorrowedFd<'_>,
buffer: &'b mut [u8],
) -> Result<&'b [u8], Errno>;
/// `#[repr(C)]` mirror of `struct input_id` (kernel names kept, ADR-029).
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InputId {
pub bustype: u16,
pub vendor: u16,
pub product: u16,
pub version: u16,
}
}
Capabilities: event bits and properties
#![allow(unused)]
fn main() {
/// `EVIOCGBIT(event_type, len)` — bitmap of the codes supported for a given
/// event type. `event_type = None` queries the supported **types**
/// (equivalent to `EVIOCGBIT(0, len)`).
pub fn evdev_supported_codes<'b>(
device: BorrowedFd<'_>,
event_type: Option<EventType>,
bitmap: &'b mut [u8],
) -> Result<&'b [u8], Errno>;
/// `EVIOCGPROP(len)` — device properties (`INPUT_PROP_*`:
/// direct pointer, semi-mt, button-pad...).
pub fn evdev_properties<'b>(
device: BorrowedFd<'_>,
bitmap: &'b mut [u8],
) -> Result<&'b [u8], Errno>;
/// `EVIOCGABS(axis)` — range and state of an absolute axis (`ABS_X`, `ABS_MT_*`...).
pub fn evdev_abs_info(
device: BorrowedFd<'_>,
axis: AbsAxis,
) -> Result<InputAbsInfo, Errno>;
/// `#[repr(C)]` mirror of `struct input_absinfo` (kernel names kept).
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InputAbsInfo {
pub value: i32,
pub minimum: i32,
pub maximum: i32,
pub fuzz: i32,
pub flat: i32,
pub resolution: i32,
}
}
EventType and AbsAxis are typed enums (the kernel’s EV_* and ABS_*
constants) — layer 0 exposes named values rather than magic integers (ADR-029).
A Raw(u16) variant allows passing a not-yet-named code without blocking.
Current state
#![allow(unused)]
fn main() {
/// `EVIOCGKEY(len)` — current state (pressed/released) of all keys.
pub fn evdev_key_state<'b>(d: BorrowedFd<'_>, bitmap: &'b mut [u8]) -> Result<&'b [u8], Errno>;
/// `EVIOCGLED(len)` — state of the LEDs.
pub fn evdev_led_state<'b>(d: BorrowedFd<'_>, bitmap: &'b mut [u8]) -> Result<&'b [u8], Errno>;
/// `EVIOCGSND(len)` — state of the sound outputs.
pub fn evdev_sound_state<'b>(d: BorrowedFd<'_>, bitmap: &'b mut [u8]) -> Result<&'b [u8], Errno>;
/// `EVIOCGSW(len)` — state of the switches (lid, jack...).
pub fn evdev_switch_state<'b>(d: BorrowedFd<'_>, bitmap: &'b mut [u8]) -> Result<&'b [u8], Errno>;
}
Exclusive control and clock
#![allow(unused)]
fn main() {
/// `EVIOCGRAB` with argument `1` — exclusive grab of the device.
/// (ADR-021 conv. 1: no magic argument, two distinct functions.)
pub fn evdev_grab(device: BorrowedFd<'_>) -> Result<(), Errno>;
/// `EVIOCGRAB` with a null pointer — releases the exclusive grab.
pub fn evdev_release(device: BorrowedFd<'_>) -> Result<(), Errno>;
/// `EVIOCREVOKE` — permanently revokes access to this FD (irreversible).
/// Used by display servers to neutralize a handed-over FD.
pub fn evdev_revoke(device: BorrowedFd<'_>) -> Result<(), Errno>;
/// `EVIOCSCLOCKID` — chooses the clock for the event timestamps.
pub fn evdev_set_clock(
device: BorrowedFd<'_>,
clock: EventClock,
) -> Result<(), Errno>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EventClock {
/// `CLOCK_REALTIME` (kernel default).
Realtime,
/// `CLOCK_MONOTONIC` (recommended for input correlation).
Monotonic,
}
}
Underlying syscall (all EVIOC*). ioctl (x86_64 #16, ARM64 #29), each
request being a distinct EVIOC* constant. No generic ioctl wrapper is
exposed (ADR-021 convention 3): each operation is a typed function above. The
request values (_IOR('E', ...) etc.) are computed internally in
air-sys-syscall::device.
Preconditions and errors (cross-cutting evdev).
EVIOCGRABfails withEBUSYif another client already holds the grab.evdev_set_clockonly acceptsRealtime/Monotonic(typing), so noEINVALon an invalid clockid on Air’s side.ENOTTYif the FD is not an evdev (wrong file type).EFAULTimpossible to reach from the safe API (buffers provided by Air).
Performance.
Query ioctl: ~1-3 µs. evdev_read_events: ~1-2 µs per batch. The grab is
negligible.
Tricky tests.
- The nominal tests for the
EVIOC*require a real evdev. Strategy: create a virtual device viauinput(/dev/uinput) in a test harness, inject events, read them back via evdev, verify the round-trip (type/code/value).uinputalso allows testingEVIOCGID,EVIOCGNAME,EVIOCGBITdeterministically. - Lacking the
uinputprivilege, the tests are marked “ignore” with an explicit skip, and the decoding logic (InputEvent::slice_from_bytes) is tested purely on synthetic buffers + proptest + fuzz. - Coverage: the ioctl error branches that are hard to trigger (e.g.
ENOTTY) are recorded inCOVERAGE-EXCEPTIONS.md(category “feature/kernel” or “impossible-value”).
Subsection 3: sysfs — no dedicated wrapper
sysfs (/sys/...) is a pseudo file system. Reading an attribute
(/sys/class/input/event3/device/name), writing into a uevent
(/sys/.../uevent to re-trigger an event), traversing the tree
(/sys/devices/...): all of this is done with the fs family (openat,
read, write, getdents64).
Decision (layer 0, anti-duplication). The
devicefamily exposes nosysfswrapper. Re-exposingread/writeunder a “sysfs” name would be valueless duplication, and all the added value (building the path/sys/class/<subsystem>/<name>/<attr>, parsing an attribute as an integer/boolean, correlating a uevent’sDEVPATHwith its sysfs entry, enumerating a subsystem) is logic → layer 1 (futureair-devicecrate). Layer 0 merely provides thefsanduevent/evdevprimitives on which this logic will rely.
This subsection exists to explicitly remove the ambiguity: if a developer
looks for “the layer 0 sysfs function”, the answer is: there is none, it’s fs
- (layer 1).
Device family summary
Exposed functions:
| Category | Main functions |
|---|---|
| uevent | uevent_socket_open, UEventSocket::read, UEventMessage::{action,subsystem,device_path,property,properties,as_bytes} |
| evdev — reading | evdev_read_events, InputEvent::slice_from_bytes |
| evdev — identity | evdev_driver_version, evdev_device_id, evdev_name, evdev_physical_location, evdev_unique_id |
| evdev — capabilities | evdev_supported_codes, evdev_properties, evdev_abs_info |
| evdev — state | evdev_key_state, evdev_led_state, evdev_sound_state, evdev_switch_state |
| evdev — control | evdev_grab, evdev_release, evdev_revoke, evdev_set_clock |
| sysfs | (none — see subsection 3) |
Total: ~22 main public functions.
Non-wrapped syscalls (listed in UNSUPPORTED.md):
EVIOCSABS(set abs info): rare write, reserved for calibration; to be added later without breaking the API if needed.EVIOCGKEYCODE/EVIOCSKEYCODE(and_V2): scancode remapping; specialized, out of the initial layer 0 scope.EVIOCGMTSLOTS: per-slot multi-touch state; possible future addition.EVIOCSFF/EVIOCRMFF/force feedback: force feedback; dedicated later effort.- The old
/sbin/hotplughotplug andnetlink genl: obsolete / out of scope.
Distribution of types between the two crates
In air-sys-types (pure, no syscall)
InputEvent,InputId,InputAbsInfo—#[repr(C)]mirrors of kernel structures (fields with kernel names, ADR-029).EventType,AbsAxis,EventClock,UEventAction— typed enums.UEventGroups,UEventSocketFlags— bitflags.UEventMessage<'b>,UEventProperties<'b>— borrowed views, pure decoding (no syscall); placed with the types since the parsing does not touch the kernel.- The
UEVENT_RECOMMENDED_BUFFER_SIZEconstant.
In air-sys-syscall::device (calls syscalls)
UEventSocket— RAII owning anOwnedFd;open/readcallsocket/bind/recvmsg. Same rule asSignalFd/LandlockRuleset: a type that calls a syscall lives in the wrappers crate, never inair-sys-types.- All the
evdev_*functions (ioctl/readwrappers).
That is ~10 types added to air-sys-types.
Underlying decisions that emerged in the device family
1. uevent/input_event decoding in layer 0 (borrowed parsers).
The key=value (uevent) and struct input_event formats are stable kernel
ABI wire formats. Reflecting them without allocating is structure
mirroring, exactly like SignalFdInfo: layer 0 does it. The boundary with
layer 1 is clear-cut: decoding the format = layer 0; interpreting (subsystem
enums, device model, correlation) = layer 1.
2. UEventSocket::read verifies authenticity.
Without the nl_pid == 0 / uid == 0 verification, the API would be a security
trap (forgeable uevents). The validation is therefore a safety precondition of
the primitive, legitimate in layer 0 — distinct from a convenience.
3. No dedicated evdev opening.
An evdev is a char device: you open it with fs::openat. The device family
only adds what is specific (typed reading + EVIOC*).
4. No sysfs wrapper.
Anti-duplication of fs; all the sysfs added value is layer 1 logic. A dedicated
subsection to remove the ambiguity.
5. EVIOCGRAB → two functions (grab/release).
Application of ADR-021 conv. 1: the magic argument (1 vs null pointer) becomes
two explicit functions, without a sentinel.
6. Bitmaps returned as borrowed slices.
EVIOCGBIT/EVIOCGPROP/EVIOCG{KEY,LED,SND,SW} write into a buffer provided by
the caller: zero allocation (ADR-021 conv. 4). The bit-by-bit interpretation
(“is key K supported?”) is a layer 1 helper.
Document license: MPL 2.0
Status: Technical specification of the air-sys-syscall::device module (layer 0).
Layer 0 Spec — ebpf Family
Technical specification — Version 1.0
Family overview
The air-sys-syscall::ebpf module exposes the programming primitives of the
Linux kernel’s eBPF subsystem: the bpf() syscall (creating and
manipulating maps, loading and attaching programs, introspection, pinning, BTF,
iterators, statistics) and the perf_event_open() syscall (performance
counters, sampling, trace points), which is the canonical attachment point for
tracing-type eBPF programs.
eBPF is, along with seccomp-BPF (security family) and Landlock, one of the
deliberately embraced technical pillars of Air’s Linux-only choice (ADR-004). It
serves observability (profiling, tracing), security (network/LSM filtering), and
fine-grained system instrumentation — capabilities leveraged by the layer 5
system services.
Layer 0 ↔ layer 1 boundary: we load, we do not fabricate
Architecture decision (aligned with the
securityfamily, Q4). As with seccomp, layer 0 does not assemble and does not compile an eBPF program. Producing eBPF bytecode (encodingstruct bpf_insn, register allocation, CO-RE relocations, BTF generation, helper resolution) is generative logic, not a syscall wrapper. Layer 0 exposes the primitive: loading an already assembled program into the kernel (a slice ofBpfInstructionprovided by the caller), creating/querying maps, attaching, pinning, introspecting.Therefore outside layer 0 (specified in layer 1, future
air-bpfcrate) are: the instruction assembler, the libbpf-style loader (ELF parsing, sections, relocations), the generation and rich manipulation of BTF, the decoding of theperf/ring-buffer sampling records. Layer 0 provides the building blocks on which this logic relies.
Family scope
Exhaustive coverage of the bpf() syscall: the 37 sub-commands of
enum bpf_cmd (kernel target 6.12 LTS, like io_uring) are each exposed by a
dedicated, typed function, in line with ADR-021 convention 3 —
no generic bpf(cmd, attr, size) wrapper is offered. Plus the
perf_event_open() syscall and its control ioctls, also as dedicated
functions.
Cross-cutting characteristics of the family.
-
Universal CLOEXEC. All eBPF FDs (maps, programs, links, BTF) and the
perf_eventFDs are createdCLOEXEC(theBPF_F_*CLOEXEC/PERF_FLAG_FD_CLOEXECflag is set by the wrapper). -
No generic multiplexed syscall (ADR-021 conv. 3).
bpf()multiplexes 37 operations,ioctlon perf multiplexes a dozen: each becomes a typed function. The complexity is concentrated on the Air side, not the caller side. -
Kernel sentinels →
Option<T>/ enums (ADR-021 conv. 1). The-1“all CPUs / all processes” ofperf_event_open, theid0 “start of iteration”, the optional fds becomeOption<T>or enums. -
RAII for all resources.
BpfMap,BpfProgram,BpfLink,Btf,PerfEventown anOwnedFdand close it onDrop. No FD leaks. -
EINTR surfaced as-is (ADR-021 conv. 2). No automatic retry.
-
Privileges. Most operations require
CAP_BPF(+CAP_PERFMONfor tracing,CAP_NET_ADMINfor networking) since Linux 5.8, orCAP_SYS_ADMINbefore that. Unprivileged BPF is often disabled (kernel.unprivileged_bpf_disabled). Documented per function; not hidden. -
Typed slices rather than raw pointers. Map keys/values, program instructions, verifier log buffers are passed as
&[u8]/&[BpfInstruction]whose length is validated against the geometry of the resource (key_size,value_size…) before the call (Principle 4, “validate upstream”). Mismatch ⇒EINVALon the Air side, without touching the kernel.
Subsection 1: Fundamental types
These types are pure (no syscalls) and live in air-sys-types::ebpf,
except the RAII ones that call syscalls (placed in air-sys-syscall::ebpf).
Instruction and program
#![allow(unused)]
fn main() {
/// `#[repr(C)]` mirror of `struct bpf_insn` (8 bytes). "Mirror" type:
/// explicit type name (ADR-029), fields keep their kernel names.
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BpfInstruction {
pub code: u8,
/// 4 bits `dst_reg` + 4 bits `src_reg` (kernel layout).
pub registers: u8,
pub offset: i16,
pub immediate: i32,
}
}
The program is a slice &[BpfInstruction] owned by the caller.
Layer 0 neither inspects nor transforms it: the kernel verifier handles it
at load time. The BPF_MAXINSNS (4096) limit only concerns classic BPF
(seccomp); an eBPF program is bounded by the complexity that the verifier
accepts (up to ~1M instructions analyzed, privileged).
Typed enumerations
#![allow(unused)]
fn main() {
/// `enum bpf_map_type`. `Other(u32)` variant for types not yet named.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BpfMapType {
Hash, Array, ProgramArray, PerfEventArray, PercpuHash, PercpuArray,
StackTrace, CgroupArray, LruHash, LruPercpuHash, LpmTrie, ArrayOfMaps,
HashOfMaps, DevMap, SockMap, Cpumap, XskMap, SockHash, CgroupStorage,
ReuseportSockArray, PercpuCgroupStorage, Queue, Stack, SkStorage, DevmapHash, StructOps,
RingBuf, InodeStorage, TaskStorage, BloomFilter, UserRingBuf, CgrpStorage,
Arena,
Other(u32),
}
/// `enum bpf_prog_type`. Fallback `Other(u32)` variant.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BpfProgramType {
SocketFilter, Kprobe, SchedCls, SchedAct, Tracepoint, Xdp, PerfEvent,
CgroupSkb, CgroupSock, LwtIn, LwtOut, LwtXmit, SockOps, SkSkb, CgroupDevice,
SkMsg, RawTracepoint, CgroupSockAddr, LwtSeg6local, LircMode2, SkReuseport,
FlowDissector, CgroupSysctl, RawTracepointWritable, CgroupSockopt, Tracing,
StructOps, Ext, Lsm, SkLookup, Syscall, Netfilter,
Other(u32),
}
/// `enum bpf_attach_type` (attachment points for programs and links).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BpfAttachType {
CgroupInetIngress, CgroupInetEgress, CgroupInetSockCreate, CgroupSockOps,
SkSkbStreamParser, SkSkbStreamVerdict, CgroupDevice, SkMsgVerdict,
CgroupInet4Bind, CgroupInet6Bind, CgroupInet4Connect, CgroupInet6Connect,
TraceFentry, TraceFexit, ModifyReturn, LsmMac, TraceIter, XdpDevmap,
XdpCpumap, SkLookup, Xdp, /* … */ Other(u32),
}
}
Naming (ADR-029). Type names are explicit (
BpfMapType, notBpfMt). The variants mirror the kernel constantsBPF_MAP_TYPE_*/BPF_PROG_TYPE_*(kernel authority, which the developer finds again inuapi/linux/bpf.h), in CamelCase, with universally accepted abbreviations tolerated (Lru,Xdp,Lsm). TheOther(u32)variant avoids blocking a type added by a kernel more recent than 6.12.
RAII handles (in air-sys-syscall::ebpf)
#![allow(unused)]
fn main() {
pub struct BpfMap { /* OwnedFd */ }
pub struct BpfProgram { /* OwnedFd */ }
pub struct BpfLink { /* OwnedFd */ }
pub struct Btf { /* OwnedFd, loaded BTF object */ }
pub struct PerfEvent { /* OwnedFd */ }
// Each exposes: as_fd(&self) -> BorrowedFd<'_>, into_fd(self) -> OwnedFd,
// and a from_fd(OwnedFd) constructor to re-adopt an fd obtained from *_get_fd_by_id.
}
Subsection 2: Maps
Creation
#![allow(unused)]
fn main() {
pub fn bpf_map_create(request: &BpfMapCreateRequest<'_>) -> Result<BpfMap, Errno>;
pub struct BpfMapCreateRequest<'a> {
pub map_type: BpfMapType,
pub key_size: u32,
pub value_size: u32,
pub max_entries: u32,
pub flags: BpfMapCreateFlags,
/// Name (≤ 15 useful bytes + NUL), for introspection. `None` = anonymous.
pub name: Option<&'a CStr>,
/// For map-of-maps: the fd of the inner map template.
pub inner_map: Option<BorrowedFd<'a>>,
/// Preferred NUMA node (`None` = no preference).
pub numa_node: Option<u32>,
/// Optional BTF info (typed key/value).
pub btf: Option<BpfMapBtfInfo<'a>>,
}
bitflags! {
pub struct BpfMapCreateFlags: u32 {
const NO_PREALLOC = 1 << 0;
const NO_COMMON_LRU = 1 << 1;
const NUMA_NODE = 1 << 2;
const READ_ONLY = 1 << 3;
const WRITE_ONLY = 1 << 4;
const STACK_BUILD_ID = 1 << 5;
const ZERO_SEED = 1 << 6;
const READ_ONLY_PROG = 1 << 7;
const WRITE_ONLY_PROG = 1 << 8;
const CLONE = 1 << 9;
const MMAPABLE = 1 << 10;
const PRESERVE_ELEMS = 1 << 11;
const INNER_LOCK = 1 << 12;
}
}
}
Underlying syscall. bpf(BPF_MAP_CREATE, &attr, size). bpf:
x86_64 #321, ARM64 #280. Available since Linux 3.18; types and flags
depending on version (6.12 for the frozen scope).
Preconditions (validated upstream, Principle 4). key_size/value_size
consistent with the map_type (some types impose a fixed key/value size;
e.g. Array ⇒ key_size == 4). max_entries > 0 except for entry-less types.
READ_ONLY/WRITE_ONLY mutually exclusive.
Errors. EPERM/EACCES (privilege, unprivileged BPF disabled),
EINVAL (invalid geometry), E2BIG (map too large), ENOMEM.
Element-by-element access
#![allow(unused)]
fn main() {
pub fn bpf_map_lookup_element(
map: BorrowedFd<'_>, key: &[u8], value_out: &mut [u8], flags: BpfMapLookupFlags,
) -> Result<(), Errno>;
pub fn bpf_map_update_element(
map: BorrowedFd<'_>, key: &[u8], value: &[u8], flags: BpfMapUpdateFlags,
) -> Result<(), Errno>;
pub fn bpf_map_delete_element(map: BorrowedFd<'_>, key: &[u8]) -> Result<(), Errno>;
/// `None` for `key` = "first key" (kernel `NULL` sentinel, ADR-021 c.1).
pub fn bpf_map_get_next_key(
map: BorrowedFd<'_>, key: Option<&[u8]>, next_key_out: &mut [u8],
) -> Result<bool, Errno>; // false if end of iteration (ENOENT)
/// `BPF_MAP_LOOKUP_AND_DELETE_ELEM` (Queue/Stack maps in particular).
pub fn bpf_map_lookup_and_delete_element(
map: BorrowedFd<'_>, key: Option<&[u8]>, value_out: &mut [u8], flags: BpfMapLookupFlags,
) -> Result<(), Errno>;
bitflags! { pub struct BpfMapUpdateFlags: u64 {
const ANY = 0; const NO_EXIST = 1; const EXIST = 2; const F_LOCK = 4;
}}
bitflags! { pub struct BpfMapLookupFlags: u64 { const F_LOCK = 4; }}
}
Length validation. key.len() must equal key_size,
value.len()/value_out.len() must equal value_size (or value_size * num_possible_cpus for per-CPU maps). The wrapper checks via a cached
BPF_OBJ_GET_INFO_BY_FD, or requires the caller to provide correct slices and
returns EINVAL if the kernel rejects them. Decision taken:
do not introspect implicitly (hidden cost) — the caller guarantees the
size; mismatch ⇒ EINVAL surfaced from the kernel. Documented.
Batch operations
#![allow(unused)]
fn main() {
pub fn bpf_map_lookup_batch(map: BorrowedFd<'_>, request: &mut BpfMapBatchRequest<'_>)
-> Result<u32, Errno>; // number of elements processed
pub fn bpf_map_lookup_and_delete_batch(map: BorrowedFd<'_>, request: &mut BpfMapBatchRequest<'_>)
-> Result<u32, Errno>;
pub fn bpf_map_update_batch(map: BorrowedFd<'_>, request: &BpfMapBatchInput<'_>)
-> Result<u32, Errno>;
pub fn bpf_map_delete_batch(map: BorrowedFd<'_>, request: &BpfMapBatchInput<'_>)
-> Result<u32, Errno>;
}
BpfMapBatchRequest carries the key/value buffers, the
in_batch/out_batch cursor (opaque, managed by the kernel), the requested
count and the flags. Batch operations amortize the syscall cost over large
maps.
Freezing
#![allow(unused)]
fn main() {
/// `BPF_MAP_FREEZE` — makes the map non-modifiable from user space
/// (the eBPF program can still write to it according to its rights). Irreversible.
pub fn bpf_map_freeze(map: BorrowedFd<'_>) -> Result<(), Errno>;
}
Performance (maps). unit lookup/update/delete: ~1-3 µs (dominated by the syscall). The batch variants divide this cost by the batch factor.
Subsection 3: Programs
Loading (already-assembled program)
#![allow(unused)]
fn main() {
pub fn bpf_program_load(
request: &BpfProgramLoadRequest<'_>,
verifier_log: Option<&mut [u8]>,
) -> Result<BpfProgram, Errno>;
pub struct BpfProgramLoadRequest<'a> {
pub program_type: BpfProgramType,
/// **Already-assembled** program (layer 0 does not fabricate it).
pub instructions: &'a [BpfInstruction],
/// Program license (`GPL`, `Dual BSD/GPL`…). Conditions access to
/// helpers marked GPL-only by the kernel.
pub license: &'a CStr,
pub name: Option<&'a CStr>,
pub expected_attach_type: Option<BpfAttachType>,
/// For fentry/fexit/LSM/tracing: target BTF + id of the attachment point.
pub attach_btf: Option<BorrowedFd<'a>>,
pub attach_btf_id: Option<u32>,
/// Program to extend (`Ext` / freplace).
pub attach_program: Option<BorrowedFd<'a>>,
pub flags: BpfProgramLoadFlags,
pub log_level: BpfVerifierLogLevel,
}
bitflags! { pub struct BpfProgramLoadFlags: u32 {
const STRICT_ALIGNMENT = 1 << 0;
const ANY_ALIGNMENT = 1 << 1;
const TEST_RND_HI32 = 1 << 2;
const TEST_STATE_FREQ = 1 << 3;
const SLEEPABLE = 1 << 4;
const XDP_HAS_FRAGS = 1 << 5;
}}
#[derive(Debug, Clone, Copy)]
pub enum BpfVerifierLogLevel { Disabled, Basic, Verbose, Stats }
}
Underlying syscall. bpf(BPF_PROG_LOAD, …). The kernel verifier analyzes
the program: memory safety, termination (no unbounded loop), types.
On rejection, the wrapper returns the kernel Errno (often EACCES/EINVAL)
and fills verifier_log with the verifier’s textual diagnostic — an element
indispensable to debugging. On a log too short: ENOSPC (and the log truncated).
Safety.
bpf_program_loadis notunsafein the Rust sense: the kernel verifier guarantees the memory safety of the loaded program. The wrapper exposes no raw pointer. (The effect of an attached program on the system is another question, addressed at attachment.)
Test and binding
#![allow(unused)]
fn main() {
/// `BPF_PROG_TEST_RUN` / `BPF_PROG_RUN` — runs the program over a provided
/// context and data, without attaching it. Returns return value + duration.
pub fn bpf_program_test_run(
program: BorrowedFd<'_>, request: &mut BpfProgramTestRunRequest<'_>,
) -> Result<BpfProgramTestRunResult, Errno>;
/// `BPF_PROG_BIND_MAP` — explicitly binds a map to a program (lifetime).
pub fn bpf_program_bind_map(
program: BorrowedFd<'_>, map: BorrowedFd<'_>, flags: u32,
) -> Result<(), Errno>;
}
Subsection 4: Attachment and links
Two models coexist: the historical attachment (PROG_ATTACH/DETACH,
mostly cgroup and sockmap, “anonymous” attachment without an object) and the
modern bpf_link model (an FD object representing the attachment, detached
on Drop).
#![allow(unused)]
fn main() {
/// `BPF_PROG_ATTACH` — historical attachment (cgroup, sk_skb, flow_dissector…).
pub fn bpf_program_attach(
program: BorrowedFd<'_>, target: BorrowedFd<'_>,
attach_type: BpfAttachType, flags: BpfAttachFlags,
) -> Result<(), Errno>;
/// `BPF_PROG_DETACH`.
pub fn bpf_program_detach(
target: BorrowedFd<'_>, attach_type: BpfAttachType,
program: Option<BorrowedFd<'_>>,
) -> Result<(), Errno>;
/// `BPF_LINK_CREATE` — creates a modern link (returns a RAII FD).
pub fn bpf_link_create(request: &BpfLinkCreateRequest<'_>) -> Result<BpfLink, Errno>;
/// `BPF_LINK_UPDATE` — hot-swaps the program of a link.
pub fn bpf_link_update(
link: BorrowedFd<'_>, new_program: BorrowedFd<'_>,
old_program: Option<BorrowedFd<'_>>, flags: u32,
) -> Result<(), Errno>;
/// `BPF_LINK_DETACH` — detaches the program while keeping the link (auto-detaches).
pub fn bpf_link_detach(link: BorrowedFd<'_>) -> Result<(), Errno>;
/// `BPF_RAW_TRACEPOINT_OPEN` — attaches a program to a raw tracepoint.
pub fn bpf_raw_tracepoint_open(
name: &CStr, program: BorrowedFd<'_>,
) -> Result<BpfLink, Errno>;
/// `BPF_ITER_CREATE` — creates a BPF iterator from an iteration `BpfLink`.
/// Returns a readable FD that produces the iterator's output.
pub fn bpf_iterator_create(link: BorrowedFd<'_>, flags: u32) -> Result<OwnedFd, Errno>;
bitflags! { pub struct BpfAttachFlags: u32 {
const ALLOW_OVERRIDE = 1 << 0;
const ALLOW_MULTI = 1 << 1;
const REPLACE = 1 << 2;
}}
}
System effect — safety note (not
unsafe, but documented). Attaching a program modifies the behavior of the system (network filtering, LSM hooks, tracing). These functions are notunsafein the memory sense, but their# Effectsdocumentation reminds that an attached program runs on critical paths. Layer 5 (system services) orchestrates these attachments under the control of entitlements (ADR-010).
Performance. Creating a link / attachment: ~10-50 µs. The Drop of a
BpfLink detaches automatically.
Subsection 5: Pinning (bpf filesystem)
#![allow(unused)]
fn main() {
/// `BPF_OBJ_PIN` — pins an object (map/program/link) under a path of the
/// `bpffs` mount (`/sys/fs/bpf/...`), to make it outlive the process.
pub fn bpf_object_pin(object: BorrowedFd<'_>, path: &CStr) -> Result<(), Errno>;
/// `BPF_OBJ_GET` — retrieves an FD to a previously pinned object.
pub fn bpf_object_get(path: &CStr, flags: BpfObjectGetFlags) -> Result<OwnedFd, Errno>;
bitflags! { pub struct BpfObjectGetFlags: u32 {
const RDONLY = 1 << 3; const WRONLY = 1 << 4;
}}
}
The FD returned by bpf_object_get is re-adoptable into BpfMap::from_fd /
BpfProgram::from_fd / BpfLink::from_fd depending on the type (verifiable via
bpf_object_get_info_by_fd).
Subsection 6: Introspection by identifier
The kernel assigns a stable id to each live object. These commands iterate
over the ids and convert id → FD (with privilege). The “start at the
beginning” sentinel (start_id = 0) becomes Option<u32> (ADR-021 conv. 1).
#![allow(unused)]
fn main() {
pub fn bpf_program_get_next_id(after: Option<u32>) -> Result<Option<u32>, Errno>;
pub fn bpf_map_get_next_id(after: Option<u32>) -> Result<Option<u32>, Errno>;
pub fn bpf_btf_get_next_id(after: Option<u32>) -> Result<Option<u32>, Errno>;
pub fn bpf_link_get_next_id(after: Option<u32>) -> Result<Option<u32>, Errno>;
// `Ok(None)` = end of iteration (the kernel returns ENOENT).
pub fn bpf_program_get_fd_by_id(id: u32) -> Result<BpfProgram, Errno>;
pub fn bpf_map_get_fd_by_id(id: u32) -> Result<BpfMap, Errno>;
pub fn bpf_btf_get_fd_by_id(id: u32) -> Result<Btf, Errno>;
pub fn bpf_link_get_fd_by_id(id: u32) -> Result<BpfLink, Errno>;
/// `BPF_OBJ_GET_INFO_BY_FD` — fills a kernel info structure for an object.
/// `info_out` is a buffer whose size depends on the type of object queried.
pub fn bpf_object_get_info_by_fd(
object: BorrowedFd<'_>, info_out: &mut [u8],
) -> Result<u32, Errno>; // bytes actually written
/// `BPF_PROG_QUERY` — lists the programs attached to a target (cgroup…).
pub fn bpf_program_query(request: &mut BpfProgramQueryRequest<'_>) -> Result<u32, Errno>;
/// `BPF_TASK_FD_QUERY` — queries the program behind a perf/tracepoint fd
/// of a given task (introspection of kprobe/uprobe).
pub fn bpf_task_fd_query(request: &mut BpfTaskFdQueryRequest<'_>) -> Result<(), Errno>;
}
Privilege. *_get_fd_by_id and PROG_QUERY typically require
CAP_SYS_ADMIN/CAP_BPF. Documented.
Subsection 7: BTF (BPF Type Format)
#![allow(unused)]
fn main() {
/// `BPF_BTF_LOAD` — loads an **already-formed** BTF blob into the kernel.
/// (BTF *generation* is logic → layer 1.)
pub fn bpf_btf_load(
btf_blob: &[u8],
verifier_log: Option<&mut [u8]>,
flags: BpfBtfLoadFlags,
) -> Result<Btf, Errno>;
bitflags! { pub struct BpfBtfLoadFlags: u32 { const TOKEN_FD = 1 << 0; }}
}
bpf_btf_get_fd_by_id / bpf_btf_get_next_id are in subsection 6. Layer
0 loads and references a BTF blob; the decoding of the BTF format (types, names,
CO-RE relocations) is layer 1 logic.
Subsection 8: Statistics and tokens
#![allow(unused)]
fn main() {
/// `BPF_ENABLE_STATS` — globally enables a type of statistics (CPU time
/// spent in programs…). Returns an FD: the stats stay active as long
/// as it is open (RAII).
pub fn bpf_enable_statistics(kind: BpfStatsType) -> Result<OwnedFd, Errno>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BpfStatsType { RunTime }
/// `BPF_TOKEN_CREATE` (Linux 6.9+) — creates a BPF privilege-delegation token
/// from a `bpffs` mount, to authorize BPF operations within a
/// container/namespace without global `CAP_SYS_ADMIN`. Consistent with Air's
/// confinement model.
pub fn bpf_token_create(bpffs: BorrowedFd<'_>, flags: u32) -> Result<OwnedFd, Errno>;
}
BpfStatsType is extensible (#[non_exhaustive] or a fallback variant) for the
stat types added after 6.12.
Subsection 9: perf_event_open and event control
perf_event_open opens a performance counter/sampler. It is the
canonical attachment point for tracing eBPF programs: you open a
perf_event on a kprobe/uprobe/tracepoint, then attach a program to it via
perf_event_set_bpf_program.
Opening
#![allow(unused)]
fn main() {
pub fn perf_event_open(
attr: &PerfEventAttr,
scope: PerfEventScope<'_>,
group_leader: Option<BorrowedFd<'_>>,
flags: PerfEventOpenFlags,
) -> Result<PerfEvent, Errno>;
/// Kernel `pid`/`cpu` encoded without the `-1` sentinel (ADR-021 conv. 1).
pub enum PerfEventScope<'a> {
/// Current process, on any CPU (`pid=0, cpu=-1`).
CallingProcessAnyCpu,
/// Current process, on a specific CPU (`pid=0, cpu=N`).
CallingProcessOnCpu(u32),
/// A specific process, any CPU (`pid=P, cpu=-1`).
ProcessAnyCpu(Pid),
/// A specific process on a specific CPU (`pid=P, cpu=N`).
ProcessOnCpu { process: Pid, cpu: u32 },
/// All processes on a CPU (system-wide sampling, `pid=-1, cpu=N`).
AllProcessesOnCpu(u32),
/// Per-cgroup monitoring (`flags |= PID_CGROUP`, `pid = cgroup_fd`).
Cgroup { cgroup: BorrowedFd<'a>, cpu: u32 },
}
bitflags! { pub struct PerfEventOpenFlags: u64 {
const FD_NO_GROUP = 1 << 0;
const FD_OUTPUT = 1 << 1;
// PID_CGROUP (1<<2) is handled via PerfEventScope::Cgroup, not exposed here.
const FD_CLOEXEC = 1 << 3; // always set by the wrapper
}}
}
Underlying syscall. perf_event_open: x86_64 #298, ARM64 #241.
Available since Linux 2.6.31.
PerfEventAttr is a #[repr(C)] mirror of struct perf_event_attr
(~120 bytes): explicit type name, fields with kernel names (type, config,
sample_period, sample_type, read_format, bitfields disabled, inherit,
exclude_kernel…). It implements Default (all zero = valid). Helper enums
name the common values of the type field
(PerfTypeId::{Hardware, Software, Tracepoint, HwCache, Raw, Breakpoint}) and
hardware config values (PerfHardwareCounter::{CpuCycles, Instructions, …}),
without hiding the raw structure. An ergonomic builder (high-level
assembly) belongs to layer 1.
Preconditions / errors. EACCES/EPERM (privilege, perf_event_paranoid),
EINVAL (inconsistent attr), ENOENT (unknown type/config), EMFILE,
ENODEV, EOPNOTSUPP.
Reading the counters
#![allow(unused)]
fn main() {
impl PerfEvent {
pub fn as_fd(&self) -> BorrowedFd<'_>;
pub fn into_fd(self) -> OwnedFd;
/// Reads the scalar value of the counter (simple `read_format`).
pub fn read_count(&self) -> Result<u64, Errno>;
}
}
Reading in group mode (PERF_FORMAT_GROUP, several counters at
once) and the decoding of the sampling ring (mmap of the ring buffer,
walking the struct perf_event_header / PERF_RECORD_* records) are
logic → layer 1. Layer 0 provides the FD: the ring is mmaped via
mem::mmap (mem family), its interpretation is done above.
Control (dedicated ioctls — no generic ioctl, ADR-021 c.3)
#![allow(unused)]
fn main() {
pub fn perf_event_enable(event: BorrowedFd<'_>) -> Result<(), Errno>; // IOC_ENABLE
pub fn perf_event_disable(event: BorrowedFd<'_>) -> Result<(), Errno>; // IOC_DISABLE
pub fn perf_event_reset(event: BorrowedFd<'_>) -> Result<(), Errno>; // IOC_RESET
pub fn perf_event_refresh(event: BorrowedFd<'_>, count: u32) -> Result<(), Errno>; // IOC_REFRESH
pub fn perf_event_set_period(event: BorrowedFd<'_>, period: u64) -> Result<(), Errno>; // IOC_PERIOD
pub fn perf_event_set_filter(event: BorrowedFd<'_>, filter: &CStr) -> Result<(), Errno>; // IOC_SET_FILTER
pub fn perf_event_id(event: BorrowedFd<'_>) -> Result<u64, Errno>; // IOC_ID
/// `PERF_EVENT_IOC_SET_BPF` — attaches an eBPF program to this perf_event
/// (kprobe/uprobe/tracepoint). THE eBPF ↔ perf BRIDGE.
pub fn perf_event_set_bpf_program(
event: BorrowedFd<'_>, program: BorrowedFd<'_>,
) -> Result<(), Errno>;
/// `PERF_EVENT_IOC_SET_OUTPUT` — redirects the output to another event's
/// ring; `None` detaches the redirection (`-1` sentinel, ADR-021 c.1).
pub fn perf_event_set_output(
event: BorrowedFd<'_>, output: Option<BorrowedFd<'_>>,
) -> Result<(), Errno>;
pub fn perf_event_pause_output(event: BorrowedFd<'_>, pause: bool) -> Result<(), Errno>; // IOC_PAUSE_OUTPUT
pub fn perf_event_query_bpf(event: BorrowedFd<'_>, ids_out: &mut [u32]) -> Result<u32, Errno>; // IOC_QUERY_BPF
pub fn perf_event_modify_attributes(event: BorrowedFd<'_>, attr: &PerfEventAttr) -> Result<(), Errno>; // IOC_MODIFY_ATTRIBUTES
}
Underlying syscall (control). ioctl on the perf FD, one
PERF_EVENT_IOC_* constant per function. No generic ioctl exposed.
Performance. Opening: ~10-30 µs. enable/disable/reset: ~1-2 µs.
read_count: ~1-2 µs.
ebpf family summary
Exposed functions, by category:
| Category | Main functions |
|---|---|
| Maps — lifetime | bpf_map_create, bpf_map_freeze |
| Maps — elements | bpf_map_lookup_element, bpf_map_update_element, bpf_map_delete_element, bpf_map_get_next_key, bpf_map_lookup_and_delete_element |
| Maps — batch | bpf_map_lookup_batch, bpf_map_lookup_and_delete_batch, bpf_map_update_batch, bpf_map_delete_batch |
| Programs | bpf_program_load, bpf_program_test_run, bpf_program_bind_map |
| Attachment / links | bpf_program_attach, bpf_program_detach, bpf_link_create, bpf_link_update, bpf_link_detach, bpf_raw_tracepoint_open, bpf_iterator_create |
| Pinning | bpf_object_pin, bpf_object_get |
| Introspection | bpf_program_get_next_id, bpf_map_get_next_id, bpf_btf_get_next_id, bpf_link_get_next_id, bpf_program_get_fd_by_id, bpf_map_get_fd_by_id, bpf_btf_get_fd_by_id, bpf_link_get_fd_by_id, bpf_object_get_info_by_fd, bpf_program_query, bpf_task_fd_query |
| BTF | bpf_btf_load |
| Stats / tokens | bpf_enable_statistics, bpf_token_create |
| perf — lifetime | perf_event_open, PerfEvent::read_count |
| perf — control | perf_event_enable, perf_event_disable, perf_event_reset, perf_event_refresh, perf_event_set_period, perf_event_set_filter, perf_event_id, perf_event_set_bpf_program, perf_event_set_output, perf_event_pause_output, perf_event_query_bpf, perf_event_modify_attributes |
Total: 37 bpf_* functions (exhaustive coverage of the 37 sub-commands of
enum bpf_cmd, target 6.12) + ~14 perf_event_* functions, i.e. ~51
main public functions.
Deferred to layer 1 (out of scope): eBPF instruction assembler,
ELF/libbpf loader, rich BTF generation and decoding, CO-RE relocations,
decoding of the perf sampling ring / ring-buffer, ergonomic builders
of PerfEventAttr and of programs.
Non-wrapped syscalls / operations (listed in UNSUPPORTED.md):
bpf(): no sub-command omitted — coverage is exhaustive for 6.12. Sub-commands added by a kernel later than 6.12 will be added by RFC, without breaking the API (Other(u32)variants already provided for).perf_event_open: the decoding of the sampling ring-buffer is in layer 1 (logic), not a missing syscall.
Distribution of types between the two crates
In air-sys-types (pure, no syscall)
BpfInstruction— mirror ofstruct bpf_insn.BpfMapType,BpfProgramType,BpfAttachType,BpfStatsType,BpfVerifierLogLevel,PerfTypeId,PerfHardwareCounter,PerfEventScope,EventClock(reused fromdeviceif relevant) — typed enums.BpfMapCreateFlags,BpfMapUpdateFlags,BpfMapLookupFlags,BpfProgramLoadFlags,BpfAttachFlags,BpfObjectGetFlags,BpfBtfLoadFlags,PerfEventOpenFlags— bitflags.PerfEventAttr— mirror ofstruct perf_event_attr(fields with kernel names).- The request structs (
BpfMapCreateRequest,BpfProgramLoadRequest,BpfLinkCreateRequest,BpfMapBatchRequest, …) — pure views/aggregates passed to the wrappers.
In air-sys-syscall::ebpf (RAII calling syscalls)
BpfMap,BpfProgram,BpfLink,Btf,PerfEvent— own anOwnedFd, close onDrop. Same rule asSignalFd/LandlockRuleset/UEventSocket: a type that calls a syscall does not live inair-sys-types.
That is ~25 types added to air-sys-types (including 2 heavy mirrors) + 5 RAII.
Tests (strategy)
- Maps — pure kernel round-trip: create a
Hash/Array,updatethenlookupa key,get_next_keyto iterate,delete, verifyENOENT. Deterministic without a program, but requiresCAP_BPF→ harness that skips cleanly if privilege is absent, and marks it inCOVERAGE-EXCEPTIONS.md(“privilege” category). - Minimal program: load a trivial
SocketFilterprogram (“return 0”) pre-assembled as a test constant (a few hand-writtenBpfInstruction), verify success; load an invalid program (uninitialized register) and verify thatverifier_logcontains the diagnostic. This tests the log path without an assembler. - Attachment/links: attach a
Tracepoint/Kprobeprogram viaperf_event_open+perf_event_set_bpf_program, trigger the event, verify a map counter;Dropdetaches. - Introspection:
*_get_next_id+*_get_fd_by_idon an object created in the test. - perf: open a
Software/PERF_COUNT_SW_TASK_CLOCKcounter on the current process,enable, do some work,read_count> 0,disable. - Property-based (proptest): encoding/decoding of the mirrors (
BpfInstruction,PerfEventAttr,input-like) round-trip; any&[u8]passed as a key/value never panics (upstream length validation). - Fuzzing (
cargo-fuzz): the decoding of the info structures returned byBPF_OBJ_GET_INFO_BY_FDaccepts external data (kernel) → fuzz harness on the info decoders. - Coverage: the privileged branches not reachable in non-root CI are
recorded in
COVERAGE-EXCEPTIONS.md(“privilege”, “feature/kernel” categories). Cross-arch validation x86_64 + aarch64.
Foundational decisions that emerged in the ebpf family
1. Exhaustive coverage of bpf(), one function per sub-command.
The 37 sub-commands of enum bpf_cmd (6.12) are all exposed as dedicated
typed functions. No generic bpf(cmd, attr, size) (ADR-021 conv. 3). The
family is large, that is embraced: the complexity goes on the Air side.
2. We load already-formed programs/BTF (layer 1 boundary).
Like seccomp (Q4 security family), layer 0 loads a program or a
BTF blob already assembled; it does not assemble, does not compile, does not relocate.
The assembler, the libbpf loader, BTF generation, the decoding of the
perf ring-buffer are in layer 1.
3. bpf_program_load is not unsafe; it fills the verifier log.
The kernel verifier guarantees memory safety ⇒ no unsafe. The log buffer
is a first-class parameter: without it, debugging a rejection is impossible.
4. Kernel sentinels → Option/enums everywhere.
start_id = 0, pid/cpu = -1, key = NULL (first key), null output fd:
all replaced by Option<T> or enums (PerfEventScope), in line with
ADR-021 conv. 1.
5. RAII for maps, programs, links, BTF, perf_event.
No FD leaks; Drop detaches links. *_get_fd_by_id / obj_get
return FDs re-adoptable via from_fd.
6. perf_event in the same family as eBPF.
Because perf_event_open + PERF_EVENT_IOC_SET_BPF is the bridge for
attaching tracing programs. Grouping them avoids an orphaned perf family and
reflects real usage.
7. Other(u32) / #[non_exhaustive] variants for longevity.
The type enums (map/prog/attach/stats) provide for a fallback so as not to break when a kernel later than 6.12 adds a value, without giving up typing.
Conclusion: layer 0 is complete
With the device and ebpf families, all eleven families of layer 0 are
specified: process, fs, mem, signal, time, net, ipc,
security, system, device, ebpf, plus the cross-cutting io_uring module
and the air-sys-types types crate. To fully deliver layer 0, all that
remains is the implementation of device, ebpf and the remaining io_uring
phases (2a→4, 3a–3f), following the documented-skeleton-first method.
Document license: MPL 2.0
Status: Technical specification of the air-sys-syscall::ebpf module (layer 0).
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.
Layer 0 spec — mem family: MmapRegion (shareable mapping + io_uring liveness handle)
Technical specification — Version 1.0 (target kernel: Linux 6.12 LTS). Extension
of the mem family.
Position
This document specifies MmapRegion, the shareable and io_uring-compatible
mapping on which two io_uring operations so far deferred depend:
submit_madvise (Temps 2a §6.2) and submit_futex_wait/wake/waitv
(Temps 2c §6.1). It unblocks both of them.
Emergence (doc-first method). The need surfaced while specifying io_uring (madvise at 2a, confirmed by futex at 2c): an io_uring operation that references userspace memory reads it asynchronously — after the façade returns, up until completion. The memory must remain valid until then. The
Mappingtype (unique RAII,Drop=munmap) does not guarantee this: nothing prevents itsDrop(hencemunmap) while an op is in flight → use-after-unmap (the kernel reads unmapped memory). A mapping is needed whose lifetime can be shared with the S1 slot of an in-flight op.
The naming mystery, resolved
The io_uring specs name MmapRegion; the mem family has Mapping. These are
two distinct types, by design:
Mapping(existing, unchanged): unique RAII (move-only),Drop=munmap, zero-cost (no allocation, ADR-021 c.4). For any ordinary synchronous use. We do not touch it — its 100% coverage stays intact.MmapRegion(new): shareable, reference-counted mapping,munmapat the last drop. It is the only type passable to io_uring’s asynchronous memory operations (madvise, futex). Opt-in: you only pay for sharing if you need it.
1. MmapRegion
#![allow(unused)]
fn main() {
/// **Shareable** mmap mapping, io_uring-compatible: its pages stay
/// valid as long as an in-flight io_uring operation (madvise/futex) references
/// memory in it. Reference-counted — `munmap` at the **last** drop (the region
/// **and** all liveness guards of in-flight ops).
#[derive(Clone)]
pub struct MmapRegion { /* Arc<MmapRegionInner> : ptr, len, prot */ }
impl MmapRegion {
/// Shareable anonymous mapping (equivalent to `mmap_anonymous` but `MmapRegion`).
/// # Errors `Errno` (cf. `mem` family).
pub fn new_anonymous(len: usize, prot: ProtectionFlags, flags: MapFlags)
-> Result<Self, Errno>;
/// Shareable file mapping.
pub fn from_file(fd: BorrowedFd<'_>, len: usize, prot: ProtectionFlags,
flags: MapFlags, offset: u64) -> Result<Self, Errno>;
/// Promotes a unique `Mapping` into a shareable region: **transfers** the
/// responsibility for the `munmap` to the shared inner (the `Mapping` is consumed,
/// its `Drop` no longer munmaps). A single allocation (the inner). The `prot`
/// **must reflect** the one the `Mapping` was created with (a bare `Mapping`
/// does not memorize its protections) — **no `READ|WRITE` default**,
/// otherwise `bytes()`/`futex_word()` would decide accessibility on a
/// false basis (a reference that faults on use).
pub fn from_mapping(mapping: Mapping, prot: ProtectionFlags) -> Self;
pub fn len(&self) -> usize;
pub fn is_empty(&self) -> bool;
/// Readable byte slice (if `prot` is readable). **Safe**, bounded access.
pub fn bytes(&self) -> &[u8];
/// Reference to a **futex word** (`AtomicU32`) at `offset` bytes. The reference
/// is **shared-mutable** (`store`/`load` via `&`): the region must therefore
/// be **writable**.
/// # Errors `EINVAL` if `prot` does not contain `WRITE`, or if `offset` is
/// out of bounds (`offset + 4 > len`) or not aligned on 4.
pub fn futex_word(&self, offset: usize) -> Result<&AtomicU32, Errno>;
/// Raw pointer + length (advanced consumers; no ownership).
pub fn as_ptr(&self) -> *const u8;
}
}
Ownership model. MmapRegion wraps an Arc<MmapRegionInner>;
MmapRegionInner::Drop calls munmap (ignoring the error, like Mapping).
Cloning an MmapRegion increments the strong counter; munmap only happens at the
last drop. No page copy — only ownership sharing.
Upfront validation (Principle 4). futex_word checks writability
(prot ⊇ WRITE, since the returned reference is mutable), bounds and alignment (a
futex is an aligned u32) before returning the reference — EINVAL otherwise. bytes()
accesses are bounded by construction (empty slice if prot lacks READ).
“No alloc in the happy path” exception (ADR-021 c.4), justified.
MmapRegionmakes one allocation (the innerArc). This is allowed and documented: (1) it is opt-in — the commonMappingpath stays zero-cost; (2) the cost is negligible and amortized against themmapsyscall that creates the region (which dominates it by several orders of magnitude); (3) lifetime sharing is intrinsically necessary to the safety of asynchronous use (cf. ADR-021 c.4, “documented necessity” clause). To be recorded in the family notes.
2. Liveness handle (consumed by io_uring’s S1 slot)
#![allow(unused)]
fn main() {
/// Liveness guard retained by the S1 slot of an in-flight io_uring operation that
/// references the region. Keeps the pages **mapped** without granting access:
/// as long as a guard exists, `munmap` cannot occur.
pub struct MmapRegionLiveness { /* clone of the inner Arc */ }
impl MmapRegion {
/// Produces a liveness guard (internal clone). Used by the io_uring façades
/// `submit_madvise`/`submit_futex_*`: the guard is **parked in the
/// slot**, and released at completion → `munmap` at the last drop.
pub(crate) fn liveness_handle(&self) -> MmapRegionLiveness;
}
}
Safety mechanics (the core). When an io_uring façade submits an op referencing the region:
- it validates the range/futex word against the region’s bounds (Principle 4);
- it clones an
MmapRegionLiveness(strong counter +1); - it parks the guard in the S1 slot next to the op.
At completion, the slot is freed → the guard drops (counter −1). munmap only
occurs when the user region AND all in-flight guards are
dropped. The kernel therefore never reads unmapped memory: neither
use-after-unmap, nor a leak (munmap at the last drop, not “leak rather than UAF”).
Refinement of ADR-028 / Temps 2a §6.2. The initial wording envisaged, for madvise, a “leak rather than UAF” scheme (do not munmap if an op is in flight → leak).
MmapRegiondoes strictly better: reference-counted, it does not leak (munmap at the last drop) and cannot UAF. This is the chosen mechanism.
3. Consumption by io_uring (corrected signatures)
madvise (Temps 2a §6.2) — already compliant
#![allow(unused)]
fn main() {
pub fn submit_madvise(&mut self, region: &MmapRegion, range: Range<usize>, advice: MadviseAdvice)
-> Result<SubmissionToken, Errno>;
}
range.end ≤ region.len() validated upfront; the slot retains
region.liveness_handle().
futex (Temps 2c §6.1) — signature CORRECTION
The 2c spec showed futex: &AtomicU32: insufficient for async (the borrow
does not survive the façade’s return). It is replaced by a reference into an
MmapRegion + offset (the slot retains the liveness guard):
#![allow(unused)]
fn main() {
pub fn submit_futex_wait(&mut self, region: &MmapRegion, offset: usize,
expected: u64, mask: u64, flags: FutexFlags)
-> Result<SubmissionToken, Errno>; // OP 51
pub fn submit_futex_wake(&mut self, region: &MmapRegion, offset: usize,
nr: u64, mask: u64, flags: FutexFlags)
-> Result<SubmissionToken, Errno>; // OP 52
pub fn submit_futex_waitv(&mut self, waiters: Vec<FutexWaiter>, flags: FutexFlags)
-> Result<SubmissionToken, Errno>; // OP 53
/// One waiter of `futex_waitv`: the region + the word's offset and the expected
/// value. The region is kept alive by the slot. **No mask**: the kernel
/// `struct futex_waitv` carries none (cf. note below).
pub struct FutexWaiter {
pub region: MmapRegion, // owned (clone) ⇒ natural liveness guard
pub offset: usize,
pub expected: u64,
}
}
offset locates the futex word within the region (writable, 4-aligned, bounded —
EINVAL otherwise). ABI note: kernel struct futex_waitv = { val, uaddr, flags, __reserved } (no per-waiter mask); FutexWaiter therefore does not expose
a mask (a silently ignored field would be a footgun, ADR-032). For
a masked wait, use submit_futex_wait (single-wait, which keeps
mask). The io-uring-2c-async.md spec and the mem family are reconciled
on these signatures.
4. Type distribution
air-sys-types::mem: no new pure type required (reuses the existingProtectionFlags/MapFlags).air-sys-syscall::mem:MmapRegion,MmapRegionLiveness(RAII callingmunmap→ lives in the wrappers crate, likeMapping).Mappingstays unchanged (no modification of its representation nor itsDrop) → existingmemcoverage intact.
5. Tests
- Safety (Miri) — the central point: park an op (simulated madvise/futex) that
holds an
MmapRegionLiveness, drop the userMmapRegion→ nomunmapas long as the guard lives; free the slot →munmapat the last drop. Verify no UAF (memory stays readable as long as a guard exists) and no leak (munmap does happen at the last drop). Exact strong counter under loom (concurrent clones/drops). - Validation:
futex_word/rangeout of bounds or unaligned →EINVALbefore submission. - Integration (once consumed by io_uring):
madviseandfutex_waitwoken byfutex_wakeon anMmapRegionshared between two threads. from_mapping: the consumedMappingno longer munmaps; the region does so at the last drop (no double munmap).- Coverage 100% lines + branches; non-triggerable resource errors →
COVERAGE-EXCEPTIONS.md(STRUCTURAL).
6. Core decisions
MmapRegiondistinct fromMapping:Mappingstays unique/zero-cost and untouched (coverage preserved);MmapRegionis the shareable opt-in for async io_uring.- Reference-counted → neither UAF nor leak: refines the “leak rather than UAF”
envisaged (ADR-028/2a §6.2);
munmapat the last drop. MmapRegionLivenessguard parked in the S1 slot: the memory safety of madvise/futex is by construction, with no exposedunsafe.- Corrected futex signature (
&MmapRegion+ offset, not&AtomicU32) — reconciles 2c with the asynchronous ownership model. - One allocation (Arc) accepted (ADR-021 c.4 “documented necessity”): opt-in,
negligible against
mmap.
7. Work to resume
This spec unblocks: MmapRegion implementation (coordinated mem PR), then
submit_madvise (Temps 2a, remainder) and submit_futex_* (Temps 2c, remainder) —
reconcile the signatures in io-uring-2a-filesystem.md and
io-uring-2c-async.md. fanotify, epoll (depending on 2c feedback): independent.
Document license: MPL 2.0
Status: Technical specification of MmapRegion (extension air-sys-syscall::mem),
target kernel 6.12 LTS. Unblocks madvise (2a) and futex (2c).
Layer 0 spec — process family: privsep extension (setgroups / setresgid / setresuid + getres*)
Technical specification — Version 1.0 (target kernel: Linux 6.12 LTS). Extension
of the process family.
Position
This extension adds the missing primitives essential to a correct
privilege reduction (drop_privileges of air-process, layer 1,
Principle 10). The process family exposes neither setgroups, setresgid,
setresuid, nor the verification getres* (not even setuid/setgid), and
no Uid/Gid types — these types are added by this extension
(observed at implementation time, PR #34).
Emergence (doc-first method). Gap revealed while specifying
air-process: performing a privsep withsetuid/setgidalone is a security footgun — the supplementary groups are not dropped, and the saved-set-uid/gid is not set → possibility of regaining privileges. 4th layer 0 gap identified, and the highest priority (security), alongsidefs::inotify,MmapRegion(resolved) andepoll.
Submodule: air-sys-syscall::process (extension). Privileged: these
operations require the appropriate capabilities (CAP_SETUID/CAP_SETGID) or
being root at call time.
1. Types
Reuses the typed newtypes Uid/Gid from the process family (ADR-029:
never a raw u32 for an identifier). If they do not yet exist, add them
(air-sys-types::process) at the same time — Uid(u32)/Gid(u32),
#[repr(transparent)], with as_raw/from_raw. (To be confirmed at implementation time.)
2. Dropping supplementary groups
#![allow(unused)]
fn main() {
/// `setgroups` — replaces the process's supplementary groups list.
/// For a privsep, pass **`&[]`** (full drop) while still privileged.
/// # Errors `EPERM` (without `CAP_SETGID`), `EINVAL` (too many groups), `EFAULT`.
pub fn set_groups(groups: &[Gid]) -> Result<(), Errno>;
/// `getgroups` — reads the current list (defensive verification). Caller-provided
/// buffer, zero alloc; returns the filled slice.
pub fn get_groups<'b>(buffer: &'b mut [Gid]) -> Result<&'b [Gid], Errno>;
}
Syscalls. setgroups (x86_64 n°116, ARM64 n°159), getgroups (x86_64 n°115,
ARM64 n°158). No sentinel; the empty list is the normal privsep value
(not a magic case).
3. Real/effective/saved identity
#![allow(unused)]
fn main() {
/// `setresgid` — sets **real + effective + saved** GID in one call. To be done
/// **before** `setresuid`. `None` for a component = "unchanged" (kernel
/// sentinel `-1` typed as `Option`, ADR-021 conv. 1).
/// # Errors `EPERM`, `EINVAL`.
pub fn set_resgid(real: Option<Gid>, effective: Option<Gid>, saved: Option<Gid>)
-> Result<(), Errno>;
/// `setresuid` — sets **real + effective + saved** UID in one call. After the GID.
/// `None` = "unchanged" (typed `-1`).
/// # Errors `EPERM`, `EINVAL`.
pub fn set_resuid(real: Option<Uid>, effective: Option<Uid>, saved: Option<Uid>)
-> Result<(), Errno>;
/// `getresgid` / `getresuid` — reads the three components (defensive
/// verification: confirm that reverting is **no longer** possible).
pub fn get_resgid() -> Result<ResGid, Errno>;
pub fn get_resuid() -> Result<ResUid, Errno>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResUid { pub real: Uid, pub effective: Uid, pub saved: Uid }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResGid { pub real: Gid, pub effective: Gid, pub saved: Gid }
}
Syscalls. setresgid (x86_64 n°119, ARM64 n°149), setresuid (x86_64 n°117,
ARM64 n°147), getresgid (x86_64 n°120, ARM64 n°150), getresuid (x86_64 n°118,
ARM64 n°148). (Numbers verified against uapi 6.12 per architecture at
implementation time — setresgid ARM64 corrected 143 → 149, PR #34.)
Why
setres*and notset*.setresuid/setresgidset all three identities (real/effective/saved) explicitly in one syscall. Setting the saved-set is what makes regaining privileges impossible — a guarantee that a plainsetuid/setgiddoes not reliably provide. This is the raison d’être of this extension.None(= kernel-1) leaves a component unchanged, cleanly typed (no magic-1).
4. Summary
| Function | Role | Syscall |
|---|---|---|
set_groups / get_groups | drop / read the supplementary groups | setgroups / getgroups |
set_resgid / get_resgid | GID real+effective+saved | setresgid / getresgid |
set_resuid / get_resuid | UID real+effective+saved | setresuid / getresuid |
Total: ~6 functions. Types added to air-sys-types::process: ResUid, ResGid
(+ Uid/Gid if absent).
5. Tests
- Privileged (root /
CAP_SETUID+CAP_SETGID), in isolated subprocesses (fork+ observation viawaitid), clean skip if not privileged →COVERAGE-EXCEPTIONS.md(PRIVILEGE category):set_groups(&[])thenget_groupsreturns an empty list.set_resgidthenset_resuidto a non-root uid/gid, then verify the impossibility of reverting:set_resuid(Some(root), …)→EPERM;get_resuidconfirms real==effective==saved==target.None(component unchanged): a single component modified.
- Errors:
EPERMwithout capability (covered on a non-root executor),EINVAL. - Property-based: encoding of
Option<Uid/Gid>→-1forNone, round-trip. - Cross-arch validation x86_64 + aarch64.
6. Foundational decisions
setres*(and notset*) to set the saved-set — prohibition of regaining privileges by construction.setgroups(&[])as first class — dropping supplementary groups, a mandatory step of the privsep.None= component unchanged (typed kernel-1, ADR-021 conv. 1).getres*/getgroupsexposed for the defensive verification ofdrop_privileges(Principle 5: confirm the reduction).
Document license: MPL 2.0
Status: Technical specification of the privsep extension of
air-sys-syscall::process (layer 0), target kernel 6.12 LTS. Prerequisite of
air-process::drop_privileges.
Layer 0 Spec — process Family, CPU Affinity (sched_setaffinity)
Technical specification — Version 1.0 (target kernel: Linux 6.12 LTS). Extension
of the process family.
Position
This document specifies the CPU affinity primitive of layer 0 — pinning a
task to a subset of logical CPUs. Gap identified while specifying layer 1:
air-thread::cpu_affinity needs it and no
sched_setaffinity/sched_getaffinity wrapper existed.
Placement: process family. Affinity is a property of a task’s
scheduling (identified by a Tid); it therefore lives alongside gettid,
clone3, waitid, setpgid… in air-sys-syscall::process. The mask
reuses CpuSet (already defined in air-sys-types::system in Temps 3a for
IORING_REGISTER_IOWQ_AFF) — a shared type, not recreated. (Rejected
alternative: the system family — but system covers machine information
(uname, sysinfo, entropy), not the scheduling of a specific task.)
Emergence (doc-first method). Like inotify, this gap was revealed by the specification of layer 1 — consuming layer 0 surfaces its holes. No prior spec: this document is produced before the implementation.
Surface
#![allow(unused)]
fn main() {
/// `sched_setaffinity(2)` — sets a task's CPU affinity: it will only be
/// scheduled on the CPUs present in `cpus`. `tid = None` = calling task
/// (kernel sentinel `0` typed as `Option`, ADR-021 conv. 1).
pub fn set_cpu_affinity(tid: Option<Tid>, cpus: &CpuSet) -> Result<(), Errno>;
/// `sched_getaffinity(2)` — reads the current affinity **into** `cpus` (buffer
/// supplied by the caller, zero allocation). `tid = None` = calling task.
pub fn get_cpu_affinity(tid: Option<Tid>, cpus: &mut CpuSet) -> Result<(), Errno>;
}
CpuSetreused (air-sys-types::system,CPU_SETSIZE = 1024bits / 128 bytes,cpu_set_tlayout):new/set/clear/contains/count/is_empty/as_bytes.get_cpu_affinitywrites into the mask via a pointer to the&mut CpuSet(#[repr(C)]without padding ⇒ every byte pattern valid) — no new accessor added to the type.None= calling task: translated topid = 0(never exposed as a magic integer — ADR-021 conv. 1).- EINTR:
sched_*affinityare not interruptible; no hidden retry (ADR-021 conv. 2 remains the general rule).
Underlying syscalls (verified against uapi 6.12, per arch)
| Syscall | x86_64 | aarch64 |
|---|---|---|
sched_setaffinity | 203 | 122 |
sched_getaffinity | 204 | 123 |
set: the kernel reads len bytes of the mask (asm readonly). get: the
kernel writes into the mask (asm without readonly) and returns the
number of bytes filled (≥ 0) or -errno. The size passed is
size_of::<CpuSet>() (128 bytes) ≥ the kernel size of cpumask — the kernel
fills what it has and leaves the rest at zero.
Preconditions / errors
set_cpu_affinity:- [
Errno::EINVAL]:cpuscontains no allowed / online CPU. - [
Errno::ESRCH]: no task bears thistid. - [
Errno::EPERM]: insufficient privileges (changing the affinity of a task belonging to another user, or a cpuset cgroup constraint).
- [
get_cpu_affinity:- [
Errno::ESRCH]: nonexistenttid. - [
Errno::EINVAL]: inconsistent mask size — does not occur with aCpuSetofCPU_SETSIZEbits (always ≥ the kernel size).
- [
Tests
- Round-trip (real):
getthe current affinity (count ≥ 1),setto a subset (CPU 0, always online),getand verifycontains(0)+count == 1, then restore the original affinity. NonevsSome(tid): both target the calling task (Some(gettid())).- Errors: empty mask →
EINVAL; nonexistenttid→ESRCH. - Cross-arch: numbers verified x86_64 + aarch64; tests run on both.
- Coverage 100 % lines + branches (the real
Ok/Errpaths cover both arms;affinity_pidNone/Somecovered); no expected exception.
Core decisions
- Affinity in
process(scheduling of a task), mask shared fromsystem::CpuSet— no type duplication. None= calling task (pid = 0typed), compliant with ADR-021 conv. 1.- Buffer supplied, zero allocation for
get(the caller owns theCpuSet). - Primitive unblocking layer 1 (
air-thread::cpu_affinity).
Document license: MPL 2.0
Status: Technical specification of CPU affinity (sched_set/getaffinity),
extension of the process family (layer 0), target kernel 6.12 LTS.
Layer 0 spec — io_uring module (redirection)
Redirection document — Version 2.0
This document is no longer authoritative. It was the early-stage synthesis of the
io_uringmodule, calibrated on an old kernel. Its inventory and partitioning function has been entirely revised and corrected by the master document and the detailed per-Temps specifications (frozen kernel target 6.12 LTS).It is kept as a redirection point so as not to break inbound links; all of its substantive content now lives in the documents below.
Where to read the io_uring module specification
Architecture decisions
- ADR-022 — io_uring module architecture (10 structuring decisions)
- ADR-028 — Soundness and teardown (S1/S2/S3) (complements ADR-022)
Master document (entry point)
- io_uring — master inventory (target 6.12): exhaustive enumeration of the 8 axes, retained/obsolete classification, soundness decisions, partitioning into Temps, rustdoc skeleton of the core.
Detailed specifications per Temps
- Temps 1 — API core
- Temps 2a — filesystem
- Temps 2b — network
- Temps 2c — async-specific
- Temps 2d — URING_CMD (passthrough)
- Temps 3a — registration
- Temps 3b — provided buffers (ring)
- Temps 3c — linked operations
- Temps 3d — multishot
- Temps 3e — multi-thread
- Temps 3f — confinement (sandbox)
- Temps 4 — raw access (raw)
Historical note — soft/hard link semantics (corrected — ref. spec 3c §2). An earlier version of this document described the soft/hard link semantics backwards and gave an incomplete inventory (calibrated on ~5.15). The correct semantics, kernel-verified and authoritative in
io-uring-3c-linked.md§2, are:
- soft (
IOSQE_IO_LINK): the chain is broken on a completion error of a link —short readincluded (a partial read counts as a chaining error) → the following links receive-ECANCELED;- hard (
IOSQE_IO_HARDLINK): the chain is not broken by a completion error of a link (the following ones execute anyway);- in both cases, the execution order of the links is guaranteed;
- a submission failure of the parent link breaks the chain.
In case of divergence, the master document and the per-Temps specs are authoritative.
Document license: MPL 2.0 Status: Redirection document. The normative content lives in the master document and the per-Temps specs.
Spec couche 1 — air-base-lib (cœur : erreurs, chaînes/chemins, temps)
Spécification technique — Version 1.0 (cœur). Couche 1 « Primitives système ».
Mise à jour de layering — ADR-054. Le cœur sans i18n décrit par cette spec (erreurs
AirError/AirResult, identifiants, chemins-octetsAirPath, tempsAirInstant/AirDuration(monotone),AirSystemTime(horloge murale) +sleep/sleep_until(nanosleep), encodages, log) a été extrait dans la crateair-base-core(no_std, zéroicu4x) pour débloquer la fermeture runtime*-linux-air.air-base-libconserve l’i18n (AirString/AirLocale, temps calendaireAirDateTime/AirCalendar), dépend d’air-base-coreet le ré-exporte intégralement : la surface publique (air_base_lib::AirError,…::AirPath,…::AirInstant, …) reste inchangée. La spec n’est pas réécrite ; le porteur du cœur erreur/temps-monotone est désormaisair-base-core(cf. ADR-054 D1/D5).
Position et méthode
air-base-lib est la crate la plus consommée du système (l’équivalent du
libSystem des autres Unix) : toutes les couches supérieures en dépendent. Là où
la couche 0 (air-sys-types / air-sys-syscall) expose des syscalls et des
primitives kernel, air-base-lib expose des concepts applicatifs : erreurs
riches et traçables, chaînes Unicode, chemins, temps.
Cette spec couvre le cœur (décision de périmètre, 2026-06-11) :
- Erreurs —
AirError,AirResult, l’absorption d’Errno(couche 0). - Chaînes & chemins —
AirString(Unicode, viaicu4x),AirPath(octets, potentiellement non-UTF-8),AirLocale. - Temps —
AirInstant(monotone),AirDuration,AirDateTime+AirCalendar(calendaire, viaicu4x).
Différé à des specs air-base-lib ultérieures (hors de ce document) :
AirLog (logging structuré → journald), les identifiants (AirUuid,
AirId128, AirMonotonicId), AirConfig (TOML/XDG). Différé à une passe
dédiée : la surface ABI C (libair-base.so, symboles air_*, versioned
symbols, stabilité 10 ans — ADR-012/ADR-027) ; ce document fige d’abord l’API
Rust idiomatique, dont l’ABI C dépendra.
Décision de périmètre actée. On spécifie le cœur d’un bloc (délégable en une passe), API Rust d’abord, ABI C plus tard. La méthode reste doc-d’abord : ce contrat est validé avant implémentation ; Claude Code produit ensuite un squelette documenté puis l’implémente sans changer la surface validée.
Conventions transverses de la couche 1
-
Rust idiomatique, aucun
unsafeexposé. La frontière couche 0 → couche 1 est « API Rust sûre,unsafeconfiné en couche 0 » (macro-architecture §frontières).air-base-libn’expose aucune fonctionunsafe; toutunsafeinterne éventuel porte un// SAFETY:(vérifié CI). -
Couverture 100 % (lignes + branches), couche fondatrice (Principe 1, comme la couche 0). Property-based + fuzzing sur toute API ingérant des données externes (validation UTF-8, parsing de chemins/locales).
-
Nommage explicite (ADR-029). Surface verbeuse, sans abréviation (
AirString,AirDateTime,normalize,to_lowercase_in_locale…). Noms d’autorité conservés : typesstd(OsString,Path), termesicu4xréexposés, vocabulaire Unicode (NFC,grapheme). -
Arithmétique défensive (Principe 2). Toute opération temporelle pouvant déborder utilise
checked_*/saturating_*explicitement ; conversions lossy parTryFrom, jamaisas. -
Zéro présomption d’encodage (Principe 3). Distinction stricte
AirString(UTF-8 garanti) vsAirPath(octets, peut être non-UTF-8). Toute construction depuis des octets externes passe par une validation faillible. -
Pas de proc-macro Air ici (macro-architecture §air-base-lib) : la crate doit rester simple à exposer en ABI C.
AirErrorest écrit à la main (pas dethiserrordansair-base-lib) ;thiserrorreste l’idiome des autres crates couche 1+ (ADR-019).
Dépendances assumées (règle des 80 %, ADR-024)
icu4x: dépendance structurante, exception explicite à la règle des 80 % déjà actée (ADR-016 ;docs/EXCEPTIONS.md). Fournit normalisation, casing locale-aware, segmentation, collation, calendriers. Composantsicu_*tirés à la carte (modularité native d’icu4x).air-sys-types(couche 0) : pour absorberErrno.air-sys-syscall(couche 0) : pourAirInstant::now/AirDateTime::now(horloges) — seul point de contact syscall du cœur.- Aucune autre dépendance externe dans le cœur (pas de
serde/toml: la config est différée).
Section 1 — Erreurs : AirError / AirResult
Réconciliation ADR-019 ↔ macro-architecture (décision)
Décision (à graver). L’ADR-019 fige un modèle hybride :
Errnominimaliste en couche 0 ; un type d’erreur enrichi par crate (viathiserror) au-dessus. La macro-architecture pose en plus unAirErrorgénérique dansair-base-lib. On réconcilie ainsi, sans contredire ADR-019 :
AirErrorest la monnaie d’erreur commune d’air-base-libet le futur porteur de la frontière ABI C (où l’on ne peut pas exposer les enums Rust par-crate). Il absorbeErrnoet porte une chaîne de causalité + du contexte.- Les enums d’erreur par crate (idiome ADR-019, via
thiserror) restent valides au-dessus : une crate qui veut un matching exhaustif définit son enum ; il convertit vers/depuisAirErroraux frontières (From).AirErrorne remplace pas ces enums ; il est la base commune, utilisée là où un enum de domaine serait disproportionné, et à la frontière C.
Type
#![allow(unused)]
fn main() {
/// Erreur générique d'`air-base-lib` : catégorie + message + chaîne de causalité.
/// Écrite à la main (pas de `thiserror` ici, cf. convention 6).
pub struct AirError { /* opaque : kind + message + source optionnelle */ }
pub type AirResult<T> = Result<T, AirError>;
/// Catégorie large, stable, mappable depuis `Errno` et exposable en ABI C.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AirErrorKind {
NotFound, // ENOENT
PermissionDenied, // EACCES, EPERM
AlreadyExists, // EEXIST
InvalidInput, // EINVAL
Interrupted, // EINTR
WouldBlock, // EAGAIN/EWOULDBLOCK
Unsupported, // ENOSYS, EOPNOTSUPP
TimedOut, // ETIMEDOUT
OutOfMemory, // ENOMEM
BrokenResource, // EPIPE, ECONNRESET…
Io, // fourre-tout E/S kernel
InvalidData, // données mal formées (UTF-8, parsing) — pas d'Errno
Other,
}
impl AirError {
/// Crée une erreur avec catégorie + message.
pub fn new(kind: AirErrorKind, message: impl Into<String>) -> Self;
/// Catégorie.
pub fn kind(&self) -> AirErrorKind;
/// Message (sans la chaîne de causalité).
pub fn message(&self) -> &str;
/// L'`Errno` kernel d'origine s'il existe (remonte la chaîne de `source`).
pub fn errno(&self) -> Option<Errno>;
/// Attache une source (chaînage de causalité, `Principe 4`).
pub fn with_source(self, source: impl Into<Box<dyn core::error::Error + Send + Sync + 'static>>) -> Self;
}
impl From<Errno> for AirError { /* mappe l'errno → kind, conserve l'Errno en source */ }
impl core::fmt::Display for AirError { /* message + « : » + chaîne source */ }
impl core::error::Error for AirError {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)>;
}
}
Attache de contexte ergonomique
#![allow(unused)]
fn main() {
/// Extension sur `Result` pour attacher du contexte sans cérémonie (Principe 4).
pub trait ResultContextExt<T> {
/// Enrobe l'erreur courante d'un message de contexte (la place en `source`).
fn context(self, message: impl Into<String>) -> AirResult<T>;
/// Variante paresseuse (message construit seulement en cas d'erreur).
fn with_context<F, S>(self, f: F) -> AirResult<T>
where F: FnOnce() -> S, S: Into<String>;
}
impl<T, E> ResultContextExt<T> for Result<T, E>
where E: Into<Box<dyn core::error::Error + Send + Sync + 'static>> { /* … */ }
}
Mapping Errno → AirErrorKind. Table déterministe (ENOENT→NotFound,
EACCES/EPERM→PermissionDenied, EEXIST→AlreadyExists, EINVAL→InvalidInput,
EINTR→Interrupted, EAGAIN→WouldBlock, ENOSYS/EOPNOTSUPP→Unsupported,
ETIMEDOUT→TimedOut, ENOMEM→OutOfMemory, EPIPE/ECONNRESET→BrokenResource,
défaut→Io). L’Errno exact reste toujours récupérable via errno() (placé en
source), pour le matching fin (réagir à EAGAIN/EINTR — cf. ADR-019
alternative 2 rejetée : on ne perd jamais le code précis).
Note (extension errno couche 0, prompt
074). La table ci-dessus est désormais satisfiable par nom :ETIMEDOUT,EPIPE,ECONNRESET(et l’aliasEWOULDBLOCK ≡ EAGAIN) ont été ajoutés aux constantesair_sys_types::Errno(extension additive du sceau couche 0, pilotée par ce contrat). Le mapping n’a donc pas besoin de numéros bruts en couche 1.
# Errors. AirError n’échoue pas à se construire. La construction
d’AirError::new est infaillible.
Exemple.
#![allow(unused)]
fn main() {
use air_base_lib::{AirResult, ResultContextExt};
fn open_config(path: &AirPath) -> AirResult<Vec<u8>> {
let fd = air_sys_syscall::fs::openat(/* … */) // -> Result<_, Errno>
.with_context(|| format!("ouverture de la config {path}"))?; // Errno -> AirError + contexte
// …
}
}
Tests. Table de mapping Errno→kind (chaque entrée) ; errno() remonte bien
la chaîne ; Display rend la chaîne complète ; context/with_context
(paresseux non évalué en cas de succès) ; source() chaîné sur ≥ 2 niveaux.
Section 2 — Chaînes & chemins
2.1 AirLocale — identifiant de locale (BCP 47)
Frontière de couche (décision). ADR-016 place la cascade de fallback et les formats culturels (AirNumber, AirCurrency, formatage localisé) en couche 2. Mais les opérations locale-aware d’
AirString(casing, collation) etAirDateTimeont besoin du type de locale. On place donc le typeAirLocale(parsing/représentation BCP 47, enrobage deicu4x ::Locale) en couche 1 ; la résolution de la locale effective (système → utilisateur → application, cascade macOS-style) et le formatage culturel restent en couche 2. Couche 1 = le type ; couche 2 = la politique.
#![allow(unused)]
fn main() {
/// Locale BCP 47 (enrobe `icu::locid::Locale`). Pas POSIX (ADR-016).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AirLocale { /* … */ }
impl AirLocale {
/// Parse un identifiant BCP 47 (`"fr-FR"`, `"zh-Hans"`, `"ar"`).
/// # Errors
/// `AirError { kind: InvalidData }` si l'identifiant est mal formé.
pub fn parse(tag: &str) -> AirResult<Self>;
/// La locale racine « und » (undetermined), neutre.
pub fn root() -> Self;
/// Rend l'identifiant BCP 47 canonique.
pub fn to_bcp47(&self) -> String;
pub fn language(&self) -> &str;
pub fn region(&self) -> Option<&str>;
}
}
2.2 AirString — chaîne Unicode (UTF-8 garanti)
Surface Unicode complète (décision de périmètre), adossée à icu4x.
#![allow(unused)]
fn main() {
/// Chaîne UTF-8 valide, Unicode-aware. Enrobe `String`.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AirString { /* String interne, invariant : UTF-8 valide */ }
impl AirString {
// --- Construction ---
pub fn new() -> Self;
pub fn from_str(s: &str) -> Self; // infaillible (déjà UTF-8)
/// Depuis des octets externes (validation, Principe 3).
/// # Errors `InvalidData` si non-UTF-8.
pub fn from_utf8(bytes: Vec<u8>) -> AirResult<Self>;
/// Conversion avec remplacement U+FFFD (jamais d'erreur).
pub fn from_utf8_lossy(bytes: &[u8]) -> Self;
pub fn as_str(&self) -> &str;
pub fn into_bytes(self) -> Vec<u8>;
// --- Normalisation Unicode (icu4x) ---
pub fn normalize(&self, form: NormalizationForm) -> AirString; // NFC/NFD/NFKC/NFKD
pub fn is_normalized(&self, form: NormalizationForm) -> bool;
// --- Casing locale-aware (icu4x) ---
pub fn to_uppercase_in_locale(&self, locale: &AirLocale) -> AirString;
pub fn to_lowercase_in_locale(&self, locale: &AirLocale) -> AirString;
pub fn to_titlecase_in_locale(&self, locale: &AirLocale) -> AirString;
// --- Segmentation (icu4x) ---
pub fn graphemes(&self) -> GraphemeIterator<'_>; // clusters de graphèmes étendus
pub fn words(&self) -> WordIterator<'_>;
pub fn sentences(&self) -> SentenceIterator<'_>;
pub fn grapheme_count(&self) -> usize; // ≠ .len() (octets) ≠ chars
// --- Comparaison locale-aware (icu4x collator) ---
pub fn compare_in_locale(&self, other: &AirString, locale: &AirLocale) -> core::cmp::Ordering;
pub fn eq_ignore_case_in_locale(&self, other: &AirString, locale: &AirLocale) -> bool;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NormalizationForm { Nfc, Nfd, Nfkc, Nfkd }
}
Invariants. UTF-8 valide par construction (le seul point d’entrée faillible
est from_utf8). Eq/Hash portent sur les octets (égalité binaire) ; la
comparaison Unicode/locale passe explicitement par compare_in_locale
(jamais d’égalité « magique » normalisée — l’appelant normalize() d’abord s’il
le veut). grapheme_count est distinct de la longueur en octets et du nombre de
char — documenté pour éviter le piège classique.
Performance. from_str/as_str triviaux. Normalisation/segmentation/
collation : coût icu4x (données chargées à la demande). Pas d’allocation cachée
sur as_str/graphemes (itérateurs empruntés).
2.3 AirPath — chemin (octets, potentiellement non-UTF-8)
Frontière de couche (décision).
AirPathne fait que de la manipulation lexicale (pas d’accès disque). La canonicalisation (résolution de symlinks, vérification de confinement) exige des syscalls → elle vit dansair-filesystem(couche 1, autre crate), pas ici.air-base-lib::AirPath= chemin valeur + opérations pures + pont vers la couche 0.
#![allow(unused)]
fn main() {
/// Chemin de fichier : octets, **peut contenir du non-UTF-8** (Unix). Enrobe `PathBuf`/`OsString`.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AirPath { /* … */ }
impl AirPath {
pub fn from_str(s: &str) -> Self;
pub fn from_bytes(bytes: &[u8]) -> Self; // jamais d'erreur (octets bruts)
pub fn as_bytes(&self) -> &[u8];
// --- Conversion explicite vers/depuis AirString (Principe 3) ---
/// # Errors `InvalidData` si le chemin n'est pas UTF-8.
pub fn to_air_string(&self) -> AirResult<AirString>;
pub fn to_air_string_lossy(&self) -> AirString; // U+FFFD
pub fn from_air_string(s: &AirString) -> Self;
// --- Manipulation lexicale (aucun syscall) ---
pub fn join(&self, component: &AirPath) -> AirPath;
pub fn parent(&self) -> Option<AirPath>;
pub fn file_name(&self) -> Option<&[u8]>;
pub fn extension(&self) -> Option<&[u8]>;
pub fn components(&self) -> ComponentIterator<'_>;
pub fn is_absolute(&self) -> bool;
/// Normalisation **lexicale** (résout `.`/`..` sans toucher au disque).
pub fn normalize_lexically(&self) -> AirPath;
// --- Pont couche 0 (frontière syscall) ---
/// `CString` consommable par les wrappers couche 0 (`openat`…).
/// # Errors `InvalidInput` si le chemin contient un octet NUL interne.
pub fn to_c_string(&self) -> AirResult<CString>;
}
}
Décision. Le pont to_c_string (rejet du NUL interne) est ce que
consommeront air-filesystem/air-process pour appeler la couche 0 : il est ici parce
qu’il est purement lexical (pas de syscall), et centralise la validation NUL une
fois pour toutes.
2.4 AirOsStr / AirOsString — chaîne du système (octets opaques)
Distinct d’
AirPath(décision, remodel pré-sceau env/args). Une variable d’environnement, un argument de programme (argv), une cible dereadlink: ce sont des suites d’octets (non garanties UTF-8) qui ne sont pas des chemins — leur imposerAirPath(qui porte une sémantique lexicale de chemin : séparateurs/,./..,join,parent) serait un abus de type. On introduit le pendant Air destd::ffi::OsStr/OsString: la « chaîne-OS » sans aucune sémantique au-delà d’être des octets opaques. Deux types, patronstdexact :AirOsStremprunté (#[repr(transparent)]sur[u8]) etAirOsStringpossédé (Vec<u8>). Zone 2 ADR-029 : noms miroir destd(OsStr/OsString).
#![allow(unused)]
fn main() {
/// Chaîne-OS empruntée : octets, potentiellement non-UTF-8. Pendant de `std::ffi::OsStr`.
#[repr(transparent)]
#[derive(PartialEq, Eq, Hash)] // + Debug échappé (aucune présomption UTF-8)
pub struct AirOsStr { /* [u8] */ }
impl AirOsStr {
pub fn from_bytes(bytes: &[u8]) -> &AirOsStr; // sûre ; unsafe repr(transparent) INTERNE, sans copie
pub fn as_bytes(&self) -> &[u8];
pub fn to_os_string(&self) -> AirOsString;
pub fn is_empty(&self) -> bool;
pub fn len(&self) -> usize;
}
/// Chaîne-OS possédée. Pendant de `std::ffi::OsString`. `Deref<Target = AirOsStr>`.
#[derive(Clone, PartialEq, Eq, Hash)] // + Debug échappé, Default (vide)
pub struct AirOsString { /* Vec<u8> */ }
impl AirOsString {
pub fn new() -> AirOsString; // vide
pub fn from_bytes(bytes: &[u8]) -> AirOsString; // copie
pub fn as_os_str(&self) -> &AirOsStr;
pub fn as_bytes(&self) -> &[u8];
pub fn into_bytes(self) -> Vec<u8>;
}
}
Consommateurs (signatures figées pré-sceau). air_runtime::args::get
→ Option<&'static AirOsStr> (zéro-copie ; argv immortel, jamais muté) ;
air_env::get(&AirOsStr) -> Option<AirOsString> (copie possédée race-safe,
façon std::env::var_os : aucune référence rendue ne survit à un futur set_var) ;
air_env::vars() en paires (AirOsString, AirOsString) ; AirCommand::env(&AirOsStr, &AirOsStr). Aucune fonction unsafe exposée (l’unsafe repr(transparent)
est interne à from_bytes, avec // SAFETY:).
Tests (section 2). Round-trips AirString::from_utf8 (valide/invalide),
from_utf8_lossy (U+FFFD), normalisation idempotente (normalize(NFC) ∘
normalize(NFC) = normalize(NFC)) et vecteurs Unicode connus ; casing turc
(i/İ) locale-dépendant (tr vs en) ; grapheme_count sur emoji ZWJ /
combinaisons ; collation fr (accents) ; AirPath non-UTF-8 (octets 0x80),
to_c_string rejette le NUL interne, join/parent/normalize_lexically
inverses. Fuzzing : AirString::from_utf8 et AirPath::from_bytes +
normalize (données externes, Principe 3) — jamais de panique.
Section 3 — Temps
Distinction cardinale (macro-architecture). Temps monotone (
AirInstant, pour mesurer des durées, ne recule jamais, sans rapport au calendrier) vs temps calendaire (AirDateTime, mur, sujet aux sauts d’horloge/NTP/DST). On ne les confond jamais : pas d’« instant » converti en date sans passer par une horloge mur explicite.
Frontière de couche (décision). Le formatage localisé d’une date (rendu en chaîne selon une locale et un motif culturel) relève de la couche 2 (ADR-016, formats culturels via
air-runtime). En couche 1,AirDateTimeest le type valeur calendaire (champs, arithmétique, conversion de calendriers viaicu4x), sans formatage localisé. La surfaceicu4x« complète » au sens couche 1 = normalisation/segmentation/collation (section 2) + calendriers (ici) ; le formatage reste couche 2.
3.1 AirInstant / AirDuration
#![allow(unused)]
fn main() {
/// Point monotone (consomme l'horloge `CLOCK_MONOTONIC` de la couche 0).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct AirInstant { /* … */ }
/// Durée signée bornée (enrobe une représentation ns en i128 ou (secs,nanos)).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct AirDuration { /* … */ }
impl AirInstant {
/// Maintenant (monotone). Consomme la couche 0.
/// # Errors `AirError` si l'horloge kernel échoue (très rare).
pub fn now() -> AirResult<Self>;
/// Durée écoulée depuis `earlier` (saturating si `self < earlier`).
pub fn duration_since(&self, earlier: AirInstant) -> AirDuration;
pub fn checked_add(&self, d: AirDuration) -> Option<AirInstant>; // Principe 2
pub fn saturating_add(&self, d: AirDuration) -> AirInstant;
}
impl AirDuration {
pub const ZERO: Self;
pub fn from_secs(s: u64) -> Self;
pub fn from_millis(ms: u64) -> Self;
pub fn from_nanos(ns: u64) -> Self;
pub fn as_secs_f64(&self) -> f64;
pub fn checked_add(&self, other: AirDuration) -> Option<AirDuration>;
pub fn checked_sub(&self, other: AirDuration) -> Option<AirDuration>;
}
/// Point d'horloge murale (temps depuis l'époque UNIX, `CLOCK_REALTIME`) —
/// équivalent brut de `std::time::SystemTime`. Distinct d'`AirInstant`
/// (monotone) et d'`AirDateTime` (calendaire, au-dessus). Peut **reculer**.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct AirSystemTime { /* nanos: i128 depuis l'époque UNIX */ }
impl AirSystemTime {
pub const UNIX_EPOCH: AirSystemTime;
/// Maintenant (horloge murale). Consomme la couche 0 (`CLOCK_REALTIME`).
/// # Errors `AirError` si l'horloge kernel échoue (très rare).
pub fn now() -> AirResult<Self>;
/// Durée écoulée depuis `earlier`, ou **erreur** si l'horloge a reculé
/// (`earlier > self`) — pas de négatif silencieux, pas de panique.
/// # Errors `InvalidInput` si `earlier > self`.
pub fn duration_since(&self, earlier: AirSystemTime) -> AirResult<AirDuration>;
pub fn checked_add(&self, d: AirDuration) -> Option<AirSystemTime>; // Principe 2
pub fn checked_sub(&self, d: AirDuration) -> Option<AirSystemTime>; // Principe 2
/// Composantes époque UNIX (comme `timespec` / `SystemTime`).
pub fn from_unix(seconds: i64, nanoseconds: u32) -> Self;
pub fn as_unix_seconds(&self) -> i64; // division euclidienne
pub fn subsec_nanos(&self) -> u32; // reste euclidien ∈ [0, 1e9)
}
/// Sommeil (libc `nanosleep` / PAL `thread::sleep`), via `clock_nanosleep`
/// (couche 0) contre une échéance **absolue monotone** (pas de dérive).
/// # Errors `InvalidInput` (durée/échéance hors amplitude) ou `AirError`
/// mappée depuis l'`Errno` couche 0 (hors `EINTR`, retenté).
pub fn sleep(duration: AirDuration) -> AirResult<()>;
pub fn sleep_until(deadline: AirInstant) -> AirResult<()>;
}
Décisions. AirInstant ne s’expose pas en valeur absolue (pas d’« epoch
monotone » comparable entre processus) — uniquement des différences. Arithmétique
checked_*/saturating_* explicite (Principe 2), jamais d’opérateur nu qui
panique.
Horloge murale (AirSystemTime). Socle brut sous le calendaire
AirDateTime (qu’il ne remplace pas). Contrairement au monotone, elle peut
reculer (NTP, réglage manuel) : duration_since renvoie donc un AirResult et
refuse honnêtement une durée négative (InvalidInput) plutôt que de mentir ou de
paniquer. Les composantes brutes (from_unix/as_unix_seconds/subsec_nanos,
découpage euclidien ⇒ sous-seconde toujours dans [0, 1e9), même avant 1970)
alimentent les deux faces : timespec/time_t côté libc, SystemTime côté PAL.
Sommeil (sleep/sleep_until). Attente absolue contre CLOCK_MONOTONIC
(sleep(d) = sleep_until(now + d)) : viser un point fixe élimine la dérive.
Retry EINTR = responsabilité de la couche 1 : la couche 0 remonte EINTR
sans jamais retenter (ADR-021, convention 2) ; la boucle de sleep_until
re-appelle clock_nanosleep avec la même échéance absolue jusqu’au succès
(correct par construction, aucune dérive). Une échéance déjà passée rend
immédiatement Ok(()). no_std-propre (zéro alloc), exposable aux deux faces.
3.2 AirDateTime / AirCalendar
#![allow(unused)]
fn main() {
/// Calendrier (icu4x). Grégorien par défaut, autres disponibles (ADR-016).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AirCalendar { Gregorian, Buddhist, Japanese, Islamic, Hebrew, Persian, /* … icu4x */ }
/// Date-heure calendaire (mur). Enrobe un `icu::calendar::DateTime`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AirDateTime { /* … */ }
impl AirDateTime {
/// Maintenant, horloge mur (`CLOCK_REALTIME`, couche 0), calendrier grégorien UTC.
/// # Errors `AirError` si l'horloge kernel échoue.
pub fn now_utc() -> AirResult<Self>;
/// Construit depuis des composantes, dans un calendrier donné.
/// # Errors `InvalidInput` si la date est invalide (ex. 30 février).
pub fn from_components(cal: AirCalendar, year: i32, month: u8, day: u8,
hour: u8, minute: u8, second: u8) -> AirResult<Self>;
/// Convertit vers un autre calendrier (même instant, représentation différente).
pub fn to_calendar(&self, cal: AirCalendar) -> AirDateTime;
// Accès aux champs
pub fn year(&self) -> i32;
pub fn month(&self) -> u8;
pub fn day(&self) -> u8;
pub fn hour(&self) -> u8;
pub fn minute(&self) -> u8;
pub fn second(&self) -> u8;
// Arithmétique calendaire (déléguée à icu4x, gère les irrégularités de calendrier)
pub fn add_days(&self, days: i64) -> AirResult<AirDateTime>;
// PAS de méthode de formatage localisé ici → couche 2.
}
}
Tests (section 3). Monotonie d’AirInstant::now (deux appels successifs
non décroissants) ; duration_since saturant ; arithmétique checked_*
(débordement → None) ; AirDateTime round-trip de composantes ; rejet du
30 février ; conversion grégorien↔autre calendrier (vecteurs icu4x connus) ;
absence de formatage localisé (vérifier qu’aucune API de rendu localisé n’est
exposée). Property-based : from_secs(n).as_secs_f64() ≈ n.
Horloge murale. AirSystemTime::now > UNIX_EPOCH et plausible (postérieur à une
date récente) ; duration_since cas normal et cas reculé (earlier > self →
erreur) construits déterministement via from_unix ; checked_add/checked_sub
(normal + débordement → None) ; round-trip des composantes époque (positives et
négatives).
Sommeil. sleep(petite durée) mesuré ≥ la durée demandée ; sleep_until
d’une échéance déjà passée rend immédiatement Ok ; rejet des durées/échéances
hors amplitude. Branche retry EINTR couverte par injection de faute (doctrine
Air) : un signal réel (SIGUSR1 via tgkill, handler sans SA_RESTART) tombe
en plein clock_nanosleep ; la boucle re-appelle avec la même échéance absolue
et termine à l’heure malgré l’interruption. Les helpers de classification/mapping
d’erreur de sommeil sont testés unitairement (cross-clock EINVAL, variantes
SleepError).
Section 4 — Encodage : base64 / hex (encoding)
Extension du périmètre du cœur, actée le 2026-06-15. Le module encoding
(sous-modules base64 et hex) entre dans le cœur d’air-base-lib,
std-only et sans dépendance.
Rationale. L’analyse « AirData / CFData » a conclu qu’Air n’introduit pas
de wrapper d’octets : std couvre déjà le besoin (&[u8]/Vec<u8>, chunks,
from_utf8, to/from_{le,be}_bytes). Le seul trou réel côté octets est
l’absence de base64 et de hex ; on les ajoute donc ici, et rien d’autre.
Le vocabulaire d’octets reste celui de std (&[u8]/Vec<u8>/impl AsRef<[u8]>).
#![allow(unused)]
fn main() {
pub mod base64 {
/// RFC 4648 §4 (standard, alphabet `+/`) ou §5 (URL-safe, alphabet `-_`).
pub enum Base64Alphabet { Standard, UrlSafe }
/// Bourrage `=` : émis/exigé (`Padded`) ou absent/interdit (`Unpadded`).
pub enum Padding { Padded, Unpadded }
/// Total (infaillible) ; sortie ASCII.
pub fn encode(input: impl AsRef<[u8]>, alphabet: Base64Alphabet, padding: Padding) -> String;
/// # Errors `InvalidData` (caractère hors alphabet, longueur/padding invalides, bits de fin non nuls).
pub fn decode(input: &str, alphabet: Base64Alphabet, padding: Padding) -> AirResult<Vec<u8>>;
}
pub mod hex {
/// Total (infaillible) ; sortie **minuscule** (`0-9 a-f`).
pub fn encode(input: impl AsRef<[u8]>) -> String;
/// Accepte maj. et min. # Errors `InvalidData` (longueur impaire, caractère non hexadécimal).
pub fn decode(input: &str) -> AirResult<Vec<u8>>;
}
}
Choix retenus (Passe 1 — à valider). Encodage fait main (table d’alphabet
explicite), std-only (règle des 80 %, ADR-024) ; variante base64 + politique de
padding explicites à l’appel (pas de défaut caché, Principe 7) ; décodage
strict (« parse, don’t validate », Principe 4) → erreurs en
AirErrorKind::InvalidData (ADR-019, variant existant réutilisé) ; familles
exposées namespacées (encoding::base64 / encoding::hex, pas d’aplatissement
pour éviter la collision encode/decode).
Délibérément hors v1. Encodage incrémental/streaming (one-shot ; le
chunking reste l’affaire des slices std) ; autres bases (base32/58/85) ;
constant-time — base64/hex ne sont pas des primitives crypto et ne
garantissent pas un temps constant ; l’encodage de secrets sensibles au canal
temporel relève d’air-crypto. Ces non-objectifs sont assumés, pas des oublis.
Tests (section 4 — Passe 2). Vecteurs RFC 4648 (KAT) base64 standard/URL-safe
padded/unpadded et hex ; property-based round-trip decode(encode(x)) == x ;
fuzzing du décodage (données externes, Principe 3) ; rejet exhaustif des
entrées invalides (alphabet, longueur, padding).
Récapitulatif du cœur air-base-lib
| Domaine | Types / API principaux |
|---|---|
| Erreurs | AirError, AirResult, AirErrorKind, From<Errno>, ResultContextExt::{context, with_context} |
| Locale | AirLocale (type seul ; résolution/formatage → couche 2) |
| Chaînes | AirString (UTF-8, normalisation, casing locale, segmentation, collation), NormalizationForm |
| Chemins | AirPath (octets, lexical, pont to_c_string), conversions AirString↔AirPath |
| Temps | AirInstant, AirDuration (monotone) ; AirSystemTime (horloge murale) ; sleep/sleep_until (nanosleep, retry EINTR couche 1) ; AirDateTime, AirCalendar (calendaire, sans formatage) |
| Encodage | encoding::base64 (Base64Alphabet, Padding, encode/decode), encoding::hex (encode/decode) — std-only, sans dépendance |
Différé : AirLog, AirUuid/AirId128/AirMonotonicId, AirConfig, et la
surface ABI C.
Stratégie de tests (cœur)
- Couverture 100 % lignes + branches (Principe 1, couche fondatrice), mesurée comme la couche 0.
- Unitaires : table de mapping
Errno→AirErrorKindexhaustive ; invariantsAirString/AirPath; arithmétique temporelle (bordschecked_*). - Property-based (proptest) : normalisation idempotente ;
from_utf8∘into_bytesround-trip ;join/parent; durées. - Fuzzing (cargo-fuzz) :
AirString::from_utf8,AirPath::from_bytes,AirString::normalize,AirLocale::parse(données externes, Principe 3). - Vecteurs
icu4x: casing turc, collation française, segmentation emoji/ZWJ, conversions de calendriers — comparés à des valeurs de référence. - Doctests : chaque exemple de la spec compile et passe.
Décisions de fond (cœur air-base-lib)
AirErrorgénérique réconcilie ADR-019 : monnaie commune + futur porteur ABI C ; les enums par-cratethiserrorrestent l’idiome au-dessus ; l’Errnoexact est toujours récupérable (errno()), on ne perd jamais le code kernel.AirErrorécrit à la main (pas dethiserrordansair-base-lib) pour rester simple à exposer en ABI C (macro-architecture).AirLocale(type) en couche 1, résolution + formatage en couche 2 : couche 1 = le type, couche 2 = la politique de cascade (ADR-016).AirString(UTF-8) ≠AirPath(octets) strictement distincts (Principe 3) ; conversions explicites, jamais implicites ;Eqbinaire, comparaison Unicode/locale explicite.AirPathpurement lexical ; la canonicalisation (syscalls) est dansair-filesystem. Pontto_c_string(validation NUL) centralisé ici.- Temps monotone vs calendaire jamais confondus ; pas de formatage de date localisé en couche 1 (→ couche 2) ; arithmétique défensive (Principe 2).
icu4x= unique dépendance externe structurante du cœur (exception 80 % déjà actée, ADR-016).
Décision enregistrée pour la spec AirLog (différée)
AirLog écrira vers journald via le protocole socket natif
(/run/systemd/journal/socket), sans dépendre d’une bibliothèque systemd →
la couche 1 reste pure (pas de dépendance vers la couche 2). À détailler dans la
spec logging à venir.
Travail à reprendre
- Specs
air-base-libsuivantes : logging (AirLog, décision journald-socket ci-dessus), identifiants (AirUuidv7,AirId128,AirMonotonicId), configuration (AirConfigTOML/XDG). - Surface ABI C (
libair-base.so,air_*, versioned symbols, conformité ABI — ADR-012/ADR-027), une fois l’API Rust du cœur stabilisée. - Autres crates couche 1 :
air-filesystem,air-process,air-socket,air-crypto,air-device,air-thread,air-memory.
Licence du document : MPL 2.0
Statut : Spécification technique du cœur de air-base-lib (couche 1). API Rust ;
ABI C différée. Établit le format de spec couche 1.
Spec couche 1 — air-base-lib (services : logging, identifiants)
Spécification technique — Version 1.1 (2026-06-25). Couche 1 « Primitives
système ». Fait suite à air-base-lib-core.md (erreurs, chaînes/chemins, temps).
Périmètre
air-base-libservices = logging (§1) + identifiants (§2). La §3 Configuration est SUPERSEDED (ADR-033/040/041) : la config devient la crate dédiéeair-config(compilateur + artefact Cap’n Proto), horsair-base-lib. Voir l’encadré en tête de §3.
Position et méthode
Ce document complète air-base-lib avec ses services : journalisation
structurée (AirLog), identifiants (AirUuid, AirId128, AirMonotonicId),
configuration (AirConfig). Mêmes conventions que le cœur (Rust idiomatique,
aucun unsafe exposé, couverture 100 %, nommage ADR-029, arithmétique et
encodages défensifs).
API Rust d’abord ; la surface ABI C (libair-base.so, ADR-012/027) reste
différée à une passe dédiée. Méthode doc-d’abord : ce contrat est validé avant
implémentation.
C’est ici qu’
air-base-libconsomme réellement la couche 0 (le cœur ne la touchait qu’aux horloges). Carte de consommation (utile pour le journal de dette doc lors de l’implémentation) :
AirLog→air-sys-syscall::net(socketAF_UNIX/SOCK_DGRAM,sendmsg
SCM_RIGHTS),::mem(memfd_createpour les grandes entrées),fcntl(scellement du memfd) ;AirUuid→::system::getrandom+ horloge (AirInstant/AirDateTimedu cœur) ;AirId128→::fs(lecture de/etc/machine-id). Chaque fois que l’implémentation devra descendre dans le code source d’une de ces primitives faute de doc suffisante, on le consigne : c’est le signal de dette doc couche 0 à amender.
Dépendances assumées (règle des 80 %, ADR-024)
- Aucune dépendance systemd/
libsystemd:AirLogparle le protocole socket natif de journald ;AirId128lit/etc/machine-idcomme un fichier. La couche 1 reste pure (pas de dépendance montante vers la couche 2). serde+toml: nécessaires àAirConfig. Isolés derrière une feature cargoconfig-toml(cf. décision §3) pour que la fondation reste minimale et C-ABI-friendly ; audit 80 % à mener à l’ajout (serde/toml sont consommés largement → vraisemblablement conformes ou exception justifiée, à documenter dansDEPENDENCIES.md/EXCEPTIONS.md).tracing: seulement si le ponttracingest activé (featuretracing-bridge, cf. §1.4), sinon absent.- Pas de proc-macro Air (cohérent cœur).
Section 1 — Journalisation : AirLog
1.1 Décision de couche (rappel, gravée)
AirLogécrit vers journald via le protocole socket natif documenté (/run/systemd/journal/socket,AF_UNIX/SOCK_DGRAM), sans dépendre d’une bibliothèque systemd. La couche 1 reste pure ; journald reste un détail kernel/IPC consommé via la couche 0, pas une dépendance versair-systemd(couche 2). Cohérent ADR-005 (intégration journald) sans inversion de couche.
1.2 API
#![allow(unused)]
fn main() {
/// Niveaux alignés sur les priorités syslog/journald (0..7).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum AirLogLevel {
Emergency = 0, Alert = 1, Critical = 2, Error = 3,
Warning = 4, Notice = 5, Info = 6, Debug = 7,
}
/// Logger structuré. Cible journald (socket natif). `Clone` bon marché (handle partagé).
#[derive(Clone)]
pub struct AirLog { /* socket + namespace + champs par défaut */ }
impl AirLog {
/// Ouvre un logger vers journald, dans un **namespace** donné (`None` = défaut).
/// # Errors `AirError` si le socket journald est indisponible.
pub fn open(namespace: Option<&str>) -> AirResult<Self>;
/// Émet une entrée : message + champs structurés. Le champ `PRIORITY` est
/// dérivé du `level`, `MESSAGE` du message ; les champs custom complètent.
pub fn log(&self, level: AirLogLevel, message: &str, fields: &AirLogFields);
/// Raccourcis ergonomiques.
pub fn info(&self, message: &str);
pub fn warning(&self, message: &str);
pub fn error(&self, message: &str);
pub fn debug(&self, message: &str);
/// Retourne un logger enfant avec des **champs par défaut** attachés à toutes
/// les entrées (contexte de service, identifiants de requête…).
pub fn with_default_fields(&self, fields: AirLogFields) -> AirLog;
}
/// Champs structurés (clé/valeur). Clés validées au format journald.
#[derive(Debug, Clone, Default)]
pub struct AirLogFields { /* … */ }
impl AirLogFields {
pub fn new() -> Self;
/// Ajoute un champ texte. La clé doit être `[A-Z][A-Z0-9_]*` (convention
/// journald) ; sinon le champ est **rejeté silencieusement** et l'incident
/// noté (un log ne doit jamais faire échouer le chemin appelant).
pub fn with(self, key: &str, value: &str) -> Self;
/// Champ binaire (valeurs avec retours-ligne/octets bruts → format binaire
/// journald : `KEY\n` + longueur LE 64 bits + données).
pub fn with_bytes(self, key: &str, value: &[u8]) -> Self;
}
}
1.3 Protocole journald (ce que l’implémentation doit faire)
- Petites entrées : un datagramme
AF_UNIXvers le socket journal, corps = suite de lignesCLÉ=valeur\n; valeurs multi-lignes/binaires → formeCLÉ\n+ longueur little-endian 64 bits + octets. Champs obligatoires :MESSAGE=,PRIORITY=(0..7). - Grandes entrées (datagramme trop gros,
EMSGSIZE) : repli memfd — créer unmemfd_create(couche 0mem), y écrire l’entrée, le sceller (fcntlF_SEAL), puis l’envoyer enSCM_RIGHTSviasendmsg(couche 0net). C’est le protocole documenté de journald pour les entrées volumineuses. - Non-bloquant et non-fatal : journald injoignable ⇒ l’entrée est perdue
silencieusement (compteur de pertes), jamais d’erreur propagée à
l’appelant — logger ne doit pas casser la logique métier.
open()peut échouer (diagnostic initial), maislog()n’échoue jamais.
1.4 Pont tracing (optionnel, feature tracing-bridge)
Décision. Le cœur d’
AirLog(API native + sink journald) est le primaire et n’impose aucune dépendancetracing. La macro-architecture veut une intégration avectracingstandard Rust : on la fournit comme adaptateur optionnel (untracing_subscriber::Layerqui réémet versAirLog) derrière la featuretracing-bridge, pour ne pas imposertracingà tous les consommateurs (notamment C-ABI). Spécifié ici dans sa forme ; activable au besoin.
1.5 Tests / décisions
- Intégration : un journald de test (ou un socket
AF_UNIXfactice qui capture les datagrammes) reçoitMESSAGE/PRIORITY/champs ; vérifier l’encodage texte et binaire (valeur multi-lignes), le repli memfd + SCM_RIGHTS sur grande entrée, la validation des clés, les champs par défaut hérités, et quelog()n’échoue jamais (socket fermé → perte comptée). - Fuzzing : l’encodeur d’entrée (clé/valeur arbitraires → trame journald) ne panique jamais et n’émet pas de trame malformée.
- Décisions : niveaux = priorités syslog ; clés invalides rejetées sans
paniquer ; logger non-fatal ;
tracingoptionnel ; zéro dépendance systemd.
Section 2 — Identifiants
#![allow(unused)]
fn main() {
/// UUID 128 bits. v7 (ordonné dans le temps) préféré (ADR — observabilité/tri).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct AirUuid([u8; 16]);
impl AirUuid {
/// Génère un UUID **v7** : timestamp ms (horloge mur, cœur) + aléa
/// (`getrandom`, couche 0). Monotone-friendly pour les index.
/// # Errors `AirError` si l'entropie ou l'horloge échoue.
pub fn new_v7() -> AirResult<Self>;
/// Génère un UUID **v4** (purement aléatoire) si l'ordre temporel n'est pas voulu.
pub fn new_v4() -> AirResult<Self>;
pub fn as_bytes(&self) -> &[u8; 16];
pub fn to_hyphenated(&self) -> String; // 8-4-4-4-12
/// # Errors `InvalidData` si la chaîne n'est pas un UUID valide.
pub fn parse(s: &str) -> AirResult<Self>;
}
/// Identifiant 128 bits générique (concept `sd-id128`), **sans dépendance systemd**.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct AirId128([u8; 16]);
impl AirId128 {
/// L'ID machine, lu depuis `/etc/machine-id` (fichier, couche 0 `fs`).
/// # Errors `AirError` (NotFound/InvalidData) si absent ou mal formé.
pub fn machine_id() -> AirResult<Self>;
pub fn as_bytes(&self) -> &[u8; 16];
pub fn to_hex(&self) -> String; // 32 hex, comme machine-id
}
/// Compteur monotone **local au processus** (pas global), pour corréler des
/// événements dans un même run. Atomique, jamais réutilisé dans le processus.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct AirMonotonicId(u64);
impl AirMonotonicId {
pub fn next() -> Self; // incrément atomique global au processus
pub fn value(&self) -> u64;
}
}
Décisions.
- Pas de
sd-id128:AirId128::machine_idlit/etc/machine-iddirectement (pureté de couche). 2. La dérivation « app-specific » d’un ID machine (équivalentsd_id128_get_machine_app_specific, qui repose sur un HMAC) est différée à un module consommantair-crypto(sibling couche 1) — elle n’a pas sa place dans la fondation sans crypto. 3. L’aléa vient toujours degetrandom(couche 0), jamais d’un PRNG userspace (cohérent ADR-016/sécurité). 4.AirMonotonicIdest local au processus, pas un identifiant distribué.
Tests : v7 monotone-croissant sur la milliseconde + format correct (version
7, variant) ; v4 (version 4) ; round-trip to_hyphenated/parse ; rejet de
chaînes invalides ; machine_id (fichier de test) + rejet d’un contenu mal
formé ; AirMonotonicId::next strictement croissant, thread-safe (property-based
concurrent). Fuzzing : AirUuid::parse (données externes).
Section 3 — Configuration : AirConfig — ⚠️ SUPERSEDED (ne plus implémenter ici)
⚠️ SECTION OBSOLÈTE (2026-06-25). Cette section (v1.0, antérieure aux ADRs de configuration) décrit un modèle abandonné : un
AirConfigTOML/serde derrière une feature, dansair-base-lib. Elle est superseded par :
- ADR-033 — la config a pour source de vérité une source typée compilée en artefact binaire reproductible ; le texte n’est qu’une projection. Ce n’est PAS un parseur TOML chargé au runtime.
- ADR-040 — l’artefact est en Cap’n Proto, dans une crate dédiée
air-config(PAS dansair-base-lib, pour ne pas l’alourdir) ; TOML/JSON ne survivent que comme projections d’import/export. (Addendum :capnpcexige le tool C++ au build → politique « code généré committé ».)- ADR-041 — coexistence
/etc(projection générée, lecture seule sélective,air-configseul écrivain).Conséquence pratique :
air-base-libservices = logging (§1) + identifiants (§2) UNIQUEMENT. La configuration est le chantierair-config(crate couche 1 dédiée, à spécifier). Niserdenitomlne sont ajoutés àair-base-lib. Le contenu ci-dessous est conservé pour mémoire historique.
3.1 Décision (OBSOLÈTE — cf. encadré ci-dessus) : API dans la fondation, backend sérialisation feature-gated
Tension à trancher : la macro-architecture place
AirConfigdansair-base-lib, mais la fondation se veut minimale et C-ABI-friendly (le cœur n’a queicu4x). OrAirConfigveutserde+toml(etserdederive est une proc-macro). Décision retenue : l’APIAirConfig+ la résolution de chemins XDG vivent dansair-base-lib(purement logiques) ; le backend de (dé)sérialisation TOML/serde est derrière la feature cargoconfig-toml(activée par défaut pour les consommateurs Rust, désactivable pour un profil minimal/C). Ainsi la fondation reste légère par défaut de compilation, sans renoncer à l’APIAirConfigannoncée. (Si tu préfères un crateair-configséparé, c’est l’alternative — à arbitrer ; je recommande la feature, plus simple et cohérent avec « AirConfig ∈ air-base-lib ».)
3.2 API
#![allow(unused)]
fn main() {
/// Résout les emplacements de config selon XDG + conventions Air.
pub struct AirConfigPaths { /* … */ }
impl AirConfigPaths {
/// Pour un composant nommé : `$XDG_CONFIG_HOME/<name>/`, repli `~/.config/<name>/`,
/// puis répertoires système (`$XDG_CONFIG_DIRS`, `/etc/xdg/<name>/`).
pub fn for_component(name: &str) -> Self;
/// Liste ordonnée (la plus prioritaire d'abord) des chemins candidats d'un fichier.
pub fn config_file_candidates(&self, file: &AirPath) -> Vec<AirPath>;
}
/// Config typée d'un composant. `T: Deserialize` (feature `config-toml`).
pub struct AirConfig;
impl AirConfig {
/// Charge et **désérialise** la config d'un composant en `T` validé par le
/// typage Rust + serde. Fusionne selon la cascade XDG (système < utilisateur).
/// # Errors `AirError` (NotFound si aucun fichier ; InvalidData si TOML/typage invalide).
#[cfg(feature = "config-toml")]
pub fn load<T: serde::de::DeserializeOwned>(component: &str) -> AirResult<T>;
/// Variante depuis une source TOML explicite (tests, override).
#[cfg(feature = "config-toml")]
pub fn from_toml_str<T: serde::de::DeserializeOwned>(s: &str) -> AirResult<T>;
}
}
3.3 Décisions / tests
- TOML par défaut (macro-architecture). Format unique en v1 ; pas de YAML/JSON (simplicité, déterminisme).
- Validation par schéma = typage Rust + serde : un champ manquant/du mauvais
type →
AirError { InvalidData }avec le chemin du champ fautif (contexte). - Cascade XDG : système < utilisateur (l’utilisateur surcharge). La résolution
est pure (construction de chemins) ; la lecture effective passe par
air-filesystem(sibling) ou la couche 0fs—AirConfig::loadconsomme l’un des deux (à préciser à l’implémentation :air-filesystemsi disponible, sinonfscouche 0). - Tests : résolution XDG (avec
XDG_CONFIG_HOMEposé/absent,XDG_CONFIG_DIRS) ; désérialisation TOML valide →T; TOML invalide / champ manquant → erreur contextualisée ; cascade système/utilisateur (fusion). Fuzzing : parsing TOML (données externes) ne panique jamais (sous featureconfig-toml).
Récapitulatif des services
| Domaine | Types / API principaux |
|---|---|
| Logging | AirLog, AirLogLevel, AirLogFields (journald socket natif ; pont tracing optionnel) |
| Identifiants | AirUuid (v7/v4), AirId128 (machine-id via fichier), AirMonotonicId |
| Configuration | AirConfig, AirConfigPaths (TOML/serde derrière config-toml, XDG) |
Différé : surface ABI C ; dérivation app-specific de l’ID machine (→ via
air-crypto) ; formats de config autres que TOML.
Consommation de la couche 0 (récap pour la dette doc)
| Service | Primitives couche 0 consommées |
|---|---|
AirLog | net (socket AF_UNIX, sendmsg+SCM_RIGHTS), mem (memfd_create), fcntl (seals) |
AirUuid | system::getrandom, horloge (cœur) |
AirId128 | fs (lecture /etc/machine-id) |
AirConfig | fs / air-filesystem (lecture des fichiers de config) |
Stratégie de tests (services)
- Couverture 100 % lignes + branches (couche fondatrice).
- Unitaires + property-based : niveaux/priorités, validation des clés
journald,
AirUuidv7 monotone,AirMonotonicIdconcurrent, résolution XDG. - Intégration : journald (socket de capture),
machine_id(fichier),AirConfig::load(fixtures TOML + variables XDG). - Fuzzing : encodeur d’entrée journald,
AirUuid::parse, parsing TOML. - Doctests : exemples compilent et passent.
Décisions de fond (services)
AirLog→ journald via socket natif, zéro dépendance systemd ; non-fatal (un log ne casse jamais l’appelant) ; ponttracingoptionnel (feature).AirId128lit/etc/machine-id(passd-id128) ; dérivation app-specific différée àair-crypto.- Aléa toujours via
getrandom(couche 0), jamais de PRNG userspace. AirConfig: API + XDG dans la fondation, backendserde/tomlfeature-gatedconfig-toml(fondation minimale par défaut). Alternative notée : crateair-configséparé.- TOML seul en v1 (déterminisme, simplicité).
Travail à reprendre
- Surface ABI C d’
air-base-lib(cœur + services), une fois l’API Rust stabilisée (ADR-012/027). - Crates couche 1 suivantes :
air-filesystem,air-process,air-socket,air-crypto(dont la dérivation app-specific de l’ID machine),air-device,air-thread,air-memory.
Licence du document : MPL 2.0
Statut : Spécification technique des services de air-base-lib (couche 1). API
Rust ; ABI C différée.
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.
Spec couche 1 — air-memory (allocateurs spécialisés + comptabilité mémoire)
Spécification technique — Version 1.0. Couche 1 « Primitives système ».
Position et méthode
air-memory fournit des allocateurs spécialisés — arène (bump), pool
d’objets, slab — et une comptabilité mémoire par composant (au service du
Principe 9 : tenir les budgets mémoire sur matériel modeste, Pi 4). Elle s’appuie
sur air-base-lib (AirError/AirResult). API Rust d’abord (ABI C
différée). Méthode doc-d’abord.
La crate couche 1 la plus «
unsafeinterne ». Un allocateur manipule de la mémoire brute (pointeur de bump,MaybeUninit, alignement, réutilisation de slots). Conséquences non négociables : aucune fonctionunsafeexposée (frontière couche 1),// SAFETY:sur chaque blocunsafe, Miri obligatoire (détection d’UB sur les internes), property-based sur les séquences alloc/reset, couverture 100 % (Principe 1).
air-memoryv1 ne consomme PAS la couche 0. Backing heap par défaut (allocateur global Rust) → pure-Rust, aucun syscall. Le backingMmapRegion(couche 0) est enfichable mais différé (cf. §5). C’est donc une crate couche 1 qui ne touche pas encore au journal de dette doc couche 0.
Conventions
- Mono-thread par défaut (décision) : les allocateurs allouent via
&mut self(exclusivité garantie par le borrow checker, zéro coût de synchro). Le partage entre threads est à la charge de l’appelant (Mutex, ou une instance par thread). Des variantesSync(lock / free-list atomique) sont différées à un besoin réel (cf. §7). - Comptabilité opt-in : un allocateur peut être rattaché à un
AirMemoryTracker; sans tracker, zéro surcoût. - Nommage ADR-029, arithmétique défensive (Principe 2 :
checked_*sur tout calcul de taille/offset/alignement), aucune dépendance externe en v1.
Section 1 — Comptabilité mémoire par composant
#![allow(unused)]
fn main() {
/// Compteur de mémoire d'un **composant** (nommé). Handle **partageable** et
/// interrogeable depuis un autre thread (compteurs atomiques) — même si les
/// allocateurs qui l'alimentent sont mono-thread.
#[derive(Clone)]
pub struct AirMemoryTracker { /* Arc<{ name, in_use: AtomicUsize, peak, live }> */ }
impl AirMemoryTracker {
pub fn new(component: &str) -> Self;
pub fn name(&self) -> &str;
/// Instantané atomique de la consommation courante.
pub fn usage(&self) -> AirMemoryUsage;
// Internes (`pub(crate)`), appelés par les allocateurs :
// fn record_alloc(&self, bytes: usize); // in_use += ; peak = max ; live += 1
// fn record_free(&self, bytes: usize); // in_use -= ; live -= 1
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AirMemoryUsage {
pub bytes_in_use: usize,
pub bytes_peak: usize,
pub live_allocations: usize,
}
}
Décision. La comptabilité est per-composant par construction : elle mesure
exactement « combien tient ce composant via les allocateurs Air » (la question
budget du Principe 9), pas le heap total du process. Coût : un +atomic par
alloc/free seulement si un tracker est attaché. Un allocateur sans tracker
ne paie rien. (Un éventuel allocateur global traçant exhaustif relève d’un outil
distinct, hors v1 — cf. §7.)
Tests. record_alloc/free mettent à jour in_use/peak/live
correctement (y compris peak monotone) ; usage() cohérent ; interrogeable
depuis un autre thread pendant qu’un allocateur mono-thread l’alimente
(property-based concurrent lecture seule côté tracker).
Section 2 — Arène (bump allocator) : AirArena
#![allow(unused)]
fn main() {
/// Arène à **pointeur de bump** : allocation O(1) par avancement d'un offset ;
/// **libération en masse** via `reset` (rembobine l'offset). Mono-thread.
pub struct AirArena { /* backing + offset + tracker optionnel */ }
impl AirArena {
/// Arène **heap** d'une capacité donnée.
/// # Errors `OutOfMemory` si l'allocation backing échoue.
pub fn with_capacity(capacity: usize) -> AirResult<Self>;
/// Arène sur un backing **enfichable** (cf. §5).
pub fn with_backing(backing: AirBacking) -> AirResult<Self>;
/// Rattache un tracker (builder).
pub fn tracked(self, tracker: &AirMemoryTracker) -> Self;
/// Alloue une valeur dans l'arène ; rend `&mut T` (durée de vie liée à `&mut self`).
/// # Errors `OutOfMemory` si capacité dépassée.
pub fn alloc<T>(&mut self, value: T) -> AirResult<&mut T>;
/// Alloue et copie une tranche `Copy`.
pub fn alloc_slice_copy<T: Copy>(&mut self, src: &[T]) -> AirResult<&mut [T]>;
/// Réserve une tranche **non initialisée** (l'appelant l'initialise).
pub fn alloc_uninit_slice<T>(&mut self, len: usize) -> AirResult<&mut [MaybeUninit<T>]>;
/// **Libère tout** d'un coup (rembobine le bump à 0). Le `&mut self` garantit
/// qu'aucune référence allouée ne survit (borrow checker).
pub fn reset(&mut self);
pub fn bytes_used(&self) -> usize;
pub fn capacity(&self) -> usize;
}
}
Contrat des destructeurs (à graver, précondition documentée).
AirArenaapplique une sémantique de libération en masse :reset/Dropde l’arène n’exécute PAS leDropdes valeurs allouées (rembobinage d’offset, pas de destruction individuelle). Conséquence : n’allouer dans une arène que des types dont sauter leDropest acceptable —Copy/POD, ou données de travail sans ressource possédée. Pour des types qui doivent être détruits (possèdent un FD, un buffer heap…), utiliserAirObjectPool(qui respecteDrop) ou les gérer à la main. Ce contrat est documenté en tête de chaque méthode d’allocation (pas de surprise silencieuse).
Sûreté. Alignement calculé avec align_offset/checked_* (jamais
d’arithmétique de pointeur nue non vérifiée) ; // SAFETY: sur l’écriture dans la
mémoire réservée et la fabrication des références (la mémoire est dans les bornes,
alignée, exclusive via &mut self). Miri sur alloc/reset, alignements
hétérogènes, types de tailles variées.
Performance. alloc = quelques instructions (comparer offset, avancer). Cas
canonique : mémoire de travail par tâche/par frame, libérée d’un coup.
Section 3 — Pool d’objets : AirObjectPool<T>
#![allow(unused)]
fn main() {
/// Pool recyclant des instances de `T` (évite alloc/free répétés d'un type
/// fréquent). **Respecte `Drop`** : les `T` retenus sont détruits au `Drop` du
/// pool ; au retour d'un objet, il est conservé (et réinitialisé si fourni).
/// Handle **mono-thread, clonable** (partage interne `Rc` + `RefCell`).
#[derive(Clone)]
pub struct AirObjectPool<T> { /* Rc<RefCell<{ free: Vec<T>, factory, reset, tracker }>> */ }
impl<T> AirObjectPool<T> {
/// Fabrique appelée quand le pool est vide.
pub fn new(factory: impl Fn() -> T + 'static) -> Self;
pub fn with_capacity(capacity: usize, factory: impl Fn() -> T + 'static) -> Self;
/// Fonction de réinitialisation appliquée au retour d'un objet (builder).
pub fn with_reset(self, reset: impl Fn(&mut T) + 'static) -> Self;
pub fn tracked(self, tracker: &AirMemoryTracker) -> Self;
/// Emprunte un objet (recyclé, sinon fabriqué). Le guard le **rend au pool**
/// à son `Drop`. `&self` (interior mutability) → **plusieurs objets** peuvent
/// être sortis simultanément.
pub fn acquire(&self) -> AirPooled<T>;
pub fn available(&self) -> usize;
}
/// Garde RAII : `Deref`/`DerefMut` vers `T` ; rend l'objet au pool au `Drop`.
pub struct AirPooled<T> { /* Rc<inner> + Option<T> */ }
impl<T> core::ops::Deref for AirPooled<T> { type Target = T; /* … */ }
impl<T> core::ops::DerefMut for AirPooled<T> { /* … */ }
}
Décision. Le pool utilise Rc<RefCell<…>> (mono-thread, pas Arc/Mutex
— zéro coût atomique) ; acquire(&self) permet N objets sortis en même temps
(contrairement à un &mut self qui n’en autoriserait qu’un). C’est le design
utilisable d’un pool. Le guard ne tient qu’un Rc (pas un emprunt du pool).
Sûreté / tests. acquire quand le pool est vide fabrique ; quand non vide
recycle (vérifier l’identité d’objet recyclé) ; reset appliqué au retour ; le
Drop du pool détruit bien tous les objets retenus (Miri : pas de fuite, pas
de double-drop) ; emprunts RefCell jamais en conflit (un seul emprunt à la fois
en interne, court). Property-based : séquences acquire/release arbitraires →
invariant available + sortis = total fabriqué.
Section 4 — Slab : AirSlab<T>
#![allow(unused)]
fn main() {
/// Stockage à **slots**, indices **stables** et **anti-ABA** (clé = index +
/// génération). Même schéma éprouvé que le slab S1 d'io_uring (Temps 1). Mono-thread.
pub struct AirSlab<T> { /* Vec<Entry<T>> + free-list d'index + générations */ }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct AirSlabKey { /* index: u32, generation: u32 (opaque) */ }
impl<T> AirSlab<T> {
pub fn new() -> Self;
pub fn with_capacity(capacity: usize) -> Self;
/// Insère ; rend une clé stable (réutilisation de slot avec génération++).
pub fn insert(&mut self, value: T) -> AirSlabKey;
/// Retire ; `None` si la clé est périmée (slot réutilisé → génération ≠).
pub fn remove(&mut self, key: AirSlabKey) -> Option<T>;
pub fn get(&self, key: AirSlabKey) -> Option<&T>;
pub fn get_mut(&mut self, key: AirSlabKey) -> Option<&mut T>;
pub fn len(&self) -> usize;
pub fn is_empty(&self) -> bool;
}
}
Décision. La génération par slot empêche le bug ABA (une clé d’un slot
libéré puis réutilisé est rejetée — remove/get rendent None). Drop du slab
détruit les valeurs vivantes restantes (respecte Drop). Débordement de
génération géré par wrapping_add documenté (probabilité de collision négligeable,
ou saturation selon décision d’implémentation — à fixer, cf. slab io_uring).
Tests. insert/get/remove ; clé périmée rejetée (test ABA : insert→remove→
insert sur le même slot→ ancienne clé None) ; Drop détruit les vivants (Miri) ;
property-based (suite d’insert/remove → cohérence len / clés valides).
Section 5 — Backing enfichable : AirBacking
#![allow(unused)]
fn main() {
/// Source de mémoire brute d'une `AirArena`. **Enfichable** : heap par défaut ;
/// d'autres backings s'ajoutent **sans casser l'API** (type opaque + constructeurs).
pub struct AirBacking { /* enum interne #[non_exhaustive] : Heap(Box<[u8]>) | … */ }
impl AirBacking {
/// Backing **heap** (bloc `Box<[u8]>` de l'allocateur global).
/// # Errors `OutOfMemory`.
pub fn heap(capacity: usize) -> AirResult<Self>;
pub fn len(&self) -> usize;
// pub fn mmap(region: MmapRegion) -> Self; // DIFFÉRÉ (cf. §7)
}
}
Décision. v1 = heap uniquement, mais l’API est enfichable dès maintenant
(AirBacking opaque, variantes internes #[non_exhaustive]) : ajouter un backing
MmapRegion (couche 0 — aligné page, munmap déterministe,
io_uring-enregistrable au Temps 3a) plus tard ne cassera pas l’API d’AirArena.
Récapitulatif air-memory
| Domaine | API principale |
|---|---|
| Comptabilité | AirMemoryTracker, AirMemoryUsage (per-composant, opt-in, atomique) |
| Arène | AirArena (bump, libération en masse ; ne run pas Drop) |
| Pool d’objets | AirObjectPool<T>, AirPooled<T> (recycle, respecte Drop, Rc/RefCell) |
| Slab | AirSlab<T>, AirSlabKey (slots stables, anti-ABA, respecte Drop) |
| Backing | AirBacking (heap ; mmap différé, API non-cassante) |
Différé : backing MmapRegion, variantes Sync (thread-safe), allocateur
global traçant exhaustif, surface ABI C.
Dépendances (règle des 80 %, ADR-024)
air-base-lib(erreurs). Aucune dépendance externe en v1 (allocateur global Rust +core/alloc). Pas deMmapRegiontant que le backing mmap est différé.
Stratégie de tests
- Couverture 100 % lignes + branches (Principe 1).
- Miri (impératif, c’est du code mémoire bas niveau) :
AirArena(alloc/ alignement/reset),AirObjectPool(pas de fuite/double-drop),AirSlab(slots/ générations/Drop). Aucun UB. - Property-based (proptest) : séquences alloc/reset (arène) ; acquire/release (pool, invariant de conservation) ; insert/remove (slab, clés valides, ABA).
- Unitaires : arithmétique de taille/alignement aux bords (
checked_*, capacité dépassée →OutOfMemory),peakmonotone, clé périmée rejetée. - Doctests : exemples compilent et passent.
- (Pas de fuzzing dédié en v1 : pas de décodage de données externes — les entrées sont des tailles/alignements, couverts par proptest. À ajouter si un backing ingère des données externes.)
Décisions de fond
- Comptabilité per-composant (atomique, opt-in) — répond à la question budget du Principe 9 ; pas un allocateur global (qui mesurerait le total mais attribuerait mal). Zéro coût sans tracker.
- Mono-thread par défaut (
&mut self, zéro synchro) ;Syncdifféré (Principe 5 : ne pas payer l’atomique pour le cas courant des arenas). - Arène = libération en masse,
Dropnon exécuté — contrat documenté (pas de surprise) ; pour les types à détruire →AirObjectPool(qui respecteDrop). - Pool
Rc/RefCell— mono-thread mais N objets sortables (interior mutability), pasArc/Mutex. - Slab anti-ABA (clé = index + génération) — schéma éprouvé du slab io_uring.
- Backing enfichable — heap en v1,
MmapRegionajoutable sans casser l’API.
Travail à reprendre
- Backing
MmapRegion(AirBacking::mmap) — grandes arenas alignées page,munmapdéterministe, buffers io_uring-enregistrables (Temps 3a). Première consommation couche 0 d’air-memory. - Variantes
Syncdes allocateurs (lock / free-list atomique loom-vérifiée) si un besoin de pool partagé apparaît. - Surface ABI C d’
air-memory. - Autres crates couche 1 :
air-process,air-socket,air-crypto,air-device,air-thread; etair-base-lib(ABI C).
Licence du document : MPL 2.0
Statut : Spécification technique d’air-memory (couche 1). API Rust ; ABI C
différée. Backing heap (mmap enfichable différé) ; mono-thread (Sync différé).
Spec couche 1 — air-process (gestion de processus + séparation de privilèges)
Spécification technique — Version 1.0. Couche 1 « Primitives système ».
Renvoi ADR-055 (no_std + environnement). Depuis ADR-055 D2,
air-processest#. L’héritage d’environnement deAirCommand::build_envpne lit plusstd::env::vars_os()mais la crateair-env(ADR-055 D1 :air_env::vars(), octets) — qui vit sousair-processETair-runtimepour casser le cycleair-runtime → air-process. La monnaie d’erreur vient d’air-base-core(ADR-054), elle aussino_std.
Position et méthode
air-process fournit la gestion applicative de processus : lancer / attendre
/ signaler des enfants (AirProcess), des canaux typés (AirPipe), et le helper
de séparation de privilèges drop_privileges (Principe 10), consommé partout
par les services Air. Elle s’appuie sur air-base-lib (AirError) et consomme
massivement la couche 0 : process (clone3/execve/waitid/pidfd/ids/caps/prctl),
ipc (pipe2), security (seccomp/landlock), signal.
API Rust d’abord (ABI C différée). Méthode doc-d’abord.
Décision de couche : synchrone.
AirProcess::waitbloque (viawaitidsur un pidfd). La supervision asynchrone (attente via l’event loop io_uring,waitid/pidfd dans le reactor) relève de la couche 2. Pas de supervision longue durée ici (→ systemd viaair-launchd, couche 5).
Carte de consommation couche 0 (journal de dette doc).
AirProcess→process(clone3, execve, waitid, pidfd_open/send_signal/getfd) ;AirPipe→ipc(pipe2) ;drop_privileges→process(prctl no_new_privs, capset, setgroups/setresgid/setresuid — cf. manque ci-dessous) +security(seccomp, landlock) ; signaux → typeSignal.
⚠️ Manque couche 0 sécurité-critique (4ᵉ découvert) — à combler AVANT drop_privileges
La famille process expose setuid/setgid mais pas setgroups,
setresgid, setresuid. Une réduction de privilèges correcte les exige :
setgroups(&[])— larguer tous les groupes supplémentaires ; les oublier laisse le processus dans des groupes privilégiés (faille de privsep classique).setresgid/setresuid— fixer real + effective + saved ;setuid/setgidseuls ne garantissent pas le saved-set → possibilité de regagner les privilèges. La séquence sûre estsetresgidpuissetresuid.
Action : ajouter setgroups, setresgid, setresuid à la famille process
(couche 0) — une petite extension, prioritaire (sécurité) — avant
d’implémenter drop_privileges. (À côté de fs::inotify, MmapRegion, epoll ;
celui-ci est le plus urgent.)
Section 1 — Lancement : AirCommand / AirProcess
#![allow(unused)]
fn main() {
/// Constructeur d'un processus enfant.
pub struct AirCommand { /* program, args, env, redirections, cwd, child_setup */ }
pub enum AirStdio { Inherit, Null, Piped, Fd(OwnedFd) }
impl AirCommand {
pub fn new(program: &AirPath) -> Self;
pub fn arg(self, arg: &AirPath) -> Self; // arguments = octets (AirPath)
pub fn args<I>(self, args: I) -> Self; // I: IntoIterator<Item = &AirPath>
pub fn env(self, key: &AirOsStr, value: &AirOsStr) -> Self; // env = chaîne-OS (pas un chemin)
pub fn env_clear(self) -> Self; // environnement vide (services confinés)
pub fn current_dir(self, dir: &AirPath) -> Self;
pub fn stdin(self, io: AirStdio) -> Self;
pub fn stdout(self, io: AirStdio) -> Self;
pub fn stderr(self, io: AirStdio) -> Self;
/// Réduction de privilèges à appliquer **dans l'enfant**, après fork, avant
/// execve (le pattern launcher Air). Voir §3.
pub fn drop_privileges_to(self, config: AirPrivilegeDrop) -> Self;
/// Lance via `clone3` + `execve` ; retourne un `AirProcess` (adossé à un pidfd).
/// # Errors `AirError` (programme introuvable, permissions, ressources…).
pub fn spawn(&self) -> AirResult<AirProcess>;
}
/// Handle d'un enfant, **adossé à un pidfd** (pas de course sur PID recyclé).
pub struct AirProcess { /* pidfd: OwnedFd, pid: Pid, stdin/out/err: Option<AirPipe*> */ }
impl AirProcess {
pub fn id(&self) -> Pid;
pub fn pidfd(&self) -> BorrowedFd<'_>; // intégrable dans un event loop (couche 2)
/// Attend la fin (bloquant, `waitid` sur le pidfd).
pub fn wait(&mut self) -> AirResult<AirExitStatus>;
/// Non bloquant : `Some` si terminé, `None` sinon.
pub fn try_wait(&mut self) -> AirResult<Option<AirExitStatus>>;
/// Envoie un signal via `pidfd_send_signal` (jamais sur un PID recyclé).
pub fn signal(&self, signal: Signal) -> AirResult<()>;
pub fn kill(&self) -> AirResult<()>; // SIGKILL
/// Prend les extrémités de pipe créées par `AirStdio::Piped`.
pub fn take_stdin(&mut self) -> Option<AirPipeWriter>;
pub fn take_stdout(&mut self) -> Option<AirPipeReader>;
pub fn take_stderr(&mut self) -> Option<AirPipeReader>;
}
pub struct AirExitStatus { /* opaque */ }
impl AirExitStatus {
pub fn success(&self) -> bool;
pub fn code(&self) -> Option<i32>; // None si tué par signal
pub fn terminating_signal(&self) -> Option<Signal>;
}
}
Sûreté du chemin post-fork / pré-exec (à graver). Entre
clone3etexecve, dans l’enfant, seules des opérations async-signal-safe sont permises (pas d’allocation heap, pas de verrou, pas de code Rust arbitraire). La mise en place des redirections (dup2 des FD), ledrop_privilegeset l’execvesont écrits en conséquence (FD/buffers préparés avant le fork ; aucune alloc dans l’enfant). C’est la zone la plus délicate de la crate ; tests dédiés + revue.
pidfd partout (anti-course). L’enfant est manipulé via son pidfd
(clone3 avec CLONE_PIDFD) : wait/signal ne peuvent jamais cibler un PID
recyclé par un autre processus. C’est l’invariant de robustesse d’AirProcess.
Section 2 — Canaux : AirPipe
#![allow(unused)]
fn main() {
/// Crée une paire de pipe (CLOEXEC par défaut), via `pipe2` (couche 0 `ipc`).
/// # Errors `AirError`.
pub fn air_pipe() -> AirResult<(AirPipeReader, AirPipeWriter)>;
pub struct AirPipeReader { /* OwnedFd */ }
pub struct AirPipeWriter { /* OwnedFd */ }
impl AirPipeReader {
pub fn read(&mut self, buffer: &mut [u8]) -> AirResult<usize>; // EINTR remonté tel quel (ou helper)
pub fn as_fd(&self) -> BorrowedFd<'_>;
}
impl AirPipeWriter {
pub fn write(&mut self, data: &[u8]) -> AirResult<usize>;
pub fn as_fd(&self) -> BorrowedFd<'_>;
}
}
Décision. CLOEXEC par défaut (cohérent couche 0) ; les extrémités sont des
types distincts (Reader/Writer) — un pipe a un sens de circulation, le
typage l’impose. read/write synchrones ; le zéro-copie (splice/tee) reste
en couche 0 / couche 2 selon le besoin.
Section 3 — Séparation de privilèges : drop_privileges
Périmètre acté : orchestration + politiques fournies.
air-processorchestre la séquence atomique dans le bon ordre ; il applique un filtre seccomp et un ruleset Landlock déjà construits par l’appelant (types couche 0security). Le compilateur seccomp déclaratif (ruleset → BPF, par-arch) et le helper Landlock → chemins sont des specs séparées / différées (pas dansair-process).
#![allow(unused)]
fn main() {
/// Configuration d'une réduction de privilèges. Politiques seccomp/landlock
/// **pré-construites** (couche 0) — optionnelles.
pub struct AirPrivilegeDrop {
pub uid: Uid,
pub gid: Gid,
pub supplementary_groups: Vec<Gid>, // souvent vide (largage total)
pub seccomp_filter: Option<SockFprog<'static>>, // BPF déjà formé (couche 0)
pub landlock: Option<LandlockRuleset>, // ruleset déjà construit (couche 0)
pub clear_ambient_caps: bool, // défaut true
}
/// Applique la réduction **dans l'ordre sûr**, **IRRÉVERSIBLE**. À appeler dans
/// l'enfant (après fork, avant execve) ou par un service sur lui-même au démarrage.
///
/// # Errors
/// `AirError` si une étape échoue. **Toute erreur après le début de la séquence
/// est FATALE** : l'état de privilèges est partiellement réduit, l'appelant ne
/// doit pas continuer (typiquement `exit` immédiat). Documenté.
///
/// # Safety / async-signal-safety
/// Pensée pour être appelable dans le chemin post-fork (pas d'alloc dans le cœur
/// de la séquence ; `supplementary_groups` matérialisé **avant**).
pub fn drop_privileges(config: &AirPrivilegeDrop) -> AirResult<()>;
}
L’ordre sûr (le cœur de la valeur ajoutée)
prctl(PR_SET_NO_NEW_PRIVS)— permet d’appliquer seccomp sansCAP_SYS_ADMINet empêche de regagner des privilèges via un binaire setuid.setgroups(supplementary_groups)(souvent&[]) — larguer les groupes supplémentaires, tant qu’on est encore privilégié.setresgid(gid, gid, gid)— real + effective + saved (avant l’uid).setresuid(uid, uid, uid)— real + effective + saved → plus de retour à root.- Réduire les capabilities :
capsetà l’ensemble cible (souvent vide) ; vider l’ambient set (clear_ambient_caps) ; (le bounding set est réduit en amont si nécessaire). - Appliquer le filtre seccomp fourni (
seccomp_set_mode_filter) — aprèsno_new_privs. - Appliquer Landlock fourni (
restrict_self) — irréversible. - Vérification défensive :
getres*/contrôles confirmant qu’on ne peut pas regagner les privilèges (defense in depth, Principe 5 « sur-sécuriser »).
Pourquoi cet ordre. Groupes/gid/uid se règlent tant qu’on est privilégié (étapes 2-4) ; les capabilities après ; seccomp/Landlock en dernier (une fois posés, on ne peut plus défaire).
setres*(et nonset*) garantit le saved-set → impossible de revenir en arrière. C’est précisément ce quedrop_privilegesrend impossible à se tromper pour les services Air.
Dépendance : nécessite l’ajout couche 0 de setgroups/setresgid/setresuid
(cf. encadré en tête). Sans eux, drop_privileges ne peut être correct —
donc à implémenter après cette petite extension couche 0.
Section 4 — Attente : bornée, interruptible, ou infinie
Cadrage détaillé : docs/draft/wait-timeout-design-fr.md. Couche 0 : famille poll
(ppoll), ADR-044.
Motivation — supprimer le hang infini subi
L’incident fork du 2026-06-25 (PR #128) a exposé un footgun : wait() faisait un
waitid bloquant non borné sur le pidfd ; un enfant qui deadlock ne se termine
jamais → le parent hang indéfiniment, sans que le développeur l’ait anticipé. Un
deadlock est indétectable depuis le parent (indiscernable d’un enfant lent) : on ne
peut donc pas le détecter, seulement garantir que le parent ne perd jamais le
contrôle en silence.
Décision : le wait() bloquant non borné est retiré. Il n’existe plus de façon
innocente de bloquer à l’infini. L’attente passe par des portes nommées ; toute
attente potentiellement infinie est consciente, greppable, observable (Principe 4 ;
pas de défaut dangereux silencieux).
Surface
| Méthode | Réveil sur | Bloque indéfiniment ? |
|---|---|---|
try_wait() | (sonde, waitid NOHANG) | non |
wait_timeout(AirDuration) | exit ou échéance | non — Ok(None) à l’échéance |
wait_until(&mut [PollFd]) | exit ou un fd du caller | non — rompue par un wakeups[i] |
wait_forever() | exit seul | oui, assumé (nom greppable, doc qui crie) |
#![allow(unused)]
fn main() {
impl AirProcess {
pub fn wait_timeout(&mut self, timeout: AirDuration) -> AirResult<Option<AirExitStatus>>;
pub fn wait_until(&mut self, wakeups: &mut [PollFd<'_>]) -> AirResult<AirWaitWakeup>;
pub fn wait_forever(&mut self) -> AirResult<AirExitStatus>;
}
pub enum AirWaitWakeup {
Exited(AirExitStatus), // enfant terminé (récolté + mémorisé)
Interrupted, // un wakeups[i] est prêt ; lire ses revents pour savoir lequel
}
}
Sémantique
Ok(None)= expiration (pas une erreur ;AirErrorKind::TimedOutnon utilisé ici — choix BDFL).AirDuration::ZERO⇒ équivaut àtry_wait.wait_until= exposition fidèle deppollmulti-fd : le caller passe ses propres fd de réveil (signalfdADR-020,timerfdwatchdog,eventfdannulation). Lesreventsne sont pas masqués (ADR-032 / doctrine frontière 1).wakeupsvide ⇒ équivaut àwait_forever.wait_foreverréservé au cas « vivacité garantie par un superviseur externe » (ex. watchdog systemd) ; ne revient jamais si l’enfant deadlock — doc qui crie.- Anti-discard (ADR-032) :
try_waitavant de bloquer et après réveilppoll⇒ une terminaison survenue dans la fenêtre est toujours récoltée ; l’expiration n’écrase jamais un statut disponible. EINTRrepris en couche 1 à délai recalculé depuis une échéanceAirInstantmonotone (la valeur ajoutée vs couche 0, qui remonteEINTR). Statut mémorisé.
Idiome canonique — l’attente infinie observable
#![allow(unused)]
fn main() {
let status = loop {
match proc.wait_timeout(AirDuration::from_secs(30))? {
Some(s) => break s,
None => { /* tick : le parent reprend la main — log, vérifie, décide */ }
}
};
}
Infini (un enfant lent n’est jamais tué) mais jamais un hang silencieux : un
deadlock se manifeste (ticks répétés). Le « borne dure puis kill » se compose
(wait_timeout → None → kill → wait_forever) ; pas de helper dédié
(air-process reste minimal). Quand le parent a des événements propres, wait_until
remplace la boucle.
Tests
Expiration (enfant vivant), terminaison dans la fenêtre (statut exact + mémoïsation),
ZERO ≡ try_wait, course « termine à l’échéance » (anti-discard), wait_until
(interruption signalfd, watchdog timerfd, quel fd via revents, wakeups vide ≡
wait_forever), mémoïsation croisée, EINTR (STRUCTURAL si non injectable).
Récapitulatif air-process
| Domaine | API principale | Couche 0 consommée |
|---|---|---|
| Lancement | AirCommand, AirProcess, AirStdio, AirExitStatus | process (clone3/execve/waitid/pidfd_*) |
| Canaux | air_pipe, AirPipeReader/Writer | ipc (pipe2) |
| Privsep | drop_privileges, AirPrivilegeDrop | process (prctl/capset/setgroups/setres*), security (seccomp/landlock) |
| Attente | wait_timeout, wait_until, wait_forever, try_wait, AirWaitWakeup | poll (ppoll, ADR-044), process (waitid/pidfd) |
Différé / séparé : compilateur seccomp déclaratif (ruleset → BPF) ; helper
Landlock → chemins (domaine confinement fs — naturellement côté air-filesystem,
qui fait déjà resolve_within) ; supervision async (couche 2) ; ABI C.
Stratégie de tests
- Couverture 100 % lignes + branches (Principe 1, couche fondatrice).
- Intégration (kernel réel) : spawn d’un binaire (
/bin/true,/bin/echo),wait/try_wait(code de sortie, mort par signal),signal/killvia pidfd, redirections (Piped: écrire stdin / lire stdout, round-trip),env_clear,current_dir.drop_privileges: dans un enfant (fork), vérifier qu’après l’appel l’enfant ne peut pas regagner root (setresuid échoue), que les groupes supplémentaires sont vides, qu’un syscall interdit par le filtre seccomp est bien bloqué, et que Landlock confine. Tests en sous-processus isolés (comme les tests seccomp/landlock de la couche 0), skip propre si l’environnement ne le permet pas (→COVERAGE-EXCEPTIONS.mdprivilège). - Sûreté post-fork : un test cible que le chemin enfant n’alloue pas (allocateur
de test qui panique sur
allocdans la fenêtre fork→exec). - Property-based : parsing/encodage des arguments/env (octets non-UTF-8 via
AirPath),AirExitStatus(code vs signal). - Miri : sur la logique pure (construction d’
AirCommand,AirExitStatus) ; les chemins syscall/fork sont en run natif (#[cfg_attr(miri, ignore)]). - Fuzzing : décodage du statut de sortie (
siginfo/waitid, données kernel).
Décisions de fond
- Synchrone ; supervision async = couche 2.
pidfdpartout (clone3CLONE_PIDFD, pidfd_send_signal, waitid-sur-pidfd) — robustesse anti-course, jamais d’action sur un PID recyclé.- Chemin post-fork async-signal-safe — aucune alloc dans la fenêtre fork→exec ; zone la plus délicate, testée et revue.
drop_privileges= orchestration de l’ordre sûr + application de politiques fournies (seccomp/landlock pré-construits). Le compilateur déclaratif est ailleurs/différé.setres*+setgroupsobligatoires —setuid/setgidseuls sont un footgun ; d’où l’extension couche 0 prioritaire.- Erreur en cours de séquence = fatale (état partiellement réduit) — documenté ; l’appelant sort.
Travail à reprendre
- Extension couche 0
process:setgroups,setresgid,setresuid(sécurité-critique, prioritaire — prérequis dedrop_privileges). - Compilateur seccomp déclaratif (
SeccompFilter/SeccompRule→ BPF) — spec séparée (substantielle, par-arch). - Helper Landlock → chemins — naturellement côté
air-filesystem. - ABI C d’
air-process; autres crates couche 1 (air-socket,air-crypto,air-device,air-thread).
Licence du document : MPL 2.0
Statut : Spécification technique d’air-process (couche 1). API Rust ; ABI C
différée. Synchrone ; privsep = orchestration + politiques fournies.
Spec couche 1 — air-thread (threading et synchronisation)
Spécification technique — Version 1.0. Couche 1 « Primitives système ».
Renvoi ADR-055 (no_std + spawn gardé per-cible). Depuis ADR-055 D3,
air-threadest#![no_std]: la surface livrée pour la cible*-linux-air(AirMutex/AirRwLock/AirSemaphore/AirChannelsurfutex(2),runtime_primitives) est no_std pure. Le spawn ergonomiqueAirThreadBuilder/AirThreadHandle(thread.rs, enrobantstd::thread) est gardé#[cfg(not(target_env = "air"))]: disponible sur l’hôte (gnu, sound sous glibc), non compilé pour la cible — le spawn Air-natif (closure sur TCB Air) relève d’air-runtime+ allocateur réel (chantier distinct, ADR-052 D7.3). Les mentions « s’appuie surstd» ci-dessous valent donc pour l’hôte.
Position et méthode
air-thread fournit les primitives de threading (AirThread), de
synchronisation (AirMutex/AirRwLock/AirSemaphore), les channels MPSC
(AirChannel) et un ré-export d’atomics. S’appuie sur air-base-lib
(AirError). API Rust d’abord (ABI C différée). Méthode doc-d’abord.
Pas d’async runtime ici — l’asynchrone (event loop, tâches) est le runtime Air de la couche 2 (ADR-023 : runtime maison sur io_uring, pas Tokio).
air-thread= primitives synchrones de threads système et de synchronisation bloquante.
air-threads’appuie surstd— c’est légitime. La règle « pas de libc / syscalls directs » vaut pour la couche 0. Les couches ≥ 1 utilisentstdnormalement. Réimplémenter les threads surclone3(TLS, stacks, machinerie pthread) serait déraisonnable et risqué :AirThreadenrobestd::thread; les locks enrobentstd::sync. Conséquence : très peu d’unsafeinterne dans cette crate (contraste avecair-memory).
Décision : backend std::sync pour la synchro
Depuis Rust 1.62,
std::sync::Mutex/RwLocksur Linux sont des locks basés sur le futex kernel (chemin rapide en quelques atomiques, syscall futex en contention) — essentiellement ce qu’on écrirait à la main. On les enrobe : Air possède le type (pour l’ABI C et un nommage uniforme) ;stdfournit le backend prouvé. Zéro dépendance externe, zéro manque couche 0. Écarté :parking_lot(dépendance, à contre-courant de la philo minimiser-les-deps qui a écarté Tokio).
stdest un expédient de v1 — le cap est une implémentation futex maison (à graver). Adopterstd::syncpermet de livrer maintenant sans risque, mais ce n’est pas l’état cible. Avecstd, on hérite des contraintes de la bibliothèque standard de Rust — portabilité multi-plateforme, sémantique de poisoning, choix d’équité, garanties de panic-safety — qui ne sont pas forcément les nôtres. Notre propre implémentation, sur un futex synchrone couche 0 (à produire), nous fait maîtriser exactement le comportement (équité, file d’attente, sémantique sur panique, budgets) — cohérent avec la raison d’être du projet (contrôle vertical du stack, build-own). Ce remplacement est planifié (cf. travail à reprendre), pas un simple plan B de performance. La frontière d’APIair-threadest conçue pour que ce basculementstd→ futex-maison soit interne (l’API publique ne change pas).(Réconciliation passe-2 : la macro-archi disait « au-dessus de parking_lot ».)
Dépendances (règle des 80 %, ADR-024)
air-base-lib(erreurs) +std. Aucune dépendance externe en v1 (niparking_lot, nicrossbeam).
Section 1 — Threads : AirThread
#![allow(unused)]
fn main() {
/// Constructeur de thread (enrobe `std::thread::Builder`).
pub struct AirThreadBuilder { /* name, stack_size, affinity */ }
impl AirThreadBuilder {
pub fn new() -> Self;
pub fn name(self, name: &str) -> Self; // posé via PR_SET_NAME (std / couche 0)
pub fn stack_size(self, bytes: usize) -> Self;
/// Affinité CPU souhaitée (cf. **manque couche 0** ci-dessous).
pub fn cpu_affinity(self, cpus: &[u32]) -> Self;
/// Lance le thread. Le corps retourne `T` ; `AirThreadHandle::join` le récupère.
/// # Errors `AirError` si la création échoue (ressources).
pub fn spawn<F, T>(self, body: F) -> AirResult<AirThreadHandle<T>>
where F: FnOnce() -> T + Send + 'static, T: Send + 'static;
}
/// Handle joignable d'un thread.
pub struct AirThreadHandle<T> { /* std::thread::JoinHandle<T> */ }
impl<T> AirThreadHandle<T> {
/// Attend la fin et récupère la valeur.
/// # Errors `AirError` (catégorie `Other`) si le thread a **paniqué** (la
/// panique est capturée à la frontière, pas propagée silencieusement).
pub fn join(self) -> AirResult<T>;
pub fn is_finished(&self) -> bool;
}
/// Nom du thread courant (le cas échéant).
pub fn current_thread_name() -> Option<String>;
}
⚠️ Manque couche 0 :
sched_setaffinity/sched_getaffinity(absents).cpu_affinityexigesched_setaffinity— non wrappé en couche 0. L’API est spécifiée ici, mais son implémentation est différée à une petite extension couche 0sched(coordonnée), commefs::inotify/epoll/privsep. Sans elle,cpu_affinityrenvoieUnsupported(ou la méthode est#[cfg]-gated).nameetstack_sizefonctionnent dès v1 (viastd/set_thread_nameprésent).
Section 2 — Synchronisation
#![allow(unused)]
fn main() {
/// Mutex Air (sur `std::sync::Mutex`), **sans poisoning** : une panique en section
/// critique **n'empoisonne pas** le mutex — `lock` rend toujours un guard, jamais
/// une erreur de poison. (La panique est récupérée à la frontière du thread, via
/// `join`.) Trade-off documenté : si une panique laisse la donnée incohérente,
/// les verrouilleurs suivants la voient telle quelle — à l'appelant de rétablir
/// l'invariant explicitement s'il y tient.
pub struct AirMutex<T> { /* std::sync::Mutex<T> */ }
impl<T> AirMutex<T> {
pub fn new(value: T) -> Self;
pub fn lock(&self) -> AirMutexGuard<'_, T>; // pas de Result (no poisoning)
pub fn try_lock(&self) -> Option<AirMutexGuard<'_, T>>;
pub fn get_mut(&mut self) -> &mut T; // accès exclusif sans lock
pub fn into_inner(self) -> T;
}
pub struct AirMutexGuard<'a, T> { /* Deref / DerefMut */ }
/// RwLock Air (sur `std::sync::RwLock`), **sans poisoning**.
pub struct AirRwLock<T> { /* std::sync::RwLock<T> */ }
impl<T> AirRwLock<T> {
pub fn new(value: T) -> Self;
pub fn read(&self) -> AirRwLockReadGuard<'_, T>; // N lecteurs simultanés
pub fn write(&self) -> AirRwLockWriteGuard<'_, T>; // 1 écrivain exclusif
pub fn try_read(&self) -> Option<AirRwLockReadGuard<'_, T>>;
pub fn try_write(&self) -> Option<AirRwLockWriteGuard<'_, T>>;
}
/// Sémaphore comptant (sur `Mutex<usize>` + `Condvar` — `std` n'en fournit pas).
pub struct AirSemaphore { /* Mutex + Condvar */ }
impl AirSemaphore {
pub fn new(permits: usize) -> Self;
/// Acquiert un permis (bloque si 0). Le permis est rendu au `Drop` du guard.
pub fn acquire(&self) -> AirSemaphorePermit<'_>;
pub fn try_acquire(&self) -> Option<AirSemaphorePermit<'_>>;
pub fn available_permits(&self) -> usize;
}
pub struct AirSemaphorePermit<'a> { /* rend le permis au Drop */ }
}
Décision (no poisoning). Le poisoning de std (un Mutex devient Err après
un panic d’un détenteur) est plus souvent une nuisance qu’une aide (c’est la raison
n°1 d’adopter parking_lot). air-thread l’absorbe (unwrap_or_else(|e| e.into_inner()) en interne) : l’API ne rend jamais d’erreur de poison. Cohérent
avec la gestion de panique à la frontière du thread (join rapporte la
panique).
Section 3 — Channels MPSC : AirChannel
#![allow(unused)]
fn main() {
/// Canal **multi-producteurs / un consommateur** (sur `std::sync::mpsc`).
pub fn air_channel<T: Send>() -> (AirSender<T>, AirReceiver<T>);
pub struct AirSender<T> { /* Clone — chaque clone est un producteur */ }
pub struct AirReceiver<T> { /* unique consommateur */ }
impl<T: Send> AirSender<T> {
/// # Errors `AirError` (`BrokenResource`) si le récepteur a été abandonné.
pub fn send(&self, value: T) -> AirResult<()>;
}
impl<T: Send> AirReceiver<T> {
/// Bloque jusqu'à un message. # Errors `BrokenResource` si **tous** les
/// senders sont abandonnés (canal vide et fermé).
pub fn recv(&self) -> AirResult<T>;
/// `Ok(None)` si vide, `Err(BrokenResource)` si fermé.
pub fn try_recv(&self) -> AirResult<Option<T>>;
/// `Ok(None)` si le délai expire avant un message.
pub fn recv_timeout(&self, timeout: AirDuration) -> AirResult<Option<T>>;
}
}
Section 4 — Atomics
Ré-export des types atomiques de std sous air_thread::atomic
(AtomicU32/AtomicUsize/AtomicBool/… + Ordering) — pour une surface Air
uniforme (macro-architecture), sans réécriture (les atomiques de core sont la
référence kernel/ISA). Un développeur qui connaît std les retrouve à l’identique.
Récapitulatif air-thread
| Domaine | API | Backend |
|---|---|---|
| Threads | AirThreadBuilder, AirThreadHandle, current_thread_name | std::thread (+ set_thread_name) |
| Synchro | AirMutex, AirRwLock, AirSemaphore (+ guards) | std::sync (no poisoning) ; sémaphore = Mutex+Condvar |
| Channels | air_channel, AirSender, AirReceiver | std::sync::mpsc |
| Atomics | ré-export air_thread::atomic | core::sync::atomic |
Différé : cpu_affinity (dépend de l’ajout couche 0 sched_setaffinity) ;
variantes/parités async (couche 2) ; ABI C.
Stratégie de tests
- Couverture 100 % lignes + branches (Principe 1).
- Concurrence :
AirMutex— incréments concurrents de N threads → total exact ; no-poisoning : un panic en section critique laisse le mutex utilisable ;AirRwLock— N lecteurs simultanés, écrivain exclusif ;AirSemaphore— N permis,acquirebloque à 0 puis débloqué par un release ;AirChannel— MPSC (plusieurs senders, un receiver),recvbloque, sender abandonné →recverreur,recv_timeout. - loom sur la seule structure concurrente maison :
AirSemaphore(Mutex+Condvar),std↔loomcommuté souscfg(loom)(même schéma que le slab io_uring). Les wrappersMutex/RwLock(surstd, déjà prouvés) n’en ont pas besoin. - Miri (avec préemption) sur les chemins concurrents courts ; threads/join.
- Threads : valeur retournée par
join; panic →joinrend une erreur (capturée, non propagée). - Doctests : exemples compilent et passent.
- (Pas de fuzzing : aucune ingestion de données externes.)
Décisions de fond
std::thread/std::synccomme backend de v1 — cap acté vers futex maison. Air possède les types (ABI C, nommage) ;stdfournit le futex-based prouvé, zéro dep, zéro manque couche 0. Maisstdporte les contraintes de la std de Rust (pas les nôtres) → remplacement planifié par une implémentation futex synchrone maison (contrôle total du comportement), basculement interne (API inchangée). Voir travail à reprendre.- No poisoning — l’API ne rend jamais d’erreur de poison ; panique gérée à la
frontière du thread (
join). Trade-off documenté. - Pas d’async ici (ADR-023 ; couche 2).
AirThreadenrobestd::thread(pas de réimplémentation surclone3).- Ré-export d’atomics plutôt que réécriture (autorité
core). cpu_affinityimplémenté (PR #97) — via la couche 0set_cpu_affinity(PR #55) ; handshake par canaux (l’enfant publie sonTid, l’appelant pose l’affinité avant de débloquer le corps ; un masque refusé fait échouerspawnavant exécution du corps).
Travail à reprendre
- Extension couche 0
sched:sched_setaffinity/sched_getaffinity(prérequis decpu_affinity). 5ᵉ manque couche 0, non urgent. - Build-natif futex des locks (
AirMutex/AirRwLock/AirSemaphore) — PLANIFIÉ, pas conditionnel. Raison de fond : maîtriser le comportement (équité, file, sémantique sur panique, budgets) plutôt que d’hériter des contraintes de la std de Rust ; la performance (Principe 5) n’est qu’un bonus. Prérequis : une primitive couche 0 futex synchrone (FUTEX_WAIT/WAKEbloquants — 6ᵉ manque couche 0 à produire ; distincte du futex io_uring async déjà livré). Locks à vérifier loom. Le basculement est interne (APIair-threadinchangée). - ABI C d’
air-thread; autres crates couche 1 (air-socket,air-crypto,air-device).
Licence du document : MPL 2.0
Statut : Spécification technique d’air-thread (couche 1). API Rust ; ABI C
différée. Backend std ; synchro sans poisoning ; pas d’async (couche 2).
Spec couche 1 — air-socket (sockets bas niveau + résolution de noms enfichable)
Spécification technique — Version 1.0. Couche 1 « Primitives système ».
Position et méthode
air-socket fournit les sockets typés (AirTcpSocket/Listener,
AirUdpSocket, AirUnixStream/Listener/Datagram), les adresses, et un
framework de résolution de noms enfichable. (Netlink retiré — cf. Section 3.) S’appuie sur air-base-lib
(AirError) et consomme la famille net de la couche 0 (socket/bind/listen/
connect/accept4/send/recv/sendmsg/recvmsg/shutdown/sockopts) + fs (lecture
/etc/hosts, /etc/resolv.conf). API Rust d’abord (ABI C différée).
Décision de couche : synchrone. connect/accept/read/write bloquent. La stack réseau asynchrone (io_uring) et le framework de connexion sémantique (
AirConnection, HTTP, TLS de session…) relèvent de la couche 2 ; la résolution async/annulable aussi (lancer le resolver synchrone sur un thread, ou un client DNS non-bloquant côté reactor).
Renommage validé : la crate sockets bas niveau s’appelle
air-socket(et nonair-network, réservé au framework réseau couche 2).
Carte de consommation couche 0 (dette doc) : sockets →
net(tout) ; résolution →fs(/etc/hosts,/etc/resolv.conf) +net(requêtes DNS UDP/TCP).
Section 1 — Adresses
#![allow(unused)]
fn main() {
/// Adresse IP (v4 ou v6). Couche **valeur** : parsing/affichage purs, **sans libc**
/// (`std::net::IpAddr` est pur-Rust ; seul `getaddrinfo` est libc, qu'on n'utilise
/// PAS). Type Air pour la surface uniforme / ABI C future ; enrobe `std::net` en interne.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AirIpAddress { V4([u8; 4]), V6([u8; 16]) }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct AirSocketAddress { pub ip: AirIpAddress, pub port: u16 /* + scope_id v6 */ }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AirUnixAddress { /* path (AirPath) OU abstract (octets, Linux) */ }
impl AirIpAddress {
pub fn parse(s: &str) -> AirResult<Self>; // via std::net (pur Rust)
pub fn is_v6(&self) -> bool;
}
impl AirSocketAddress { pub fn parse(s: &str) -> AirResult<Self>; /* "host:port" / "[v6]:port" */ }
}
Décision. Les valeurs d’adresse réutilisent std::net (parsing/octets/
affichage — pur Rust, zéro libc) ; on les enrobe en types Air (ABI C future).
La conversion vers air-sys-syscall::net::SocketAddr (sérialisation sockaddr)
fait le pont vers les syscalls couche 0.
Section 2 — Résolution de noms enfichable et ordonnée (le cœur conceptuel)
Décision de fond (override macro-archi). La macro-architecture disait « resolver système, pas de DNS Air spécifique ». Air change d’avis : pas de dépendance à la libc/NSS, et Air doit pouvoir offrir d’autres piles de résolution que le DNS. On bâtit donc un framework de résolution enfichable, où plusieurs sources (piles) sont consultées dans un ordre configurable ; notre propre client DNS est l’une d’elles (un fallback parmi d’autres). (À réconcilier dans la macro-archi — item passe-2.)
#![allow(unused)]
fn main() {
/// Une **source** de résolution (pile) : DNS, /etc/hosts, mDNS, custom…
pub trait AirNameResolutionSource: Send + Sync {
fn name(&self) -> &str;
/// Résout un nom → IPs. `Ok(vec![])` = « pas trouvé ici » (passer à la source
/// suivante) ; `Err` = erreur dure (stoppe la chaîne).
fn resolve(&self, hostname: &str) -> AirResult<Vec<AirIpAddress>>;
}
/// Résolveur : consulte une **liste ordonnée** de sources jusqu'au premier résultat.
pub struct AirNameResolver { /* Vec<Box<dyn AirNameResolutionSource>> */ }
impl AirNameResolver {
/// Défaut Air : `[hosts file, DNS]` (ordre par défaut, **configurable**).
pub fn system_default() -> AirResult<Self>;
pub fn with_sources(sources: Vec<Box<dyn AirNameResolutionSource>>) -> Self;
/// Ajoute / ordonne des piles (push = priorité la plus basse).
pub fn push_source(&mut self, source: Box<dyn AirNameResolutionSource>);
/// Résout, **IPv6 d'abord, IPv4 en fallback** (cf. décision ci-dessous) :
/// l'ordre des adresses rendues place les V6 avant les V4.
pub fn resolve(&self, hostname: &str) -> AirResult<Vec<AirIpAddress>>;
pub fn resolve_socket(&self, host: &str, port: u16) -> AirResult<Vec<AirSocketAddress>>;
}
}
Sources fournies
#![allow(unused)]
fn main() {
/// Pile `/etc/hosts` (parsing pur ; lecture via `fs`).
pub struct AirHostsFileSource { /* entrées chargées */ }
impl AirHostsFileSource { pub fn load() -> AirResult<Self>; }
/// **Client DNS Air** (RFC 1035 ; A + AAAA), **sans libc**. Nameservers depuis
/// `/etc/resolv.conf` (ou fournis). UDP, **repli TCP** sur réponse tronquée (bit TC).
pub struct AirDnsSource { /* nameservers: Vec<AirSocketAddress>, timeout, retries */ }
impl AirDnsSource {
pub fn from_resolv_conf() -> AirResult<Self>;
pub fn with_nameservers(servers: Vec<AirSocketAddress>) -> Self;
}
// (mDNS, LLMNR, sources custom : piles futures implémentant le trait.)
}
Décision : IPv6-first, IPv4-fallback (politique Air). Le résolveur interroge AAAA puis A, et ordonne les résultats V6 avant V4. Les helpers de connexion (
AirTcpSocket::connect_to_host) tentent donc l’IPv6 d’abord, l’IPv4 ensuite. (À ce niveau de couche, c’est surtout l’ordre des adresses rendues ; une vraie stratégie « Happy Eyeballs » concurrente relève de la couche 2.)
⚠️ Sécurité — parsing de réponses DNS = données externes hostiles. Le décodage des messages DNS (RFC 1035 : compression de noms par pointeurs, RR de longueur variable) est une surface de vulnérabilité classique. → fuzzing obligatoire (
cargo-fuzz) sur le parseur DNS (Principe 3) ; slicing viaget, bornes sur la décompression de noms (anti-boucle de pointeurs),checked_*. Zéro panique, zéro OOB sur toute entrée.
Section 3 — Sockets typés
#![allow(unused)]
fn main() {
// --- TCP ---
pub struct AirTcpSocket { /* OwnedFd connecté */ }
pub struct AirTcpListener { /* OwnedFd en écoute */ }
impl AirTcpSocket {
pub fn connect(addr: &AirSocketAddress) -> AirResult<Self>;
/// Résout `host` (résolveur fourni ou défaut) puis connecte **IPv6 d'abord**.
pub fn connect_to_host(host: &str, port: u16, resolver: &AirNameResolver) -> AirResult<Self>;
pub fn read(&mut self, buffer: &mut [u8]) -> AirResult<usize>;
pub fn write(&mut self, data: &[u8]) -> AirResult<usize>;
pub fn shutdown(&self, how: AirShutdown) -> AirResult<()>;
pub fn peer_address(&self) -> AirResult<AirSocketAddress>;
pub fn set_nodelay(&self, on: bool) -> AirResult<()>; // options typées (cf. family-net)
pub fn set_keepalive(&self, on: bool) -> AirResult<()>;
pub fn as_fd(&self) -> BorrowedFd<'_>; // intégration event loop (couche 2)
}
impl AirTcpListener {
pub fn bind(addr: &AirSocketAddress) -> AirResult<Self>; // SO_REUSEADDR par défaut ? cf. décision
pub fn listen(addr: &AirSocketAddress, backlog: u32) -> AirResult<Self>;
pub fn accept(&self) -> AirResult<(AirTcpSocket, AirSocketAddress)>; // CLOEXEC
pub fn local_address(&self) -> AirResult<AirSocketAddress>;
}
// --- UDP ---
pub struct AirUdpSocket { /* OwnedFd */ }
impl AirUdpSocket {
pub fn bind(addr: &AirSocketAddress) -> AirResult<Self>;
pub fn send_to(&self, data: &[u8], dest: &AirSocketAddress) -> AirResult<usize>;
pub fn recv_from(&self, buffer: &mut [u8]) -> AirResult<(usize, AirSocketAddress)>;
pub fn connect(&self, addr: &AirSocketAddress) -> AirResult<()>; // pour send/recv
}
// --- Unix (stream + datagram, path & abstract) — signatures FIGÉES (PASSE 2) ---
pub struct AirUnixStream { /* OwnedFd */ }
impl AirUnixStream {
pub fn connect(addr: &AirUnixAddress) -> AirResult<Self>;
pub fn read(&mut self, buffer: &mut [u8]) -> AirResult<usize>;
pub fn write(&mut self, data: &[u8]) -> AirResult<usize>;
/// Passage de FD (capability AirCom, ADR-001) via sendmsg/recvmsg + SCM_RIGHTS.
pub fn send_fd(&self, fd: BorrowedFd<'_>) -> AirResult<()>;
pub fn recv_fd(&self) -> AirResult<OwnedFd>; // reçu en CLOEXEC
pub fn shutdown(&self, how: AirShutdown) -> AirResult<()>;
pub fn as_fd(&self) -> BorrowedFd<'_>;
}
pub struct AirUnixListener { /* OwnedFd */ }
impl AirUnixListener {
pub fn bind(addr: &AirUnixAddress) -> AirResult<Self>;
pub fn accept(&self) -> AirResult<(AirUnixStream, AirUnixAddress)>; // pair souvent non lié
pub fn as_fd(&self) -> BorrowedFd<'_>;
}
pub struct AirUnixDatagram { /* OwnedFd */ }
impl AirUnixDatagram {
pub fn bind(addr: &AirUnixAddress) -> AirResult<Self>;
pub fn send_to(&self, data: &[u8], dest: &AirUnixAddress) -> AirResult<usize>;
pub fn recv_from(&self, buffer: &mut [u8]) -> AirResult<(usize, AirUnixAddress)>;
pub fn as_fd(&self) -> BorrowedFd<'_>;
}
// --- Netlink : RETIRÉ d'air-socket (décision 2026-06-15) ---
// La couche 0 scellée n'expose pas de socket netlink GÉNÉRIQUE (seul un socket
// uevent privé existe dans `device`). Netlink relève de la **configuration kernel**
// (plan de contrôle réseau), un concern distinct → crate `air-netlink`
// (**couche 1**, tranché ADR-079 ; révise la mention « couche 2 » de 2026-06-15),
// qui exige l'additif couche 0 `AF_NETLINK`/`sockaddr_nl` (`couche-0-v1.11`, RFC).
pub enum AirShutdown { Read, Write, Both }
}
Décisions sockets.
- CLOEXEC + (NOSIGNAL sur send) par défaut — hérité des invariants
netcouche 0. - Options typées (
set_nodelay/set_keepalive/set_reuseaddr…) — pas d’APIsetsockoptgénérique (ADR-021 conv. 3), via les options defamily-net. as_fd()exposé partout → la couche 2 enregistrera ces FD dans l’event loop io_uring (les façades synchrones ici, l’async là-haut).- Passage de FD Unix réutilise
sendmsg/recvmsg+SCM_RIGHTSdenet— c’est la matérialisation des capabilities AirCom (ADR-001).
Récapitulatif air-socket
| Domaine | API | Couche 0 / dép |
|---|---|---|
| Adresses | AirIpAddress, AirSocketAddress, AirUnixAddress | std::net (valeur, pur Rust) → pont net::SocketAddr |
| Résolution | AirNameResolver, AirNameResolutionSource, AirHostsFileSource, AirDnsSource | fs (hosts/resolv.conf), net (DNS UDP/TCP) |
| TCP/UDP | AirTcpSocket/Listener, AirUdpSocket | net |
| Unix | AirUnixStream/Listener/Datagram (+ passage de FD SCM_RIGHTS) | net |
| Netlink | retiré → crate air-netlink (couche 1, ADR-079 ; additif couche 0 AF_NETLINK requis) | — |
Différé : async + framework de connexion sémantique (couche 2) ; résolution annulable/concurrente (couche 2) ; mDNS/LLMNR et autres piles ; ABI C.
Dépendances (règle des 80 %, ADR-024)
air-base-lib,air-sys-syscall::net/::fs(couche 0),std::net(types adresse purs, sansgetaddrinfo— donc sans libc de résolution). Aucune dépendance externe (pas de crate DNS tierce : le client DNS est maison).
Stratégie de tests
- Couverture 100 % lignes + branches (Principe 1).
- Résolution :
AirHostsFileSource(fixtures/etc/hosts) ; ordre des piles (une pile renvoie vide → la suivante répond ; erreur dure → stoppe) ; IPv6-first (AAAA avant A dans l’ordre rendu) ;AirDnsSourcecontre un serveur DNS de test (réponses A/AAAA, troncature → repli TCP). - Fuzzing (cargo-fuzz) — obligatoire : parseur de réponses DNS (RFC 1035,
compression de noms : anti-boucle de pointeurs, bornes) et parseur
/etc/hosts. Zéro panique/OOB sur données hostiles. - Property-based : parsing d’adresses (
"[::1]:80", IPv4-mapped…), round-trip. - Intégration (loopback v4 et v6, AF_UNIX) : TCP connect/accept/echo, UDP
send/recv, Unix stream + passage de FD (envoyer un FD, le recevoir = test cœur
AirCom),
connect_to_host(résout via hosts puis connecte v6-first). - Doctests.
Décisions de fond
- Synchrone ; async + framework de connexion = couche 2.
- Résolution enfichable et ordonnée (override macro-archi) — sources DNS/hosts/…, ordre configurable ; client DNS maison, sans libc ; Air peut offrir d’autres piles que le DNS.
- IPv6-first, IPv4-fallback (politique Air) — ordre des adresses + ordre de connexion des helpers.
- Parsing DNS = surface hostile → fuzzing obligatoire (sécurité, Principe 3).
- Adresses sur
std::net(pur Rust, pasgetaddrinfo/libc) ; pont versnetcouche 0 pour les syscalls. - Options socket typées (ADR-021 c.3) ; CLOEXEC/NOSIGNAL par défaut ;
as_fdpour l’event loop couche 2.
Additifs planifiés — support air-mdns (descellement additif, ADR-080)
Statut.
air-socketest scellée (couche 1). Les deux additifs ci-dessous sont instruits pour la specair-mdns(mDNS/DNS-SD) ; ils entrent par descellement additif (délégation ADR-065, neutres non-sécurité) tranché par ADR-080. Purement additifs (aucune signature existante modifiée), zéro-C, zérounsafe.
A.1 — Multicast UDP typé (sur AirUdpSocket)
#![allow(unused)]
fn main() {
impl AirUdpSocket {
pub fn join_multicast_v4(&self, group: Ipv4Addr, interface: Ipv4Addr) -> AirResult<()>;
pub fn join_multicast_v6(&self, group: Ipv6Addr, interface_index: u32) -> AirResult<()>;
pub fn leave_multicast_v4(&self, group: Ipv4Addr, interface: Ipv4Addr) -> AirResult<()>;
pub fn leave_multicast_v6(&self, group: Ipv6Addr, interface_index: u32) -> AirResult<()>;
pub fn set_multicast_loop_v4(&self, on: bool) -> AirResult<()>;
pub fn set_multicast_ttl_v4(&self, ttl: u32) -> AirResult<()>;
pub fn set_multicast_hops_v6(&self, hops: u32) -> AirResult<()>;
pub fn set_multicast_interface_v4(&self, interface: Ipv4Addr) -> AirResult<()>;
}
}
- Fonctions dédiées typées (jamais d’
setsockoptbrut exposé, ADR-021 conv. 3). - Requiert l’additif couche 0
couche-0-v1.12:setsockoptstructuré (setsockopt_bytes, ADR-080) —IP_ADD_MEMBERSHIP/IPV6_ADD_MEMBERSHIPprennent unip_mreq/ipv6_mreq(structure), hors dusetsockoptentier d’ADR-071.
A.2 — Codec DNS exposé + étendu (SRV/TXT/PTR)
#![allow(unused)]
fn main() {
pub mod dns {
/// Codec de noms DNS **public** (encodage/décodage, **compression suivie AVEC
/// borne** — invariant anti-boucle déjà éprouvé/fuzzé). Réutilisé par air-mdns.
pub fn encode_name(out: &mut Vec<u8>, name: &DnsName<'_>);
pub fn read_name(msg: &[u8], offset: usize) -> AirResult<(DnsName<'static>, usize)>;
/// Types d'enregistrement ajoutés (A/AAAA existants) : DNS-SD.
pub enum ResourceRecord<'a> { A(Ipv4Addr), Aaaa(Ipv6Addr),
Ptr(DnsName<'a>), Srv { priority: u16, weight: u16, port: u16, target: DnsName<'a> },
Txt(TxtPairs<'a>) }
pub fn parse_records(message: &[u8]) -> AirResult<RecordIter<'_>>;
}
}
- Promeut le codec de noms (aujourd’hui privé,
skip_name/read_u16…) en module public sans-IO + ajoute SRV/TXT/PTR. Le résolveur A/AAAA existant est inchangé (même codec sous-jacent). Re-fuzz (fuzz_dns_parseétendu aux records SRV/TXT/PTR).
Travail à reprendre
- Additifs
air-mdns(ci-dessus, ADR-080) : multicast UDP typé (couche-0-v1.12setsockoptstructuré) + codec DNS public SRV/TXT/PTR. Instruits, non actifs avant descellement. - Piles de résolution supplémentaires : mDNS (→
air-mdns), LLMNR, sources custom. - Résolution async/annulable et framework de connexion (
AirConnection, HTTP, TLS) — couche 2. - ABI C d’
air-socket; autres crates couche 1 (air-crypto,air-device).
Licence du document : MPL 2.0
Statut : Spécification technique d’air-socket (couche 1). API Rust ; ABI C
différée. Synchrone ; résolution maison enfichable (DNS sans libc) ; IPv6-first.
Spec couche 1 — air-crypto (primitives cryptographiques)
Spécification technique — Version 1.0. Couche 1 « Primitives système ».
Position et méthode
air-crypto fournit les primitives cryptographiques : aléa, hachage, MAC,
chiffrement authentifié (AEAD), dérivation de clés, signatures, échange de clés.
S’appuie sur air-base-lib (AirError) et, pour l’aléa, sur getrandom (couche 0
system). API Rust d’abord (ABI C différée).
On ne réimplémente JAMAIS de crypto. C’est la crate où la philosophie « build-own / minimiser les deps » est délibérément renversée : rouler sa propre crypto est dangereux (attaques temporelles, bugs subtils, zéro audit). La sécurité vient de l’adoption massive et auditée. RustCrypto (constant-time, audité, accélération AES-NI/ARMv8 automatique) et
zeroize/subtlesont des dépendances structurantes, exceptions nommées à la règle des 80 % — commeicu4x(ADR-016/ADR-024 ;docs/EXCEPTIONS.md).
La valeur d’
air-crypto= une API Air anti-mésusage : clés type-safe qui se zeroize auDrop, AEAD qui gère les nonces (réutilisation impossible par construction), vérifications constant-time,AirRandomtoujours via le CSPRNG kernel. On enrobe RustCrypto d’une surface qui rend les erreurs courantes difficiles.
Périmètre v1 : cœur moderne (RSA/legacy différés ; TLS séparé)
- Inclus :
AirRandom; hachage (SHA-256/384/512, SHA3-256/512, BLAKE3) ; HMAC ; AEAD (AES-256-GCM, ChaCha20-Poly1305) ; KDF (HKDF, Argon2) ; signatures Ed25519 ; échange de clés X25519. - Différé : modes hérités (AES-CBC brut, PKCS#1v1.5 chiffrement)…, jamais
ajoutés (omission = sécurité). TLS (rustls) = protocole de session sur une
connexion → spec séparée (
air-tls/ framework de connexion), plus tard. - Additifs planifiés (descellement
couche-1-v1.x) pour l’interop WebPKI de la contingenceair-tlsmaison (specair-tls, ADR-042 §Contingence) : AES-128-GCM, ECDSA P-256/P-384, RSA-PSS vérification seule, ML-KEM-768 hybride. Détail : §« Additifs planifiés » ci-dessous. Non encore dans l’API scellée ; chaque additif = RFC de descellement (modèle ADR-065/066/067) + KAT-gate (ADR-034).
Conventions
- Aucune fonction
unsafeexposée (peu d’unsafeinterne — RustCrypto porte le constant-time/unsafe). Couverture 100 %. - Vecteurs de test connus (KAT) : tout primitif est testé contre des vecteurs de référence RFC/NIST (cf. §tests) — l’étalon-or de la correction crypto.
- Secrets
zeroize: les types de clé/secret effacent leur mémoire auDrop, n’implémentent niDebugqui fuit, niClonegratuit. - Comparaisons constant-time (
subtle) pour les tags/MAC (jamais==qui fuiterait par le timing). - Carte de consommation couche 0 :
AirRandom→system::getrandom(seul contact couche 0 ; le reste est du calcul pur RustCrypto).
Section 1 — Aléa : AirRandom
#![allow(unused)]
fn main() {
/// Source d'aléa **cryptographique**, toujours le CSPRNG kernel (`getrandom`),
/// **jamais** un PRNG userspace.
pub struct AirRandom;
impl AirRandom {
/// Remplit `buffer` d'octets aléatoires sûrs. # Errors `AirError`.
pub fn fill(buffer: &mut [u8]) -> AirResult<()>;
/// Génère une clé symétrique de `N` octets (zeroize au Drop).
pub fn generate_key<const N: usize>() -> AirResult<AirSymmetricKey<N>>;
pub fn u64() -> AirResult<u64>;
}
}
Section 2 — Clés (type-safe, zeroize)
#![allow(unused)]
fn main() {
/// Clé symétrique de `N` octets. **Zeroize au `Drop`** ; pas de `Debug`/`Clone` qui fuit.
pub struct AirSymmetricKey<const N: usize> { /* [u8; N], zeroize */ }
impl<const N: usize> AirSymmetricKey<N> {
pub fn from_bytes(bytes: [u8; N]) -> Self;
pub fn expose(&self) -> &[u8; N]; // accès explicite, nommé « expose »
}
}
Décision. Toute matière secrète (clés sym., clés privées de signature/ECDH,
sorties de KDF) est encapsulée dans des types zeroizés au Drop, sans Debug
exposant les octets, l’accès se faisant par une méthode nommée expose (le
nom signale qu’on touche du secret).
Section 3 — Hachage : AirHash
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AirHashAlgorithm { Sha256, Sha384, Sha512, Sha3_256, Sha3_512, Blake3 }
/// Hachage en flux (incrémental) ou one-shot.
pub struct AirHash { /* état RustCrypto selon l'algo */ }
impl AirHash {
pub fn new(algorithm: AirHashAlgorithm) -> Self;
pub fn update(&mut self, data: &[u8]);
pub fn finalize(self) -> Vec<u8>; // digest
/// One-shot.
pub fn digest(algorithm: AirHashAlgorithm, data: &[u8]) -> Vec<u8>;
}
}
Section 4 — MAC : AirHmac
#![allow(unused)]
fn main() {
pub struct AirHmac { /* HMAC-SHA256/384/512 */ }
impl AirHmac {
pub fn new(algorithm: AirHashAlgorithm, key: &[u8]) -> AirResult<Self>;
pub fn update(&mut self, data: &[u8]);
pub fn finalize(self) -> Vec<u8>; // tag
/// Vérification **constant-time** (jamais `==`). `true` ssi le tag est valide.
pub fn verify(self, expected_tag: &[u8]) -> bool;
}
}
Section 5 — AEAD (chiffrement authentifié) : AirAead
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AirAeadAlgorithm { Aes256Gcm, ChaCha20Poly1305 }
/// Chiffrement authentifié. **Anti-mésusage : la façade gère les nonces** —
/// l'appelant n'en manipule jamais (la réutilisation de nonce, catastrophique,
/// est impossible par construction).
pub struct AirAead<const N: usize> { algorithm: AirAeadAlgorithm, key: AirSymmetricKey<N> }
impl AirAead<32> {
pub fn new(algorithm: AirAeadAlgorithm, key: AirSymmetricKey<32>) -> Self;
/// Chiffre : génère un **nonce aléatoire** (via `AirRandom`) et rend
/// `nonce || ciphertext || tag`. `aad` = données authentifiées non chiffrées.
/// # Errors `AirError`.
pub fn seal(&self, plaintext: &[u8], aad: &[u8]) -> AirResult<Vec<u8>>;
/// Déchiffre `nonce || ciphertext || tag`. **Échoue** si le tag ne valide pas
/// (intégrité/authenticité) — `Err(AirError { InvalidData })`, **pas** de
/// plaintext partiel rendu.
pub fn open(&self, sealed: &[u8], aad: &[u8]) -> AirResult<Vec<u8>>;
}
}
Décision nonce (anti-mésusage).
sealgénère le nonce en interne et le préfixe au message ; l’appelant ne le voit ni ne le choisit → réutilisation impossible. Limite documentée : avec AES-256-GCM (nonce 96 bits), le nonce aléatoire est sûr jusqu’à ~2³² messages par clé (borne d’anniversaire) ; ChaCha20-Poly1305 est recommandé pour les très gros volumes. Une variante à compteur de session (pour streamer beaucoup de messages sous une clé) est prévue ultérieurement. Aucune donnée perdue sur erreur (ADR-032) :openrend tout le plaintext ou rien.
Section 6 — KDF : AirHkdf / AirArgon2
#![allow(unused)]
fn main() {
/// HKDF (dérivation de clé à partir d'un secret + sel + info).
pub struct AirHkdf;
impl AirHkdf {
/// # Errors `AirError`. Rend une clé dérivée de `output_len` octets (zeroize).
pub fn derive(algorithm: AirHashAlgorithm, secret: &[u8], salt: &[u8], info: &[u8],
output_len: usize) -> AirResult<AirDerivedKey>;
}
/// Argon2id — hachage de **mot de passe** (lent, résistant GPU/ASIC).
pub struct AirArgon2;
impl AirArgon2 {
/// Hache un mot de passe (sel aléatoire interne) → chaîne PHC vérifiable.
pub fn hash_password(password: &[u8], params: AirArgon2Params) -> AirResult<String>;
/// Vérifie un mot de passe contre une chaîne PHC (**constant-time**).
pub fn verify_password(password: &[u8], phc: &str) -> AirResult<bool>;
}
}
Section 7 — Signatures (Ed25519) & échange de clés (X25519)
#![allow(unused)]
fn main() {
/// Paire de signature Ed25519. La clé privée est zeroizée au Drop.
pub struct AirSigningKey { /* ed25519 secret, zeroize */ }
pub struct AirVerifyingKey { /* ed25519 public */ }
impl AirSigningKey {
pub fn generate() -> AirResult<Self>;
pub fn verifying_key(&self) -> AirVerifyingKey;
pub fn sign(&self, message: &[u8]) -> [u8; 64];
}
impl AirVerifyingKey {
pub fn from_bytes(bytes: [u8; 32]) -> AirResult<Self>; // # Errors si point invalide
/// `true` ssi la signature est valide pour `message`.
pub fn verify(&self, message: &[u8], signature: &[u8; 64]) -> bool;
}
/// Échange de clés X25519 (Diffie-Hellman sur courbe). Secret partagé → KDF.
pub struct AirEcdhPrivateKey { /* zeroize */ }
pub struct AirEcdhPublicKey { /* [u8;32] */ }
impl AirEcdhPrivateKey {
pub fn generate() -> AirResult<Self>;
pub fn public_key(&self) -> AirEcdhPublicKey;
/// Secret partagé (à passer **toujours** dans un KDF, jamais utilisé brut).
pub fn diffie_hellman(&self, peer: &AirEcdhPublicKey) -> AirDerivedKey;
}
}
Additifs planifiés — support air-tls (descellement couche-1-v1.x, RFC)
Statut.
air-cryptoest scellée (couche-1). Les primitives ci-dessous ne sont pas dans l’API figée : elles sont instruites ici pour l’interop WebPKI réelle de la contingenceair-tlsmaison (ADR-042 §Contingence). Leur entrée exige un RFC de descellement additif (modèle ADR-065/066/067) et un KAT-gate (ADR-034). Elles restent pur Rust, zéro C, zérounsafeexposé, sous la même discipline que le cœur v1 (exception 80 % nommée,no_std-strict, pins=, fast-lane sécurité). Aucune réécriture de crypto — on enrobe des crates RustCrypto auditées.
A.1 — AES-128-GCM (extension d’AirAeadAlgorithm)
#![allow(unused)]
fn main() {
pub enum AirAeadAlgorithm { Aes256Gcm, ChaCha20Poly1305, Aes128Gcm /* additif */ }
// `AirAead<16>` : même façade anti-mésusage (nonce géré), clé 128 bits.
}
- Backend :
aes-gcm(déjà dépendance, RustCrypto) — seule la variante 128 bits s’ajoute. KAT : vecteurs NIST GCM. - Motif : suite TLS 1.3
TLS_AES_128_GCM_SHA256(mandatory-to-implement, RFC 8446 §9.1) — indispensable à l’interop.
A.2 — Signatures ECDSA P-256 / P-384
#![allow(unused)]
fn main() {
pub enum AirEcCurve { P256, P384 }
/// Clé de signature ECDSA. Privée zeroizée au Drop. **Nonce déterministe RFC 6979.**
pub struct AirEcdsaSigningKey { /* p256/p384 secret, zeroize */ }
pub struct AirEcdsaVerifyingKey { /* point public */ }
impl AirEcdsaSigningKey {
pub fn generate(curve: AirEcCurve) -> AirResult<Self>;
pub fn verifying_key(&self) -> AirEcdsaVerifyingKey;
pub fn sign(&self, message: &[u8]) -> AirResult<Vec<u8>>; // ASN.1 DER (r,s)
}
impl AirEcdsaVerifyingKey {
pub fn from_sec1_bytes(curve: AirEcCurve, bytes: &[u8]) -> AirResult<Self>;
pub fn verify(&self, message: &[u8], signature: &[u8]) -> bool;
}
}
- Backend :
p256/p384(RustCrypto, pur Rust,no_std+alloc, arithmétique de corps pure Rust — pas d’asm/cpufeatures, donc moins de réserveaarch64queaes-gcm). KAT : NIST CAVP (FIPS 186-4). - Décision nonce : RFC 6979 (déterministe) — élimine par construction la classe de failles « nonce ECDSA faible/réutilisé » (désastres PS3/Bitcoin). Jamais de nonce ECDSA tiré d’un PRNG.
- Motif : schémas TLS
ecdsa_secp256r1_sha256,ecdsa_secp384r1_sha384(RFC 8446 §4.2.3) — vérif. des chaînes serveur ECDSA (fréquentes) ; sign. côté mTLS.
A.3 — ECDH P-256 / P-384 (groupes secp256r1/secp384r1)
#![allow(unused)]
fn main() {
pub struct AirEcdhNistPrivateKey { /* p256/p384 scalar, zeroize */ }
pub struct AirEcdhNistPublicKey { /* point SEC1 */ }
// Même contrat qu'`AirEcdhPrivateKey` (X25519) : `diffie_hellman` rend un
// `AirDerivedKey` à passer TOUJOURS dans un KDF, jamais brut.
}
- Backend :
p256/p384(featureecdh, RustCrypto). KAT : NIST. - Motif : groupes d’échange TLS
secp256r1/secp384r1(interop avec pairs sans X25519). X25519 reste le défaut ; NIST en repli d’interop.
A.4 — RSA-PSS vérification seule (jamais d’opération privée)
#![allow(unused)]
fn main() {
/// Clé PUBLIQUE RSA — **vérification uniquement**. Pas de clé privée RSA dans Air.
pub struct AirRsaPssVerifyingKey { /* modulus + exposant public */ }
impl AirRsaPssVerifyingKey {
pub fn from_pkcs1_der(der: &[u8]) -> AirResult<Self>; // borné, jamais de panic
/// Vérifie une signature RSA-PSS (hash SHA-256/384/512). `true` ssi valide.
pub fn verify(&self, hash: AirHashAlgorithm, message: &[u8], signature: &[u8]) -> bool;
}
}
- Backend :
rsa(RustCrypto, pur Rust). On n’expose AUCUNE génération, signature, ni déchiffrement RSA — seule la vérification (opération publique). - Note sécurité (importante) : l’advisory RUSTSEC-2023-0071 « Marvin » est un canal temporel sur l’opération PRIVÉE RSA (déchiffrement/signature). Air ne fait aucune opération privée RSA → cette faille ne s’applique pas. On vérifie seulement des signatures de certificats serveur (WebPKI, où RSA domine encore le parc). PKCS#1 v1.5 chiffrement (Bleichenbacher) : omis ; RSA-PSS vérif. seule.
- Motif : schémas TLS
rsa_pss_rsae_sha256/384/512(RFC 8446 §4.2.3) — sans eux, la majorité des certificats WebPKI actuels ne validerait pas.
A.5 — ML-KEM-768 (hybride post-quantique X25519MLKEM768)
#![allow(unused)]
fn main() {
/// KEM post-quantique ML-KEM-768 (FIPS 203). **Utilisé UNIQUEMENT en hybride.**
pub struct AirMlKem768;
pub struct AirMlKemEncapsulation { /* ciphertext */ }
impl AirMlKem768 {
pub fn generate_keypair() -> AirResult<(AirMlKemPublicKey, AirMlKemSecretKey /*zeroize*/)>;
pub fn encapsulate(peer: &AirMlKemPublicKey) -> AirResult<(AirMlKemEncapsulation, AirDerivedKey)>;
pub fn decapsulate(secret: &AirMlKemSecretKey, ct: &AirMlKemEncapsulation) -> AirResult<AirDerivedKey>;
}
}
- Backend :
ml-kem(RustCrypto, FIPS 203, pur Rustno_std). KAT : vecteurs NIST ACVP / FIPS 203. - Décision hybride-only : ML-KEM n’est jamais utilisé seul. Le groupe TLS
X25519MLKEM768combine le secret X25519 et le secret ML-KEM (concaténation → HKDF). La sécurité est ≥ celle du plus fort des deux : si ML-KEM (récent, moins éprouvé) tombait, X25519 protège encore ; réciproquement face à un ordinateur quantique. C’est le consensus de déploiement 2024-2025 (Chrome/Cloudflare). ML-KEM seul refusé tant que la primitive n’a pas l’ancienneté d’X25519.
Discipline & gouvernance de ces additifs
| Additif | Crate (RustCrypto) | Pur Rust / zéro-C | Schéma(s) TLS 1.3 débloqué(s) |
|---|---|---|---|
| AES-128-GCM | aes-gcm (déjà dép.) | ✅ | TLS_AES_128_GCM_SHA256 |
| ECDSA P-256/P-384 | p256, p384 | ✅ | ecdsa_secp256r1_sha256, ecdsa_secp384r1_sha384 |
| ECDH P-256/P-384 | p256, p384 (ecdh) | ✅ | groupes secp256r1/secp384r1 |
| RSA-PSS (vérif.) | rsa | ✅ | rsa_pss_rsae_sha256/384/512 |
| ML-KEM-768 | ml-kem | ✅ | groupe X25519MLKEM768 (hybride) |
- Zéro-C : les 5 crates sont pur Rust →
cargo xtask check-c-surfacereste vert (aucune n’introduitcc/*-sys). À re-vérifier au bump (notamment la réserveaarch64/cpufeaturesdéjà documentée pouraes-gcm,docs/EXCEPTIONS.md). no_std-strict :default-features = false, features aléa exclues (l’aléa vient toujours d’AirRandom→getrandom(2)couche 0), comme le cœur v1.- KAT-gated (ADR-034) : chaque additif entre avec ses vecteurs officiels (NIST GCM/CAVP/ACVP, FIPS 203) avant merge ; bumps re-passent les KAT.
- Exception 80 % nommée : ajouter
p256/p384/rsa/ml-kemà l’entréeair-cryptodedocs/EXCEPTIONS.mdet auDEPENDENCIES.mdde la crate. - Gate de descellement : RFC additif (ADR-015, modèle ADR-065/066/067) — le
BDFL scelle
couche-1-v1.xaprès validation 2 arches.
Additifs planifiés — support air-quic / air-ssh (ADR-081)
Complète les additifs
air-tls(ci-dessus, ADR-078). Révélés parair-quic(header protection) etair-ssh(signatures RSA v1.5, cipher custom). Même discipline (RFC de descellement, KAT-gate ADR-034, ratification BDFL — surface crypto). Zéro-C, zérounsafeexposé.
B.1 — Module bas niveau air_crypto::lowlevel (opt-in, expert-only)
Primitives nues requises par les implémenteurs de protocole (QUIC/SSH), séparées de la façade anti-mésusage (qui reste le défaut) :
#![allow(unused)]
fn main() {
pub mod lowlevel { // documenté expert-only ; JAMAIS le chemin par défaut applicatif
/// Bloc AES brut (single-block) — QUIC header protection (RFC 9001 §5.4.3).
pub struct AirAesBlock { /* aes */ }
impl AirAesBlock {
pub fn new_128(key: &AirSymmetricKey<16>) -> Self;
pub fn new_256(key: &AirSymmetricKey<32>) -> Self;
pub fn encrypt_block(&self, block: [u8; 16]) -> [u8; 16];
}
/// Keystream ChaCha20 (RFC 8439 §2.4) — QUIC HP (suite ChaCha) + SSH cipher custom.
pub struct AirChaCha20 { /* chacha20 */ }
/// Poly1305 one-shot (RFC 8439 §2.5) — SSH chacha20-poly1305@openssh.
pub struct AirPoly1305 { /* poly1305 */ }
}
}
- Backends :
aes,chacha20,poly1305(RustCrypto) — déjà transitifs (viaaes-gcm/chacha20poly1305), promus directs. KAT : RFC 8439 / NIST. - Décision anti-mésusage : module
lowlevelséparé, opt-in ; la façadeAirAead(nonce géré) reste le défaut. La construction (header protection, chacha20-poly1305@openssh) vit en couche 2 (air-quic/air-ssh), pas ici.
B.2 — RSA PKCS#1 v1.5 — vérification seule (SSH rsa-sha2-256/512)
#![allow(unused)]
fn main() {
/// Vérif. RSASSA-PKCS#1 v1.5 — clé PUBLIQUE only. Distinct du RSA-PSS (ADR-078).
pub struct AirRsaPkcs1VerifyingKey { /* rsa (déjà ADR-078) */ }
}
- Aucune opération privée RSA → hors Marvin (RUSTSEC-2023-0071). PKCS#1 v1.5 signature ≠ chiffrement : la vérif. v1.5 est sûre ; le chiffrement v1.5 (Bleichenbacher) reste omis.
B.3 — Kex PQ SSH : réutilise ML-KEM-768 (ADR-078)
air-ssh utilise mlkem768x25519-sha256 (défaut OpenSSH ≥ 9.9) → réutilise
ml-kem-768 déjà instruit (ADR-078). sntrup761x25519 différé (pas de crate
pure-Rust vettée). Aucun additif crypto nouveau pour le PQ SSH.
Discipline
| Additif | Crate (RustCrypto) | Zéro-C | Consommateur |
|---|---|---|---|
lowlevel::AirAesBlock | aes (direct) | ✅ | QUIC header protection |
lowlevel::AirChaCha20 | chacha20 (direct) | ✅ | QUIC HP + SSH cipher |
lowlevel::AirPoly1305 | poly1305 (direct) | ✅ | SSH chacha20-poly1305@openssh |
AirRsaPkcs1VerifyingKey | rsa (déjà ADR-078) | ✅ | SSH rsa-sha2-256/512 |
| (PQ SSH) | ml-kem (déjà ADR-078) | ✅ | SSH mlkem768x25519 |
Exceptions 80 % : aes/chacha20/poly1305 promues directes dans EXCEPTIONS.md
DEPENDENCIES.md. KAT-gated (ADR-034). Ratification BDFL (surface crypto).
Additifs planifiés — AEAD nonce explicite, BLAKE2s, PBKDF2 (ADR-082)
Complète ADR-078/081. Révélés par
air-wireguard/air-mailet une relecture des piles TLS/QUIC/SSH. Même discipline (ratification BDFL, KAT-gate ADR-034). Pur Rust, zéro-C.
C.1 — lowlevel::AirAeadExplicitNonce (AEAD à nonce fourni) — le point important
#![allow(unused)]
fn main() {
pub mod lowlevel {
/// AEAD (AES-128/256-GCM | ChaCha20-Poly1305) où **l'appelant fournit le nonce**.
/// Pour les protocoles à nonce DÉTERMINISTE (nonce = IV ⊕ compteur/séquence).
pub struct AirAeadExplicitNonce { /* aes-gcm | chacha20poly1305 (déjà présents) */ }
impl AirAeadExplicitNonce {
pub fn seal(&self, nonce: &[u8; 12], plaintext: &[u8], aad: &[u8]) -> AirResult<Vec<u8>>;
pub fn open(&self, nonce: &[u8; 12], sealed: &[u8], aad: &[u8]) -> AirResult<Vec<u8>>;
}
}
}
Pourquoi c’est nécessaire. La façade
AirAead(§5) génère un nonce aléatoire interne et le préfixe — parfait pour l’applicatif, inutilisable pour un protocole de session : TLS 1.3 record (RFC 8446 §5.3), QUIC packet (RFC 9001 §5.3), WireGuard, SSH aes-gcm dérivent un nonce déterministeIV ⊕ compteur, non préfixé.air-tls/air-quic/air-ssh/air-wireguardutilisent doncAirAeadExplicitNonce(danslowlevel, opt-in expert-only), pas la façade. Contrat : l’appelant garantit l’unicité du nonce par clé (les key schedules l’assurent par construction — compteur jamais réutilisé). La façade à nonce géré reste le défaut applicatif.
C.2 — BLAKE2s (WireGuard)
#![allow(unused)]
fn main() {
pub enum AirHashAlgorithm { /* … */ Blake2s /* additif */ }
pub struct AirBlake2sMac { /* blake2 : MAC keyed (MAC1/MAC2) + HMAC-BLAKE2s (KDF Noise) */ }
}
- Backend :
blake2(RustCrypto) — pur Rust, ZÉRO-C (≠blake3qui tirecc). KAT : RFC 7693.
C.3 — PBKDF2-HMAC-SHA256 (SASL SCRAM)
#![allow(unused)]
fn main() {
pub struct AirPbkdf2;
impl AirPbkdf2 { pub fn derive_sha256(password: &[u8], salt: &[u8], iterations: u32, out: &mut [u8]) -> AirResult<()>; }
}
- Backend :
pbkdf2(RustCrypto), pur Rust. KAT : RFC 6070. Requis pourSCRAM-SHA-256(sinonOAUTHBEARERtoken, sans PBKDF2).
Discipline
| Additif | Crate (RustCrypto) | Zéro-C | Consommateur |
|---|---|---|---|
lowlevel::AirAeadExplicitNonce | aes-gcm/chacha20poly1305 (déjà) | ✅ | record/packet TLS/QUIC/SSH/WireGuard |
AirHashAlgorithm::Blake2s + AirBlake2sMac | blake2 (nouveau) | ✅ | WireGuard (Noise) |
AirPbkdf2 | pbkdf2 (nouveau) | ✅ | mail SASL SCRAM-SHA-256 |
Exceptions 80 % : blake2/pbkdf2 ajoutées (EXCEPTIONS.md + DEPENDENCIES.md).
KAT-gated (ADR-034). Ratification BDFL (surface crypto).
Récapitulatif air-crypto
| Domaine | API | Backend (exception 80 %) |
|---|---|---|
| Aléa | AirRandom | getrandom (couche 0) |
| Clés/secrets | AirSymmetricKey, AirDerivedKey (zeroize) | zeroize |
| Hachage | AirHash, AirHashAlgorithm | sha2/sha3/blake3 |
| MAC | AirHmac (verify constant-time) | hmac + subtle |
| AEAD | AirAead (nonces gérés) | aes-gcm, chacha20poly1305 |
| KDF | AirHkdf, AirArgon2 | hkdf, argon2 |
| Signatures / ECDH | AirSigningKey/AirVerifyingKey, AirEcdh* | ed25519-dalek, x25519-dalek |
Différé : RSA & modes hérités ; variante AEAD à compteur de session ; TLS (rustls) → spec séparée ; ABI C.
Dépendances (règle des 80 %, ADR-024 — exceptions nommées)
La discipline de ces dépendances crypto (exemption de vendoring, fast-lane sécurité, veille des upstreams, KAT-gating des bumps) est régie par ADR-034. Cette spec ne la redétaille pas — s’y reporter et la respecter.
Trois upstreams (purs Rust, sans C ; licences Apache-2.0 / MIT / BSD-3-Clause ;
blake3 en dual CC0-1.0 OR Apache-2.0 → option Apache dans deny.toml) :
- RustCrypto :
sha2,sha3,blake2,hmac,aes-gcm,chacha20poly1305,hkdf,argon2, les trait-crates, etzeroize; - dalek-cryptography :
ed25519-dalek,x25519-dalek,curve25519-dalek, etsubtle(à ne pas attribuer à RustCrypto) ; - équipe BLAKE3 :
blake3.
Toutes sont des exceptions structurantes à la règle des 80 % (comme icu4x) —
à documenter nommément dans docs/EXCEPTIONS.md (« crypto auditée, sécurité par
adoption massive », ADR-016 par analogie) et exemptées de vendoring (ADR-034).
getrandom = couche 0. Pas de réécriture de primitive.
Stratégie de tests
- Couverture 100 % lignes + branches.
- Vecteurs de test connus (KAT) — impératif : chaque primitif comparé à des vecteurs RFC/NIST officiels (SHA, HMAC RFC 4231, AES-GCM NIST, ChaCha20-Poly1305 RFC 8439, HKDF RFC 5869, Ed25519 RFC 8032, X25519 RFC 7748, Argon2). C’est la preuve de correction d’une primitive crypto.
- Round-trips :
seal/open,sign/verify, ECDH des deux côtés → même secret, KDF déterministe. - Tests négatifs (sécurité) : ciphertext/tag altéré →
openéchoue (pas de plaintext) ; mauvaise clé ; signature falsifiée →verifyfalse ; mot de passe faux →verify_passwordfalse. zeroize: vérifier que la mémoire d’un secret est effacée auDrop(test mémoire dédié).- Fuzzing (cargo-fuzz) : décodage des entrées externes —
AirVerifyingKey::from_bytes,AirEcdhPublicKey,open/verifysur octets arbitraires, parsing PHC Argon2. Zéro panique. - Doctests.
Décisions de fond
- Zéro crypto maison — adoption de RustCrypto/rustls auditées ; exceptions
80 % nommées (
EXCEPTIONS.md), commeicu4x. - API anti-mésusage — clés
zeroize, AEAD à nonces gérés (réutilisation impossible), vérifications constant-time, accès secret viaexposenommé. AirRandomtoujoursgetrandomkernel — jamais de PRNG userspace.- Cœur moderne (Ed25519/X25519/AEAD/Argon2…) ; RSA & legacy différés (plus sûrs par omission).
- TLS hors périmètre (protocole de session → spec séparée).
- KAT contre vecteurs officiels — étalon de correction crypto.
Travail à reprendre
- TLS — spec séparée : production
air-tls(rustls, ADR-042) ; contingence maisondocs/specs/layer-2/air-tls.md(TLS 1.3 pur Rust). - Additifs
air-tls(descellementcouche-1-v1.x, RFC) — AES-128-GCM, ECDSA P-256/P-384 (sign+ECDH, RFC 6979), RSA-PSS vérif. seule (pas d’opération privée RSA → hors Marvin), ML-KEM-768 hybride ; cf. §« Additifs planifiés ». KAT-gated (ADR-034), exception 80 % à nommer (EXCEPTIONS.md). - PKCS#1 v1.5 chiffrement, RSA privé, AES-CBC — jamais (omission = sécurité).
- Variante AEAD à compteur de session (gros volumes sous une clé).
- ABI C d’
air-crypto; dérivation app-specific de l’ID machine (AirId128, consommateur d’air-crypto, cf.air-base-libservices). - Dernière crate couche 1 :
air-device.
Licence du document : MPL 2.0
Statut : Spécification technique d’air-crypto (couche 1). API Rust ; ABI C
différée. Cœur moderne sur RustCrypto (exceptions 80 %) ; TLS séparé.
Spec couche 1 — air-device (énumération, surveillance, identité des périphériques)
Spécification technique — Version 1.0. Couche 1 « Primitives système ». Dernière crate couche 1.
Position et méthode
air-device construit, au-dessus de la famille device couche 0 (uevent
netlink + evdev + sysfs via fs), le modèle de périphériques d’Air :
énumération du matériel, surveillance du hotplug, accès typé aux propriétés et à
l’identité. C’est l’équivalent fonctionnel de libudev, réécrit nativement en
Rust, sans aucune dépendance C.
Décision de socle (gravée, 2026-06-12).
air-deviceest natif : zérolibudev, zéro dépendance àsystemd-udevd. Il énumère en parcourant/syslui-même (viaair-filesystem) et reçoit le hotplug en lisant le socket uevent kernel (couche 0). Conséquence assumée : Air ne dépend pas, pour la découverte de matériel, d’un démon systemd qui tourne — c’est le premier maillon de l’indépendance progressive vis-à-vis de systemd (cf. stratégie de remplacement : udevd est le candidat précoce, peu risqué). À réconcilier : la ligne «libudeven couche 1 » d’ADR-005 doit être amendée (pré-public → édition directe ou RFC, ADR-015). Même nature que les réconciliations passées : AirLog→socket journald natif, DNS maison→pas degetaddrinfo.
Règle du joint (anti-contrat-à-vie). La surface publique d’
air-devices’exprime exclusivement dans le vocabulaire d’Air —AirDevice,AirSubsystem, propriétés typées — jamais un miroir d’udev(udev_device, nomsID_*bruts). C’est ce qui rend l’implémentation dessous (sysfs natif aujourd’hui, autre chose demain) remplaçable sans douleur. À vérifier en CI : aucune couche > 1 ne dépend d’un type systemd/udev-spécifique.
API Rust d’abord ; ABI C (libair-base/dédié) différée. Méthode
doc-d’abord : ce contrat est validé avant implémentation.
Conventions de couche 1 (rappel)
Rust idiomatique, aucun unsafe exposé ; couverture 100 % (couche
fondatrice) ; nommage explicite (ADR-029) ; arithmétique défensive (Principe 2) ;
zéro présomption d’encodage (Principe 3 — les noms/attributs sysfs sont des
octets, pas forcément UTF-8 → AirString faillible vs &[u8]/AirPath) ;
validation en amont (Principe 4).
Dépendances assumées (règle des 80 %, ADR-024)
- Aucune dépendance externe. Pur Rust sur la couche 0 (
air-sys-syscall::device,::fs) et la couche 1 (air-base-lib,air-filesystem). Pas delibudev. - Pas de logique politique (macro-archi) :
air-deviceénumère, identifie, notifie. Les décisions (autoriser/monter/appliquer une règle) sont en couche 5.
Carte de consommation couche 0 (pour le journal de dette doc) :
- énumération/attributs →
air-filesystem(/sys:openat,read,getdents64, lecture de symlinks pour la résolutionDEVPATH/parent) ;- hotplug →
device::UEventSocket+UEventMessage(décodeur emprunté couche 0, déjà fuzzé) ;- identité d’entrée →
device::evdev_*(§5).
Section 1 — Le modèle : AirDevice
Un AirDevice est un nœud de l’arbre /sys/devices, identifié par son chemin
sysfs. Construction paresseuse : les attributs sont lus à la demande (un
device est bon marché à créer, on ne lit /sys que si on interroge).
#![allow(unused)]
fn main() {
/// Un périphérique du système, ancré sur son entrée sysfs.
pub struct AirDevice { /* syspath possédé + cache d'attributs paresseux */ }
impl AirDevice {
/// Construit depuis un chemin sysfs absolu (`/sys/devices/...`).
/// # Errors `NotFound` si le chemin n'existe pas / n'est pas un device.
pub fn from_sys_path(path: &AirPath) -> AirResult<Self>;
/// Construit depuis un `DEVPATH` d'uevent (relatif à `/sys`).
pub fn from_dev_path(devpath: &[u8]) -> AirResult<Self>;
/// Nom court du device (dernier composant du syspath), p. ex. `event3`, `sda`.
pub fn sys_name(&self) -> &AirPath;
/// Chemin sysfs absolu.
pub fn sys_path(&self) -> &AirPath;
/// Sous-système (`/sys/.../subsystem` résolu), typé.
pub fn subsystem(&self) -> AirResult<AirSubsystem>;
/// Nœud `/dev` associé (`DEVNAME`), s'il existe (p. ex. `/dev/input/event3`).
pub fn device_node(&self) -> AirResult<Option<AirPath>>;
/// Lit un attribut sysfs brut (octets), p. ex. `name`, `uevent`, `dev`.
/// # Errors `NotFound` si l'attribut n'existe pas.
pub fn attribute(&self, name: &str) -> AirResult<AirByteBuffer>;
/// Attribut interprété en UTF-8 trimé (commodité ; faillible, Principe 3).
pub fn attribute_str(&self, name: &str) -> AirResult<AirString>;
/// Attribut parsé en entier (base 10 ou `0x`). # Errors `InvalidData`.
pub fn attribute_int(&self, name: &str) -> AirResult<i64>;
/// Écrit un attribut (p. ex. déclencher `uevent`, régler `power/control`).
/// **Privilégié** selon l'attribut. # Errors `PermissionDenied`.
pub fn write_attribute(&self, name: &str, value: &[u8]) -> AirResult<()>;
/// Propriétés `uevent` du device (lues depuis `/sys/.../uevent`), itérables.
pub fn properties(&self) -> AirResult<AirDeviceProperties>;
/// Valeur d'une propriété par clé.
pub fn property(&self, key: &str) -> AirResult<Option<AirByteBuffer>>;
/// Parent direct dans l'arbre sysfs (`..` jusqu'au prochain device réel).
pub fn parent(&self) -> AirResult<Option<AirDevice>>;
/// Premier ancêtre du sous-système (et `devtype`) donné — remplace
/// `udev_device_get_parent_with_subsystem_devtype`.
pub fn parent_with_subsystem(&self, subsystem: AirSubsystem)
-> AirResult<Option<AirDevice>>;
}
}
AirSubsystem : enum typé des sous-systèmes courants (Input, Block, Net,
Usb, Pci, Tty, Hwmon, Drm, Sound…) + Other(AirString) pour ceux
non encore nommés (jamais de blocage — cohérent ADR-029 « variante Raw »).
Section 2 — Énumération : AirDeviceEnumerator
#![allow(unused)]
fn main() {
/// Parcourt `/sys` et liste les devices correspondant à un filtre.
pub struct AirDeviceEnumerator { /* filtres accumulés */ }
impl AirDeviceEnumerator {
pub fn new() -> Self;
/// Restreint à un sous-système (`/sys/class/<sub>` ou `/sys/bus/<sub>`).
pub fn match_subsystem(self, subsystem: AirSubsystem) -> Self;
/// Restreint par nom court (`sys_name`).
pub fn match_sys_name(self, pattern: &str) -> Self;
/// Restreint aux devices ayant une propriété `key=value`.
pub fn match_property(self, key: &str, value: &str) -> Self;
/// Restreint aux devices ayant l'attribut sysfs `name=value`.
pub fn match_attribute(self, name: &str, value: &[u8]) -> Self;
/// Exécute le parcours et retourne les devices (allocation autorisée
/// couche 1 ; le parcours sysfs est intrinsèquement allouant).
/// # Errors propage les erreurs `fs` non triviales (≠ `NotFound` toléré).
pub fn enumerate(&self) -> AirResult<Vec<AirDevice>>;
}
}
Le parcours suit /sys/class/<subsystem>/ et /sys/bus/<subsystem>/devices/,
résout les symlinks vers /sys/devices/..., déduplique par syspath
canonique. Filtrage paresseux : un device n’est matérialisé que s’il passe le
filtre (on ne lit pas tous les attributs de toute la machine).
Section 3 — Surveillance du hotplug : AirDeviceMonitor
#![allow(unused)]
fn main() {
/// Flux d'événements d'apparition/disparition de matériel.
pub struct AirDeviceMonitor { /* UEventSocket + filtres */ }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AirDeviceAction { Added, Removed, Changed, Bound, Unbound, Moved, Online, Offline, Other }
/// Un événement hotplug : action + device concerné.
pub struct AirDeviceEvent { /* action + AirDevice + propriétés de l'événement */ }
impl AirDeviceEvent {
pub fn action(&self) -> AirDeviceAction;
pub fn device(&self) -> &AirDevice;
pub fn property(&self, key: &str) -> Option<&[u8]>;
}
impl AirDeviceMonitor {
/// Ouvre la surveillance sur le **groupe kernel** (brut, sans démon udev).
/// # Errors si le socket netlink ne peut être ouvert/lié.
pub fn open() -> AirResult<Self>;
/// Filtre côté Air par sous-système (n'expose que les events pertinents).
pub fn match_subsystem(self, subsystem: AirSubsystem) -> Self;
/// FD sous-jacent, pour intégration dans un reactor (io_uring/poll).
pub fn as_fd(&self) -> BorrowedFd<'_>;
/// Lit le prochain événement (bloquant, ou `WouldBlock` si non-bloquant).
/// Décodé via le décodeur uevent couche 0 (emprunté), puis matérialisé.
pub fn next_event(&mut self) -> AirResult<AirDeviceEvent>;
}
}
Décision (groupe
KERNEL, autonomie). On écoute le groupe netlink kernel (brut), pas le groupeUSERSPACEre-diffusé parudevd— c’est ce qui rend la surveillance indépendante d’un démon systemd. Contrepartie honnête : un event kernel peut précéder la stabilisation complète de/sys(un attribut pas encore peuplé). Mitigations spécifiées :AirDevicelit les attributs à la demande (pas au moment de l’event), et un consommateur qui a besoin d’un attribut tardif re-lit (le device est ré-interrogeable). On ne ré-implémente pas le moteur de règles d’udev (hors périmètre couche 1 ; politique = couche 5).
Section 4 — Sous-systèmes typés (cœur v1)
Tout AirDevice expose ses propriétés en générique (clé/valeur). Au-dessus,
quelques sous-systèmes à forte valeur reçoivent un accès typé (vues légères
construites depuis sysfs), extensibles au besoin :
#![allow(unused)]
fn main() {
/// Vue typée d'un device réseau (`/sys/class/net/<iface>`).
pub struct AirNetDevice<'d>(&'d AirDevice);
impl AirNetDevice<'_> {
pub fn interface_name(&self) -> AirResult<AirString>; // sys_name
pub fn mac_address(&self) -> AirResult<AirMacAddress>; // address
pub fn is_up(&self) -> AirResult<bool>; // operstate/flags
}
/// Vue typée d'un device bloc (`/sys/class/block/<name>`).
pub struct AirBlockDevice<'d>(&'d AirDevice);
impl AirBlockDevice<'_> {
pub fn size_bytes(&self) -> AirResult<u64>; // size × 512
pub fn is_removable(&self) -> AirResult<bool>; // removable
pub fn model(&self) -> AirResult<Option<AirString>>;
}
/// Vue typée d'un device USB (`bus usb`).
pub struct AirUsbDevice<'d>(&'d AirDevice);
impl AirUsbDevice<'_> {
pub fn vendor_id(&self) -> AirResult<u16>; // idVendor (hex)
pub fn product_id(&self) -> AirResult<u16>; // idProduct (hex)
pub fn serial(&self) -> AirResult<Option<AirString>>;
}
}
Décision (v1 : générique + 3 typés).
net,block,usbsont typés (les plus utiles tôt) ; tout le reste reste générique (clé/valeur via §1), le typage s’ajoutant au besoin sans casser la surface. L’entrée typée (input) est traitée à part en §5.
Section 5 — Périphériques d’entrée : AirInputDevice
air-device couvre l’entrée au niveau périphérique : ouverture, identité,
capacités, capture, et lecture typée d’événements (mince enrobage des
evdev_* couche 0). L’interprétation de l’entrée (layouts clavier, gestes,
focus, association siège) est hors périmètre — c’est de la politique
compositeur (ADR-003), spécifiée ailleurs.
#![allow(unused)]
fn main() {
pub struct AirInputDevice { /* OwnedFd evdev + identité */ }
impl AirInputDevice {
/// Ouvre `/dev/input/eventX` (via un `AirDevice` du sous-système `Input`).
/// Horloge des horodatages réglée sur **monotone** (corrélation fiable).
pub fn open(device: &AirDevice) -> AirResult<Self>;
pub fn name(&self) -> AirResult<AirString>; // EVIOCGNAME
pub fn id(&self) -> AirResult<AirInputId>; // EVIOCGID (bus/vendor/product)
pub fn physical_location(&self) -> AirResult<AirString>; // EVIOCGPHYS
/// Capacités : types d'événements et codes supportés, propriétés.
pub fn capabilities(&self) -> AirResult<AirInputCapabilities>;
/// Capture exclusive (`grab`) / relâche. Révocation irréversible d'un FD cédé.
pub fn grab(&mut self) -> AirResult<()>;
pub fn release(&mut self) -> AirResult<()>;
pub fn as_fd(&self) -> BorrowedFd<'_>; // pour reactor
/// Lit un lot d'événements typés (enrobe `evdev_read_events`).
pub fn read_events<'b>(&self, out: &'b mut [AirInputEvent]) -> AirResult<&'b [AirInputEvent]>;
}
}
AirInputEvent enrichit l’InputEvent couche 0 d’un horodatage AirInstant
(l’horloge étant connue = monotone) et d’un event_type/code typés.
Section 6 — Décisions de fond
- Natif, zéro
libudev/udevd— autonomie de découverte, premier pas de l’indépendance systemd ; ADR-005 (ligne libudev) à amender. - Vocabulaire Air, pas de fuite udev — le joint qui rend l’implémentation remplaçable ; vérifié en CI.
- Groupe uevent
KERNEL(pasUSERSPACE/udevd) — autonomie, au prix d’une stabilisation sysfs non garantie (attributs lus/relus à la demande). - Pas de moteur de règles, pas de politique — énumération + identité + notification seulement ; règles/nommage persistant/décisions = couche 5.
- Générique partout + typage des sous-systèmes à forte valeur (net/block/usb,
- input à part), extensible.
- Octets vs UTF-8 stricts (Principe 3) — noms/attributs sysfs faillibles en
AirString.
Section 7 — Stratégie de tests
- Énumération : contre le vrai
/sysdu runner (présent en CI) — listernet/block, vérifier syspath/sysname/subsystem ; filtres (subsystem, propriété, attribut) ; dédup ;parent/parent_with_subsystem. - Attributs : parsing
attribute_int/_str(base 10/hex, trim, non-UTF-8 → erreur propre) — property-based + fuzzing sur les parseurs d’attributs (ils ingèrent des octets sysfs = entrée externe, Principe 3). - Hotplug :
AirDeviceMonitor— harnais privilégié déclenchant un vrai uevent (modprobe/rmmodd’un module factice, ou écriture/sys/.../uevent) ; à défaut de privilège, skip propre (COVERAGE-EXCEPTIONScatégorie PRIVILEGE) et test du décodage sur trames synthétiques. - Input : périphérique virtuel
uinput(comme la couche 0) — injecter des événements, relire typé, vérifier identité/capacités/grab(EBUSYau second grab). Skip propre hors privilège. - Couverture 100 % hors exceptions ; ensemble « couvrable » VIDE ; exceptions (branches privilégiées non atteignables) documentées et justifiées.
Section 8 — Travail à reprendre
- Couche 1 complète côté specs après ce document (8/8 crates).
- Manques couche 0 éventuels révélés à l’implémentation (parcours sysfs) → à
consigner (dette doc). Aucun anticipé :
fs(openat/getdents64/readlink) etdevice(uevent/evdev) couvrent le besoin. - Hors périmètre, à spécifier ailleurs : interprétation de l’entrée (compositeur, ADR-003) ; nommage persistant / règles (couche 5) ; amendement ADR-005 (ligne libudev).
Licence du document : MPL 2.0
Statut : Spécification technique de air-device (couche 1), construite nativement
sur la famille device couche 0. Dernière crate couche 1 spécifiée.
Spec couche 1 — air-runtime (runtime asynchrone natif io_uring)
Spécification technique — Version 1.0 (décisions validées BDFL 2026-06-24). Couche 1 « Primitives système ».
Position et méthode
air-runtime est le runtime d’exécution asynchrone d’Air : ordonnanceur de tâches,
réacteur io_uring, primitives async (sleep/timeout/select, mutex/channel/notify),
signaux et timers async. Il vit en couche 1, consomme directement
air-sys-syscall::io_uring (couche 0), et n’a aucune dépendance externe —
en particulier pas de tokio (ADR-038). Il s’appuie sur air-base-lib
(AirError/AirResult, AirInstant/AirDuration).
Décisions de socle déjà gravées (ne sont PAS rouvertes ici) :
- Pas de tokio, runtime maison sur io_uring — ADR-023, ADR-038.
- Nom
air-runtime(couche 1) ; le modèle d’objet estair-object(couche 2) ;air-event(couche 2) est la façade C-ABI par-dessus ce runtime — ADR-039.- Synchrone-first / async opt-in : ce runtime n’est jamais imposé. Les API couche 1 (
air-socket/air-filesystem/…) restent synchrones et exposentas_fd();air-runtimeles pilote quand un consommateur choisit l’async.- Ordonnancement : exécuteur single-thread comme unité, scalable en thread-per-core shared-nothing (réutilise io_uring Temps 3e :
RingPool/msg_ring/LockedIoUring/SqpollIoUring), sans work-stealing v1.- Soundness io_uring : tout repose sur les garanties de ADR-028 (S1/S2/S3, téardown).
Découpage multi-crate (ADR-039)
air-runtime-core/ — exécuteur single-thread, Task, spawn, block_on, réacteur
air-runtime-io/ — intégration io_uring : futures sur complétions, ownership buffers
air-runtime-time/ — sleep / timeout / intervalle (io_uring timeout ; timerfd si besoin)
air-runtime-signal/ — signaux async (signalfd lu via io_uring) — cf. ADR-020
air-runtime-sync/ — Mutex / channel MPSC / Notify **async** (≠ air-thread, bloquant)
air-runtime/ — façade qui réexporte la surface consommée par les couches hautes
air-runtime-sync est distinct d’air-thread (Principe : deux familles de synchro,
ADR-038/039) : air-thread = bloquant (std::sync/parking_lot) ; air-runtime-sync
= async (réveil par waker, pas de blocage de thread OS).
DÉCISION CENTRALE (validée BDFL 2026-06-24) — modèle de futures sur complétions io_uring
C’est le choix structurant : il détermine toute la surface I/O async.
Le problème. io_uring est à complétion (≠ epoll, à readiness) : on soumet un SQE
référençant un buffer, le kernel écrit plus tard dans ce buffer, puis poste un CQE.
Le buffer doit rester valide jusqu’au CQE. Or une Future Rust peut être abandonnée
(drop) avant le CQE (annulation, select!, timeout) → si le buffer est libéré, le
kernel écrit dans de la mémoire libérée (UB). C’est l’écueil de soundness connu des
runtimes io_uring.
Décision (validée) — modèle « buffers possédés » + reclaim différé à l’annulation :
- Les opérations I/O prennent possession du buffer et le rendent à la complétion :
Le runtime détient le buffer dans le slot d’opération (slab à#![allow(unused)] fn main() { // au lieu de read(&mut buf) (readiness, tokio-style) : async fn read(&self, buf: AirIoBuf) -> (AirResult<usize>, AirIoBuf); }user_data, même schéma anti-ABA que le slab io_uring couche 0) jusqu’au CQE. - À l’annulation (drop de la future avant CQE) : le runtime soumet un
ASYNC_CANCELet conserve buffer + slot vivants jusqu’au CQE d’annulation (reclaim différé) — jamais de use-after-free. Adossé à ADR-028 (cycle de vie S1/S2/S3, téardown). - Téardown du runtime : drainage des opérations en vol avant libération (ADR-028).
Conséquence ergonomique assumée : l’API passe les buffers par valeur (moins
fluide que &mut buf), mais sound par construction — cohérent avec la rigueur des
couches fondatrices (Principe 1) et le « sur-sécuriser puis dégraisser » (Principe 5).
Des helpers (lecture vers Vec réutilisé via air-memory, BufRing io_uring 3b pour la
réception) atténuent le coût.
Alternatives écartées : buffers empruntés (&mut) → exigeraient bloquer au drop
ou fuiter l’op (non-sound / non-ergonomique) ; rejeté.
Les sections 2+ ci-dessous reposent sur ce modèle ratifié.
Section 1 — air-runtime-core : exécuteur, Task, réacteur
/// Exécuteur **single-thread** (unité d'ordonnancement, ADR-039). Possède son ring
/// io_uring et sa file de tâches prêtes. Non `Send` (affinité thread).
pub struct AirRuntime { /* ring + slab d'ops + file de tâches + réacteur */ }
impl AirRuntime {
/// Crée un runtime single-thread (un ring io_uring dimensionné).
/// # Errors `AirError` si la création du ring échoue.
pub fn new() -> AirResult<Self>;
pub fn with_config(config: AirRuntimeConfig) -> AirResult<Self>;
/// Exécute `future` jusqu'à complétion en pilotant le réacteur (point d'entrée
/// synchrone d'un programme : `fn main` appelle `block_on`).
pub fn block_on<F: Future>(&self, future: F) -> F::Output;
/// Planifie une tâche concurrente ; rend un handle attendable/annulable.
pub fn spawn<F: Future + 'static>(&self, future: F) -> AirTask<F::Output>;
}
/// Handle d'une tâche : `await` son résultat, ou `cancel()` (annulation coopérative).
pub struct AirTask<T> { /* … */ }
impl<T> AirTask<T> { pub fn cancel(self); /* Future for AirTask<T> -> T */ }
/// Config : taille du ring, mode (single-thread / per-core), SQPOLL on/off…
pub struct AirRuntimeConfig { /* … */ }
Réacteur (boucle interne). Une seule boucle : (a) draine la file des tâches prêtes
(poll), (b) soumet les SQE accumulés, (c) attend des CQE (io_uring_enter), (d) pour
chaque CQE : retrouve le slot par user_data, dépose le résultat, réveille le waker.
Pas de thread de réacteur séparé en mode single-thread (le thread courant est le
réacteur entre deux poll). Verbosité assumée (Principe 7) : aucune magie, la boucle
est lisible.
Scheduling. File FIFO simple en v1 (round-robin des tâches prêtes), pas de work-stealing (ADR-039). Équité suffisante pour des charges I/O-bound.
Section 2 — air-runtime-io : futures sur complétions
Cœur du modèle « buffers possédés » (décision centrale). Expose :
AirIoBuf: buffer possédé (longueur initialisée + capacité), convertible depuis/versVec<u8>et tranchesair-memory; passé par valeur aux ops.- Enregistrement de fd :
AirAsyncFd::new(borrowed_fd)— adapte unas_fd()d’une API couche 1 synchrone (air-socket/air-filesystem) pour soumettre des ops io_uring dessus. C’est la couture sync→async (ADR-038). - Opérations (par valeur, rendent le buffer) :
read,write,accept,recv,send,connect,poll_ready, etc. — adossées aux opcodes io_uring couche 0 (Temps 2a/2b). Multishot (accept/recv) exposé via un stream (réutilise Temps 3d). submit/await_cqeinternes : un seul slab d’ops àuser_data(bit de tag cohérent avec le slab couche 0, Temps 4) ; annulation =ASYNC_CANCEL+ reclaim différé.
Sûreté. Aucune fonction unsafe exposée. Les blocs unsafe internes (soumission de
SQE pointant un buffer, lecture du CQE) portent // SAFETY: référant ADR-028 (le buffer
vit jusqu’au CQE par construction du slot). Miri sur les transitions
poll→submit→complete→wake et sur l’annulation (reclaim différé). loom sur le réveil
de waker concurrent (mode per-core).
Section 3 — air-runtime-time : sleep / timeout / intervalle
#![allow(unused)]
fn main() {
pub async fn sleep(duration: AirDuration);
pub async fn sleep_until(deadline: AirInstant);
/// Course « future vs délai » ; `None` si le délai expire d'abord.
pub async fn timeout<F: Future>(duration: AirDuration, future: F) -> Option<F::Output>;
pub struct AirInterval { /* tick périodique */ }
}
Décision (validée). Les délais internes du runtime s’appuient sur IORING_OP_TIMEOUT
(io_uring natif, déjà couche 0 Temps 2c/3d) plutôt que sur un timerfd par timer —
zéro fd par timer, intégré au même ring. timerfd reste disponible (couche 0) si un
AirTimer exposable (façade air-event) le justifie.
Section 4 — air-runtime-signal : signaux async
Conforme ADR-020 (signalfd par défaut). signalfd est lu via io_uring → un
stream async de signaux. Pas de handler async-signal-unsafe ; le masquage et la
sémantique restent ceux de la couche 0.
#![allow(unused)]
fn main() {
pub struct AirSignals { /* signalfd enregistré */ }
impl AirSignals {
pub fn watch(set: &[AirSignal]) -> AirResult<Self>;
pub async fn next(&self) -> AirResult<AirSignalInfo>;
}
}
Section 5 — air-runtime-sync : primitives async
Distinctes des primitives bloquantes d’air-thread (ADR-038/039). Réveil par
waker (pas de blocage de thread OS) :
#![allow(unused)]
fn main() {
pub struct AirAsyncMutex<T> { /* … */ } // lock().await
pub struct AirNotify { /* … */ } // notified().await / notify_one()
pub fn channel<T>(capacity: usize) -> (AirAsyncSender<T>, AirAsyncReceiver<T>); // MPSC, back-pressure
}
Back-pressure explicite (cohérent AirCom, ADR-001) : le channel borné bloque async l’émetteur quand plein (pas de gonflement mémoire silencieux).
Section 6 — Mode multi-cœur : thread-per-core (ADR-039)
Le scaling n’introduit pas de work-stealing : on lance N exécuteurs single-thread indépendants (un par cœur), shared-nothing, chacun son ring. Coordination via les primitives couche 0 déjà livrées (io_uring Temps 3e) :
msg_ring: envoi d’un message/tâche d’un cœur à un autre (pas de file partagée verrouillée).RingPool(ATTACH_WQ) : partage du pool de workers kernel entre rings (workers bornés).SqpollIoUring: option SQPOLL (réveilNEED_WAKEUP) pour la latence.
#![allow(unused)]
fn main() {
pub struct AirRuntimePerCore { /* un AirRuntime par cœur + msg_ring */ }
impl AirRuntimePerCore {
pub fn with_cores(n: usize) -> AirResult<Self>;
pub fn spawn_on(&self, core: usize, future: impl Future + Send + 'static);
}
}
CPU-bound : déporté vers un pool de threads bloquants (air-thread) via
spawn_blocking-like, pour ne pas bloquer un réacteur.
Périmètre v1 / différé
- v1 : exécuteur single-thread, réacteur io_uring, ops I/O (buffers possédés), sleep/timeout, signaux, sync async, thread-per-core de base.
- Différé (ADR-023) : work-stealing, multi-runtime coexistants complexes,
optimisations mesurées (Principe 5). Façade C-ABI =
air-event(couche 2, spec séparée).
Dépendances (règle des 80 %, ADR-024)
air-sys-syscall(io_uring, signalfd, timerfd) +air-base-lib(erreurs, temps). Aucune dépendance externe (pas de tokio/mio/futures-rs : on définit nos propresFuture/Wakerau-dessus decore::task). À tracer dansDEPENDENCIES.md.
Stratégie de tests
- Couverture 100 % lignes + branches (Principe 1).
- Miri : transitions de futures (poll/submit/complete/wake), annulation + reclaim différé (pas d’UB, pas de use-after-free du buffer), téardown (ADR-028).
- loom : réveils de waker concurrents,
msg_ringinter-cœurs, primitives sync async. - Property-based : séquences spawn/cancel/await ; invariants du slab d’ops.
- Tests d’intégration réels : echo TCP/UDP via
air-socketpiloté par le runtime ; timeouts ; signaux (signalfd réel) ; charge thread-per-core. - (Pas de fuzzing dédié : aucun parsing de données externes — le runtime orchestre, il ne décode pas. Le décodage des CQE bruts est couvert/fuzzé en couche 0.)
Décisions de fond
- Buffers possédés + reclaim différé à l’annulation (décision centrale, à valider) — soundness io_uring par construction (ADR-028).
- Single-thread comme unité, thread-per-core sans work-stealing (ADR-039, Principe 9).
- io_uring seul : sleep/timeout/signaux via io_uring/signalfd, pas d’epoll (ADR-038).
- Deux familles de synchro : async (ici) vs bloquante (
air-thread). - Façade C-ABI séparée :
air-event(couche 2) ;air-runtimereste Rust pur couche 1.
Décisions ratifiées (BDFL 2026-06-24)
- (centrale) modèle buffers possédés + reclaim différé à l’annulation.
- Timers via
IORING_OP_TIMEOUT(zéro fd par timer). spawnexige'static(pas de scoped tasks en v1).block_onnon réentrant (un seul réacteur par thread).
Licence du document : MPL 2.0 Statut : v1.0 — décisions validées par le BDFL (2026-06-24), dont le modèle « buffers possédés ». Surface I/O figée sur cette base ; implémentation à suivre. Bases : ADR-023 / ADR-028 / ADR-038 / ADR-039.
Spec couche 1 — air-config (compilateur de configuration + artefact Cap’n Proto)
Spécification technique — Version 0.2 (décisions #1–#5 validées BDFL 2026-06-25 ;
grammaire de source + schémas .capnp à concevoir avant implémentation). Couche 1
« Primitives système ». Crate DÉDIÉE (pas dans air-base-lib).
Position et méthode
air-config est le cœur de configuration d’Air : il transforme une source typée
(éditée par l’humain ou une GUI) en un artefact binaire reproductible que le runtime
consomme, et projette vers les fichiers texte attendus par le monde C-Unix. C’est un
composant sécurité-critique (il ingère de la donnée humaine = donnée hostile,
Principe 3) → rigueur couches 0/1 : couverture 100 %, fuzzing obligatoire,
property-based, aucune fn unsafe exposée, arithmétique défensive, nommage ADR-029.
Crate dédiée (décision ADR-040).
air-confign’est pas dansair-base-lib: elle tire Cap’n Proto (cf. §dépendances), etair-base-lib— la crate la plus consommée — doit rester légère. La §3 d’air-base-lib-services(vieuxAirConfigTOML/serde) est superseded par cette spec.
Décisions de socle déjà gravées (NON rouvertes ici)
- ADR-033 — modèle : source typée (canonique) → compilateur validant → artefact binaire ; texte = projection, jamais la forme runtime ; générations + switch atomique + rollback ; backends d’émission.
- ADR-040 — l’artefact est
en Cap’n Proto ; addendum :
capnpcexige le tool C++ au build → politique code généré committé (schémas.capnp+.rsgénéré versionnés ; tool C++ = opération mainteneur, hors CI). - ADR-041 — backends
/etc: projections générées, lecture seule sélective,air-configseul écrivain (swap atomique) ; mutables-runtime →/run;/etc/systemd/systempossédé par le backend systemd.
Vue d’ensemble (le flux)
SOURCE TYPÉE (canonique, versionnable, éditée humain/GUI)
│ air-config-compile : VALIDE en amont (schéma + types + invariants)
│ diagnostics qualité compilateur (ligne/col, message actionnable)
▼
ARTEFACT Cap'n Proto (binaire, reproductible, version + checksum)
├──────────────► RUNTIME Air : lecture ZÉRO-COPIE / mmap (jamais de /etc)
└──► BACKENDS d'émission ──► /etc/* générés (libc/Unix), RO sélectif, /run pour mutables
(sens inverse : IMPORT /etc existant → source, au bootstrap/migration)
Section 1 — Le schéma (Cap’n Proto)
La structure de la configuration est décrite par des schémas .capnp (versionnés,
numérotation de champs = évolution avant/arrière par construction). Le code Rust de
(dé)sérialisation est généré par capnpc et committé (ADR-040 addendum). Chaque
domaine de config (réseau, services, locale…) a son schéma ; un en-tête commun porte
version de schéma + checksum (corruption détectée à l’ouverture, jamais lue en silence).
Section 2 — Le compilateur : air-config-compile
Cœur sécurité-critique (ADR-033 §3). Pipeline pur (sans-IO sur la validation) :
parse(source) → AST typé → valide(schéma + types + invariants) → encode(Cap'n Proto)
- Validation en amont (Principe 4 / « parse, don’t validate ») : schéma, types, invariants métier ; refuse toute entrée invalide avant de produire l’artefact. Le runtime ne voit jamais de config invalide.
- Diagnostics qualité compilateur : ligne/colonne, message actionnable, suggestion
(ADR-019 :
AirErrorriches/contextualisés). - Entrée hostile → fuzzing obligatoire sur le parseur de source + le décodeur d’artefact ; property-based ; 100 % couverture.
DÉCISION #1 (VALIDÉE) — syntaxe de la source = format maison minimal, hand-rolled, zéro-dép, fuzzé. La « source typée » est un texte validé contre le schéma Cap’n Proto, parsé par un parseur maison minimal (pas de
toml/serde→ reste zéro-dép, cohérent ethos ; même discipline que les parseurs DNS/glob déjà hand-rollés + fuzzés). Reste à concevoir : la grammaire concrète de ce format (passe de design dédiée avant implémentation — petit format typé clé/valeur/sections, diagnostics ligne/colonne).
Section 3 — Le lecteur runtime
#![allow(unused)]
fn main() {
/// Ouvre un artefact compilé en lecture ZÉRO-COPIE (mmap via couche 0 `mem`).
/// Vérifie version de schéma + checksum ; corruption → AirError (jamais de lecture muette).
pub struct AirConfig { /* mmap de l'artefact + accès Cap'n Proto */ }
impl AirConfig {
/// # Errors `InvalidData` (version/checksum), `NotFound`.
pub fn open(path: &AirPath) -> AirResult<Self>;
/// Accès typé zéro-copie aux domaines (généré depuis le schéma).
pub fn reader<T: CapnpRoot>(&self) -> AirResult<T::Reader<'_>>;
}
}
- Zéro-copie / mmap (ADR-033 §4, Principe 9) : pas de parse au runtime, lecture par
offsets — gain réel au boot (Pi). Le runtime Air-natif lit l’artefact, jamais
/etc.
Section 4 — Générations, switch atomique, rollback (ADR-033 §6)
- Conserver la dernière génération known-good ; basculer atomiquement
(
air-filesystem::write_atomic) ; retour à la génération précédente (y compris une entrée de secours au boot pour le cas pré-userspace). La validité ne suffit pas — une config valide peut exprimer une mauvaise intention ; le filet anti-brick est le modèle générationnel.
Section 5 — Résolution de chemins (XDG)
Repris de la partie utile de l’ancien air-base-lib §3 (pure construction de chemins) :
AirConfigPaths::for_component(name) → cascade $XDG_CONFIG_HOME / ~/.config/<name> /
$XDG_CONFIG_DIRS / système (système < utilisateur). Lecture effective via air-filesystem.
Section 6 — Backends d’émission /etc + import (ADR-041)
- Émission :
air-configest le seul écrivain des fichiers/etcqu’il possède (générés depuis la source, swap atomique). Backend systemd (possède/etc/systemd/system:*.wants, drop-ins), backend resolv.conf/hosts/nsswitch, etc. Mutables-runtime → symlinks/run. La protection (montage RO sélectif) est une décision couche 5 / intégration OS (horsair-config, qui génère). - Import inverse (bootstrap/migration) : lire
/etcexistant → source Air (avant d’émettre). Périmètre de possession explicite (tout/etcn’appartient pas à Air).
DÉCISION #2 (VALIDÉE) — backends v1, séquencés par risque croissant :
resolv.conf+hosts(simples, quasi-statiques — bons premiers backends) ;- systemd (
/etc/systemd/system:*.wants/drop-ins — activation de services) ;passwd(+shadow) en dernier : c’est la base de comptes, la plus sensible —shadowporte les hashes (secrets → zeroize, jamais loggés, manipulation extra-prudente ; interaction PAM/login). Traité avec le plus grand soin, après que les 3 premiers backends soient éprouvés. Périmètre progressif (ADR-041) ; d’autres backends s’ajouteront ensuite.
Crates / structure — 3 crates (DÉCISION #3 validée)
air-config-schema/ — schémas .capnp + code Rust GÉNÉRÉ (committé, ADR-040) + en-tête version/checksum
air-config-compile/ — le compilateur (parseur source maison → valide → émet Cap'n Proto) [sécurité-critique]
air-config/ — façade : lecteur runtime (mmap zéro-copie), AirConfigPaths, générations/rollback,
ET les backends d'émission /etc + import inverse (modules internes)
Les backends sont des modules d’air-config (pas une 4ᵉ crate) → 3 crates au total.
Dépendances (règle des 80 %, ADR-024)
capnp(runtime, zéro dépendance, pur Rust — vérifié) +capnpc(build-time, code généré committé ADR-040 → tool C++ hors CI ; exception 80 % partagée AirCom).air-base-lib(erreurs/chemins),air-filesystem(lecture/écriture atomique), couche 0mem(mmap). Parseur de source : selon décision ouverte #1 (idéalement zéro-dép). Aucune dépendance systemd (le backend systemd écrit des fichiers).
Stratégie de tests
- Couverture 100 % ; fuzzing : parseur de source (hostile) + décodeur d’artefact +
import
/etc. Property-based : round-trip source→artefact→lecture ; cascade XDG ; générations (rollback rétablit l’exact known-good). Intégration : compile une config réelle, mmap, relit ; émet/etcpuis ré-importe (idempotence) ; corruption d’artefact → erreur (jamais lecture muette). Reproductibilité (ADR-025) : même source → artefact bit-pour-bit (gaterepro) ; encodage Cap’n Proto déterministe.
Décisions ratifiées (BDFL 2026-06-25)
- Syntaxe source = format maison minimal, hand-rolled, zéro-dép, fuzzé (§2 ; grammaire concrète à concevoir avant impl).
- Backends v1 séquencés :
resolv.conf+hosts→ systemd →passwd/shadowen dernier (§6). - 3 crates :
air-config-schema+air-config-compile+air-config(backends = modules d’air-config). - GUI contrainte par schéma : DIFFÉRÉE (un frontend ultérieur ; le cœur validant reste le même).
- Tool C++
capnp: version pinnée (ADR-025), confiné à la régénération mainteneur (mode prébuilt vs source à fixer à l’impl).
Pré-implémentation restante : concevoir la grammaire du format de source (#1) et
définir les schémas .capnp des domaines v1.
Différé
- Frontend GUI ; backends
/etcau-delà du périmètre v1 ; surface ABI C d’air-config(si pertinente) ; intégrationair-network/air-systemd(couche 2) consommant la config.
Licence du document : MPL 2.0
Statut : v0.2 — crate dédiée couche 1, sécurité-critique. Décisions #1–#5 validées.
Restent à concevoir avant implémentation : la grammaire du format de source (maison,
fuzzé) et les schémas .capnp des domaines v1. Bases : ADR-033 / ADR-040 (+ addendum) /
ADR-041.
Spec couche 1 — Format de source air-config (grammaire, design)
Spécification technique — Version 0.1 (design ; décision #1 d’air-config validée :
format maison minimal, hand-rolled, zéro-dép, fuzzé). Couche 1. Companion de
air-config.md.
Objet
Définit la grammaire du format de source qu’air-config-compile parse, valide
contre un schéma Cap’n Proto (ADR-040), et compile en artefact binaire (ADR-033). C’est
la forme canonique éditée par l’humain/le technicien (versionnable, diffable,
greppable) — pas la forme runtime.
Principes de conception (et pourquoi)
- Schema-directed (l’idée maîtresse). Le schéma Cap’n Proto connaît les types. Le parseur ne fait donc aucune inférence de type : il lit des lexèmes et le compilateur les valide/convertit selon le type attendu par le schéma. Cela élimine d’un coup la classe d’ambiguïtés de TOML/YAML (dates, types inférés) → grammaire régulière, petite, fuzzable.
- Familier mais maison. Surface inspirée de TOML (sections,
clé = valeur, tableaux) → l’admin reconnaît ; mais sous-ensemble strict hand-rollé (pas de dépendancetoml/serde), sans les pièges de TOML (cf. exclusions §4). - Défensif (Principe 3). UTF-8 validé, slicing borné
get(), bornes anti-DoS (taille document, profondeur d’imbrication, longueur de ligne), arithmétiquechecked_*(entiers validés vs le type cible du schéma → rejet d’overflow), aucun panic sur octets arbitraires (fuzzé). - Déterministe (ADR-025). L’ordre des clés dans la source n’affecte pas l’artefact (encodage canonique Cap’n Proto) → même source ⇒ artefact bit-pour-bit.
- Diagnostics qualité compilateur : ligne/colonne, type attendu (issu du schéma),
suggestion (
did-you-meansur clés/valeurs d’enum).
Section 1 — Lexique
- Commentaire :
#jusqu’à fin de ligne. - Identifiant (clé, nom de section, valeur d’enum) :
[A-Za-z][A-Za-z0-9_-]*. - Fin de ligne :
\n(les\rfinaux tolérés). Encodage UTF-8 obligatoire (sinon erreur de lexing, pas de panic). - Espaces : insignifiants hors chaînes.
- Bornes (valeurs par défaut, configurables, anti-DoS) : document ≤ N Mio, ligne ≤ 64 Kio, profondeur de sections ≤ 32, longueur de tableau ≤ N. Dépassement → erreur diagnostiquée.
Section 2 — Structure
# En-tête OBLIGATOIRE : quel schéma + version cette source cible (évolution, ADR-040)
schema = "air.network@1"
# Affectation simple (clé = valeur)
ipv6_first = true
# Section = struct imbriqué du schéma ; chemin pointé autorisé
[resolver]
timeout_ms = 5000
servers = ["2001:db8::1", "192.0.2.1"]
[resolver.cache] # sous-struct (chemin pointé)
max_entries = 100_000
# Tableau de structs (liste de structs du schéma) — double crochet
[[rules]]
name = "allow-dns"
action = allow # valeur d'enum (identifiant nu, validé vs le schéma)
[[rules]]
name = "deny-all"
action = deny
- En-tête
schema = "domaine@version": obligatoire, première directive signifiante. Détermine le schéma de validation + la version (compat avant/arrière). [section]↔ struct/sous-struct (chemin pointé[a.b]).[[liste]]↔List(Struct)(chaque bloc = un élément).clé = valeur↔ champ. Champ absent = non défini (défaut du schéma / optionnel).
Section 3 — Valeurs (lexèmes ; types résolus par le schéma)
| Lexème source | Types schéma acceptés (validés par le compilateur) |
|---|---|
true / false | Bool |
123, 0xFF, 1_000, -5 | Int8..64 / UInt8..64 — checked_* vs le type, rejet d’overflow/signe |
1.5, 1e9 | Float32/64 |
"texte\n\u{1F600}" (guillemets, échappements définis) | Text (UTF-8) |
hex:"00ff…" ou b64:"…" | Data (octets bruts) |
nom_nu | valeur d’enum (validée vs les valeurs du schéma ; did-you-mean sinon) |
[ v, v, … ] | List(T) — homogène, T donné par le schéma |
| (absent) | champ optionnel non posé / défaut |
- Échappements de chaîne : ensemble défini et borné (
\n \t \r \" \\ \u{...}) ; tout autre → erreur (pas d’interprétation permissive). - Sucre typé optionnel (à confirmer) : durées (
5s,250ms) et tailles (10MiB) reconnues uniquement si le champ schéma porte l’annotation correspondante → converties en entier par le compilateur. Sinon, entier nu. (Différable si jugé trop large pour v1.)
Section 4 — Exclusions volontaires (anti-pièges TOML, minimalisme)
Pour rester petit, non-ambigu, fuzzable :
- Pas de dates/heures natives (le piège de TOML) → durées via sucre typé ci-dessus.
- Pas de tables inline
{…}→ utiliser des sections. - Pas de tableaux hétérogènes → le schéma impose l’homogénéité.
- Pas d’inférence de type → schema-directed.
- Pas de clés dupliquées (erreur), pas de redéfinition de section.
Section 5 — Sémantique & déterminisme
- Validation en amont (Principe 4) : chaque valeur convertie/validée vers le type
schéma ; champ inconnu → erreur (
did-you-mean) ; champ requis manquant → erreur. - Cascade (XDG, ADR-033) : fusion système < utilisateur au niveau de la source (fusion de documents typés), puis une seule compilation → artefact.
- Déterminisme : l’artefact ne dépend pas de l’ordre des clés/sections de la source
(ordre canonique d’encodage) → repro bit-pour-bit (gate
repro, ADR-025). - Secrets (backend
passwd/shadow, ADR-041) : les valeurs marquées « secret » dans le schéma sont zeroizées dans les tampons du parseur/compilateur après usage, jamais loggées dans les diagnostics (valeur élidée).
Section 6 — Stratégie de test
- Fuzzing obligatoire :
source arbitraire (octets) → parsene panique jamais, ne produit jamais un AST invalide ;source + schéma → compilene panique jamais. - Property-based : ordre des clés indifférent (artefact identique) ; round-trip source→artefact→relecture ; bornes respectées.
- Diagnostics : tests d’erreurs (type incompatible, enum inconnue, champ requis manquant, overflow entier, UTF-8 invalide, profondeur/taille dépassée) → message ligne/colonne correct.
- Conformité : un corpus de sources valides/invalides de référence (par domaine).
Décisions / à confirmer
- Extension de fichier :
.airconf(proposé) — à valider. - Sucre durées/tailles (§3) : inclus v1 ou différé ?
- Multi-lignes de texte (
"""…""") : utile pour certaines valeurs ? (sinon\n). - Inclusion de fichiers (
include) : exclue v1 (surface + risques de cycle) — la cascade XDG couvre la composition.
Travail à reprendre
- Définir les schémas
.capnpdes domaines v1 (cf. backends ADR-041 : resolver/hosts, systemd, passwd) — c’est l’autre pré-requis d’implémentation d’air-config. - Geler la grammaire ci-dessus en BNF complète + table de lexèmes avant codage du parseur.
Licence du document : MPL 2.0
Statut : design v0.1 — grammaire du format de source air-config (maison, schema-directed,
fuzzé). À geler en BNF + définir les schémas .capnp avant implémentation. Bases :
ADR-033 / ADR-040 / ADR-041 + spec air-config.
Spec couche 1 — air-config : schémas .capnp v1 + BNF du format de source (design)
Spécification technique — Version 0.2 (décisions validées BDFL 2026-06-25 ; v1 = réseau/
services/comptes + mounts/fstab). Couche 1. Dernier pré-requis avant implémentation
d’air-config (cf. air-config.md §« pré-implémentation » et
air-config-source-format.md).
Objet
Définit (A) les schémas Cap’n Proto des domaines de config v1 (alignés sur les
backends validés : resolv.conf+hosts, systemd, passwd/shadow — décision #2) et
(B) la BNF gelée du format de source. Ensemble = la matière prête pour
l’implémentation.
Conventions de schéma (communes à tous les domaines)
- Annotations Air (fichier
air-config-annotations.capnp) :@0xada1ada1ada1ada1; annotation secret @0xacf0...01 (field) :Void; # champ = secret → zeroize, jamais loggé annotation domain @0xacf0...02 (struct) :Text; # nom de domaine ("air.network", "air.accounts"…) annotation backend @0xacf0...03 (struct) :Text; # backend /etc cible ("resolv.conf"…) - Enveloppe versionnée (en-tête commun, ADR-040 §version+checksum) :
struct ConfigEnvelope { schemaDomain @0 :Text; # ex. "air.network" schemaVersion @1 :UInt32; # version du schéma de domaine (évolution) # checksum : calculé sur l'artefact, porté HORS payload (en-tête de fichier), pas ici payload @2 :AnyPointer; # le struct racine du domaine } - Optionnalité : Cap’n Proto n’a pas d’
Optionnatif → convention « champ absent = valeur par défaut du schéma » ; pour distinguer non-posé de valeur par défaut quand c’est sémantiquement requis, on utilise un groupe unionunset | set :T. - Types réutilisés :
struct IpAddress { union { v4 @0 :UInt32; v6 @1 :Data; } } # v6 = 16 octets struct Duration { millis @0 :UInt64; } # source : sucre 5s/250ms → millis
(A) Schémas v1
A.1 — Domaine air.network (backends resolv.conf + hosts)
@0x0001...; # file id (à générer)
using Air = import "/air-config-annotations.capnp";
struct NetworkConfig $Air.domain("air.network") {
resolver @0 :ResolverConfig;
hosts @1 :HostsConfig;
}
struct ResolverConfig $Air.backend("resolv.conf") {
nameservers @0 :List(IpAddress); # ordonné (priorité)
searchDomains @1 :List(Text);
ndots @2 :UInt8; # option resolv.conf
timeout @3 :Duration;
attempts @4 :UInt8;
ipv6First @5 :Bool;
}
struct HostsConfig $Air.backend("hosts") {
entries @0 :List(HostEntry);
}
struct HostEntry {
address @0 :IpAddress;
names @1 :List(Text); # canonique d'abord, puis alias
}
A.2 — Domaine air.services (backend systemd)
@0x0002...;
struct ServicesConfig $Air.domain("air.services") $Air.backend("systemd") {
units @0 :List(UnitConfig);
defaultTarget @1 :Text; # → /etc/systemd/system/default.target (symlink généré)
}
struct UnitConfig {
name @0 :Text; # ex. "sshd.service"
state @1 :UnitState; # enabled → *.wants/ ; masked → symlink /dev/null
dropIns @2 :List(DropIn); # → /etc/systemd/system/<unit>.d/*.conf
}
enum UnitState { disabled @0; enabled @1; masked @2; }
struct DropIn { name @0 :Text; section @1 :Text; key @2 :Text; value @3 :Text; }
systemd : air-config ÉMET ces fichiers (
*.wants, drop-ins) ; l’humain ne fait plussystemctl enable(ADR-041). systemd ne fait que lire.
A.3 — Domaine air.accounts (backends passwd + shadow) — LE PLUS SENSIBLE
@0x0003...;
struct AccountsConfig $Air.domain("air.accounts") {
users @0 :List(User);
groups @1 :List(Group);
}
struct User $Air.backend("passwd") {
name @0 :Text;
uid @1 :UInt32;
gid @2 :UInt32;
gecos @3 :Text;
home @4 :Text;
shell @5 :Text;
# --- champs shadow ---
passwordHash @6 :Text $Air.secret; # SECRET : zeroize, jamais loggé, backend `shadow` (0600)
lastChange @7 :UInt32; # jours depuis epoch
minAge @8 :UInt32;
maxAge @9 :UInt32;
# … warn/inactive/expire selon besoin
}
struct Group $Air.backend("passwd") {
name @0 :Text;
gid @1 :UInt32;
members @2 :List(Text);
}
$Air.secretsurpasswordHash⇒ le compilateur zeroize ce champ dans ses tampons après usage, l’élide des diagnostics, et le backend l’écrit dans/etc/shadow(mode 0600), jamais danspasswd. Domaine livré en dernier (décision #2).
A.4 — Domaine air.mounts (backend fstab)
@0x0004...;
struct MountsConfig $Air.domain("air.mounts") $Air.backend("fstab") {
entries @0 :List(MountEntry);
}
struct MountEntry {
source @0 :Text; # device / UUID= / LABEL= / partage réseau
target @1 :Text; # point de montage
fsType @2 :Text; # ext4, btrfs, nfs, tmpfs…
options @3 :List(Text); # rw, noatime, nofail…
dumpFreq @4 :UInt8; # champ 5 de fstab
passNumber @5 :UInt8; # champ 6 (ordre fsck)
}
fstab= table de montage STATIQUE (config)./etc/mtab(mnttab) est EXCLU : c’est de l’état runtime (symlink kernel vers/proc/self/mounts), pas de la config → air-config n’y touche pas. ⚠️ Unfstaberroné peut bloquer le boot : les générations + rollback + entrée de secours au boot (ADR-033 §6) sont ici critiques.
Backends candidats (périmètre PROGRESSIF, ADR-041 — hors v1 strict)
À ajouter ensuite, par ordre de pertinence : nsswitch.conf (lie résolution réseau ↔
comptes), locale.conf/hostname/localtime(tz)/vconsole.conf (domaine air.locale/
air.hostname), sysctl.d, crypttab (pair de fstab). À DIFFÉRER (très sensibles) :
sudoers, pam.d (escalade/auth — prudence façon passwd). Hors air-config-cœur :
sshd_config (→ config du service air-ssh, couche réseau).
(B) BNF gelée du format de source
Formalise air-config-source-format.md (schema-directed).
document = header , { line } ;
header = "schema" , ws , "=" , ws , string , newline ; (* obligatoire, en tête *)
line = ( comment | section | array_table | assignment | blank ) , newline ;
comment = "#" , { any_char_no_nl } ;
blank = { ws } ;
section = "[" , path , "]" ;
array_table = "[[" , path , "]]" ;
path = ident , { "." , ident } ;
assignment = ws , key , ws , "=" , ws , value ;
key = ident ;
ident = alpha , { alpha | digit | "_" | "-" } ;
value = bool | integer | float | string | bytes | enum_val | list ;
bool = "true" | "false" ;
integer = [ "-" ] , ( dec_int | hex_int ) ; (* checked vs type schéma *)
dec_int = digit , { digit | "_" } ;
hex_int = "0x" , hexdigit , { hexdigit | "_" } ;
float = [ "-" ] , digit , { digit } , [ "." , digit , { digit } ] , [ exp ] ;
exp = ("e"|"E") , [ "+" | "-" ] , digit , { digit } ;
string = '"' , { str_char | escape } , '"' ;
escape = "\" , ( "n" | "t" | "r" | '"' | "\" | "u{" , hexdigit{1,6} , "}" ) ;
bytes = ( "hex:" , string ) | ( "b64:" , string ) ;
enum_val = ident ; (* validé vs enum du schéma *)
list = "[" , ws , [ value , { ws , "," , ws , value } , [ "," ] ] , ws , "]" ;
duration = dec_int , ( "ms" | "s" | "m" | "h" | "d" ) ; (* sucre, si champ Duration *)
size = dec_int , ( "B" | "KiB" | "MiB" | "GiB" ) ; (* sucre, si champ taille *)
(Bornes anti-DoS : taille document/ligne, profondeur de path, longueur de list —
appliquées par le lexer, cf. air-config-source-format §1. Clés dupliquées = erreur.)
Mapping lexème → type Cap’n Proto (schema-directed)
| Champ schéma | Lexèmes acceptés |
|---|---|
Bool | true/false |
UInt8..64/Int8..64 | integer (checked_* vs largeur+signe) |
Float32/64 | float (+ integer promu) |
Text | string |
Data | bytes (hex:/b64:) |
enum | enum_val (∈ valeurs schéma ; sinon did-you-mean) |
List(T) | list (homogène T) ; ou [[table]] pour List(Struct) |
struct | [section] |
Duration/taille | duration/size (sucre) ou integer |
$secret | string — zeroizé, élidé des diagnostics |
Décisions validées (BDFL 2026-06-25)
- Extension de fichier =
.airconf. ✓ - Sucre
duration/size(5s,10MiB) inclus v1. ✓ — unités explicites ; la conversion vers l’entier cible estchecked_*→ tout overflow rejeté (diagnostic). - Texte multi-lignes
"""…"""inclus (drop-ins, gecos). ✓ - IP = struct typé
IpAddress(union v4/v6). ✓ - File ids
.capnpgénérés (capnp id) au câblage. ✓ fstabajouté (domaineair.mounts, A.4)./etc/mtabexclu (état runtime). ✓ Autres backends → périmètre progressif (cf. « Backends candidats »).
Travail à reprendre (→ implémentation)
- Générer les file ids + figer les
.capnpdansair-config-schema/; committer le.rsgénéré (ADR-040). - Implémenter
air-config-compile(lexer/parseur BNF ci-dessus + validateur schema-directed) — fuzzé ; puis le lecteur runtime + générations + backends (resolv.conf/hosts d’abord).
Licence du document : MPL 2.0
Statut : v0.2 — schémas .capnp v1 (réseau/services/comptes/mounts) + BNF gelée du
format de source. Décisions validées (extension .airconf, sucre durée/taille checked,
multi-lignes, IP struct, fstab ; mtab exclu). Prêt pour implémentation : câbler
air-config-schema (file ids + code généré committé) puis air-config-compile (fuzzé).
Bases : ADR-033 / ADR-040 / ADR-041 + specs air-config + air-config-source-format.
Spec couche 1 — air-base-capi (ABI C → libair-base.so)
Spécification technique — Version 1.0. Première passe ABI C du projet (ADR-027
addendum C). Cadrage : docs/draft/abi-c-t1-design-fr.md.
Position et méthode
air-base-capi est la crate FFI dédiée qui expose air-base-lib (qui reste Rust
pur) en ABI C, produisant libair-base.so. Décisions de contrat :
- Erreurs :
AirStatusin-band, sanserrno,panic = "abort"— ADR-045. - Doc :
///export-quality → cbindgen → header committé → Doxygen + guides FR/EN — ADR-027. - Stabilité : ABI 10 ans, header = contrat diffé en CI — ADR-012.
Cette passe est le banc d’essai de la méthodologie de toute la future libc Air (vision libc : userland Rust, contrats redessinés, propriété mémoire explicite).
Structure de crate
crates/air-base-capi/
├── Cargo.toml # crate-type = ["cdylib","rlib"] ; dep air-base-lib ; [profile.release] panic="abort"
├── cbindgen.toml # config pinnée (déterminisme ADR-025)
├── include/air_base.h # HEADER GÉNÉRÉ + COMMITTÉ (contrat ABI, diffé en CI)
└── src/lib.rs # surface `pub extern "C"` + AirStatus + validation amont
Header régénéré par cargo xtask gen-capi-header (mainteneur) ; diff CI sur toute
dérive (air-abi-check/air-symver). cbindgen = dépendance d’outillage, à auditer
ADR-024/030 + consigner DEPENDENCIES.md/EXCEPTIONS.md à l’ajout.
Modèle d’erreurs — AirStatus (ADR-045)
#![allow(unused)]
fn main() {
#[repr(C)]
pub enum AirStatus {
Ok = 0,
NotFound = 1, PermissionDenied = 2, AlreadyExists = 3, InvalidInput = 4,
Interrupted = 5, WouldBlock = 6, Unsupported = 7, TimedOut = 8,
OutOfMemory = 9, BrokenResource = 10, Io = 11, InvalidData = 12, Other = 13,
NullArgument = 100, // pointeur requis NULL (validation amont, jamais de déréf)
BufferTooSmall = 101, // buffer fourni trop petit (cf. chaînes)
}
}
- Rendu in-band (valeur de retour) ; conversion
AirError → AirStatusmappe lekind(message dynamique non transporté en T1). air_status_message(AirStatus) -> *const c_char: chaîne statique (+0, jamais libérée).panic = "abort": aucuncatch_unwind, aucunAIR_PANIC(panic = bug Air → abort fail-fast ; tests enunwindvia Cargo).
Surface T1 — identifiants
Types POD #[repr(C)]. cbindgen émet :
typedef struct AirUuid { uint8_t bytes[16]; } AirUuid;
typedef struct AirId128 { uint8_t bytes[16]; } AirId128;
AirStatus air_uuid_new_v7(AirUuid *out); // out emprunté (+0), écrit sur AIR_STATUS_OK seul
AirStatus air_uuid_new_v4(AirUuid *out);
AirStatus air_uuid_to_hyphenated(const AirUuid *uuid, char *buf, size_t len); // len>=37 sinon BUFFER_TOO_SMALL
AirStatus air_uuid_parse(const char *s, AirUuid *out); // s emprunté, NUL-terminé
AirStatus air_id128_machine_id(AirId128 *out);
AirStatus air_id128_to_hex(const AirId128 *id, char *buf, size_t len); // len>=33
AirStatus air_monotonic_id_next(uint64_t *out);
const char *air_status_message(AirStatus status); // chaîne STATIQUE (ne pas libérer)
- Sorties chaînes par buffer fourni par l’appelant : zéro alloc côté Air, mémoire
+0 possédée par l’appelant, aucune
air_*_free(ownership trivial). Tailles documentées (UUID hyphené 37, hex 128 bits 33, NUL inclus). Buffer trop petit →BUFFER_TOO_SMALL(jamais de troncature silencieuse, ADR-032). - Clause de propriété dans chaque
///(ADR-027 §B) : retour possédé/emprunté, arguments +0, fonction de libération (ici : aucune en T1).
Tests de conformité ABI
Harnais C réel : un .c compilé contre include/air_base.h + linké sur
libair-base.so, exécuté en CI (compilateur C présent sur les runners) : round-trip
UUID parse/format, machine_id, monotone strictement croissant, BUFFER_TOO_SMALL sur
buffer court, NULL_ARGUMENT sur pointeur nul, air_status_message non-NULL. Plus le
diff du header (régénéré ≟ committé).
Couverture
Header généré exclu ; wrappers extern "C" minces ; pas de bras
catch_unwind/AIR_PANIC (supprimés) ; la logique réelle est déjà couverte à 100 %
dans air-base-lib. Bras d’erreur (NULL_ARGUMENT, BUFFER_TOO_SMALL) testés.
Tranches
- T1 : crate +
AirStatus+ validation amont + identifiants + cbindgen → header committé +xtask gen-capi-header+ harnais conformité ABI. - T2 : reste de la surface
air-base-lib(AirLog+ services) en C-ABI. - T3 : pipeline Doxygen + guides FR+EN (obligations dev C, propriété mémoire, thread-safety, exemples C compilables).
Tranche T2 — service de journalisation AirLog (C-ABI)
Décisions de conception (autonomie BDFL 2026-06-27). Établit le pattern handle opaque + propriété + thread-safety déclarée (ADR-027 §B) pour toute la future libc.
Types
AirLogLevel— enum#[repr(C)], valeurs syslog/journald0..=7(AIR_LOG_LEVEL_EMERGENCY=0…AIR_LOG_LEVEL_DEBUG=7). POD.AirLog— handle opaque (typedef struct AirLog AirLog;), jamais déréférencé par l’appelant C. Côté Rust :Box<air_base_lib::AirLog>(cœurArc<JournalSink>—Send+Sync).AirLogFields— handle opaque (builder de champs structurés).
Surface extern "C"
// ── AirLog : ouvert/fermé par l'appelant (propriété +1 au retour) ──────────
// namespace NULL = journal par défaut. *out reçoit un handle POSSÉDÉ par l'appelant
// (il DOIT air_log_close). Écrit *out sur AIR_STATUS_OK seul.
AirStatus air_log_open(const char *namespace_, AirLog **out);
// Libère le handle (ferme le socket). NULL → no-op. Après appel, `log` invalide.
void air_log_close(AirLog *log);
// Émet une entrée. log/message/fields EMPRUNTÉS (+0). fields NULL = aucun champ.
// N'échoue pas sur indisponibilité journald (compteur interne) ; rend
// AIR_STATUS_NULL_ARGUMENT si `log`/`message` NULL, AIR_STATUS_INVALID_DATA si
// `message` non-UTF-8.
AirStatus air_log_emit(const AirLog *log, AirLogLevel level,
const char *message, const AirLogFields *fields);
// Nombre d'entrées perdues (journald injoignable). out emprunté (+0).
AirStatus air_log_lost_count(const AirLog *log, uint64_t *out);
// ── AirLogFields : builder possédé par l'appelant (propriété +1 au retour) ──
AirStatus air_log_fields_new(AirLogFields **out); // +1 → air_log_fields_free
void air_log_fields_free(AirLogFields *fields); // NULL → no-op
// Ajoute un champ texte. fields emprunté-mut (+0). key/value empruntés, NUL-term.
// Clé non conforme journald ([A-Z][A-Z0-9_]*) → rejet silencieux (compteur interne).
AirStatus air_log_fields_add(AirLogFields *fields, const char *key, const char *value);
// Champ binaire (value de `len` octets, pas de NUL requis).
AirStatus air_log_fields_add_bytes(AirLogFields *fields, const char *key,
const uint8_t *value, size_t len);
Propriété mémoire (clause ADR-027 §B — à reporter dans chaque ///)
| Fonction | Retour | Arguments | Libération |
|---|---|---|---|
air_log_open | *out possédé +1 | namespace_ emprunté +0 | air_log_close |
air_log_emit | (statut) | log/message/fields empruntés +0 | — |
air_log_close | — | consomme log | (c’est la libération) |
air_log_fields_new | *out possédé +1 | — | air_log_fields_free |
air_log_fields_add[_bytes] | (statut) | fields emprunté-mut +0 ; key/value +0 | — |
Thread-safety déclarée (ADR-027 §B.4)
AirLog= ThreadSafe : cœurArc<JournalSink>+ envoi datagramme atomique ;air_log_emit/air_log_lost_countsont sûrs depuis plusieurs threads sur le même handle.air_log_closeexige l’absence d’usage concurrent (consomme).AirLogFields= non thread-safe : muté paradd; le construire sur un thread, puis le passer (emprunté en lecture) àair_log_emit.
Notes d’implémentation
panic = "abort"(déjà workspace) ; pas decatch_unwind. Validation null amont.- Pas de troncature/perte silencieuse (ADR-032) :
addreporte un statut ; le rejet de clé non conforme est compté (exposable plus tard via un accesseur si besoin). - Header régénéré (cbindgen) + committé ; diff vérifié par le hook barrière (T1).
- Couverture : viser ≥ plancher couche 1 ; exceptions STRUCTURAL doc in-code (ADR-035).
Tranche T3 — mesure du temps (AirInstant / AirDuration, POD)
Décisions de conception (autonomie BDFL 2026-06-27). Expose la mesure d’écoulement
monotone d’air-base-lib::time (cf. crates/air-base-lib/src/time.rs) en POD ABI C
purs #[repr(C)] — premier service C-ABI sans handle (calque le pattern POD de
T1 : AirStatus in-band, validation null amont, out écrit sur AIR_STATUS_OK seul).
Types
AirInstant— POD#[repr(C)] { int64_t nanos; }, instant monotone (CLOCK_MONOTONIC). On n’expose pas l’i128interne d’air-base-lib(non portable en ABI C) : uni64de nanosecondes couvre ~292 ans d’uptime. La valeur absolue est opaque (calque la doctrine d’AirInstant: pas d’« epoch monotone » comparable entre processus) — seules les différences ont un sens.AirDuration— POD#[repr(C)] { int64_t seconds; uint32_t nanoseconds; }, façonstruct timespec(nanoseconds ∈ [0, 1e9)). Non négative (cas d’usage : mesure d’écoulement). La durée signéei128interne n’est pas exposée : la projection délègue àLibDuration::to_std_saturating(couche 1, couverte à 100 %), donc durée négative ⇒{0, 0}(sondage, cohérentto_std_saturating) et secondes excédanti64⇒ bornées (TryFrom/unwrap_or(i64::MAX), jamais d’as).
Surface extern "C"
typedef struct AirInstant { int64_t nanos; } AirInstant;
typedef struct AirDuration { int64_t seconds; uint32_t nanoseconds; } AirDuration;
// Horloge monotone (couche 0). out +0, écrit sur AIR_STATUS_OK seul ; peut échouer
// (horloge) → miroir AirStatus.
AirStatus air_instant_now(AirInstant *out);
// now - *since (saturé à 0 si *since est dans le futur). since/out empruntés +0.
AirStatus air_instant_elapsed(const AirInstant *since, AirDuration *out);
// *later - *earlier (saturé à 0 si later < earlier). Pur calcul, n'échoue jamais
// hors NULL_ARGUMENT.
AirStatus air_instant_duration_since(const AirInstant *later,
const AirInstant *earlier, AirDuration *out);
// Constructeurs de durée (secondes débordantes bornées défensivement).
AirStatus air_duration_from_secs(uint64_t s, AirDuration *out);
AirStatus air_duration_from_millis(uint64_t ms, AirDuration *out);
AirStatus air_duration_from_nanos(uint64_t ns, AirDuration *out);
// seconds + nanoseconds/1e9 (conversion lossy localisée, calque AirDuration::as_secs_f64).
AirStatus air_duration_as_secs_f64(const AirDuration *d, double *out);
- Projection sans accesseur de valeur absolue :
air-base-librefuse à dessein d’exposer la valeur brute d’unAirInstant. La frontière utilise une origine monotone déterministe (from_monotonic_micros(0, 0),nanos = 0) comme point de référence neutre pour projeter un instant sur uni64de nanosecondes (et le reconstruire) via la seule API publique (duration_since/saturating_add). - Arithmétique défensive (Principe 2) : toute recomposition est
saturating_*; toute conversion lossy est commentée et localisée (jamais d’asnon justifié).
Propriété mémoire (clause ADR-027 §B — reportée dans chaque ///)
| Fonction | Retour | Arguments | Libération |
|---|---|---|---|
air_instant_now | *out POD +0 (sur OK) | — | — (POD trivial) |
air_instant_elapsed | *out POD +0 (sur OK) | since emprunté +0 | — |
air_instant_duration_since | *out POD +0 (sur OK) | later/earlier empruntés +0 | — |
air_duration_from_{secs,millis,nanos} | *out POD +0 (sur OK) | — | — |
air_duration_as_secs_f64 | *out (double) +0 (sur OK) | d emprunté +0 | — |
Thread-safety déclarée (ADR-027 §B.4)
- Toutes ces fonctions sont réentrantes / thread-safe : sans état partagé
(lecture d’horloge kernel + POD locaux à l’appelant). Sûres d’appel concurrent depuis
plusieurs threads, chacun avec ses propres
out. Aucune n’aliase de ressource.
Notes d’implémentation
- POD purs (
AirInstant/AirDuration) ajoutés à[export] includedecbindgen.toml. air_instant_now/air_instant_elapsedpeuvent relayer une erreur d’horloge (AirStatus::from) : bras STRUCTURAL (ADR-035) —clock_gettime(CLOCK_MONOTONIC)est structurellement infaillible sur un système démarré (cf.air-base-lib::time), doc in-code. Les chemins de saturation/bornage sont, eux, couverts par tests directs.- Header régénéré (cbindgen) + committé ; diff vérifié par le hook barrière.
Tranche T4 — date-heure calendaire (AirDateTime, POD façon struct tm)
Décisions de conception (autonomie BDFL 2026-06-27). Expose le temps mur
calendaire d’air-base-lib::time (AirDateTime/AirCalendar, cf.
crates/air-base-lib/src/time.rs) en POD ABI C purs #[repr(C)] — second service
C-ABI sans handle (calque le pattern POD de T1/T3). Aucune modification
d’air-base-lib : chaque opération round-trip par l’API publique.
Types
AirCalendar— enum#[repr(C)]miroir des 6 variantes de couche 1 (Gregorian=0,Buddhist=1,Japanese=2,Islamic=3,Hebrew=4,Persian=5), discriminants explicites et committés (ADR-012). La couche 1 marque sonAirCalendar#[non_exhaustive](d’autres calendriers pourront être ajoutés) : la doc C documente que le consommateur doit rester défensif (variante future hors liste possible —default:dans unswitch). Mapping explicite C → lib (pas de castenum as u8). Le formatage localisé (ères, mois bissextiles) relève de la couche 2 (ADR-016) ; ici, composantes numériques seulement.AirDateTimeParts— POD#[repr(C)]façonstruct tm:{ int32_t year; uint8_t month; uint8_t day; uint8_t hour; uint8_t minute; uint8_t second; AirCalendar calendar; }(month ∈ [1,12],day ∈ [1,31],hour ∈ [0,23],minute ∈ [0,59],second ∈ [0,60]leap). On n’expose pas le jour absolu (rata_die) interne (détailicu4x).
Surface extern "C"
typedef enum AirCalendar { AIR_CALENDAR_GREGORIAN = 0, /* … */ AIR_CALENDAR_PERSIAN = 5 } AirCalendar;
typedef struct AirDateTimeParts {
int32_t year; uint8_t month; uint8_t day; uint8_t hour; uint8_t minute;
uint8_t second; AirCalendar calendar;
} AirDateTimeParts;
// Instant mur courant (CLOCK_REALTIME, couche 0), composantes dans `cal`. Peut
// relayer une erreur d'horloge (miroir AirStatus, bras STRUCTURAL).
AirStatus air_datetime_now_utc(AirCalendar cal, AirDateTimeParts *out);
// Valide + normalise ; date/heure invalide ⇒ AIR_STATUS_INVALID_INPUT.
AirStatus air_datetime_from_components(AirCalendar cal, int32_t year, uint8_t month,
uint8_t day, uint8_t hour, uint8_t minute, uint8_t second, AirDateTimeParts *out);
// Même instant porté sur `cal` ; *in_parts revalidé (invalide ⇒ INVALID_INPUT).
AirStatus air_datetime_to_calendar(const AirDateTimeParts *in_parts, AirCalendar cal,
AirDateTimeParts *out);
// Décalage de jour absolu (signé) ; débordement i64 ⇒ INVALID_INPUT. Rendu dans le
// MÊME calendrier que l'entrée.
AirStatus air_datetime_add_days(const AirDateTimeParts *in_parts, int64_t days,
AirDateTimeParts *out);
- Round-trip par l’API publique (aucune modif d’
air-base-lib) : chaque opération reconstruit l’AirDateTimeinterne viafrom_componentsdepuis les parts (qui valide la date), opère (to_calendar/add_days), puis relit les composantes via les accesseurs (year/month/…) vers les parts. Lerata_dieinterne n’est jamais exposé. - Pas de variante
AirStatusajoutée : la couche 1 rendAirErrorKind::InvalidInputpour toute date/heure invalide et tout débordement de jour absolu — déjà miroité parAIR_STATUS_INVALID_INPUTvia le helper T1 (From<AirErrorKind>). L’enumAirStatus(donc ADR-045) reste inchangé (aucune churn ABI).
Propriété mémoire (clause ADR-027 §B — reportée dans chaque ///)
| Fonction | Retour | Arguments | Libération |
|---|---|---|---|
air_datetime_now_utc | *out POD +0 (sur OK) | cal par valeur | — (POD trivial) |
air_datetime_from_components | *out POD +0 (sur OK) | cal + composantes par valeur | — |
air_datetime_to_calendar | *out POD +0 (sur OK) | in_parts emprunté +0 ; cal par valeur | — |
air_datetime_add_days | *out POD +0 (sur OK) | in_parts emprunté +0 ; days par valeur | — |
Thread-safety déclarée (ADR-027 §B.4)
- Toutes ces fonctions sont réentrantes / thread-safe : sans état partagé
(
now_utclit l’horloge kernel ; les autres sont du pur calcul calendaireicu4x). Sûres d’appel concurrent depuis plusieurs threads, chacun avec ses propresout.
Notes d’implémentation
- POD purs (
AirCalendar/AirDateTimeParts) ajoutés à[export] includedecbindgen.toml. air_datetime_now_utcpeut relayer une erreur d’horloge (AirStatus::from) : bras STRUCTURAL (ADR-035) —clock_gettime(CLOCK_REALTIME)est structurellement infaillible sur un système démarré (cf.air-base-lib::time::AirDateTime::now_utc), doc in-code. Les chemins de validation/erreur (date invalide, débordementadd_days,in_partsmalformé) sont, eux, couverts par tests directs (Rust + conformité C).- Header régénéré (cbindgen) + committé ; diff vérifié par le hook barrière.
Tranche T5a — chemins (AirPath)
Décisions de conception (autonomie BDFL 2026-06-27). Expose la manipulation
lexicale de chemins d’air-base-lib::path (AirPath — type ré-exporté
depuis air-base-core depuis ADR-054,
cf. crates/air-base-core/src/path.rs) en handle opaque possédé — premier
service C-ABI à valeur octets. Aucune modification d’air-base-lib :
chaque opération délègue à l’API publique (le pont AirPath ↔ AirString passe
par l’extension air_base_lib::AirPathStringExt, frontière ABI inchangée). Sont reportées à T5b/T5c : components
(itérateur), to_air_string/from_air_string (pont AirString).
Types
AirPath— handle opaque ABI C (typedef struct AirPath AirPath;), enveloppe possédée d’air_base_lib::AirPath(unVec<u8>). Un chemin est une suite d’octets pouvant contenir du non-UTF-8 (Unix, Principe 3) : les accesseurs de contenu rendent donc des octets bruts (uint8_t *), pas deschar *. Calque le pattern propriété/handle de T2 (AirLog).
Surface extern "C"
typedef struct AirPath AirPath;
// Construction (propriété +1 : à libérer via air_path_free).
AirStatus air_path_from_bytes(const uint8_t *bytes, size_t len, AirPath **out);
AirStatus air_path_join(const AirPath *path, const AirPath *component, AirPath **out);
AirStatus air_path_parent(const AirPath *path, AirPath **out); // None ⇒ NOT_FOUND
AirStatus air_path_normalize_lexically(const AirPath *path, AirPath **out);
void air_path_free(AirPath *path); // NULL toléré (no-op)
// Accesseurs (+0). Sorties octets par buffer appelant : *written = longueur réelle
// TOUJOURS (même sur BUFFER_TOO_SMALL) ; pas de NUL (sauf to_c_string).
AirStatus air_path_as_bytes(const AirPath *path, uint8_t *buf, size_t cap, size_t *written);
AirStatus air_path_to_c_string(const AirPath *path, char *buf, size_t cap, size_t *written);
AirStatus air_path_file_name(const AirPath *path, uint8_t *buf, size_t cap, size_t *written); // None ⇒ NOT_FOUND
AirStatus air_path_extension(const AirPath *path, uint8_t *buf, size_t cap, size_t *written); // None ⇒ NOT_FOUND
AirStatus air_path_is_absolute(const AirPath *path, bool *out);
- Sorties octets (Principe 3) :
as_bytes/file_name/extensionrendent des octets bruts (potentiellement non-UTF-8) dans un buffer fourni par l’appelant.*writtenreçoit la longueur réelle — TOUJOURS, y compris surAIR_STATUS_BUFFER_TOO_SMALL(l’appelant redimensionne puis réessaie ; aucune troncature, ADR-032). AucunNULn’est ajouté. to_c_stringest le pont couche 0 (frontière syscall) : chaîne CNUL-terminée,*writteninclut leNUL. Un octetNULinterne ⇒AIR_STATUS_INVALID_INPUT(incompatible avec une chaîne C).char *=c_char(i8/x86_64, u8/aarch64) : la copie passe parbuf.cast::<u8>(), identique sur les deux arches (leçon #132).- Mapping
Option::None→AIR_STATUS_NOT_FOUND:parent(racine / chemin vide),file_name(racine, vide, ou fin./..),extension(pas de., ou.en tête type.bashrc).*out/buf/*writtennon écrits dans ce cas. - Pas de variante
AirStatusajoutée :INVALID_INPUT(octetNULinterne, via le miroir T1From<AirErrorKind>),NOT_FOUND,BUFFER_TOO_SMALL,NULL_ARGUMENTsuffisent. L’enumAirStatus(ADR-045) reste inchangé.
Propriété mémoire (clause ADR-027 §B — reportée dans chaque ///)
| Fonction | Retour | Arguments | Libération |
|---|---|---|---|
air_path_from_bytes | *out handle +1 (sur OK) | bytes emprunté +0 | air_path_free |
air_path_join | *out handle +1 (sur OK) | path/component empruntés +0 | air_path_free |
air_path_parent | *out handle +1 (sur OK ; None ⇒ NOT_FOUND) | path emprunté +0 | air_path_free |
air_path_normalize_lexically | *out handle +1 (sur OK) | path emprunté +0 | air_path_free |
air_path_as_bytes | *written longueur ; buf rempli | path emprunté +0 ; buf possédé appelant +0 | — |
air_path_to_c_string | *written (incl. NUL) ; buf rempli | path emprunté +0 ; buf possédé appelant +0 | — |
air_path_file_name | *written ; buf rempli (None ⇒ NOT_FOUND) | path emprunté +0 ; buf possédé appelant +0 | — |
air_path_extension | *written ; buf rempli (None ⇒ NOT_FOUND) | path emprunté +0 ; buf possédé appelant +0 | — |
air_path_is_absolute | *out POD +0 (sur OK) | path emprunté +0 | — |
air_path_free | — | consomme path (+1 → libéré) ; NULL no-op | (c’est la libération) |
Thread-safety déclarée (ADR-027 §B.4)
AirPathest un handle possédé non partagé : prévoir un handle par thread. Les accesseurs n’empruntent qu’en lecture (lecture concurrente du même handle sound en principe), maisair_path_freeconsomme le handle et exige l’absence d’usage concurrent — ne pas partager un mêmeAirPath *entre threads sans synchronisation externe.
Notes d’implémentation
- Handle opaque (pas dans
[export] includedecbindgen.toml) :typedef struct AirPath AirPath;émis automatiquement par les signaturesextern "C". - Helper interne
write_bytes(octets bruts +*writtentoujours écrit), pendant octets dewrite_c_string(chaîne ASCII NUL-terminée T1). - Aucun bras STRUCTURAL : toute la logique est lexicale et infaillible côté
couche 1 (sauf l’octet
NULinterne deto_c_string⇒InvalidInput, couvert par test direct Rust + conformité C). Couverture 100 % atteignable sans exception. - Header régénéré (cbindgen) + committé ; diff vérifié par le hook barrière.
Tranche T5b — chaînes + locale (AirString, AirLocale)
Décisions de conception (autonomie BDFL 2026-06-27). Expose la section
chaînes d’air-base-lib::string (AirString UTF-8 garanti, AirLocale BCP
47, cf. crates/air-base-lib/src/string.rs) en handles opaques possédés
(calque T5a AirPath). Aucune modification d’air-base-lib : chaque
opération délègue à l’API publique (icu4x, couverte à 100 %). Sont reportées à
T5c : la segmentation itérative (graphemes/words/sentences). Les ponts
AirPath ↔ AirString (reportés de T5a) sont livrés ici.
Types
AirNormalizationForm— enum#[repr(C)](POD, par valeur), miroir d’air_base_lib::NormalizationForm:Nfc=0/Nfd=1/Nfkc=2/Nfkd=3. Forcé dans[export] includedecbindgen.toml. Mapping explicite (pas de castenum as u8).AirLocale— handle opaque (typedef struct AirLocale AirLocale;), enveloppe possédée d’air_base_lib::AirLocale(un identifiant BCP 47).AirString— handle opaque (typedef struct AirString AirString;), enveloppe possédée d’air_base_lib::AirString(invariant UTF-8 valide par construction). Comme unAirStringpeut excéder l’ASCII, toutes les sorties de contenu sont des octets (uint8_t *+*written), jamais deschar *(Principe 3, distinction stricte octets/UTF-8).
Surface extern "C"
typedef enum AirNormalizationForm { AIR_NORMALIZATION_FORM_NFC = 0, /* … */ } AirNormalizationForm;
typedef struct AirLocale AirLocale;
typedef struct AirString AirString;
// AirLocale (handle +1 : à libérer via air_locale_free).
AirStatus air_locale_parse(const uint8_t *tag, size_t len, AirLocale **out); // non-UTF-8 ⇒ INVALID_INPUT ; mal formé ⇒ INVALID_DATA
AirStatus air_locale_root(AirLocale **out);
AirStatus air_locale_to_bcp47(const AirLocale *loc, uint8_t *buf, size_t cap, size_t *written);
AirStatus air_locale_language(const AirLocale *loc, uint8_t *buf, size_t cap, size_t *written);
AirStatus air_locale_region(const AirLocale *loc, uint8_t *buf, size_t cap, size_t *written); // None ⇒ NOT_FOUND
void air_locale_free(AirLocale *loc); // NULL toléré (no-op)
// AirString (handle +1 : à libérer via air_string_free).
AirStatus air_string_new(AirString **out);
AirStatus air_string_from_utf8(const uint8_t *bytes, size_t len, AirString **out); // non-UTF-8 ⇒ INVALID_DATA
AirStatus air_string_from_utf8_lossy(const uint8_t *bytes, size_t len, AirString **out); // U+FFFD, jamais d'erreur
AirStatus air_string_as_bytes(const AirString *s, uint8_t *buf, size_t cap, size_t *written);
AirStatus air_string_len(const AirString *s, size_t *out); // longueur en octets
AirStatus air_string_normalize(const AirString *s, AirNormalizationForm form, AirString **out);
AirStatus air_string_is_normalized(const AirString *s, AirNormalizationForm form, bool *out);
AirStatus air_string_to_uppercase_in_locale(const AirString *s, const AirLocale *loc, AirString **out);
AirStatus air_string_to_lowercase_in_locale(const AirString *s, const AirLocale *loc, AirString **out);
AirStatus air_string_to_titlecase_in_locale(const AirString *s, const AirLocale *loc, AirString **out);
AirStatus air_string_compare_in_locale(const AirString *s, const AirString *other, const AirLocale *loc, int32_t *out); // -1/0/1
AirStatus air_string_eq_ignore_case_in_locale(const AirString *s, const AirString *other, const AirLocale *loc, bool *out);
AirStatus air_string_grapheme_count(const AirString *s, size_t *out); // clusters étendus (Annex #29)
void air_string_free(AirString *s); // NULL toléré (no-op)
// Ponts AirPath ↔ AirString (Principe 3).
AirStatus air_path_to_air_string(const AirPath *p, AirString **out); // non-UTF-8 ⇒ INVALID_DATA
AirStatus air_path_to_air_string_lossy(const AirPath *p, AirString **out); // U+FFFD, jamais d'erreur
AirStatus air_path_from_air_string(const AirString *s, AirPath **out);
- Sorties octets / UTF-8 (Principe 3) :
air_string_*etair_locale_*rendent du contenu UTF-8 en octets (uint8_t *+*written= longueur réelle TOUJOURS, même surBUFFER_TOO_SMALL; aucune troncature, ADR-032 ; pas deNUL). Réutilise le helper octetswrite_bytesde T5a. Ordering→int32:air_string_compare_in_localeprojette-1/0/1(Less/Equal/Greater) via un mappage explicite (pas d’aslossy, Principe 2).- Mapping
Option::None→AIR_STATUS_NOT_FOUND:air_locale_region(locale sans région).buf/*writtennon écrits dans ce cas. - UTF-8 amont vs aval :
air_locale_parsevalide l’UTF-8 en amont (octets →&str, sinonINVALID_INPUTavant tout parsing) puis parse (mal formé ⇒INVALID_DATA).air_string_from_utf8/air_path_to_air_stringmappent l’AirErrorde la couche 1 (octets non-UTF-8 ⇒INVALID_DATA). - Pas de variante
AirStatusajoutée :INVALID_INPUT/INVALID_DATA/NOT_FOUND/BUFFER_TOO_SMALL/NULL_ARGUMENTsuffisent. L’enumAirStatus(ADR-045) reste inchangé.
Propriété mémoire (clause ADR-027 §B — reportée dans chaque ///)
| Fonction | Retour | Arguments | Libération |
|---|---|---|---|
air_locale_parse / air_locale_root | *out handle +1 (sur OK) | tag emprunté +0 | air_locale_free |
air_locale_to_bcp47 / air_locale_language | *written ; buf rempli | loc emprunté +0 ; buf possédé appelant +0 | — |
air_locale_region | *written ; buf rempli (None ⇒ NOT_FOUND) | loc emprunté +0 ; buf possédé appelant +0 | — |
air_locale_free | — | consomme loc (+1 → libéré) ; NULL no-op | (c’est la libération) |
air_string_new / air_string_from_utf8{,_lossy} | *out handle +1 (sur OK) | bytes emprunté +0 | air_string_free |
air_string_normalize / casing | *out handle +1 (sur OK) | s/loc empruntés +0 | air_string_free |
air_string_as_bytes | *written ; buf rempli | s emprunté +0 ; buf possédé appelant +0 | — |
air_string_len / grapheme_count / compare / eq_* / is_normalized | *out POD +0 (sur OK) | s/other/loc empruntés +0 | — |
air_string_free | — | consomme s (+1 → libéré) ; NULL no-op | (c’est la libération) |
air_path_to_air_string{,_lossy} | *out handle AirString +1 (sur OK) | p emprunté +0 | air_string_free |
air_path_from_air_string | *out handle AirPath +1 (sur OK) | s emprunté +0 | air_path_free |
Thread-safety déclarée (ADR-027 §B.4)
AirString/AirLocalesont des handles possédés non partagés : prévoir un handle par thread. Les accesseurs n’empruntent qu’en lecture, maisair_string_free/air_locale_freeconsomment le handle et exigent l’absence d’usage concurrent — ne pas partager un même handle entre threads sans synchronisation externe. Les transformations rendent toujours un nouvel handle (jamais de mutation en place).
Notes d’implémentation
- Handles opaques (pas dans
[export] include) :typedef struct AirString AirString;/AirLocaleémis automatiquement par les signaturesextern "C". SeulAirNormalizationForm(POD) est ajouté à[export] include. - Aucun bras STRUCTURAL : le mapping
NormalizationFormest exhaustif (4 formes, pas de#[non_exhaustive]) ;ordering_to_i32couvreLess/Equal/Greater; les chemins d’erreur (INVALID_INPUT/INVALID_DATA/NOT_FOUND) sont tous couverts par tests Rust + conformité C. Couverture 100 % sans exception. - Header régénéré (cbindgen) + committé ; diff vérifié par le hook barrière.
Tranche T5c — segmentation (itérateur AirByteIter)
Décisions de conception (autonomie BDFL 2026-06-27). Expose la segmentation
d’air-base-lib — AirString::graphemes/words/sentences (sous-tranches
&str, UAX #29) et AirPath::components (segments &[u8], cf.
crates/air-base-lib/src/{string,path}.rs) — derrière un seul itérateur
opaque unifié à snapshot eager. Aucune modification d’air-base-lib :
chaque constructeur délègue à l’itérateur natif de la couche 1 (couvert à 100 %)
et en copie les segments.
Types
AirByteIter— handle opaque possédé (typedef struct AirByteIter AirByteIter;), non dans[export] include(émis automatiquement par les signaturesextern "C"). Contient un snapshot possédé des segments (Vec<Vec<u8>>) collecté à la création + un curseur. Sorties en octets (uint8_t *) : un graphème/mot/phrase est de l’UTF-8, mais un composant de chemin peut être non-UTF-8 (Principe 3) — d’où un itérateur d’octets unifié, jamaischar *.
Snapshot eager (indépendance de la source)
À la création, l’itérateur collecte immédiatement tous les segments dans
un buffer possédé (.as_bytes().to_vec() pour les itérateurs &str, .to_vec()
pour components). Il est donc indépendant de la durée de vie de la source :
muter ou libérer l’AirString/AirPath après la création n’affecte pas
l’itération (aucun emprunt, aucune auto-référence). Choix assumé : simplicité et
sûreté mémoire à la frontière C priment sur l’économie d’une copie (Principe 5 ;
la source vit typiquement le temps de la segmentation, mais le contrat ne l’exige
pas).
Surface extern "C"
typedef struct AirByteIter AirByteIter;
// Constructeurs (chacun : *out handle +1, à libérer via air_byte_iter_free).
AirStatus air_string_graphemes(const AirString *s, AirByteIter **out);
AirStatus air_string_words(const AirString *s, AirByteIter **out);
AirStatus air_string_sentences(const AirString *s, AirByteIter **out);
AirStatus air_path_components(const AirPath *p, AirByteIter **out);
// Avancement (idiome two-call : sonder la taille, puis copier).
AirStatus air_byte_iter_next(AirByteIter *it, uint8_t *buf, size_t cap,
size_t *written, bool *has_value);
void air_byte_iter_free(AirByteIter *it); // NULL toléré (no-op)
- Idiome two-call (
air_byte_iter_next), tous cas in-band :- épuisé ⇒
*has_value = false,*written = 0,AIR_STATUS_OK; - le segment tient (
cap >= longueur) ⇒ octets copiés,*written = longueur,*has_value = true, le curseur avance,AIR_STATUS_OK; - buffer trop petit (
cap < longueur) ⇒*written = longueur requise,*has_value = true, le curseur n’avance PAS (retry possible sur le même élément après agrandissement),AIR_STATUS_BUFFER_TOO_SMALL(ADR-032 : aucune troncature ni copie partielle).
- épuisé ⇒
- Sondage de taille :
cap == 0est autorisé (bufpeut alors êtreNULL) — rendBUFFER_TOO_SMALL+*writtensans rien copier ni avancer. Dès quecap != 0,bufdoit être non-NULL(sinonNULL_ARGUMENT). - Pas de variante
AirStatusajoutée :OK/BUFFER_TOO_SMALL/NULL_ARGUMENTsuffisent. L’enumAirStatus(ADR-045) reste inchangé.
Propriété mémoire (clause ADR-027 §B — reportée dans chaque ///)
| Fonction | Retour | Arguments | Libération |
|---|---|---|---|
air_string_graphemes / words / sentences | *out handle +1 (sur OK) | s emprunté +0 (snapshot ⇒ libérable après) | air_byte_iter_free |
air_path_components | *out handle +1 (sur OK) | p emprunté +0 (snapshot ⇒ libérable après) | air_byte_iter_free |
air_byte_iter_next | *written/*has_value ; buf rempli (sauf BUFFER_TOO_SMALL) | it emprunté en mutation +0 (curseur) ; buf possédé appelant +0 | — |
air_byte_iter_free | — | consomme it (+1 → libéré) ; NULL no-op | (c’est la libération) |
Thread-safety déclarée (ADR-027 §B.4)
AirByteIterest un handle possédé non partagé :air_byte_iter_nextmute le curseur ⇒ prévoir un itérateur par thread. Ne pas partager un mêmeAirByteIter *entre threads sans synchronisation externe ;air_byte_iter_freeconsomme le handle et exige l’absence d’usage concurrent. Grâce au snapshot, l’itérateur ne dépend pas de la source après création.
Notes d’implémentation
- Constructeurs :
s/pempruntés en lecture le temps de.collect(); chaque segment est copié enVec<u8>possédé — aucun emprunt de la source conservé. air_byte_iter_next:*written/*has_valueécrits avant le test de capacité (longueur réelle TOUJOURS rendue) ;saturating_addpour le curseur (lintarithmetic_side_effects) ; copie seulement silen != 0(défensif —copy_nonoverlappingexige des pointeurs valides,bufpeut êtreNULLsicap == 0). Aucunaslossy (usize⇒size_texact).- Aucun bras STRUCTURAL : tous les chemins (épuisement, BUFFER_TOO_SMALL sans avancement, copie+avance, gardes NULL, free(NULL), snapshot survivant au free de la source) sont couverts par tests Rust + conformité C. Couverture 100 % sans exception.
- Header régénéré (cbindgen) + committé ; diff vérifié par le hook barrière.
air-terminal — modèle canonique de terminal + trait TerminalCodec (mince)
Couche : 1. Statut : implémentée (version mince de sceau).
Vision : ADR-061.
Companion couche 0 : ADR-060
(famille terminal : termios/winsize/PTY). no_std :
ADR-048 /
ADR-055. Nommage :
ADR-029 (zéro abréviation).
1. Raison d’être
Le PTY legacy est un seul canal d’octets non typé : sortie applicative, entrée clavier, souris encodée en fausses séquences, collage et négociation y sont tous multiplexés en-bande (modèle « téléscripteur » POSIX termios / ECMA-48). Air, qui possède tout le stack, peut faire mieux — non en encodant mieux la souris en séquences, mais en n’ayant pas à le faire (ADR-061 §Contexte).
La décision d’ADR-061 est un modèle interne canonique Air (la lingua franca) que tous les codecs traduisent : les codecs ne se parlent jamais entre eux ; chaque type de terminal = un codec de plus, jamais une refonte.
2. Portée de cette crate (MINCE — ADR-061 § « Discipline de portée »)
Cette crate grave le design (assurance anti-double-travail) sans le sur-implémenter. Elle contient exactement :
| Module | Contenu | Statut |
|---|---|---|
model | Le modèle canonique (types de données purs) | Implémenté |
codec | Le trait TerminalCodec (interface stable, object-safe) | Déclaré |
codec_ansi | Le codec ansi (AnsiCodec) — 1ᵉʳ codec, adaptateur legacy dégradant | Implémenté |
terminal_object | L’objet AirTerminal (tag + codec object-safe) | Implémenté |
tty | isatty / ttyname (dérivés couche 1 de termios L0) | Implémenté |
termios | Attributs / contrôle de ligne / winsize / make_raw (§9) | Implémenté |
pty | Allocation PTY + helper openpty (§9) | Implémenté |
session | Session & job-control d’un terminal (§9) | Implémenté |
HORS PÉRIMÈTRE — différés couche 2 (ADR-061 §Décision, conçus quand
air-console/air-tui les réclameront, pas avant) :
- les codecs riches (
kitty,air-codec-1) — le tagTerminalTag::Airet son codec riche restent à venir ; AttachSurfaceimplémenté côté compositeur : la varianteTermOut::AttachSurfaceet le registreSurfaceKindexistent dans le modèle (design gravé) et le codecansila dégrade (n’émet rien), mais aucune logique de compositeur n’est écrite ;- l’I/O réelle sur fd (boucle lire→décoder / encoder→écrire) :
AirTerminalne fait que traduire octets ↔ modèle ; le pilotage d’un fd reste à l’appelant (ou à un helper de couche 2 surair-stdio).
termcap/terminfo restent hors libc (ncurses/libtinfo, couche 2 — ADR-061
§ « Discipline de portée »).
3. Modèle canonique (model)
Types de données purs (#[derive(Debug, Clone, PartialEq, Eq)] partout ;
Hash/Copy là où le type est POD). Deux axes : TermIn (entrant) et TermOut
(sortant), plus TermCaps (capacités annoncées).
3.1 Discipline d’extensibilité (ADR-061 §Décision.3)
« Annoncer → Sélectionner → dégrader-sur-inconnu ». Mécanique Rust/fil : chaque
énumération susceptible d’évoluer est #[non_exhaustive] (compat à la
recompilation, quand on ajoute une variante) et porte une variante
Unknown (compat runtime, quand un codec reçoit plus récent que soi —
l’inconnu est catégorisé et ignoré, jamais fatal). Les octets de fil non
interprétés sont conservés dans un RawEvent (zéro perte ;
Principe 3 : ce sont des
octets, pas de l’UTF-8 présumé).
Les slots d’extension évoqués par ADR-061 (Touch/Gesture/Device côté entrée ;
Pressure/Tilt côté souris) ne sont pas matérialisés en variantes vides
spéculatives (« pas de sur-design ») : #[non_exhaustive] les réserve.
3.2 Entrée — TermIn
TermIn:Keyboard(KeyboardEvent)·Mouse(MouseEvent)·Screen(ScreenEvent)·Unknown(RawEvent).KeyboardEvent { key: Key, modifiers: Modifiers, kind: KeyEventKind }.Key:Character(char)·Enter·Tab·Backspace·Escape·Arrow(ArrowDirection)·Home·End·PageUp·PageDown·Insert·Delete·Function(u8)·Unknown.KeyEventKind:Press·Release·Repeat(les deux derniers exigent le protocole kitty ; le legacy ne rapporte quePress).Modifiers: bitflags maisonShift/Alt/Control/Super(bits inconnus retenus).
MouseEvent:Button{ button, action, position, modifiers }·Wheel{ direction, position, modifiers }·Motion{ position, modifiers }·Unknown{ subtype: u16, payload: RawEvent }.MouseButton:Left·Middle·Right·Other(u8).ButtonAction:Press·Release.WheelDirection:Up/Down/Left/Right.CellPosition { column: u16, row: u16 }.
ScreenEvent:FocusIn·FocusOut·Resize(Winsize)·Paste(Vec<u8>)·Unknown(RawEvent).Resizeréutiliseair_sys_types::terminal::Winsize(aucun type dupliqué).
3.3 Sortie — TermOut
TermOut:Text{ run: Vec<u8>, attributes: TextAttributes }·Cursor(CursorOperation)·Clear(ClearScope)·Scroll{ lines: i16 }·SetMode{ mode: TerminalMode, enabled: bool }·AttachSurface{ rectangle: CellRectangle, surface: SurfaceIdentifier }.TextAttributes { foreground: Color, background: Color, style: TextStyle };Color:Default·Indexed(u8)·Rgb{ r, g, b };TextStyle: bitflags maisonBold/Italic/Underline/Inverse.CursorOperation:MoveTo(CellPosition)·MoveBy{ columns: i16, rows: i16 }·Show·Hide.ClearScope:Screen/Line/ToEndOfScreen/ToEndOfLine.TerminalMode:MouseTracking/BracketedPaste/AlternateScreen/FocusReporting.CellRectangle { origin: CellPosition, columns: u16, rows: u16 }.SurfaceIdentifier(u64)(opaque).SurfaceKind:Framebuffer·GpuTexture·Unknown(u16)— registre extensible (ADR-061 §Décision.4 ; design seulement, aucune logique de compositeur).
3.4 Capacités — TermCaps
TermCaps { colors: ColorDepth, mouse: bool, keyboard_protocol: KeyboardProtocol, surfaces: bool, bracketed_paste: bool, focus_events: bool }. ColorDepth :
Monochrome/Ansi16/Indexed256/TrueColor. KeyboardProtocol :
Legacy/Kitty. C’est la base de la sélection d’intersection.
3.5 Note d’implémentation — bitflags maison
Modifiers/TextStyle sont des newtypes maison (un struct N(uN) +
implémentations core::ops::{BitOr, BitAnd, BitXor, Not, …} + from_bits_retain
qui retient les bits inconnus), pas la crate externe bitflags : la
surface utile est trop petite pour justifier la dépendance (règle des 80 %,
ADR-024), et le « zéro dépendance externe » de la crate est ainsi préservé.
4. Trait TerminalCodec (codec)
#![allow(unused)]
fn main() {
pub trait TerminalCodec {
fn decode_input(&mut self, wire: &[u8]) -> alloc::vec::Vec<TermIn>;
fn encode_output(&mut self, output: &TermOut, destination: &mut alloc::vec::Vec<u8>);
fn caps(&self) -> TermCaps;
}
}
- Object-safe : l’objet
AirTerminal(§6) tient unBox<dyn TerminalCodec>(le tagLegacy/Airsélectionne le codec au runtime). Aucune méthode générique, aucune constante associée, aucunSelf: Sized. - Aucune méthode par défaut, aucune implémentation : chaque codec fournit explicitement les trois opérations.
encode_outputajoute au tampon de l’appelant (pas d’allocation imposée par événement — un flux haute fréquence peut agréger par trame, ADR-061 §Décision.3).- Discipline « dégrader-sur-inconnu » :
decode_inputrange l’inconnu dans une varianteUnknown(zéro perte) ; le codec legacyansi(cf. §6) est un adaptateur dégradant en sortie (il jette ce que xterm ne sait pas piloter), sans jamais contraindre le modèle.
Le premier codec (ansi) est implémenté dans cette crate (§6) ; les codecs
riches suivront en couche 2.
5. Codec ansi (codec_ansi)
Le codec ansi/termios (AnsiCodec) est le premier (et seul, au sceau)
codec concret — l’adaptateur legacy dégradant d’ADR-061 §Décision.5. Il
implémente TerminalCodec et traduit dans les deux sens entre les octets de fil
d’un terminal xterm/VT générique et le modèle canonique. Il est borné : ce
n’est pas un émulateur xterm complet.
6.1 Décodage (decode_input) — ensemble borné, stateful
Le décodeur est stateful : une séquence (ou un caractère UTF-8) coupée en fin de tampon est mémorisée et complétée au prochain appel. Il reconnaît :
- UTF-8 imprimable →
Key::Character(char)(décodage incrémental viacore::str::from_utf8; octet invalide →Unknown, jamais de panique) ; - contrôles C0 :
\r/\n→Enter;\t→Tab;0x7f/0x08→Backspace;0x01–0x1a→Character+ modificateurControl;ESCseul (non suivi de[/O) →Escape; autres C0 →Unknown; - CSI (
ESC [ …) : flèchesA/B/C/D,H/Fet1~/4~(Home/End),5~/6~(PageUp/PageDown),2~/3~(Insert/Delete), fonctions (ESC O P/Q/R/S= F1–F4,ESC [ 15~/17~…24~= F5–F12), avec paramètres de modificateurs (ESC [ 1;5A= Ctrl+Up) ; - souris SGR (
ESC [ < b ; x ; y M|m) →MouseEvent(Button/Wheel/Motion + position 0-based + modificateurs ;M= appui,m= relâchement) ; - focus (
ESC [ I/ESC [ O) →ScreenEvent::FocusIn/FocusOut; - collage entre parenthèses (
ESC [ 200~…ESC [ 201~) →ScreenEvent::Paste(octets); - toute séquence non reconnue →
TermIn::Unknown(RawEvent)(« dégrader-sur- inconnu », zéro perte). Une CSI anormalement longue (> 64 octets sans octet final) est dégradée plutôt que bufferisée sans fin (borne anti-emballement).
6.2 Encodage (encode_output) — octets ANSI, ajoutés au tampon
Text{run, attributes}→ SGR (ESC [ 0 ; … m) : styles Bold/Italic/Underline/ Inverse ; couleursDefault→39/49,Indexed→38;5;n/48;5;n,Rgb→38;2;r;g;b/48;2;r;g;b) puis les octetsrun;Cursor(op)→ CSI (MoveTo→row;colH,MoveBy→A/B/C/D,Show/Hide→?25h/?25l) ;Clear(scope)→ ED/EL (2J/0J/2K/0K) ;Scroll{lines}→ CSIS(haut) /T(bas) selon le signe ;SetMode{mode, enabled}→ modes privés DEC (MouseTracking→?1000+?1006,BracketedPaste→?2004,AlternateScreen→?1049,FocusReporting→?1004), suffixeh/l;AttachSurface{..}→ DÉGRADE : le codec legacy ne peut pas lier de surface (un PTY n’a pas de canal hors-bande) → il n’émet rien. Le vraiAttachSurfaceest couche 2 (côté compositeur, ADR-061 §Décision.4).
6.3 Capacités (caps)
Celles d’un terminal ansi/xterm générique : colors: TrueColor, mouse: true,
keyboard_protocol: Legacy, surfaces: false, bracketed_paste: true,
focus_events: true.
6.4 Fuzzing
decode_input accepte des données externes (canal PTY hostile) : une cible
cargo-fuzz (fuzz_air_terminal_ansi_decode) pousse des octets arbitraires,
tranche par tranche, dans le codec stateful et vérifie l’invariant « ne jamais
paniquer » (CLAUDE.md — fuzzing systématique de toute API de données externes).
6. Objet AirTerminal (terminal_object)
AirTerminal (ADR-061 §Décision.1) représente le terminal-pair : un tag
(TerminalTag { Legacy, Air }, #[non_exhaustive]) + un codec. Mince et
testable : legacy_ansi() (tag Legacy + AnsiCodec), et decode_input /
encode_output / caps / tag — les trois premiers délèguent au codec.
- Codec object-safe (
Box<dyn TerminalCodec>, pas un paramètre générique) : le tag sélectionne le codec au runtime ; de futurs codecs (kitty,air-codec-1) s’ajoutent sans changer la signature d’AirTerminal(« chaque type de terminal = un codec de plus, jamais une refonte »). Le coût d’un appel virtuel par lot d’octets est négligeable devant l’I/O terminal (Principe 5). - I/O réelle sur fd = hors sceau :
AirTerminalne lit ni n’écrit de fd ; la boucle lecture→decode_inputetencode_output→écriture reste à l’appelant (ou à un helper de couche 2 surair-stdio).
7. isatty / ttyname (tty)
Dérivés couche 1 de la famille terminal de la couche 0 (ce ne sont pas des
syscalls : ADR-060 les place explicitement en couche 1).
pub fn isatty(fd: BorrowedFd<'_>) -> bool— untcgetattr(fd)(couche 0) réussi ⟺ terminal ; toute erreur (ENOTTY,EBADF, …) rendfalse(sémantique libc). Aucune erreur propagée.pub fn ttyname(fd: BorrowedFd<'_>, buffer: &mut [u8]) -> Result<&[u8], Errno>— forme réentrante, sans état statique (contrairement auttyname(3)libc et son tampon statique partagé). Vérifie d’abordisatty(sinonENOTTY), puis lit le lien/proc/self/fd/<n>viareadlinkat(couche 0, famillefs) ; le chemin est rendu en octets (Principe 3), recopié dansbuffer(ERANGEsi trop petit), et la sous-tranche remplie est renvoyée.- Le numéro de fd est formaté à la main dans un tampon de pile (
/proc+ signe + décimales +NUL≤ 26 octets, capacité 32) — sans allocation ni dépendance de formatage. Errno::ERANGE(valeur 34) a été ajoutée additivement à la couche 0 (air-sys-types::Errno) au titre de la politique de complétion additive du sceau (contrats couche 1 ; cf. INDEX §« re-sceauvX.Y»).
- Le numéro de fd est formaté à la main dans un tampon de pile (
8. Contraintes de crate
#![no_std]+extern crate alloc(couche 1 std-free) : prouvé parcargo build -p air-terminal --lib;stdautorisé uniquement sous#[cfg(test)](pty réel, prélude de test, forksetsidpour le job-control).- Zéro dépendance externe : trois crates sœurs internes — les deux de la
couche 0 (
air-sys-syscall+air-sys-types) et le cœur d’erreurs sans i18nair-base-core(couche 1,no_std, ADR-054) pour le tier plomberie tty (§9).air-base-core(et nonair-base-lib) évite d’entraîner icu4x, ce qui gardeair-terminaldans la fermeture runtime*-linux-air. Aucune surface C/C++, aucune entrée DEPENDENCIES.md (deps internes hors règle des 80 %). - Tests 100 % (lignes + branches + régions) :
model/codec/termioshost-testables ;tty/ptysur un PTY réel (open_pty_master+pty_unlock/pty_peer, sans root) ;session— chemins d’erreur ettcgetpgrpOk(None)dans le parent, cycle job-control nominal dans un enfant forkésetsid. Une exception ADR-035 in-code : la propagation?deopenpty(marqueur// COVERAGE (STRUCTURAL, ADR-035)) — échecs intermédiaires (épuisement de PTY/descripteurs, kernel < 4.13) non provoquables en CI ; le chemin succès est couvert. Le chemin d’erreurreadlinkatdettyreste couvert par le test direct deresolve_link_into.
9. Tier « plomberie tty » — termios / PTY / job-control (ADR-060, §5.3 P0.3)
Distinct du codec (traduction octets ↔ modèle, §4–§6), ce tier re-expose en
couche 1 la famille terminal scellée de la couche 0 (couche-0-v1.8,
ADR-060) avec le modèle d’erreurs d’Air (AirError/AirResult d’
air-base-core, conversion Errno → AirError). C’est un tier fidèle
(même posture qu’air-filesystem::AirFile, audit face-libc P0 item 3, famille
#15) : un appel = un ioctl, EINTR remonté tel quel (jamais de retry,
ADR-021 conv. 2 — tcdrain est le point d’annulation), FD en RAII
(OwnedFd), aucune fonction unsafe exposée (tout l’unsafe reste en
couche 0), Option/newtypes/énumérations typés (jamais un entier magique), as
proscrit. Il rend le stack suffisant pour bâtir openpty(3)/getpass(3)/
sshd en face C au-dessus sans jamais sauter en couche 0 côté consommateur —
ces fonctions ne sont pas implémentées ici (elles vivent au-dessus).
9.1 Attributs & contrôle de ligne (termios)
tcgetattr(fd) -> AirResult<Termios>/tcsetattr(fd, SetAction, &Termios) -> AirResult<()>— cœur termios (le type couche 0Termiosest ré-exposé, manipulable via ses accesseurs typéslocal_flags/char_size/…).tcdrain/tcflush(FlushQueue)/tcflow(FlowAction)/tcsendbreak— lestc*POSIX (durée de break non paramétrée — honnêteté de contrat, hérité de la couche 0).window_size(fd) -> AirResult<Winsize>/set_window_size(fd, &Winsize)— taille de fenêtre (noms verbeux ADR-029 duget/set_winsizede la couche 0).
make_raw(&Termios) -> Termios — décision d’API (SCELLÉE couche-1-v1.0). Le
cfmakeraw(3) libc mute en place et rend void — il jette l’information
(RAPPORT-CONSOLIDE §B2 : « fonctions void qui avalent l’erreur »). Air ne jette
rien : make_raw est une opération pure qui rend la nouvelle valeur (un
nouveau Termios en mode raw). N’étant jamais faillible (pure mutation de
struct), elle ne rend pas un Result factice (qui ne serait jamais Err —
ce serait malhonnête, doctrine « fait ce qu’il dit ») : elle rend une valeur
inspectable, entièrement testable sans terminal (unitaire + property-based
proptest : invariants + idempotence sur un Termios arbitraire). L’effet est
identique à musl/glibc (coupe IGNBRK BRKINT PARMRK ISTRIP INLCR IGNCR ICRNL IXON
/ OPOST / ECHO ECHONL ICANON ISIG IEXTEN / PARENB ; force CS8 ; VMIN=1,
VTIME=0).
9.2 Pseudo-terminaux (pty)
open_pty_master() -> AirResult<OwnedFd>,pty_number(master) -> AirResult<PtyNumber>,pty_unlock(master),pty_peer(master) -> AirResult<OwnedFd>(voie moderne anti-TOCTOUTIOCGPTPEER).openpty() -> AirResult<PtyPair>— helper de haut niveau : enchaîneopen_pty_master→pty_unlock→pty_peeret rend la paire(maître, esclave)enOwnedFdRAII (PtyPair: accesseurs empruntésmaster()/slave(),into_parts()cédant les deux). Les attributs/winsize initiaux ne sont pas imposés (pas de sur-conception) : une libc bâtissantopenpty(termp, winp)appelleopenpty()puistcsetattr/set_window_sizesur l’esclave.
9.3 Session & job-control (session)
tcgetpgrp(fd) -> AirResult<Option<Pid>>(sentinelle0→None, ADR-021 conv. 1) /tcsetpgrp(fd, Pid)— groupe de premier plan.session_id(fd) -> AirResult<Pid>(tcgetsid, toujours> 0→ pas d’Option).set_controlling_terminal(fd)(couche 0set_ctty, acquisition sans vol ; le volCAP_SYS_ADMINn’est pas exposé) /clear_controlling_terminal(fd)(couche 0clear_ctty; piège documenté :SIGHUPau groupe de premier plan). Noms verbeux (ADR-029) car sans nom de fonction POSIX ; les noms POSIX d’autorité (tcgetpgrp/tcsetpgrp/session_id) sont, eux, conservés.
9.4 Note fuzz
Aucune cible de fuzz pour ce tier (à la différence du codec ansi dont
decode_input fuzze un flux d’octets hostile, §6.4). La surface d’entrée est
structurée — des Termios/Winsize #[repr(C)], des énumérations typées, des
BorrowedFd — sans parsing d’octets externes. Il n’y a donc rien à fuzzer
(une cible vide serait du bruit) ; la robustesse est prouvée par la couverture
100 % (PTY réel + enfant setsid) et le property-based de make_raw.
air-stdio — brique stdio/console bas-niveau
Couche : 1. Statut : implémentée (brique bas-niveau).
Périmètre : roadmap couche 1 (libc + PAL)
§2.D.1 (décision BDFL). Verrou :
ADR-056 D3/D8 (futex maison +
reset post-fork). Conventions :
ADR-021 (EINTR
remonté, conv.2). no_std :
ADR-048 /
ADR-055. Nommage :
ADR-029 (zéro abréviation).
1. Raison d’être
Les deux toits d’Air — la libc (FILE*) et le PAL (std::io::{Stdin, Stdout, Stderr}) — ont besoin du même socle stdio bas-niveau : des handles vers les
descripteurs standard, des E/S brutes, un test « est-ce un terminal ? », et de
quoi sérialiser des séquences d’écritures. L’audit PAL a relevé que « stdio
bas-niveau manque ». air-stdio est cette brique unique et partagée : chaque
toit la réutilise au lieu de la réinventer (et de diverger).
2. Périmètre — le niveau BRUT
Cette crate fournit exactement :
| Élément | Contenu |
|---|---|
| Handles | stdin / stdout / stderr → StandardStream liant 0 / 1 / 2 |
| E/S brutes | StandardStream::read / write — un seul syscall, sans buffering |
| Terminal | StandardStream::is_terminal (via air_terminal::tty::isatty) |
| Verrou par flux | StandardStream::lock → StreamGuard (RAII), futex maison 3 états |
atfork | reset_after_fork — réinitialise les trois verrous dans l’enfant |
HORS PÉRIMÈTRE (libc §4, au-dessus)
Cette brique ne bufferise pas et ne formate pas. Vivent dans le toit :
- le moteur
FILE*bufferisé (buffers ligne/bloc,fflush,setvbuf) ; printf/scanfet tout formatage ;- la boucle sur écriture courte et le retry
EINTR(la brique remonteEINTRtel quel — ADR-021 conv.2 — et rend le compte d’octets d’un seul syscall, potentiellement partiel).
3. Conception : opérations ⟂ liaison
La logique des opérations travaille sur un BorrowedFd quelconque (fonctions
privées raw_read / raw_write / raw_is_terminal) ; la liaison aux
descripteurs 0 / 1 / 2 est isolée dans stdin / stdout / stderr. Cette
séparation rend toute la logique exerçable sur des pipes / ptys en test,
sans jamais toucher le vrai stdout/stderr (aucune pollution de la sortie de
test, aucun root).
StandardStream est une poignée triviale (Copy) : un BorrowedFd<'static>
(le descripteur, ouvert pour toute la vie du processus) + une référence 'static
vers le verrou du flux.
4. E/S brutes
read(&self, buffer: &mut [u8]) -> Result<usize, Errno>— délègue àair_sys_syscall::fs::read.0= fin de fichier.write(&self, buffer: &[u8]) -> Result<usize, Errno>— délègue àair_sys_syscall::fs::write. Le compte rendu peut être inférieur àbuffer.len()(écriture courte).
Aucune boucle, aucune bufferisation, aucun retry : un syscall par appel,
EINTR remonté à l’appelant (le toit écrit la boucle explicite).
5. Verrou par flux + atfork
Chaque flux possède un mot de verrou statique (AtomicU32) : un futex
maison à 3 états (UNLOCKED / LOCKED / CONTENDED, façon Drepper), calqué
sur air-alloc/src/lock.rs. Chemin non
contendu = un compare_exchange + un swap, sans syscall ; le futex n’entre
en jeu que sous contention réelle.
StandardStream::lock prend le verrou et rend un StreamGuard (RAII) qui le
relâche au Drop. Le toit s’en sert pour rendre atomique une séquence
d’écritures (deux write d’un même println! ne s’entrelacent pas). La brique ne
pose jamais le verrou elle-même — read/write sont brutes et non
verrouillées ; la sérialisation est une politique du toit.
reset_after_fork réinitialise les trois verrous à l’état déverrouillé.
Motivation atfork (ADR-056 D8) : après un fork, un verrou tenu par un
autre thread au moment du fork resterait éternellement bloqué dans l’enfant
(ce thread n’y existe pas). Le gardien de fork d’air-runtime l’appellera
dans l’enfant, exactement comme pour air-alloc::reset_after_fork — sens des
dépendances air-runtime → air-stdio, jamais l’inverse.
6. no_std, dépendances, tests
#![no_std], zéro allocation, core seul (couche 1 std-free). Aucune
dépendance externe : seulement les crates sœurs air-sys-syscall (read/write/
futex), air-sys-types (FD/Errno/futex) et air-terminal (isatty). Sous
#[cfg(test)] la crate redevient std (pipes/ptys réels, threads).
Couverture 100 % (lignes + branches, couche 1) : E/S sondées sur des pipes
(succès + EBADF sur mauvais sens d’accès), is_terminal sur PTY réel vs pipe,
verrou sondé en contention déterministe deux threads (technique d’air-alloc,
sans minutage) et reset_after_fork testé dans le parent sans fork réel.
7. Dépendance différée (§2.D.2)
La roadmap §2.D.2 prévoit qu’air-filesystem prenne une dépendance sur
air-stdio (E/S de plus haut niveau adossées aux handles standard). Elle est
différée jusqu’à un consommateur concret : introduire la dépendance sans usage
la rendrait inutilisée (cargo machete).
air-poll — multiplexeur d’événements générique
Couche : 1. Statut : implémentée (brique mince).
Périmètre : roadmap couche 1 (libc + PAL)
§5.3 P0.2 ; audit face-libc
famille #14 « poll/select » (§2.1) et §5 P0-2. Companion couche 0 :
family-poll.md (ppoll, ADR-044, re-sceau
couche-0-v1.5). Conventions :
ADR-021 (EINTR
remonté, conv.2). Erreurs :
ADR-019. no_std :
ADR-048 /
ADR-055. Nommage :
ADR-029 (zéro abréviation).
1. Raison d’être
Toute event loop synchrone — la boucle principale d’OpenSSH au premier chef
— doit attendre qu’un descripteur parmi un ensemble arbitraire devienne
prêt : sockets, pipes, fichiers, pidfd, signalfd, timerfd, eventfd… tous
convergent vers poll. La couche 0 fournit ppoll(2)
(air_sys_syscall::poll::ppoll, ADR-044), mais la
couche 1 n’en offrait aucun point d’entrée générique :
air-process::AirProcess::wait_until l’affleurait, couplé au pidfd de
l’enfant possédé (§14 de l’audit face-libc). air-poll comble ce manque
additif fondateur (§5 P0-2) : le tier fidèle qui expose ppoll sur un
&mut [PollFd] quelconque, typé couche 1.
Décision de placement — crate dédiée
Le multiplexage d’événements n’est ni process- ni socket-spécifique : le
greffer sur air-process (déjà porteur de wait_until) ou air-socket
reproduirait précisément le couplage que l’audit reproche à wait_until.
Conformément à la doctrine des crates couche 1 « minces et bornées » (air-env,
air-stdio, air-terminal : une responsabilité, zéro dépendance externe), le
multiplexeur mérite sa crate dédiée. air-poll s’insère au même niveau que
ses sœurs : au-dessus de la couche 0 (air-sys-syscall/air-sys-types) et du
cœur d’erreurs/temps (air-base-core, ADR-054), sans dépendre d’aucune autre
crate couche 1 — donc aucun cycle, aucune dette d’agencement (esprit ADR-055).
2. Périmètre — un appel = un ppoll
Cette crate fournit exactement :
| Élément | Contenu |
|---|---|
| Multiplexeur | [poll](&mut [PollFd], Option<AirDuration>, Option<&SignalMask>) -> AirResult<usize> — écrit les revents en place, rend le nombre de fd prêts |
| Dépouillement | [ready](&[PollFd]) -> impl Iterator<Item = &PollFd> — itère les entrées prêtes sans indexation paniquante |
| Types ré-exportés | PollFd / PollEvents / SignalMask (depuis la couche 0, inchangés sur le fil) |
Aucune intelligence propre : ni boucle de retry, ni bufferisation, ni
récolte waitid (ça, c’est le rôle spécialisé de air-process::wait_until).
HORS PÉRIMÈTRE
select(2)— modèle legacy (fd_setborné àFD_SETSIZE, coût O(max_fd), ré-armement destructif des ensembles). Inférieur àpollsur tous les axes. Une façadeselect/pselectcompatible libc s’exprimera au-dessus depoll, en face-C, si un userland l’exige — Air ne rétrograde pas sa surface native versfd_set(doctrine « variantes modernes », kernel = bible, ADR-021).epoll— surface stateful (epoll_create/_ctl/_wait) relevant du runtime asynchrone io_uring (couche 1air-runtime, ADR-038 : pas d’epoll), pas de ce multiplexeur synchrone one-shot.- La boucle de reprise
EINTRet le recalcul d’échéance : c’est l’event loop appelante qui les écrit (helpers de retry au-dessus, ADR-021 conv.2).
3. Sémantique fidèle
-
reventsen place, retour = nombre de prêts — fidèle àpoll(2): chaque [PollFd] porte au retour les événements du kernel (vide si non prêt) ; l’entier rendu est le nombre d’entrées àreventsnon vide (0= expiration). Restitution intégrale des bits du kernel (ADR-032,from_bits_retain). -
Timeout typé [
Option<AirDuration>] (jamais d’entier magique, ADR-021) :Valeur Effet NoneAttente indéfinie (timespec NULL) — seul chemin non bornéSome(AirDuration::ZERO)Sondage non bloquant (revient aussitôt) Some(d),d > 0Attente bornée à dSome(d),d ≤ 0Dégénère en sondage ( ZERO) — jamais infiniLa conversion vers le
timespeccouche 0 passe parAirDuration::to_std_saturating: bornage défensif (durée ≤ 0 →ZERO, secondes clampées àu64::MAX), aucunaslossy (Principe 2). Le piège classique « timeout négatif ⇒ attente infinie » (héritagepoll(2)enintmillisecondes) est structurellement fermé : seulNoneest infini. -
EINTRremonté enAirErrorde catégorieInterrupted, sans retry (ADR-021 conv.2). L’event loop décide de reboucler. -
Masque de signaux
Option<&SignalMask>: substitution atomique le temps de l’attente (pattern ppoll, ferme la fenêtre de course signal↔poll).None= aucune substitution.
4. Sûreté & no_std
- Zéro
unsafeexposé : tout l’unsafe(l’asm!deppoll) reste confiné en couche 0. Les descripteurs sont empruntés (BorrowedFdviaPollFd, +0) — l’appelant reste seul propriétaire (RAII). #![no_std], zéro allocation : le multiplexeur écrit dans la slice de l’appelant (jamais deVecinterne) — contraste assumé avecair-process::wait_until, qui doit allouer[pidfd, ...wakeups]pour préposer le pidfd.coreseul (ADR-048/ADR-055), prouvé parcargo build --lib.- Zéro dépendance externe (§2.D doctrine) : seules
air-sys-syscall,air-sys-types,air-base-core. Aucune surface C (check-c-surfaceVERT).
5. Tests & couverture
Cible 100 % lignes + branches (couche 1, ADR-035). Tests sur pipes réels
(couche 0 pipe2 + fs::write) : fd prêt en lecture après écriture ; expiration
sur pipe muet ; sondage ZERO non bloquant ; timeout négatif dégénéré ;
slice vide (sommeil borné) ; multi-fd (seul le prêt porte des revents) ; branche
masque Some(_) ; dépouillement ready (compte == retour de poll, entrées
prêtes seulement, itérateur vide avant remplissage). Les propriétés de la
conversion de timeout sont garanties et property-based en amont
(air-base-core::AirDuration::to_std_saturating).
Exception de couverture (1, STRUCTURAL, ADR-035, documentée in-code). Le
bras d’échec ppoll (passthrough Err(errno) => Err(errno.into())) est
inatteignable de façon déterministe via l’API safe : EINTR (signal — non
injectable en CI, choix hérité de la couche 0 scellée et de
air-process::wait_forever), EFAULT (exclu : slice/masque vivants par
construction), EINVAL (nfds > RLIMIT_NOFILE, ou timespec — ce dernier exclu
par la conversion défensive). La conversion Errno → AirError est couverte dans
air-base-core. Suivant la convention des exceptions STRUCTURAL uniques de
couche 1 (air-memory, air-thread, air-process), elle est marquée
in-code (// COVERAGE (STRUCTURAL, ADR-035)), non dans
COVERAGE-EXCEPTIONS.md (registre orienté couche 0).
Note fuzz
Aucune cible fuzz. La crate n’ingère aucune donnée externe non
structurée : ses entrées sont un &mut [PollFd] (descripteurs empruntés +
drapeaux typés), un Option<AirDuration> (timeout typé, conversion déjà
fuzzée/property-based côté air-base-core) et un Option<&SignalMask> (bitmask
typé). Ni parsing, ni désérialisation, ni buffer d’octets. À l’instar de la
section 7 d’air-filesystem (AirFile), l’absence de surface de fuzz est
documentée plutôt que matérialisée par une cible vide.
6. Point d’arbitrage — ergonomie du masque de signaux
Le paramètre signal_mask expose directement le type couche 0
[SignalMask], ré-exporté tel quel. C’est le choix le plus fidèle-kernel :
ppoll accepte nativement ce masque, et l’exposer débloque immédiatement les
event loops signal-safe. En revanche, l’ergonomie de construction d’un masque
(dériver depuis un ensemble de Signal, composer/inverser, l’articuler avec un
signalfd) relève de la « face signal générique » (roadmap §5.3 P0.4),
non encore posée. Tant qu’elle ne l’est pas, l’appelant construit son masque via
la surface couche 0 (SignalMask::{empty,from_signals,with,without}). À
arbitrer au moment de P0.4 : air-poll continuera-t-il de ré-exporter le
SignalMask brut, ou consommera-t-il un type de masque plus riche fourni par la
face signal ? La signature de [poll] est conçue pour absorber ce raffinement de
manière additive (le paramètre reste un Option<&_>).
air-signal — face signal générique
Couche : 1. Statut : implémentée (brique mince, fidèle).
Périmètre : roadmap couche 1 (libc + PAL)
§5.3 P0.4. Doctrine :
ADR-064 (signalfd
primaire, helper « bloquer + drainer », zéro unsafe exposé — pas de handler
de faute en couche 1 Rust), companion d’ADR-020
(signalfd par défaut). Companion couche 0 :
family-signal.md. Conventions :
ADR-021 (EINTR
remonté, pas de SA_RESTART) ; ADR-029
(nommage sans abréviation). Erreurs :
ADR-019. no_std :
ADR-048 /
ADR-055.
1. Raison d’être
Les signaux sont un mécanisme entièrement piloté par le noyau (ensembles
pending, masque bloqué per-thread, dispositions per-process) : aucun état de
signal n’est détenu en userland. La couche 0 fournit déjà tous les syscalls
(air-sys-syscall::signal, ADR-020) ; air-signal les re-expose fidèlement,
typés couche 1 (AirError/AirResult, EINTR remonté), et fournit le pattern
signalfd correct clé-en-main — c’est la valeur ajoutée d’ADR-064.
Décision de placement — crate dédiée
La face signal n’est ni process- ni socket-spécifique : la greffer sur
air-process reproduirait le couplage que l’audit face-libc reproche à
wait_until. Conformément à la doctrine des crates couche 1 « minces et bornées »
(air-poll, air-env, air-stdio, air-terminal), elle mérite sa crate
dédiée, au-dessus de la couche 0, d’air-base-core et d’air-poll (sœur
couche 1, consommée pour l’attente bornée) — sans cycle (air-poll ne dépend
pas d’air-signal, il ré-exporte seulement le type couche 0 SignalMask). Ce
placement résout l’arbitrage laissé ouvert par la spec air-poll §6 :
l’ergonomie de construction/articulation du SignalMask (masquage per-thread,
signalfd) vit ici, tandis qu’air-poll continue de ré-exporter le
SignalMask couche 0 inchangé sur le fil (raffinement additif).
2. Périmètre — fidèle, cadré par ADR-064
| Élément | Contenu |
|---|---|
| signalfd (face primaire) | AirSignalFd (RAII) : create/create_nonblocking/block_and_drain ; as_fd() -> BorrowedFd (→ air-poll) ; read() -> AirResult<SignalFdInfo> (bloquant, 1 événement) ; next_pending() -> AirResult<Option<SignalFdInfo>> (drainage non-bloquant) ; update_mask ; into_fd |
| Helper « bloquer + drainer » | AirSignalFd::block_and_drain(&SignalMask) — bloque le masque (thread appelant) et crée un signalfd non-bloquant, avec la discipline documentée (bloquer avant de créer les threads, ou dans chaque thread) |
| Masque per-thread | block/unblock/replace_mask/current_mask (typés SignalMask, rendent l’ancien masque) ; SignalMaskGuard (RAII : bloque, restaure au Drop) |
| Envoi | send_signal_to_process(Pid, Option<Signal>) (kill) ; send_signal_to_thread(Pid, Tid, Option<Signal>) (tgkill) ; raise_signal(Signal) (thread courant) ; queue_signal(Pid, Signal, SignalValue) (rt_sigqueueinfo) |
| Attente | wait_for_signal(&SignalMask) -> AirResult<SignalFdInfo> (bloquant) ; wait_for_signal_until(&SignalMask, AirDuration) -> AirResult<Option<SignalFdInfo>> (borné via air-poll) |
| Types ré-exportés | Signal, SignalMask, SignalFdInfo, SignalFdFlags, SignalValue, Pid, Tid, BorrowedFd, OwnedFd (depuis la couche 0, inchangés) |
HORS PÉRIMÈTRE
- Handler de faute synchrone (
sigaction). Décision BDFL (ADR-064) : la couche 1 Rust reste 100 % SAFE, zérounsafeexposé ; elle n’installe aucun handler. Les fautes synchrones —SIGSEGV/SIGFPE/SIGBUS/SIGILL— prennent l’action par défaut du noyau (terminate + core dump). Un éventuelsigaction(crash reporter,sigaltstack) pour du code C vivra dans la face libc C-ABI au-dessus de cette brique (dual-face, ADR-064 §5) — jamais ici. - API
sigactiongénérale. Les signaux normaux passent parsignalfd. La face libc Csigaction/signalPOSIX fidèle (OpenSSH…) se construira au-dessus, en face-C. - Boucle de reprise
EINTR.EINTRest remonté (ADR-021 conv.2/ADR-064 §4) ; l’event loop appelante décide de reboucler (helpers de retry au-dessus). - Décodage du
signalfd_siginfo. Il vit en couche 0 (déjà testé/couvrable là-bas) ;air-signalconsomme leSignalFdInfodéjà décodé. - Élargissement des signaux RT au-delà de 64. Hérité du
SignalMask(bitmasku64) couche 0 ; une représentation plus large relèverait d’un RFC couche 0.
3. Sémantique fidèle & doctrine
signalfd= face primaire (ADR-064 §1). Consommation synchrone, thread-safe, pollable :AirSignalFd::as_fd()rejoint unair_poll::poll. On bloque puis on lit — pas de handler réentrant.- Helper « bloquer + drainer » (ADR-064 §3) — le point de valeur. Le pattern
correct est délicat : un signal process-directed part vers n’importe quel
thread qui ne le bloque pas, court-circuitant le fd.
block_and_drainbloque le masque dans le thread appelant et crée le fd non-bloquant ; la discipline (bloquer avant de créer les threads → héritage, ou bloquer dans chaque thread) est documentée, pas laissée à l’appelant nu. Le noyau ne permet pas de modifier le masque d’un thread déjà lancé — Air fournit le pattern correct, pas une magie impossible (choix fidèle-kernel signalé). - Fautes synchrones : action par défaut du noyau (ADR-064).
SIGSEGV/SIGBUS/SIGFPE/SIGILLsont des traps non différables qu’unsignalfdne peut pas couvrir. Décision BDFL : la couche 1 Rust n’installe aucun handler — ces fautes prennent l’action par défaut du noyau (terminate + core dump). Cela préserve le « zérounsafeexposé » : Air ne peut pas prouver l’async-signal-safety d’un pointeur de fonction fourni par l’appelant, donc la facesigactionn’existe pas dans cette brique. Unsigactionlégitime (crash reporter,sigaltstack) pour du code C relèvera de la face libc C-ABI au-dessus (ADR-064 §5). Option<Signal>(convention 1 ADR-021).None= « signal 0 » = test d’existence, jamais un0magique.raise_signalcible le thread courant (fidèle POSIXraise, viatgkill), jamaiskill(getpid(), …)qui routerait vers un thread arbitraire.- Attente bornée via
air-poll:wait_for_signal_untilbloque le masque, crée unsignalfdnon-bloquant,pollavec échéance, draine si prêt. Comble la variante que la couche 0 laissait àEINVAL(faute deppollinterne). Timeout typéAirDuration(jamais d’entier magique) — voir « Timeout typé » d’air-poll. - La couche 1 ne maintient AUCUN état de signal (ADR-064 §6). Elle expose celui du noyau. Le masque est per-thread, les dispositions per-process — le helper et la doc gèrent cette asymétrie pour l’appelant.
4. Sûreté & no_std
- Zéro
unsafeexposé (décision BDFL) : tout l’unsafede bas niveau (l’asm!des syscalls) reste confiné en couche 0. Le fd est en RAII (OwnedFd). Aucune fonction publique de la crate n’estunsafe, et le code de test lui-même ne contient aucun blocunsafe(plus defork). #![no_std], zéro allocation en code lib (le drainage lit dans un tampon fixe côté couche 0 ; les messages d’AirErrorallouent dansair-base-core).coreseul (ADR-048/ADR-055), prouvé parcargo build --lib.- Zéro dépendance externe : seules
air-sys-syscall,air-sys-types,air-base-core,air-poll. Aucune surface C (check-c-surfaceVERT).
5. Tests & couverture
Cible 100 % lignes (couche 1, ADR-035). Tests sur signaux réels :
- Masque : block/unblock round-trip,
replace_masktotal,current_mask;SignalMaskGuardrestaure auDrop. Chaque test restaure le masque du worker cargo (garde interne) pour ne pas contaminer les suivants. signalfd:createbloquant +raise+read;create_nonblocking+readsans pendant →WouldBlock;block_and_drain+ drainage (next_pending:None, puisSome, puisNone) ;as_fd/into_fd;update_mask(extension du set capturé, vérifiée par unraisede SIGCHLD).- Envoi :
send_signal_to_process/_to_threadsignal 0 (existence) etESRCH(bras d’erreur, couvremap_errno) ;raise_signalauto-délivré et lu sur lesignalfd;queue_signal— bras d’erreurESRCHet chemin de succès auto-dirigé en parent, sansfork(SIGCHLD,SIG_KERNEL_IGNORE→ rejeté à la génération, sans effet de bord). Les deux tests s’exécutent dans le thread appelant : 100 % des lignes dequeue_signalsont couvertes en parent. - Attente :
wait_for_signalbloquant (signal pré-raise) ;wait_for_signal_untilexpiration (Ok(None)) et prêt (Ok(Some)).
Pas de fork, pas d’exception CHILD-EXIT. Tous les tests s’exercent sur des
signaux réels dans le thread appelant (auto-délivrance thread-directed via
raise/tgkill, ou envoi auto-dirigé d’un signal ignoré par le noyau). La face
fault (handler sigaction, unsafe, jadis testée en enfant forké) a été
supprimée (décision BDFL, ADR-064) ; le seul motif qui restait à fork — le
round-trip de valeur d’un rt_sigqueueinfo process-directed — appartient à la
couche 0 (family-signal, décodage signalfd_siginfo testé sur buffer et en
enfant mono-thread), pas ici : un signal process-directed n’est pas lisible de
façon fiable sur le signalfd du thread appelant en harnais multi-thread (le
noyau le route vers le thread de groupe). air-signal transporte la SignalValue
sans la re-décoder.
Exception de couverture (marquée in-code, ADR-035).
- STRUCTURAL (1). Le bras
Err(errno) =>autre qu’EAGAINdeAirSignalFd::next_pending: une erreur dereadsur unsignalfdvalide autre qu’EAGAIN(p.ex.EINTR) n’est pas injectable de façon déterministe.EAGAIN(drainage à vide) est couvert ; la conversionErrno → AirErrorl’est viamap_errno(ESRCH) et dansair-base-core.
Suivant la convention couche 1 (air-poll/air-process/air-terminal), cette
exception est marquée in-code (// COVERAGE (STRUCTURAL, ADR-035)), non dans
COVERAGE-EXCEPTIONS.md (registre orienté couche 0).
Note fuzz
Aucune cible fuzz. Le seul décodage d’octets kernel (le signalfd_siginfo →
SignalFdInfo) vit en couche 0 ; les entrées d’air-signal sont des types
déjà validés (SignalMask, Signal borné, Pid/Tid, AirDuration,
SignalValue). Ni parsing, ni désérialisation en couche 1. À l’instar
d’air-poll/air-filesystem, l’absence de surface de fuzz est documentée
plutôt que matérialisée par une cible vide.
6. Suite (hors périmètre P0.4)
- Face libc C
sigaction/signalPOSIX fidèle, au-dessus de cette brique (dual-face ADR-064 §5), avec avertissements async-signal-safety. C’est là que vivra tout handlerunsafe(jamais dans la couche 1 Rust). air-fault(crash reporter) installera son handler de faute via cette face C-ABI (ou un wrapperunsafedédié, hors couche 1 Rust) — la couche 1 Rust reste SAFE (ADR-064 §Suite).- Élargissement RT > 64 si un besoin concret l’exige (RFC couche 0).
Spec couche 1 — air-netlink (transport netlink générique, patron sans-IO)
Spécification technique — Version 0.1 (proposition). Couche 1 « Primitives système ». Tranchée par ADR-079.
Cadrage.
air-netlinkest un transport netlink générique de couche 1 (pair d’air-socket) : message framing, codec d’attributs TLV (rtattr/nlattr), corrélation requête/réponse/dump. Maison, pur Rust, zéro-C, zérounsafe. Il ne connaît aucune famille en propre :rtnetlink(routes/adresses/liens),nl80211(wifi),nftables,genetlinkse posent au-dessus de lui. Suit le patron sans-IO 9-composants (note réseau §2.2).
Position et méthode
Netlink est un protocole filaire kernel↔userspace : socket(AF_NETLINK, …) +
messages nlmsghdr porteurs d’attributs TLV. C’est un plan de contrôle (on
interroge/modifie l’état du noyau : routes, adresses, liens…), distinct du transport
de données d’air-socket. Comme air-socket::dns (sans-IO, fuzzé), le cœur de
air-netlink est pur (parse/sérialise/corrèle), le pilote I/O (le socket réel)
étant mince.
Premier consommateur : air-network::AirNetworkPathMonitor (L2) — énumérer et
surveiller interfaces/adresses/routes via rtnetlink. Autres consommateurs prévus :
daemons WireGuard/DHCP (L5), air-config (projection de routes), wifi (nl80211).
Dépendances
- Tierces externes : ZÉRO. Entièrement maison (netlink n’a pas de crypto).
- Couche 0 (après descellement
couche-0-v1.11, ADR-079) :SocketDomain::Netlink(AF_NETLINK = 16) — additif à l’enum typé ;SocketAddrNetlink(sockaddr_nl, 12 o) — additif pourbind;- réutilise tel quel
socket/bind/sendmsg/recvmsggénériques déjà présents (air-sys-syscall::net) — aucun nouveau syscall.
air-base-core(L1) —AirError/AirResult(ADR-019).- Zéro
unsafeexposé ; parsing TLV borné (get(), longueurs validées, alignementNLMSG_ALIGN/RTA_ALIGNvérifié — jamais de lecture hors-borne ni de boucle non bornée).
air-deviceinchangé (ADR-079) : son socketNETLINK_KOBJECT_UEVENTreste spécifique. Migration future surair-netlinkpossible, non requise.
Anatomie : les 9 composants appliqués à netlink
| # | Composant | Réalisation air-netlink | Pur ? |
|---|---|---|---|
| 1 | Framer | MessageFramer — délimite le flux en messages nlmsghdr (len/type/flags/seq/pid), gère le multipart (NLM_F_MULTI → NLMSG_DONE) et l’alignement | ✅ |
| 2 | Codec | MessageCodec (en-tête) + AttributeCodec (TLV rtattr/nlattr) — la surface hostile, décodage borné + imbrication (NLA_F_NESTED) | ✅ |
| 3 | StateMachine | RequestStateMachine — corrélation par numéro de séquence, sémantique dump (NLM_F_DUMP → multipart), ACK/erreur (NLMSG_ERROR, code errno) | ✅ |
| 4 | Handshaker | // NO HANDSHAKE : netlink n'a ni auth ni accord de clé. | — (absent voulu) |
| 5 | Flow Controller | // NO FLOW CONTROL : socket noyau. ENOBUFS (buffer plein) = signalé à l'appelant (resync dump), pas de fenêtrage. | — (absent voulu) |
| 6 | Multiplexer | // MINIMAL : abonnement à des groupes multicast (RTNLGRP_*) sur le socket ; pas de streams applicatifs. | ✅ (minimal) |
| 7 | Timer Manager | // MINIMAL : timeout de requête optionnel (horloge injectée). Pas de retransmission (kernel fiable en unicast). | ✅ (horloge injectée) |
| 8 | Session Context | NetlinkSession — compteur de séquence, nl_pid du socket, groupes abonnés, requêtes en attente | ✅ |
| 9 | Extension hooks | NetlinkFamily + modules de famille typés (rtnetlink, …) construits au-dessus du cœur générique | ✅ |
3 composants absents/minimaux marqués (Handshaker/Flow absents ; Mux/Timer minimaux) — l’absence est intentionnelle et visible (note §2.2).
Inventaire des objets — ~15 objets
A. Surface publique générique — ~9
| # | Objet | Rôle |
|---|---|---|
| 1 | NetlinkSocket | RAII — possède le fd AF_NETLINK ; open/bind/send/recv (pilote I/O) |
| 2 | NetlinkFamily | enum/protocole (Route, Generic, Netfilter…) au socket() |
| 3 | NetlinkGroup | groupe multicast à joindre (abonnement aux notifications) |
| 4 | NetlinkMessage<'a> | en-tête + payload (vue empruntée, zéro-copie) |
| 5 | NetlinkHeader | nlmsghdr typé (type, flags, seq, pid) |
| 6 | NetlinkAttribute<'a> | attribut TLV décodé (type + valeur bornée) |
| 7 | NetlinkRequest (+ Builder) | construit un message (flags REQUEST/ACK/DUMP) |
| 8 | DumpIter<'a> | itérateur borné sur une réponse multipart (dump) |
| 9 | NetlinkError | enum (ADR-019) — mappe NLMSG_ERROR (errno) + erreurs de décodage |
B. Cœur sans-IO — ~4
MessageFramer, MessageCodec + AttributeCodec (TLV), RequestStateMachine,
NetlinkSession.
C. Module rtnetlink (premier consommateur intégré) — ~2 + valeurs
RtnetlinkClient (helpers typés : list_links/list_addresses/list_routes en dump,
subscribe(link|addr|route)) + RtLink/RtAddress/RtRoute (vues typées des
attributs). (Les autres familles — nl80211, nftables — viendront en modules séparés.)
Codecs (bytes ↔ messages/attributs)
#![allow(unused)]
fn main() {
/// Lecteur borné + alignement netlink (NLMSG_ALIGN/RTA_ALIGN) — jamais d'indexation
/// paniquante, jamais de boucle sur une longueur mensongère (Principe 3).
pub struct NetlinkReader<'a> { /* … */ }
impl<'a> NetlinkReader<'a> {
pub fn header(&mut self) -> Result<NetlinkHeader, DecodeError>; // 16 o, len validée
pub fn attributes(&mut self) -> AttributeIter<'a>; // TLV bornés, alignés
pub fn nested(&self, attr: &NetlinkAttribute<'a>) -> AttributeIter<'a>; // NLA_F_NESTED
}
/// Attribut TLV : [ len(u16) ][ type(u16) ][ value… aligné 4 ].
pub struct NetlinkAttribute<'a> { pub kind: u16, pub value: &'a [u8] }
// AttributeCodec::decode : borne value par len ; refuse len < 4 ou débordant le message.
}
Décision — cœur générique, familles au-dessus. Le codec ne connaît pas la sémantique des attributs (
RTA_DST,IFLA_IFNAME…) : il rend des(type, bytes)bornés. Les modules de famille (rtnetlink) interprètent. ⇒ la surface hostile (décodage TLV) est une seule fois, fuzzée une seule fois, réutilisée partout.
Pilote I/O & machine à états (multi-thread, mono-propriétaire)
#![allow(unused)]
fn main() {
impl NetlinkSocket {
pub fn open(family: NetlinkFamily) -> AirResult<Self>; // socket(AF_NETLINK,…) RAII
pub fn bind(&self, groups: &[NetlinkGroup]) -> AirResult<()>; // sockaddr_nl
pub fn send(&self, request: &NetlinkRequest) -> AirResult<u32>; // rend le seq
pub fn recv<'b>(&self, buf: &'b mut [u8]) -> AirResult<NetlinkReader<'b>>;
}
}
- Cœur sans-IO testable sans socket : on lui jette des octets → messages/
attributs/erreurs (fuzz). Le pilote (
NetlinkSocket) est le seul contact couche 0. - Thread model :
NetlinkSocketSend/!Sync(un fd, un propriétaire, zéro verrou) — comme les objets fd d’air-socket. Plusieurs sockets = plusieurs threads, indépendants.
Sécurité
- Surface hostile = messages du noyau (attributs TLV en boucle). Défenses : toutes
les longueurs validées (
len ≥ 4,len ≤ reste du message), alignement vérifié, imbrication bornée (profondeur max surNLA_F_NESTED, anti-explosion), jamais d’allocation dictée par une longueur non vérifiée. Fuzz obligatoire surAttributeCodec/MessageFramer. - Authentification kernel : ne traiter que les messages dont
nl_pid == 0(émanant du noyau) pour les notifications — même discipline que le socket uevent d’air-device. - Privilèges : les opérations d’écriture (ajout de route/adresse) exigent
CAP_NET_ADMIN— l’appelant (daemon) porte le privilège ;air-netlinkn’élève rien.
Stratégie de tests
- Couverture 100 % (couche 1) lignes + branches.
- Cœur sans-IO : unitaires + property-based (round-trip
decode∘encode = idsur messages valides) + fuzz (fuzz_netlink_attributes,fuzz_netlink_message: octets hostiles → zéro panic, borne d’imbrication respectée). RequestStateMachine: dump multipart (→NLMSG_DONE),NLMSG_ERROR(errno mappé), corrélation de séquence.- Pilote I/O : test réel
AF_NETLINK/NETLINK_ROUTEsur la machine (dump des liens → au moinsloprésent), abonnement àRTNLGRP_LINK(interface synthétique up/down si root, sinon test décodeur). - Note fuzz : la surface de parsing d’octets externes est le cœur → fuzz couvert.
Récapitulatif & décisions
| Famille | Objets |
|---|---|
| A. Surface publique générique | ~9 |
| B. Cœur sans-IO | ~4 |
C. Module rtnetlink | ~2 + valeurs |
| Total | ~15 (3 composants absents/minimaux marqués) |
- Transport netlink générique, couche 1 (ADR-079, option B) — pair d’
air-socket, réutilisable par tous les consommateurs (path monitor, WireGuard, DHCP, config, wifi). - Cœur générique, familles au-dessus — la surface hostile TLV décodée une fois, fuzzée une fois.
- Zéro-C, zéro
unsafe, zéro tierce ; RAII pour le fd ;Send/!Sync. - Additif couche 0 modéré (
AF_NETLINK+sockaddr_nl) — syscalls génériques déjà présents, aucun nouveau syscall. air-deviceinchangé ; migration future possible, non requise.
Travail à reprendre
- Additif couche 0
couche-0-v1.11:SocketDomain::Netlink+SocketAddrNetlink(RFC de descellement, ADR-079) — prérequis d’implémentation. - Consommateur : câbler
air-network::AirNetworkPathMonitorsurRtnetlinkClient. - Familles supplémentaires :
nl80211(état/signal wifi → enrichitAirInterfaceType::WiFi),nftables(futurair-firewall),genetlink. - Migration du socket uevent d’
air-devicesurair-netlink(non bloquante). - ABI C différée.
Licence du document : MPL 2.0
Statut : Spécification air-netlink (couche 1) v0.1 — transport netlink générique
sans-IO, tranché par ADR-079 (option B, couche 1). Additif couche 0 AF_NETLINK
requis. Implémentation à suivre.
Spec couche 2 — air-object (modèle d’objet C-ABI)
Spécification technique — Version 1.0 (décisions validées BDFL 2026-06-25). Couche 2 « Modèle d’objet, IPC, services fondamentaux ».
Position et méthode
air-object est la clé de voûte de la couche 2 : le modèle d’objet C-ABI d’Air
(ADR-002). Il donne à toute classe « de surface »
une identité runtime, des propriétés observables, l’introspection, et la
bindabilité polyglotte sans glue par classe. C’est ce dont air-event,
air-aircom, les collections, air-string, et les couches 3-5 dépendent.
Nommage (ADR-039) : le modèle d’objet s’appelle
air-object(le nomair-runtimeest désormais le runtime async, couche 1). Bibliothèque C :libair-object.so, zoneair-stable(ABI 10 ans — ADR-012).
Frontière explicite (ADR-002, Principe 7) : un type qui dérive #[air_class] rejoint
le monde C-ABI (refcount atomique, classe = vtable + métadonnées, propriétés observables,
introspection). Tout le reste — algorithmes, parseurs, structures privées — reste
Rust pur (zéro surcoût). Pont entre les deux mondes par conversion explicite.
Crates (ADR-002 / ADR-039)
air-object/ — runtime d'objets : AirObject, AirClass, API C-ABI air_object_*
air-object-macros/ — macro procédurale #[air_class] (génère layout C, métadonnées, exports C)
air-value/ — AirValue (union typée) + AirStatus
Section 1 — AirObject et AirClass
AirObject : structure C opaque, en-tête fixe + payload variable.
AirObject (en-tête 24 octets sur 64-bit)
├── class : *const AirClass — pointeur vers la classe
├── refcount : AtomicU32 (ou U64) — comptage de référence atomique
└── flags : u32 — frozen, observed, traits accessibilité…
payload (taille variable selon la classe) — dont les propriétés observables
AirClass est elle-même un AirObject (méta-classe). Elle porte :
- la vtable (pointeurs vers les méthodes) ;
- les métadonnées d’introspection : nom, parente, liste de propriétés/méthodes ;
- les traits accessibilité : rôle par défaut, attributs universels (ADR-017).
Section 2 — API C-ABI universelle
AirObject* air_object_alloc(const AirClass*);
void air_object_retain(AirObject*);
void air_object_release(AirObject*);
const AirClass* air_object_class(const AirObject*);
int air_object_is_kind_of(const AirObject*, const AirClass*);
AirValue air_object_get_property(const AirObject*, const char* name);
int air_object_set_property(AirObject*, const char* name, AirValue);
typedef void (*AirObserverFn)(AirObject* obj, const char* prop, AirValue old, AirValue new_, void* ctx);
AirObservation* air_object_observe(AirObject*, const char* prop, AirObserverFn, void* ctx);
void air_object_unobserve(AirObservation*);
AirValue air_object_send_message(AirObject*, const char* selector, const AirValue* args, size_t n);
C’est l’universalité : un binding Python/Ruby appelle uniquement ces fonctions — aucun code spécifique par classe (le modèle qui fait marcher PyObjC sans glue, ADR-002).
Section 3 — AirValue et AirStatus
// Union typée (≈ NSObject/id macOS ou GValue GObject).
typedef enum { AIR_NULL, AIR_INT, AIR_FLOAT, AIR_BOOL, AIR_STRING, AIR_BYTES, AIR_OBJECT } AirValueKind;
typedef struct { AirValueKind kind; union { int64_t i; double f; bool b; /* … */ AirObject* obj; }; } AirValue;
Règle de propriété UNIFORME (validée, ≠ Create/Get d’Apple) : quand un AirValue
contient un AirObject* —
- tout
AirValueRETOURNÉ par une fonction Air est +1 (possédé) : le receveur doitair_value_release(v)(qui relâche l’objet contenu) ; - tout
AirValuepassé en ARGUMENT est +0 (emprunté) : le callee n’en devient pas propriétaire ; s’il le garde, ilretain.
Soit « retours possédés / arguments empruntés » — une seule règle, sans la mémoire
par-nom de CoreFoundation. Côté Rust, AirValue/AirHandle l’automatisent (Drop =
release) ; seul un consommateur C brut la gère à la main. AirStatus (code + message,
cohérent ADR-019/AirError) porte les erreurs à travers l’ABI (jamais de panic :
catch_unwind à la frontière).
Section 4 — Définition des classes en Rust : #[air_class]
#![allow(unused)]
fn main() {
#[air_class(thread = MainThreadOnly)] // politique de thread OBLIGATOIRE (cf. §6)
pub struct AirButton {
#[observable] pub label: AirString,
#[observable] pub enabled: bool,
#[accessibility(role = "button")] _a11y: AccessibilityMarker,
on_click: Option<AirCallback<()>>,
}
}
La macro génère : layout C opaque (ABI stable), AirClass enregistrée au runtime,
implémentations standard (retain/release/get/set property…), fonctions C exportées,
annotations accessibilité consommées par air.wm.accessibility (ADR-017).
AirHandle<T> : smart pointer Rust à sémantique Arc interopérant avec le refcount
AirObject : Drop = release, Clone = retain. Le Rust de couche 2+ manipule des
AirHandle<T> typés ; le C voit des AirObject*.
Section 5 — Propriétés observables
Fondement du data binding, des frameworks réactifs (air-ui), de l’accessibilité
(lecteur d’écran), du débogage (inspecteur). #[observable] rend un champ observable :
get/set/observe. Le set notifie les observateurs (ancienne/nouvelle valeur).
AirObservable<T> (wrapper) et le AirNotificationCenter intra-processus
(≠ AirCom inter-processus) sont fournis.
Section 6 — Mémoire et threads (politique OBLIGATOIRE)
Refcount atomique ⇒ un objet peut être détenu par plusieurs threads, mais son contenu n’est pas thread-safe par défaut. Toute classe déclare sa politique (sinon ne compile pas — sécurité par construction, ADR-002) :
Immutable: créée une fois, jamais modifiée (thread-safe trivial — modèleAirString).MainThreadOnly: thread principal uniquement (modèleAirView; vérifié en debug, coût release à mesurer).ThreadSafe: accès concurrent autorisé (l’implémentation protège ses invariants).
Section 7 — Introspection et bindings
const AirClass* air_class_list_all(size_t* n);
AirPropertyInfo air_class_properties(const AirClass*, size_t* n);
const AirClass* air_class_parent(const AirClass*);
char* air_object_describe(const AirObject*); // debug
Activée en production → permet à air-screenreader (ADR-017) de fonctionner sur
n’importe quelle app sans plugin. Bindings : Pattern A générique (Python/Ruby :
uniquement air_object_*, zéro code par classe) ; Pattern B typé (Swift via
@dynamicMemberLookup, généré depuis les métadonnées).
ABI C et stabilité
libair-object.so, symboles air_object_*/air_class_*/air_value_*, zone
air-stable (versioned symbols GNU, ABI garantie 10 ans — ADR-012). Tests de
conformité ABI en CI (air-abi-check).
Décisions ratifiées (BDFL 2026-06-25)
- Refcount =
AtomicU32(compact ; en-tête 24 o ; saturation/overflow réglés à la marge de l’impl, cohérent slab io_uring). MainThreadOnly: enforcement debug-only (assert en debug, zéro coût en release — comme le « Main Thread Checker » d’UIKit ; remesuré si besoin, Principe 5).- Refcount strict v1, GC cyclique DIFFÉRÉ (cycles évités par discipline, modèle ARC).
AirValue: union taguée à layout fixe + règle de propriété uniforme « +1 retours / +0 arguments » (§3) ; représentation figée avant l’impl (ABI 10 ans).- Exception 80 % proc-macro :
syn/quote/proc-macro2(pourair-object-macros) acceptés → à nommer dansdocs/EXCEPTIONS.md(build-time only, non livré dans l’artefact, sans alternative crédible, universel/audité). Fixe la politique proc-macro d’Air (vaut pour toute future crate*-macros).
Détail laissé à l’implémentation (non bloquant) : format exact de #[air_class].
Périmètre v1 / différé
- v1 :
AirObject/AirClass, API C-ABI universelle,AirValue,#[air_class]+AirHandle, propriétés observables, politiques de thread, introspection, ABI stable. - Différé : GC cyclique, bindings polyglottes générés (phase bindings :
cbindgen/uniffià évaluer), collections observables (air-collections, spec séparée).
Dépendances
air-base-lib(erreurs/temps),air-string(consommée par les propriétésAirString, spec séparée). Macroair-object-macros=syn/quote/proc-macro2(exception 80 % à acter — outillage de macro, comme tout l’écosystème proc-macro Rust). À tracer dansDEPENDENCIES.md+EXCEPTIONS.md.
Stratégie de tests
- Couverture très élevée (couche 2) ; tests de conformité ABI C (compilation C +
appels à
libair-object.so) ; tests depuis C/Swift/Python (universalité sans glue) ; multi-thread (refcount atomique, races, politiques de thread) ; observation (notification ancienne/nouvelle valeur, ré-entrance) ; introspection (lister classes/propriétés). Property-based sur le refcount (retain/release équilibrés).
Décisions de fond (ADR-002)
- Modèle d’objet C-ABI hybride asymétrique : C-ABI pour la surface observable/bindable (collections, strings, vues, services) ; Rust pur pour le reste.
- Universalité :
air_object_*suffit aux bindings — zéro glue par classe. - Politique de thread déclarée obligatoire (ne compile pas sinon).
- Refcount atomique +
AirHandle(Drop=release/Clone=retain). - Introspection en production (accessibilité/outils sur toute app).
Licence du document : MPL 2.0
Statut : v1.0 — décisions validées (2026-06-25). Fondation couche 2 (consommée par
air-event/air-aircom/collections). Implémentation à suivre ; l’entrée EXCEPTIONS.md
(proc-macro) sera créée au câblage d’air-object-macros. Bases : ADR-002 / ADR-012 /
ADR-017 / ADR-039 + macro-architecture §4 Partie A.
Spec couche 2 — AirCom (IPC natif d’Air)
Spécification technique — Version 1.0 (décisions validées BDFL 2026-07-12). Couche 2 « Modèle d’objet, IPC, services fondamentaux ».
Autorité et périmètre. Cette spec fige les décisions laissées ouvertes par le rapport de design AirCom (§7) et par ADR-001 (« Statut futur » : le wire format exact, le format du registre… feront l’objet d’ADRs et de specs). ADR-001 reste immuable dans son principe (AirCom = transport interne, D-Bus pour l’interop) ; cette spec en est la déclinaison technique contraignante.
Ce qu’elle normalise : le jeu de crates et leurs couches, le wire format, le transport (control plane + data plane), le modèle de capability, l’anatomie sans-IO à 9 composants (ADR-091), les deux surfaces (synchrone et asynchrone) de première classe, le pub-sub par anneau de mémoire partagée, et le bootstrap
air-registry. Les incréments d’implémentation figurent en §12.
Section 0 — Décisions structurantes (arbitrées BDFL)
| # | Sujet | Décision | Base |
|---|---|---|---|
| 0.1 | Nommage des crates | air-com (pilote) · air-com-proto (cœur sans-IO) · air-com-schema (schémas .capnp) · air-com-codegen (compilateur/outil) · air-registry (découverte, couche 5). La macro-architecture §B (air-aircom*) est réconciliée sur ce jeu (note ci-dessous). | ADR-029, design §7.1 |
| 0.2 | Wire format | Cap’n Proto (capnp = "=0.26.0", pur Rust, zéro C), déjà audité/mutualisé. Codegen commité (regenerate.sh), hors chemin de build critique. | ADR-040, ADR-001, ADR-025 |
| 0.3 | Control plane | Unix SOCK_SEQPACKET (frontières préservées + FD passing). Wrapper AirUnixSeqpacket livré (couche 1, couche-1-v2.1). | design §4.3, macro-arch §B |
| 0.4 | Data plane | memfd_create + mmap SHARED + F_ADD_SEALS, fd partagé par SCM_RIGHTS. Façade air-shm livrée (couche 1). Zero-copy. | design §4.3/§6.5 |
| 0.5 | Capability | Un fd vers une connexion AirCom = la capability (+ métadonnées de type). Unforgeable par le noyau, pas de token/crypto. Révocation = fermeture fd ; délégation = re-SCM_RIGHTS. | ADR-010, ADR-001 §117 |
| 0.6 | Surfaces API | Deux surfaces de première classe : synchrone (sur air-socket) et asynchrone (sur air-async/io_uring). Le cœur sans-IO les sert toutes deux sans duplication de logique protocolaire. | design §7.7, ADR-038 |
| 0.7 | Pub-sub | Diffusion 1→N par anneau en mémoire partagée (memfd scellé, un producteur / N lecteurs, zero-copy), distinct du RPC requête/réponse p2p. | suivi, ADR-001 (anti-broker) |
| 0.8 | Registry / bootstrap | Un fd par service : air-launchd (couche 5) distribue à chaque service son fd de capability initial par SCM_RIGHTS selon les entitlements signés ; découverte ultérieure par lookup nommé auprès de air-registry (capability implicite universelle) → renvoie un fd de connexion. Pas d’autorité ambiante. | ADR-010, macro-arch §B |
Note de réconciliation (macro-architecture §B). Le doc d’architecture nomme
air-aircom/air-aircom-schema/air-aircom-codegen/air-registry. Le préfixeaircomredouble le mot « Air » (AirCom → air-aircom). Cette spec acte le jeuair-com*— déjà bâti et re-scellé (couche-1-v2.1, PR #333) — plus court et sans redondance. La macro-architecture sera alignée par édition documentaire (grep, aucune référence cassée), pas par RFC (aucune décision de fond ne change ; ADR-029 tranche le style de nom).
Section 1 — Position et méthode
AirCom est l’IPC natif d’Air : le transport par lequel les services et les
applications .airapp s’invoquent, sans broker central (le refus de D-Bus est
argumenté par ADR-001). Il se déploie sur le motif sans-IO normatif d’Air
(ADR-091) : un cœur pur
(fuzzable, sans socket ni horloge) et un pilote mince qui le câble à l’I/O.
┌─────────────────── air-com-proto (couche 2, no_std+alloc) ───────────────┐
octets ───▶ │ Framer → Codec → StateMachine → (Session Context) │ ───▶ messages typés
(feed) │ CŒUR SANS-IO — pur, FUZZABLE, zéro socket / async / horloge │ + octets (poll_transmit)
└───────────────────────────────────────────────────────────────────────────┘
▲ API sans I/O (feed / poll_transmit / poll_timeout)
┌───────────────────┴─────────── pilotes (couche 2) ───────────────────────┐
│ air-com (async, sur air-async/io_uring) · air-com (sync, sur air-socket) │
│ control plane SEQPACKET + data plane memfd (air-shm) + SCM_RIGHTS │
└───────────────────────────────────────────────────────────────────────────┘
Rappel de couches. Le cœur (
air-com-proto) et les pilotes (air-com) sont couche 2. Ils consomment des briques couche 1 (air-socket,air-uringviaair-async,air-shm) — jamais la couche 0 en direct (check-layers).air-registryvit en couche 5. Cette spec couvre les crates couche 2 et le contrat queair-registrydoit honorer ; l’implémentation deair-registryrelève d’une spec couche 5 ultérieure.
1.1 Le cœur ne détient aucun fd (invariant capital)
La capability est un fd ; le cœur sans-IO ne détient aucun fd. Le Session Context (#8) porte les métadonnées de capability (le quoi : type, droits négociés, ids) ; le pilote détient les fds (le comment). Cet invariant est ce qui rend le cœur pur et fuzzable, et ce qui autorise une seule logique protocolaire à servir les deux surfaces (sync et async) — elles ne diffèrent que par le pilote.
Section 2 — Jeu de crates
| Crate | Couche | no_std | Rôle | Dépendances clés |
|---|---|---|---|---|
air-com-proto | 2 | ✅ (+alloc) | Cœur sans-IO : les 9 composants ADR-091. Fuzzable en isolation. | air-base-core, capnp (à terme) |
air-com | 2 | ❌ (std) | Pilotes I/O async (air-async) et sync (air-socket). Control plane + data plane + SCM_RIGHTS. | air-com-proto, air-async, air-socket, air-shm, air-sys-types |
air-com-schema | 2 | ✅ | Schémas .capnp d’AirCom + code Rust généré commité. | capnp, air-com-codegen (build) |
air-com-codegen | 2 (outil) | ❌ | Enveloppe capnpc : regenerate.sh, vérification de compat ABI (ADR-012). Hors chemin de build critique (ADR-025). | capnpc |
air-registry | 5 | ❌ | Découverte nommée + distribution de capability initiale. Contrat ici, impl en spec couche 5. | air-com, entitlements ADR-010 |
Pourquoi deux crates cœur/pilote (et non un seul proto/+io/) : air-async
est std (Rc/RefCell/thread-locals) ; un crate unique forcerait le cœur à
hériter de std et le ferait entrer dans le build de fuzz. La séparation garantit un
cœur no_std réellement isolé (fuzz sans socket ni exécuteur). Précédent
quinn-proto/quinn.
Section 3 — Anatomie sans-IO à 9 composants (spec du cœur)
Les 9 cases d’ADR-091 §2, spécifiées pour AirCom. Le squelette #333 a livré 1/2/3/8 et marqué absents 4/5/6/7/9 ; cette spec en définit la cible.
3.1 (1) Framer
- Contrôle : sur
SEQPACKET, le noyau délimite — une trame = unrecvmsg. Le Framer est trivial (identité + gardeMAX_CONTROL_FRAME_LEN). - Données : le data plane ne passe pas par le Framer (voir §5) — seule la référence (id/offset/len de segment) transite en control plane.
- Compat descendante : sur un transport
STREAM(bootstrap, ou pilote sync sans SEQPACKET), le Framer applique un préfixe de longueuru32borné. Les deux Framers exposent la même interface au Codec.
3.2 (2) Codec — Cap’n Proto
- (dé)sérialise message typé ↔ octets via
air-com-schema. Pur, sans I/O : vit dans le cœur, fuzzable octet-par-octet. - Premier schéma (
air-com-schema/aircom.capnp, figé par cette spec en v1) :struct Envelope:requestId :UInt64,channelId :UInt32,kind :MessageKind(enum :call,return,error,event,credit,close),payload :Data,capRefs :List(CapRef).struct CapRef:slot :UInt16(indice dans le tableau de fdsSCM_RIGHTS),type :UInt32(métadonnées de type de capability). Aucun fd dans le message — seulement l’indice de slot ; le pilote apparie slot↔fd reçu.struct ShmRef(data plane) :slot :UInt16,offset :UInt64,length :UInt64,sealed :Bool.
- Versioning (ADR-012) : champs réservés jamais réutilisés ; évolution par
ajout de champs optionnels ; interfaces incompatibles = nouveau type coexistant
(
EnvelopeV2), vérifié en CI parair-com-codegen(règles de compat capnp).
3.3 (3) StateMachine
- États de connexion :
Opening→Established→Draining→Closed, plusFaulted(violation protocolaire). Transitions illégales rejetées (testé). - Par requête :
Idle → AwaitingReturn → {Returned | Errored | Cancelled}. Le Multiplexer (#6) autorise plusieurs requêtes concurrentes par connexion. - Aucune I/O, aucune horloge : les échéances arrivent par
poll_timeout(voir #7), la StateMachine ne fait qu’appliquer les transitions.
3.4 (4) Handshaker
- La capability est déjà établie (par
SCM_RIGHTShors-bande, §6). Le Handshaker ne négocie pas l’identité — il négocie la version de schéma et le jeu d’extensions (#9) :Hello{ schemaVersion, extensions, maxChannels, maxFrame }échangé aux deux sens ;- intersection déterministe (min de version, extensions communes) ;
- échec → transition
Faultedsans I/O (le pilote ferme).
- Pur :
feed(hello_bytes) -> HandshakeOutcome,poll_transmit() -> Option<bytes>.
3.5 (5) Flow Controller
- Back-pressure explicite (reactive streams, exigence ADR-001) pour le streaming
data plane : crédits par canal (
MessageKind::credit), fenêtre bornée. - Le producteur ne publie une nouvelle référence shm que s’il a du crédit ; le consommateur rend du crédit après lecture. Jamais de file non bornée en mémoire.
- Pour le pub-sub anneau (§7), le back-pressure est structurel (l’anneau a une taille fixe ; un lecteur lent perd des entrées et l’apprend via un compteur de génération — sémantique overwrite, choisie pour les events best-effort).
3.6 (6) Multiplexer
channelId :UInt32par requête/stream sur un seul fd (façon canaux SSH / streams h2). Isolation inter-canaux : un canal fautif n’affecte pas les autres.- Allocation de
channelId: pair-impair par rôle (client pairs, serveur impairs) pour éviter les collisions sans négociation, comme h2 stream ids.
3.7 (7) Timer Manager
- Horloge injectée dans le cœur :
poll_timeout(now: Instant) -> Option<Instant>. Le cœur calcule les échéances (timeout de requête, keepalive) ; il ne lit jamais l’horloge lui-même. - Le pilote async branche la roue de timers d’
air-async; le pilote sync branche unSO_RCVTIMEO/pollcalculé. Virtual clock en test (déterminisme).
3.8 (8) Session Context
- État par connexion : rôle (client/serveur), ids, métadonnées de capabilities négociées, fenêtres de flow control, table de canaux.
- Sécurité : tout secret éventuel zeroïsé au drop ; zéro fuite inter-sessions (chaque connexion a son contexte, pas de global mutable partagé). Ne détient aucun fd (§1.1).
3.9 (9) Extension hooks
- Registre de hooks à interface étroite : points d’extension pour les entitlements (ADR-010) et les extensions de schéma négociées au Handshake. Deny-by-default : une extension inconnue est ignorée, jamais fatale (sauf si requise par le pair).
Marqueurs. Tant qu’un composant n’est pas implémenté, il reste marqué absent (marqueur unité +
// NO …), conformément à ADR-091 §2 règle 3 (« on retrouve toujours les mêmes cases »). Le squelette #333 marque déjà 4/5/6/7/9.
Section 4 — Transport control plane (SEQPACKET)
- Socket : Unix domain
SOCK_SEQPACKET, viaAirUnixSeqpacket/AirUnixSeqpacketListener(couche 1,couche-1-v2.1). Frontières de message préservées ⇒ Framer trivial ; FD passing natif (SCM_RIGHTS). - Adresse :
air-registrydistribue des adresses de socket (namespace abstrait de préférence — pas de fichier à nettoyer — viaAirUnixAddress::from_abstract). - Pilote async :
air-comcâble le cœur àair-async—accept/recv/sendmultishot,arm_recvmsg/arm_sendmsgpour le FD passing async (livrés #333). - Pilote sync :
air-comcâble le cœur àair-socketsynchrone (AirUnixSeqpacket::{send,recv,send_fd,recv_fd}). Même cœur, même StateMachine.
Section 5 — Data plane (memfd zero-copy)
- Segment :
air-shm::AirShmSegment(couche 1) —create → ftruncate → mmap SHARED → add_seals. Le producteur scelle (F_SEAL_WRITEpour un partage read-only ;F_SEAL_SHRINK|GROWsystématiques) puis passe le fd en control plane viaSCM_RIGHTS. - Message control plane : ne porte qu’un
ShmRef(slot + offset + length + sealed). Le récepteurmmaple fd reçu et lit zero-copy — la charge ne transite jamais par le socket. - Conventions figées (v1) :
- Sealing obligatoire avant partage (un segment non scellé est refusé côté
réception →
Faulted). - Partage read-only ⇒
F_SEAL_WRITE+mmap PROT_READ. Partage read-write (rare, buffers négociés) ⇒ pas deF_SEAL_WRITE, ownership exclusif documenté. - Taille : multiple de page ;
lengthlogique ≤ taille du segment (validé en amont).
- Sealing obligatoire avant partage (un segment non scellé est refusé côté
réception →
- Vues possédées :
AirShmMap(&[u8]) /AirShmMapMut(&mut [u8]) — pas d’unsafeexposé.
Section 6 — Modèle de capability (fd + SCM_RIGHTS)
- Une capability = un fd vers une connexion AirCom (
SEQPACKET), plus des métadonnées de type (portées par leCapRefdu message et le Session Context). Unforgeabilité par le noyau : on ne fabrique pas un fd qu’on n’a pas reçu. Pas de token, pas de nonce, pas de crypto pour la capability elle-même. - Distribution initiale :
air-launchd(couche 5) passe à chaque service son fd selon les entitlements signés (ADR-010) — un fd par service (§0.8). - Délégation : p2p, par re-
SCM_RIGHTSà travers AirCom lui-même (modèle seL4 / Mach ports). Un pair transmet unCapRef+ le fd correspondant dans le mêmerecvmsg. - Révocation : fermeture du fd côté serveur ⇒ le pair reçoit
EOF/ECONNRESET, la StateMachine transiteClosed. - Double verrou (renfort) : la sandbox io_uring couche 0
(
RestrictionSet::from_entitlements) traduit à terme les entitlements signés en restrictions — point d’intégration couche 5, non requis pour la v1 du transport.
Contrat cœur : le cœur ne voit qu’un indice de slot (
CapRef.slot) ; le pilote apparie slot↔fd. Le cœur reste sans fd, donc fuzzable.
Section 7 — Pub-sub : anneau de mémoire partagée (diffusion 1→N)
Au-delà du RPC requête/réponse p2p, AirCom offre une diffusion 1→N zero-copy pour
les notifications/events (ex. air-notifyd, premier service AirCom d’ADR-001).
- Mécanisme : un anneau en mémoire partagée (
air-shmscellé), un producteur / N consommateurs. Le producteur écrit une fois ; N abonnés lisent zero-copy. Cohérent avec le data plane (§5) et la raison d’être anti-broker d’ADR-001 (aucune copie par un intermédiaire). - Structure (figée v1) : en-tête
{ capacity, slotSize, writeSeq (atomique), generation }+ tableau de slots. Le producteur avancewriteSeq(release) ; chaque consommateur garde sonreadSeqlocal. - Sémantique de retard (overwrite / best-effort) : si un consommateur prend du
retard au-delà de
capacity, le producteur écrase les entrées les plus anciennes. Le consommateur détecte la perte (saut degeneration/writeSeq) et la signale (compteur de messages perdus). C’est le back-pressure structurel du §3.5 — choisi car les events sont best-effort (un abonné lent ne bloque pas le producteur ni les autres abonnés). - Établissement : le producteur publie le fd de l’anneau sur le control plane
(
ShmRef+MessageKind::eventd’amorçage) ; chaque abonnémmapen read-only (l’anneau est scelléF_SEAL_WRITEcôté producteur seulement — les abonnés ne peuvent pas corrompre l’anneau). - Réveil : un
eventfd(ou un futex sur mot d’en-tête) notifie les abonnés qu’une nouvelle entrée est disponible, piloté parair-async(async) oupoll(sync). - Distinct du RPC : le pub-sub ne passe pas par la StateMachine
requête/réponse ; c’est un canal séparé (
channelIddédié), spécifié comme un 9ᵉ patron autonome. Le cœur exposering_publish(&[u8]) -> Result(producteur) etring_poll() -> Option<(&[u8], lost: u64)>(consommateur) — purs, l’anneau concret (mmap) étant fourni par le pilote.
Section 8 — Surfaces API (synchrone et asynchrone)
Décision §0.6 : les deux surfaces sont de première classe. Une seule logique
protocolaire (le cœur air-com-proto) ; deux pilotes dans air-com.
8.1 Surface asynchrone (air-async, io_uring) — usage natif Rust
#![allow(unused)]
fn main() {
// Client
let conn = air_com::Connection::connect_async(cap_fd).await?; // cap_fd reçu par SCM_RIGHTS
let reply = conn.call(channel, method, &args).await?; // RPC multiplexé
// Serveur
let mut listener = air_com::Listener::from_fd_async(server_fd)?;
while let Some(conn) = listener.accept().await? { runtime.spawn(serve(conn)); }
// Pub-sub
let mut sub = air_com::Subscriber::attach_async(ring_fd)?;
while let Some((event, lost)) = sub.next().await { /* lost = messages ratés */ }
}
8.2 Surface synchrone (air-socket) — usage bloquant / scripts / bootstrap
#![allow(unused)]
fn main() {
let conn = air_com::Connection::connect_sync(cap_fd)?; // même Connection, pilote sync
let reply = conn.call_blocking(channel, method, &args)?;
}
- Contrat de non-duplication :
call/call_blocking,accept/accept_blockingpartagent le mêmeair-com-proto(Framer/Codec/State/Session). Un test de conformité croisé (client sync ↔ serveur async, et inversement) doit passer (§11) — c’est la preuve que le cœur est réellement partagé. - Façade C-ABI : une future
air-comC-ABI (zoneair-stable, ABI 10 ans, ADR-012) exposera le modèle à callbacks (cf.air-event) pour les consommateurs polyglottes. Hors périmètre v1 (Rust d’abord) ; réservé.
Section 9 — Bootstrap & découverte (air-registry)
Contrat que air-registry (couche 5) doit honorer ; l’implémentation relève
d’une spec couche 5.
- Un fd par service (§0.8) : à l’activation,
air-launchdpasse à chaque service son fd de capability initial parSCM_RIGHTS, selon les entitlements signés. - Capability implicite universelle : tout processus Air reçoit une capability
vers
air-registry(la seule autorité ambiante tolérée, car strictement de découverte). Format du handshake de découverte :lookup(serviceName) -> Result<fd, NotFound|Denied>:air-registryvérifie l’entitlement du demandeur puis renvoie un fd de connexion vers le service (une nouvelleSEQPACKETconnectée, ou une erreur). Pas d’adresse en clair à reconnecter soi-même par défaut — le registry remet le fd (unforgeable).register(serviceName, listenerFd): un service s’enregistre en cédant un fd d’écoute (ou un canal d’acceptation). Réservé aux services entitled.
- Pas de broker de données :
air-registryne relaie aucune charge applicative — uniquement la mise en relation initiale. Toute communication ultérieure est p2p (ADR-001 §« pas de broker central »).
Section 10 — Intégration au modèle d’objet (ADR-002)
AirCom transporte des invocations sur des AirObject (modèle d’objet C-ABI,
air-object).
- Une
call(§3.2MessageKind::call) désigne{ objectId, methodId, args capnp }; lereturn/errorporte le résultat. LechannelId(#6) isole les invocations concurrentes sur un même objet distant. - Le
cx.capability::<T>()de la macro-architecture §B se résout côté cœur en une entrée de Session Context (métadonnées de type) + côté pilote en un fd. - Bindings polyglottes : via le modèle d’objet C-ABI d’ADR-002 (la façade C-ABI réservée au §8.2), pas par génération de glue par classe.
Section 11 — Conformité & tests (couche 2, > 90 % + rigueur cœur)
- Cœur
air-com-proto— traité au standard couche fondatrice (proche 100 %) : tests unitaires + property-based (transitions StateMachine, round-trip Codec) + fuzz obligatoire par parseur externe :fuzz/framer(délimitation,MAX_*_LEN),fuzz/codec(capnp Envelope/CapRef/ ShmRef),fuzz/handshake(Hello malformé),fuzz/ring(en-tête d’anneau corrompu).- Model-based testing de la StateMachine (transitions illégales rejetées, pas de deadlock d’état). Virtual clock pour le Timer (déterminisme).
- Pilote
air-com— couverture > 90 % (cible couche 2) : round-trips end-to-end, FD passing async & sync, data plane shm zero-copy, pub-sub anneau (dont perte détectée par un abonné lent), fermeture/révocation. - Test croisé sync↔async (§8) : client sync ↔ serveur async et l’inverse — garantit le partage effectif du cœur.
unsafe: le cœurair-com-proton’expose aucune fnunsafe. Les raresunsafedu pilote (conversion de fd bootstrap, mmap viaair-shm) portent un// SAFETY:et restent localisés (vérifiécheck-safety-comments).check-layers: arêtes 2→2 et 2→1 uniquement ; jamais 2→0.- Barrière :
fmt/clippy -D/test/macheteverts ; couverture selon seuils ; Cap’n Proto = zéro nouvelle exception (mutualisée ADR-040,docs/EXCEPTIONS.md).
Section 12 — Incréments d’implémentation
| Inc. | Contenu | Sortie |
|---|---|---|
| 0 | ✅ Fondation (PR #333) — squelette 2 crates, composants 1/2/3/8, preuve Ping→Pong, 4 lacunes couche 1 comblées (air-shm, SCM_RIGHTS async, SEQPACKET, pont OwnedFd), preuve d’intégration zero-copy. | couche-1-v2.1 |
| 1 | Schéma capnp v1 (air-com-schema, §3.2) + Codec réel (remplace le placeholder) + versioning CI (air-com-codegen). Fuzz codec. | schéma figé |
| 2 | StateMachine multi-états + Multiplexer (channelId) + Handshaker (négo de version). Model-based tests. | RPC concurrent |
| 3 | Transport SEQPACKET de bout en bout (pilote async et sync) + test croisé sync↔async. FD passing async en régime établi. | 2 surfaces |
| 4 | Data plane zero-copy en régime établi (§5) + Flow Controller (crédits). | streaming borné |
| 5 | Pub-sub anneau (§7) : ring_publish/ring_poll, perte détectée, réveil eventfd/futex. Fuzz ring. | diffusion 1→N |
| 6 | Contrat air-registry matérialisé (mock couche 2) : lookup/register par fd. (Impl couche 5 = spec dédiée.) | découverte |
| 7 | Intégration modèle d’objet (ADR-002) : call sur AirObject. Premier service réel : air-notifyd (ADR-001). | 1er service |
Cible aval. AirCom est un prérequis de
air-sshd(la vraie cible produit, ADR-074) et du framework réseau. Le RPC (inc. 1–3) suffit àair-sshd; data plane et pub-sub (inc. 4–5) servent les services desktop (notifications, media, buffers GPU).
Section 13 — Questions résiduelles (hors périmètre v1, réservées)
- Façade C-ABI
air-com(zoneair-stable, callbacks façonair-event) pour consommateurs polyglottes — réservée, Rust d’abord. air-dbus-bridge(ADR-001 §interop) : exposer les services D-Bus (NetworkManager, BlueZ…) en AirCom — spec séparée.- Sandbox io_uring pilotée par entitlements (§6 double verrou) — intégration couche 5.
- Inspecteur/debugger de tronc AirCom (ADR-001 §outillage) — outil, spec séparée.
Licence du document : MPL 2.0.
Spec couche 2 — air-event (façade C-ABI de l’event loop async)
Spécification technique — Version 1.0 (décisions validées BDFL 2026-06-25). Couche 2 « Modèle d’objet, IPC, services fondamentaux ».
Position et méthode
air-event est la façade C-ABI de l’event loop asynchrone d’Air. Elle n’est
pas le moteur : le moteur est le runtime air-runtime (couche 1, natif io_uring,
sans tokio — ADR-038,
ADR-039,
spec air-runtime). air-event consomme air-runtime
et l’expose en ABI C stable (libair-event.so, zone air-stable, ABI 10 ans —
ADR-012) pour que C/Swift/Python/Ruby pilotent l’async d’Air sans glue par classe.
Rappel de couches (ADR-039) :
air-runtime(L1) = moteur ;air-event(L2) = façade C-ABI. Un consommateur Rust utiliseair-runtimedirectement (async/await) ; un consommateur polyglotte utiliseair-event(callbacks).
Le défi central : exposer de l’async à complétion en ABI C
Le C ne sait pas .await. Et air-runtime est à complétion avec buffers
possédés (spec air-runtime). La façade doit donc exposer un modèle à callbacks de
complétion (façon libdispatch/libuv), pas async/await :
- une opération async rend un
AirFuture(handle C opaque, refcompté) ; - le consommateur enregistre un callback de complétion (
air_future_on_complete) ; - une boucle (
air_event_loop_run) piloteair-runtimeet invoque les callbacks quand les complétions arrivent ; - annulation :
air_future_cancel→ mappe sur l’annulationair-runtime(ASYNC_CANCEL + reclaim différé) — soundness préservée (le buffer possédé reste vivant jusqu’au CQE d’annulation).
Section 1 — AirEventLoop
typedef struct AirEventLoop AirEventLoop; // opaque
AirEventLoop* air_event_loop_new(void); // possède un exécuteur air-runtime (single-thread)
void air_event_loop_free(AirEventLoop*);
int air_event_loop_run(AirEventLoop*); // bloque, pilote jusqu'à arrêt
int air_event_loop_run_once(AirEventLoop*, int timeout_ms); // un tour (intégration boucle hôte)
void air_event_loop_wakeup(AirEventLoop*); // réveille depuis un autre thread (eventfd interne)
void air_event_loop_stop(AirEventLoop*);
Modèle de thread. Un AirEventLoop = un exécuteur air-runtime single-thread
(ADR-039). Il est piloté par son thread propriétaire (sémantique MainThreadOnly,
cohérente avec les politiques de thread du modèle d’objet air-object, ADR-002).
air_event_loop_wakeup (thread-safe, via un eventfd interne soumis au ring) est le
seul point d’entrée cross-thread.
Intégration boucle hôte (run_once) : un embarqueur (GLib, boucle de jeu, sd-event)
qui possède sa boucle peut soit faire tourner air-event par tours
(run_once), soit attendre l’eventfd de complétion d’air-runtime
(air_event_loop_fd) dans sa boucle (cf. ADR-038 §5 — interop sans epoll côté Air).
Section 2 — AirFuture (complétion type-effacée, C-opaque)
typedef struct AirFuture AirFuture; // opaque, refcompté
// Callback de complétion : reçoit le statut + un AirValue résultat + le contexte user.
typedef void (*AirCompletionFn)(const AirStatus* status, AirValue result, void* ctx);
void air_future_on_complete(AirFuture*, AirCompletionFn cb, void* ctx);
void air_future_cancel(AirFuture*); // → annulation air-runtime (reclaim différé)
void air_future_retain(AirFuture*);
void air_future_release(AirFuture*);
int air_future_is_ready(const AirFuture*);
Type-effacement. AirFuture enrobe un Pin<Box<dyn Future<Output = AirValue>>> côté
Rust + un slot de résultat + la liste de callbacks. Refcompté via le mécanisme
air-object (AtomicU32 + AirHandle, cf. spec air-object) : le handle vit tant qu’un
consommateur le retient (air_future_retain/release).
Résultat = AirValue (union taguée du modèle d’objet air-object). Le callback de
complétion le reçoit +1 (possédé) — règle de propriété uniforme d’air-object
(« retours +1 / arguments +0 ») : à air_value_release après usage. Les erreurs passent
par AirStatus (code + message, cohérent ADR-019/AirError).
Côté Rust, air-event offre aussi un pont AirFuture → impl Future pour que le
code Rust de couche 2+ reste idiomatique (await) tout en partageant la même machinerie.
Section 3 — Timers, signaux, channels (exposés)
Façades C-ABI minces au-dessus de air-runtime-time/-signal/-sync :
AirFuture* air_timer_sleep(AirEventLoop*, uint64_t millis); // → IORING_OP_TIMEOUT
typedef struct AirSignals AirSignals; // signalfd via io_uring
AirSignals* air_signals_watch(AirEventLoop*, const int* signos, size_t n);
AirFuture* air_signals_next(AirSignals*); // complétion = un signal
typedef struct AirChannel AirChannel; // MPSC async borné
AirChannel* air_channel_new(AirEventLoop*, size_t capacity);
AirFuture* air_channel_send(AirChannel*, AirValue v); // back-pressure async
AirFuture* air_channel_recv(AirChannel*);
AirTimer/AirSignal/AirChannel sont des AirObject (refcomptés, introspectables) —
cohérent macro-architecture (collections/notifications observables).
Section 4 — Intégration AirCom et systemd
- AirCom (ADR-001) : toute opération AirCom rend
un
AirFuture.air-eventest le point de coordination async d’AirCom (timers + FD + IPC dans la même boucle). (Détail dans la future specair-aircom.) - systemd (ADR-005) : pont sd-event — un
AirEventLooppeut consommer une sourcesd-event(watchdog,sd_notify) ou être intégré dans une boucle pilotée par sd-event. Permet à un.airservicede coopérer avec systemd sans abandonnerair-event.
Section 5 — ABI C et bindings
libair-event.so, symbolesair_event_*/air_future_*, zoneair-stable(ABI 10 ans, versioned symbols GNU — ADR-012). Tests de conformité ABI en CI.- Header C généré ; bindings Swift (
@dynamicMemberLookup)/Python/Ruby au-dessus du modèle callback. Aucune glue par classe (universel viaAirFuture/AirValue). - Erreurs :
AirStatus(jamais de panic à travers l’ABI —catch_unwindà la frontière, conversion enAirStatus).
Décisions (ratifiées / résolues, BDFL 2026-06-25)
- VALIDÉE — modèle callbacks de complétion (
air_future_on_complete+ bouclerun/run_once) : seul modèle viable pour exposer l’async à complétion en C (cohérent libdispatch/libuv). AirEventLoop= un exécuteur single-thread (ADR-039) ; multi-loop / thread-per-core exposé en C = DIFFÉRÉ v1 (façade dédiée ultérieure).- RÉSOLU —
AirFuturerefcompté viaair-object(AtomicU32+AirHandle), maintenant qu’air-objectest spécifié. - CONFIRMÉ — buffers I/O « possédés » exposés en C (le C fournit un buffer, le
récupère via le callback) — cohérent
air-runtime. - sd-event : direction du pont à cadrer avec la spec
air-systemd(différé v1).
Périmètre v1 / différé
- v1 : boucle single-thread,
AirFuture+ callbacks, timers/signaux/channels, annulation sound, pont RustAirFuture↔Future. - Différé : multi-loop thread-per-core exposé en C, pont sd-event complet (avec spec
air-systemd), bindings polyglottes générés (phase bindings).
Dépendances
air-runtime(couche 1, le moteur),air-object/air-value(modèle d’objet, pourAirValue/refcount — couche 2, spécifié),air-base-lib(erreurs/temps). Aucune dépendance externe. À tracer dansDEPENDENCIES.md.
Stratégie de tests
- Couverture 100 % (Principe 1 ; couche 2 = très élevée, marge selon nature).
- Tests de conformité ABI C (compilation C + appels via
libair-event.so) ; tests depuis C/Swift/Python (le modèle callback fonctionne cross-langage). - Annulation sound :
air_future_cancelmid-vol → pas d’use-after-free du buffer (Miri sur la coucheair-runtimesous-jacente ; tests d’intégration ici). - Intégration : echo réseau async piloté par
air_event_loop_run; timers ; signaux ; back-pressure channel ;run_oncedans une boucle hôte factice. - (Pas de fuzzing dédié : la façade n’ingère pas de données externes ; le décodage est en couche 0/1.)
Décisions de fond
- Façade C-ABI, pas le moteur :
air-event(L2) exposeair-runtime(L1) — couches propres (ADR-039). - Modèle callbacks de complétion : seul moyen d’exposer l’async à complétion en C.
- Annulation sound héritée d’
air-runtime(buffers possédés + reclaim différé, ADR-028). AirValue/AirStatuscomme monnaie d’échange (cohérentair-object/ADR-019).- Pont Rust
AirFuture↔Future: le Rust de couche 2+ gardeasync/await.
Licence du document : MPL 2.0
Statut : v1.0 — décisions validées (2026-06-25). Façade C-ABI du runtime async
air-runtime, s’appuyant sur la spec air-object (AirValue/refcount). Reste à cadrer
avec air-aircom (§4) et air-systemd (sd-event) lors de leurs specs. Implémentation à
suivre. Bases : ADR-001 / ADR-005 / ADR-012 / ADR-038 / ADR-039 + specs air-runtime/air-object.
Spec couche 2 — air-tls (contingence maison, TLS 1.3 pur Rust)
Spécification technique — Version 0.1 (proposition). Couche 2 « Frameworks système ». Statut : spec de contingence.
Nature de ce document. ADR-042 a tranché que la pile TLS de production d’Air est rustls + aws-lc-rs + rustls-webpki (exception C nommée, étroite). L’ADR garde explicitement ouverte (§Contingence) une porte de sortie : un
air-tlsmaison, protocole pur Rust, sur le patron sans-IO 9-composants, activable si rustls devenait un passif. Le présent document spécifie cette contingence — il ne remplace pas ADR-042, il en instruit l’option maison pour qu’elle soit prête sur étagère. Toute bascule production→maison exigerait un RFC (ADR-015).Il répond à une demande précise : décrire une implémentation 100 % Rust, zéro
unsafe, zéro dépendance à la libc, aucune crate tierce horsstd(voir la réserve « primitives » en §3), qui reprend la couverture fonctionnelle de s2n-tls mais n’en garde que le moderne réputé sûr (TLS 1.3, RFC 8446 ; tout le legacy vulnérable est omis par conception).
Position et méthode
air-tls fournit le protocole de session sécurisée au-dessus d’air-socket
(L1, TCP/Unix) et sous air-network (L2, substrat de connexion ≈ Network.framework)
et les protocoles applicatifs (air-http h2, air-quic→h3). Il n’implémente aucune
primitive cryptographique : celles-ci viennent d’air-crypto (L1). Il implémente
le protocole : record layer, handshake, key schedule, machine à états, validation
X.509 — exactement la frontière tracée par ADR-042 (« le protocole memory-safe, les
primitives déléguées »).
Il suit à l’identique l’anatomie canonique des crates réseau Air
(note réseau §2.2) : cœur sans-IO
(9 composants purs, fuzzables) + pilote I/O mince. Un auditeur retrouve ici les
mêmes cases que dans air-socket::resolver, air-quic, air-ssh.
Périmètre de sécurité — ON NE GARDE QUE LE MODERNE (décision structurante)
Règle gravée. Ce qui a une classe de vulnérabilité connue n’est pas spécifié. La sécurité par omission : le code d’un protocole absent ne peut pas être exploité. C’est le même choix qu’
air-crypto(« RSA & legacy différés, plus sûrs par omission ») et que rustls (« TLS 1.2/1.3 only, zéro legacy »).
RETENU (TLS 1.3, RFC 8446 — AEAD only, forward-secrecy obligatoire) :
| Domaine | Retenu | Référence |
|---|---|---|
| Version protocole | TLS 1.3 uniquement | RFC 8446 |
| Suites (AEAD only) | TLS_AES_128_GCM_SHA256 (MTI), TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256 | RFC 8446 §9.1 |
| Échange de clés | X25519 (défaut) ; X25519MLKEM768 (hybride post-quantique, recommandé 2025+) ; secp256r1/secp384r1 (interop) | RFC 7748, RFC 9180-hybride |
| Signatures (vérif. + sign.) | ed25519, ecdsa_secp256r1_sha256, ecdsa_secp384r1_sha384, rsa_pss_rsae_sha256/384/512 (vérif. seule, interop WebPKI) | RFC 8032, RFC 8446 §4.2.3 |
| Fonctionnalités | SNI, ALPN, reprise par PSK tickets (FS), KeyUpdate, downgrade sentinel, validation X.509 (RFC 5280) + hostname (RFC 6125), OCSP stapling, CRL | RFC 8446, 5280, 6125 |
| 0-RTT (early data) | optionnel, désactivé par défaut, anti-rejeu documenté | RFC 8446 §2.3, §8 |
OMIS PAR CONCEPTION (jamais spécifié — legacy vulnérable) : SSLv3, TLS 1.0/1.1/1.2 ; RSA key transport (kex statique) ; CBC, RC4, 3DES ; signatures MD5/SHA-1 ; compression TLS (CRIME) ; renégociation (retirée en 1.3) ; groupes DH à corps fini faibles, courbes faibles ; suites NULL/anonymes/export ; tickets sans forward-secrecy.
Conséquence de test : il n’existe aucun chemin de downgrade à tester parce qu’il n’existe aucun code de version antérieure. Le downgrade sentinel de RFC 8446 §4.1.3 est néanmoins implémenté (défense en profondeur : détecter un MITM qui prétendrait qu’on négocie 1.2).
Dépendances — « std seulement » : la réserve honnête des primitives
Contrainte demandée : pur Rust, zéro
unsafe, zéro C, seulestdautorisée. Réserve technique incontournable : on ne réimplémente JAMAIS de crypto (Principe 6, ADR-034, specair-crypto) —stdn’offre aucune primitive. La résolution cohérente avec la doctrine de couches :air-tlsne prend aucune crate tierce, mais consomme les crates Air maison des couches inférieures.
- Crates tierces externes : ZÉRO. Aucune (ni rustls, ni RustCrypto en direct, ni
*-sys).check-c-surfacereste vert pour ce crate — c’est l’intérêt de la contingence maison vs ADR-042 (qui, lui, ouvre l’exception Caws-lc-sys). std: autorisée (buffers,io::{Read,Write},Vec,BTreeMap…). (Le cœur sans-IO viseno_std + allocà terme, cohérent note §2.1 ; le pilote I/O restestd. Non bloquant pour la v0.1.)- Dépendances Air internes (couches inférieures, maison, non « tierces ») :
air-crypto(L1) — toutes les primitives : protection de record =lowlevel::AirAeadExplicitNonce(nonce =static_iv ⊕ seq, RFC 8446 §5.3 — pas la façadeAirAeadà nonce aléatoire ; additif ADR-082),AirEcdh*(X25519),AirSigningKey/AirVerifyingKey(Ed25519),AirHkdf(key schedule),AirHash(transcript SHA-256/384),AirHmac,AirRandom, secretszeroize. C’est la frontière crypto vettée (RustCrypto sous exception nommée, ADR-034) —air-tlsne la franchit jamais.air-socket(L1) — transport TCP/Unix (pilote I/O synchrone).air-runtime(L1) — pilotage asynchrone (buffers possédés, ADR-038).air-base-core(L1) —AirError/AirResult(ADR-019),AirOsStr.
Additifs à demander à
air-crypto(aujourd’hui différés — voir « Travail à reprendre ») pour couvrir l’interop WebPKI réelle : AES-128-GCM, ECDSA P-256/P-384 (vérif+sign), RSA-PSS (vérif seule), ML-KEM-768 (hybride PQ). Sans eux,air-tlsv0.1 négocie X25519 + Ed25519/AES-256-GCM/ChaCha20 uniquement (interop limitée aux pairs modernes). Chaque additif = bumpair-cryptoKAT-gated (ADR-034).
- Zéro
unsafeexposé et interne (le seulunsafedu projet côté crypto vit dans RustCrypto, sousair-crypto). Parsing défensif intégral (Principe 3) :get()jamais l’indexation,&[u8]vs&strstricts,checked_*, aucune boucle de longueur non bornée.
Modèle multi-thread (décision — état protocolaire mono-propriétaire)
La machine à états est conçue multi-thread au sens Rust idiomatique (le modèle de rustls/quinn-proto, aligné ADR-038 thread-per-core sans work-stealing) :
| Type | Send | Sync | Partage | Rôle |
|---|---|---|---|---|
TlsClientConfig / TlsServerConfig | ✅ | ✅ | Arc<…> partagé entre threads/connexions ; immuable après build | Politique (certs, suites, verifier, ALPN, tickets) |
TlsClient / TlsServer (connexion) | ✅ | ❌ | possédée par une seule tâche/thread à la fois ; aucun verrou interne | La machine à états d’une connexion |
TlsStream<T> | ✅ | ❌ | idem, enveloppe I/O bloquante | Adaptateur std::io |
Secrets (ConnectionSecrets, clés) | ✅ | ❌ | jamais partagés ; zeroize au Drop | Matériel secret par connexion |
Rationnel. Une connexion TLS est un flux ordonné : deux threads ne peuvent
pas la piloter simultanément sans corrompre le key schedule. On rend donc la
connexion Send (déplaçable entre threads : passage à un worker) mais !Sync
(un seul propriétaire actif) → zéro contention, zéro Mutex, zéro unsafe. La
scalabilité vient du Config partagé Arc + N connexions indépendantes, une
par cœur (thread-per-core). C’est le patron le plus sûr et le plus rapide.
!Syncest prouvé à la compilation (un secret non-Syncdans la structure) et testé compile-fail, commeair-threadteste!Syncde ses primitives.
Anatomie : les 9 composants appliqués à TLS 1.3
Application stricte de la note réseau §2.2.
Un composant absent par conception est marqué (// NO … : raison).
| # | Composant | Réalisation air-tls | Pur ? |
|---|---|---|---|
| 1 | Framer | RecordFramer — délimite le flux TCP en TLSPlaintext/TLSCiphertext (en-tête 5 o : type, legacy_version, longueur ≤ 2¹⁴+256) | ✅ |
| 2 | Codec | RecordCodec, HandshakeCodec, ExtensionCodec, AlertCodec, X509Codec (DER/ASN.1) — (dé)sérialisation typée | ✅ |
| 3 | StateMachine | HandshakeStateMachine — transitions RFC 8446 §A (client & serveur), légalité état×message | ✅ |
| 4 | Handshaker | KeySchedule (HKDF-Expand-Label, RFC 8446 §7.1), TranscriptHash, échange X25519, auth (signature/vérif) — consomme air-crypto | ✅ + air-crypto |
| 5 | Flow Controller | // NO FLOW CONTROL : le fenêtrage est assuré par TCP (L1) sous le record layer ; TLS n'a pas de crédits. (QUIC/h2 le portent, pas TLS.) | — (absent voulu) |
| 6 | Multiplexer | // NO MULTIPLEXING : une connexion TLS = un flux d'octets. Le multiplexage de streams appartient à h2/QUIC au-dessus. | — (absent voulu) |
| 7 | Timer Manager | TlsTimers — timeout de handshake, durée de vie des tickets ; horloge injectée (Clock trait) → tests déterministes | ✅ (horloge injectée) |
| 8 | Session Context | ConnectionSecrets / SessionContext — secrets de trafic, params négociés, état de reprise ; zeroize au Drop | ✅ |
| 9 | Extension hooks | Extension trait + ExtensionRegistry — SNI, ALPN, key_share, supported_versions, signature_algorithms, psk, early_data, status_request, cookie | ✅ |
Inventaire des objets à créer (le décompte)
~55 objets Rust à produire, dont ~50 concrets + ~5 marqueurs/traits transverses, regroupés en 5 familles. Deux composants (Flow, Mux) sont absents par conception (marqueurs, pas de type).
A. Surface publique ergonomique — 25 objets (reprend les familles s2n)
| # | Objet Rust | Remplace (s2n) | Nature |
|---|---|---|---|
| 1 | TlsClientConfig | s2n_config_* (rôle client) | struct immuable (Arc) |
| 2 | TlsClientConfigBuilder | s2n_config_new + setters | builder |
| 3 | TlsServerConfig | s2n_config_* (rôle serveur) | struct immuable (Arc) |
| 4 | TlsServerConfigBuilder | idem serveur | builder |
| 5 | TlsClient | s2n_connection_* (mode client) | machine à états Send/!Sync |
| 6 | TlsServer | s2n_connection_* (mode serveur) | machine à états Send/!Sync |
| 7 | TlsStream<T> | s2n_send/recv/negotiate/shutdown | adaptateur io::{Read,Write} |
| 8 | ClientHello | s2n_client_hello_* (25 fn) | vue en lecture |
| 9 | CertifiedKey | s2n_cert_chain_and_key_* (12 fn) | chaîne + clé privée |
| 10 | Certificate | s2n_certificate_* | cert DER parsé (emprunté) |
| 11 | PrivateKeyHandle | (clé privée s2n) | poignée opaque zeroizée |
| 12 | RootCertStore | trust store s2n_config | ancres de confiance |
| 13 | CertificateRevocationList | s2n_crl_* (9 fn) | CRL parsée |
| 14 | Psk | s2n_psk_* | identité + secret (client) |
| 15 | OfferedPsk | s2n_offered_psk_* | vue serveur du PSK offert |
| 16 | SessionTicket | s2n_session_* (3 fn) | ticket de reprise (opaque) |
| 17 | EarlyDataConfig | s2n_offered_early_data_* (4 fn) | 0-RTT (gated) |
| 18 | ClientHelloFingerprint | s2n_fingerprint_* (8 fn) | JA3/JA4 |
| 19 | ServerName | SNI | newtype validé (IDNA/ASCII) |
| 20 | AlpnProtocol | ALPN | newtype &[u8] |
| 21 | TlsError | s2n_errno/s2n_strerror | enum (ADR-019) |
| 22 | Alert | alertes TLS | enum niveau+description |
| 23 | TlsVersion | (versions) | enum — Tls13 seul |
| 24 | CipherSuite | suites | enum (3 AEAD) |
| 25 | SignatureScheme | schémas signature | enum (moderne) |
B. Traits publics (idiomes Rust — points d’extension) — 8 traits
| # | Trait | Remplace (s2n) | Rôle |
|---|---|---|---|
| 26 | ServerCertVerifier | callbacks verify config | politique de validation du cert serveur (client) |
| 27 | ClientCertVerifier | mTLS verify | validation du cert client (serveur) |
| 28 | SigningKey | clé privée | signe le CertificateVerify (abstrait le stockage) |
| 29 | AsyncSigner | s2n_async_pkey_* (8 fn) | offload de signature (HSM/process séparé) |
| 30 | SessionStore | cache de session serveur | stockage/lookup des tickets |
| 31 | Ticketer | chiffrement des tickets | scelle/ouvre les NewSessionTicket |
| 32 | PskStore | lookup PSK serveur | résout un OfferedPsk → secret |
| 33 | KeyLog | debug | export SSLKEYLOGFILE (désactivé défaut) |
+ défauts fournis : WebPkiServerVerifier, WebPkiClientVerifier (impl. RFC 5280/6125),
SingleUseTicketer (chiffre les tickets via air-crypto). (comptés en C ci-dessous.)
C. Cœur sans-IO — codecs & état — 13 objets (les 9 composants + défauts)
| # | Objet | Composant | Fichier (§structure) |
|---|---|---|---|
| 34 | RecordFramer | 1. Framer | proto/framer.rs |
| 35 | RecordCodec | 2. Codec (record) | proto/codec/record.rs |
| 36 | HandshakeCodec | 2. Codec (handshake) | proto/codec/handshake.rs |
| 37 | ExtensionCodec | 2. Codec (extensions) | proto/codec/ext.rs |
| 38 | AlertCodec | 2. Codec (alertes) | proto/codec/alert.rs |
| 39 | X509Codec | 2. Codec (X.509/ASN.1) | proto/codec/x509.rs |
| 40 | PathValidator | 2/4 (validation PKIX) | proto/x509/path.rs |
| 41 | HandshakeStateMachine | 3. StateMachine | proto/state.rs |
| 42 | KeySchedule | 4. Handshaker | proto/handshake/schedule.rs |
| 43 | TranscriptHash | 4. Handshaker | proto/handshake/transcript.rs |
| 44 | TlsTimers | 7. Timer Manager | proto/timers.rs |
| 45 | ConnectionSecrets | 8. Session Context | proto/session.rs |
| 46 | ExtensionRegistry | 9. Extension hooks | proto/ext.rs |
D. Fournisseur crypto (abstraction pluggable) — 6 traits (défaut = air-crypto)
| # | Trait | Rôle |
|---|---|---|
| 47 | CryptoProvider | agrège les 5 suivants ; AirCryptoProvider = impl défaut sur air-crypto |
| 48 | AeadRecordCipher | protection de record (AES-GCM/ChaCha20) — nonce dérivé par record (RFC 8446 §5.3) |
| 49 | KeyExchangeGroup | X25519 / hybride ML-KEM — generate, agree |
| 50 | SignatureVerifier | vérifie une signature d’un schéma donné |
| 51 | HashProvider | SHA-256/384 pour transcript + HKDF |
| 52 | SecureRandom | ré-exporte air-crypto::AirRandom (jamais de PRNG userspace) |
E. Types de valeur internes (enums wire) — ~6 objets
ContentType, HandshakeType, ExtensionType, NamedGroup, AlertDescription,
ProtocolVersion (wire 0x0303/0x0304). Petits #[repr(u8/u16)] avec
(dé)sérialisation bornée.
Total ≈ 25 (A) + 8 (B) + 3 défauts + 13 (C) + 6 (D) + 6 (E) = ~61 objets, dont ~40 « lourds » (logique réelle) et ~21 enums/newtypes/marqueurs. Absents voulus : Flow Controller, Multiplexer (marqueurs commentés, pas de type).
Codecs par standard supporté (sérialisation ↔ frames)
Exigence : pour chaque standard, au minimum un codec bytes↔frames. Trait commun, lecteur borné anti-panic (
get()), écriture append-only.
#![allow(unused)]
fn main() {
/// Lecteur d'octets borné — JAMAIS d'indexation paniquante (Principe 3).
pub struct Reader<'a> { buf: &'a [u8], cursor: usize }
impl<'a> Reader<'a> {
pub fn take(&mut self, n: usize) -> Result<&'a [u8], DecodeError>; // get(), borné
pub fn u8(&mut self) -> Result<u8, DecodeError>;
pub fn u16(&mut self) -> Result<u16, DecodeError>; // big-endian
pub fn u24(&mut self) -> Result<u32, DecodeError>; // longueurs TLS
pub fn sub(&mut self, len_bytes: usize) -> Result<Reader<'a>, DecodeError>; // vecteur préfixé
pub fn expect_end(&self) -> Result<(), DecodeError>; // pas d'octets résiduels
}
/// Tout message wire (dé)sérialise via ce trait. `decode` ne panique JAMAIS.
pub trait Codec: Sized {
fn encode(&self, out: &mut Vec<u8>);
fn decode(r: &mut Reader<'_>) -> Result<Self, DecodeError>;
/// Round-trip garanti sur entrée valide : `decode(encode(x)) == x` (property test).
}
}
Standard 1 — TLS 1.3 record + handshake (RFC 8446)
Codec record (RecordCodec) — frame de 5 octets :
#![allow(unused)]
fn main() {
pub enum ContentType { Handshake, ApplicationData, Alert, ChangeCipherSpec /*compat middlebox*/ }
pub struct TlsPlaintext<'a> { // avant chiffrement
pub content_type: ContentType,
pub legacy_version: ProtocolVersion, // 0x0303 sur le fil (RFC 8446 §5.1)
pub fragment: &'a [u8], // ≤ 2^14 octets
}
pub struct TlsCiphertext<'a> { // après chiffrement (opaque_type=ApplicationData)
pub encrypted_record: &'a [u8], // ≤ 2^14 + 256
}
// RecordCodec::decode borne la longueur ; > 2^14+256 → Alert(record_overflow).
}
Codec handshake (HandshakeCodec) — msg_type(1) ‖ length(u24) ‖ body :
#![allow(unused)]
fn main() {
pub enum HandshakeMessage {
ClientHello(ClientHelloBody),
ServerHello(ServerHelloBody),
EncryptedExtensions(Vec<ExtensionData>),
Certificate(CertificateBody), // chaîne + extensions par cert
CertificateVerify(SignatureScheme, Vec<u8>),
Finished(Vec<u8>), // HMAC du transcript
NewSessionTicket(NewSessionTicketBody),
KeyUpdate(KeyUpdateRequest),
// OMIS : HelloRequest, CertificateRequest v1.2, ServerKeyExchange… (legacy)
}
}
Codec extensions (ExtensionCodec) — type(u16) ‖ len(u16) ‖ data, liste
close (RETENU) : server_name, supported_versions, supported_groups,
signature_algorithms, key_share, pre_shared_key, psk_key_exchange_modes,
application_layer_protocol_negotiation, early_data, cookie, status_request.
Extension inconnue → ignorée gracieusement (jamais de panic ; note §2.2 #9).
Codec alertes (AlertCodec) — level(1) ‖ description(1) ; seules les
descriptions RFC 8446 §6 sont acceptées.
Standard 2 — X.509 / PKIX (RFC 5280) — X509Codec + PathValidator
#![allow(unused)]
fn main() {
pub struct Certificate<'a> { der: &'a [u8], tbs: TbsView<'a> } // vue empruntée, zéro-copie
impl<'a> Certificate<'a> {
pub fn parse(der: &'a [u8]) -> Result<Self, DecodeError>; // ASN.1 DER borné, jamais de panic
pub fn subject(&self) -> Name<'a>;
pub fn subject_alt_names(&self) -> SanIter<'a>; // dNSName / iPAddress
pub fn validity(&self) -> (AirInstant, AirInstant);
pub fn public_key(&self) -> SubjectPublicKeyInfo<'a>;
pub fn signature_scheme(&self) -> Result<SignatureScheme, TlsError>;
}
/// Construction + validation de chemin (RFC 5280 §6) : c'est la « machine à états »
/// de la validation PKIX (chaînage, dates, basicConstraints, keyUsage, EKU, SAN,
/// révocation CRL/OCSP). Détaillée en tests négatifs (cert expiré, CA non-CA, …).
pub struct PathValidator<'a> { roots: &'a RootCertStore, time: AirInstant, /* … */ }
impl<'a> PathValidator<'a> {
pub fn verify_server_chain(&self, chain: &[Certificate<'_>], name: &ServerName)
-> Result<VerifiedChain, TlsError>;
}
}
Décision ASN.1 : parseur DER maison, borné, sous-ensemble strict (uniquement les structures PKIX nécessaires) — jamais un parseur ASN.1 générique (surface hostile). Exactement l’esprit du parseur DNS d’
air-socket(sous-ensemble RFC 1035, pas de compression suivie sans borne).
Machine à états — conçue multi-thread (mono-propriétaire, sans-IO)
#![allow(unused)]
fn main() {
/// Événements rendus par le cœur au pilote I/O (aucun I/O ici).
pub enum TlsEvent {
WantsWrite, // des octets sont prêts à émettre (poll_transmit)
HandshakeComplete { alpn: Option<AlpnProtocol>, peer: Option<VerifiedChain> },
ReceivedApplicationData, // du plaintext est lisible
ReceivedAlert(Alert),
KeyUpdateRequested,
PeerClosed,
}
impl TlsClient {
/// Crée le cœur (envoie le ClientHello en interne, disponible via poll_transmit).
pub fn new(config: Arc<TlsClientConfig>, server_name: ServerName) -> Result<Self, TlsError>;
/// SANS-IO : ingère des octets réseau bruts → transitions d'état + événements.
/// Ne bloque jamais, ne touche aucun socket. Cœur de la fuzzabilité.
pub fn feed(&mut self, incoming: &[u8]) -> Result<Vec<TlsEvent>, TlsError>;
/// SANS-IO : récupère les octets à écrire sur le réseau (handshake, app data, alerts).
pub fn poll_transmit(&mut self) -> Option<&[u8]>;
/// SANS-IO : plaintext applicatif déchiffré disponible (après handshake).
pub fn read_application_data(&mut self, out: &mut [u8]) -> Result<usize, TlsError>;
/// SANS-IO : chiffre du plaintext applicatif → mis en file de `poll_transmit`.
pub fn write_application_data(&mut self, plaintext: &[u8]) -> Result<usize, TlsError>;
/// SANS-IO : échéance (handshake timeout…). Horloge injectée → test déterministe.
pub fn handle_timeout(&mut self, now: AirInstant) -> Result<Vec<TlsEvent>, TlsError>;
pub fn is_handshaking(&self) -> bool;
pub fn send_key_update(&mut self, request_update: bool) -> Result<(), TlsError>;
pub fn send_close_notify(&mut self);
}
// `TlsServer` : symétrique (new(config) ; feed du ClientHello ; ServerHello/EE/Cert/CV/Finished).
}
États internes (HandshakeStateMachine, RFC 8446 §A) — client :
Start → WaitServerHello → WaitEncryptedExtensions → WaitCertificate → WaitCertificateVerify → WaitFinished → Connected. Toute réception hors-séquence →
Alert(unexpected_message) + TlsError, jamais de panic. La table
état × message est couverte à 100 % (property/model-based, note §2.2 #3).
Pilotage (pilote I/O, io/) — le seul endroit qui touche air-socket :
#![allow(unused)]
fn main() {
impl<T: Read + Write> TlsStream<T> { // T = AirTcpStream (air-socket)
pub fn connect(config: Arc<TlsClientConfig>, name: ServerName, transport: T)
-> Result<Self, TlsError>; // boucle feed/poll_transmit jusqu'à HandshakeComplete
}
impl<T: Read + Write> Read for TlsStream<T> { /* … */ } // idiome std
impl<T: Read + Write> Write for TlsStream<T> { /* … */ }
// Variante async : pilote sur air-runtime (buffers possédés, ADR-038) — même cœur.
}
Surface publique par famille (reprise de s2n, en idiome Rust)
Configuration (≈ s2n_config_*, 75 fn)
#![allow(unused)]
fn main() {
impl TlsClientConfigBuilder {
pub fn new() -> Self;
pub fn with_root_certificates(self, roots: RootCertStore) -> Self;
pub fn with_server_verifier(self, v: Arc<dyn ServerCertVerifier>) -> Self;
pub fn with_client_auth(self, key: Arc<dyn SigningKey>, chain: CertifiedKey) -> Self; // mTLS
pub fn with_alpn_protocols(self, protos: &[AlpnProtocol]) -> Self;
pub fn with_cipher_suites(self, suites: &[CipherSuite]) -> Self; // sous-ensemble des 3
pub fn with_key_exchange_groups(self, groups: &[NamedGroup]) -> Self;
pub fn with_session_resumption(self, store: Arc<dyn SessionStore>) -> Self;
pub fn enable_early_data(self, max: u32) -> Self; // 0-RTT, off par défaut
pub fn with_key_log(self, log: Arc<dyn KeyLog>) -> Self;
pub fn build(self) -> Result<Arc<TlsClientConfig>, TlsError>; // valide en amont (Principe 4)
}
// TlsServerConfigBuilder : with_certificate(CertifiedKey), with_client_cert_verifier,
// with_ticketer, with_alpn_select_callback, with_max_early_data, …
}
Les setters
s2nde legacy/tuning obsolète (versions min/max < 1.3, suites CBC, renégociation, cache d’anciennes sessions…) n’existent pas — omission = sécurité.
Connexion & I/O (≈ s2n_connection_* 91 + I/O 16)
Fusionnées dans TlsClient/TlsServer (sans-IO, ci-dessus) + TlsStream (bloquant).
Introspection post-handshake : .negotiated_alpn(), .protocol_version() (toujours
Tls13), .negotiated_cipher_suite(), .peer_certificates(), .is_resumed().
Introspection ClientHello (≈ s2n_client_hello_*, 25 fn) & Fingerprint (8 fn)
#![allow(unused)]
fn main() {
impl<'a> ClientHello<'a> {
pub fn server_name(&self) -> Option<ServerName>;
pub fn alpn_protocols(&self) -> AlpnIter<'a>;
pub fn cipher_suites(&self) -> &[CipherSuite];
pub fn supported_groups(&self) -> &[NamedGroup];
pub fn signature_schemes(&self) -> &[SignatureScheme];
pub fn raw_extensions(&self) -> ExtensionIter<'a>;
pub fn fingerprint(&self, kind: FingerprintKind) -> ClientHelloFingerprint; // JA3/JA4
}
}
Certificats, trust, révocation (≈ s2n_cert_* / crl_*)
#![allow(unused)]
fn main() {
impl CertifiedKey {
pub fn from_pem(cert_chain_pem: &[u8], key_pem: &[u8]) -> Result<Self, TlsError>;
pub fn from_der(chain: Vec<Vec<u8>>, key: PrivateKeyHandle) -> Result<Self, TlsError>;
}
impl RootCertStore {
pub fn empty() -> Self;
pub fn add_der(&mut self, der: &[u8]) -> Result<(), TlsError>;
pub fn add_pem_bundle(&mut self, pem: &[u8]) -> Result<usize, TlsError>;
}
pub trait ServerCertVerifier: Send + Sync {
fn verify_server_cert(&self, end_entity: &Certificate<'_>, intermediates: &[Certificate<'_>],
server_name: &ServerName, ocsp: Option<&[u8]>, now: AirInstant)
-> Result<VerifiedChain, TlsError>;
fn supported_verify_schemes(&self) -> &[SignatureScheme];
}
}
PSK / reprise / 0-RTT / signature offload
#![allow(unused)]
fn main() {
pub trait SigningKey: Send + Sync {
fn sign(&self, scheme: SignatureScheme, message: &[u8]) -> Result<Vec<u8>, TlsError>;
fn supported_schemes(&self) -> &[SignatureScheme];
}
pub trait AsyncSigner: Send + Sync { // ≈ s2n_async_pkey_* (offload HSM)
fn begin_sign(&self, scheme: SignatureScheme, message: Vec<u8>) -> SignTicket;
fn poll_sign(&self, ticket: &SignTicket) -> Poll<Result<Vec<u8>, TlsError>>;
}
pub trait SessionStore: Send + Sync { /* get/put tickets, expiration */ }
pub trait Ticketer: Send + Sync { // scelle/ouvre NewSessionTicket via air-crypto AEAD
fn seal(&self, state: &[u8]) -> Result<Vec<u8>, TlsError>;
fn open(&self, ticket: &[u8]) -> Result<Vec<u8>, TlsError>;
}
}
Régime de conformance (les docs de conformance à produire/rejouer)
Aligné sur la note réseau §2.6 (« vecteurs de conformité rejoués, comme les KAT d’
air-crypto») et l’homologation IETF exigée par ADR-042 §Conséquences même pour l’intégration — a fortiori pour la maison.
| Régime | Source de vérité | Ce qu’on prouve |
|---|---|---|
| Traces RFC 8448 | Example Handshake Traces for TLS 1.3 | Le handshake produit octet-pour-octet les records attendus (key schedule, transcript, Finished) — l’étalon-or de correction protocolaire |
| KAT crypto | RFC 7748/8032/8439/5869 (via air-crypto) | Primitives déjà KAT-gated (ADR-034) ; air-tls teste HKDF-Expand-Label (RFC 8446 §7.1) contre 8448 |
| BoGo suite | tests BoringSSL/rustls | Machine à états vs corpus adversarial (des milliers de cas malformés / négatifs) |
| tlsfuzzer | tlsfuzzer | Handshakes malformés → alerte correcte, jamais de panic/plaintext |
| Interop matrix | OpenSSL 3.x, rustls, s2n-tls (serveur ET client) | Négociation réelle sur loopback : les 3 suites × X25519(/hybride) × Ed25519/ECDSA/RSA |
| Fuzz par composant | cargo-fuzz | 1 cible / parseur : RecordFramer, HandshakeCodec, ExtensionCodec, X509Codec — octets arbitraires → zéro panic |
Livrable de conformance : un registre
docs/tls-conformance.md(modèledocs/libc-conformance.md) recensant chaque vecteur, son statut, et le test qui l’exerce. À créer avec l’implémentation.
Stratégie de tests
- Couverture : couche 2, cible > 90 % (CLAUDE.md) — viser 100 % sur le cœur sans-IO (surface hostile), marge tolérée sur le pilote I/O.
- Par composant, en isolation (note §2.2) : Framer/Codec fuzzés (jamais de
lecture hors-borne, jamais d’alloc non bornée) ;
Codecround-tripdecode∘encode = id; StateMachine property + model-based (transitions illégales rejetées, couverture état×message) ; Handshaker vecteurs RFC 8448 + négatifs (downgrade sentinel, signature falsifiée, cert expiré/mauvais SAN). - Session Context :
zeroizedes secrets auDropprouvé (test mémoire) ; zéro fuite inter-session (deux connexions ne partagent aucun secret). - Multi-thread :
TlsClient: Send + !Syncprouvé compile-fail ;TlsConfig: Send + Syncpartagé par N threads (stress). - Pilote I/O : intégration loopback réelle sur
air-socket(comme le fait déjàair-socket), + interop matrix. - Doctests sur la surface publique.
Récapitulatif
| Famille | Objets | Rôle |
|---|---|---|
| A. Surface publique | 25 | Config/Connexion/Stream/ClientHello/Certs/PSK/… (couverture s2n) |
| B. Traits publics (+ défauts) | 8 (+3) | Verifiers, Signers, Stores, Ticketer, KeyLog |
| C. Cœur sans-IO (9 composants) | 13 | Framer, 5 Codecs, X.509, StateMachine, KeySchedule, Timers, Secrets, Extensions |
| D. Fournisseur crypto | 6 traits | CryptoProvider (défaut = air-crypto) |
| E. Enums wire | 6 | ContentType/HandshakeType/… |
| Total | ~61 | dont ~40 « lourds », 2 composants absents voulus (Flow, Mux) |
Décisions de fond
- Contingence, pas production — production = rustls (ADR-042) ; ce document instruit la porte de sortie maison pour qu’elle soit prête (RFC requis pour basculer).
- TLS 1.3 uniquement, moderne seul — tout le legacy vulnérable omis par conception (pas de downgrade à sécuriser : il n’y a pas de code).
- Zéro tierce, zéro C, zéro
unsafe— seulesstd+ les crates Air maison (couches ≤ 1) ; primitives viaair-crypto(la frontière crypto vettée, ADR-034), jamais réimplémentées. - Sans-IO 9-composants — cœur pur fuzzable + pilote I/O mince, identique aux autres crates réseau Air (note réseau §2.2).
- Machine à états mono-propriétaire multi-thread — connexion
Send/!Sync(zéro verrou),ConfigArcSend/Syncpartagé (thread-per-core, ADR-038). - Parsing défensif intégral —
Readerborné,get(),&[u8]/&strstricts, ASN.1 en sous-ensemble strict maison (pas de parseur générique). - Conformance = vecteurs rejoués — RFC 8448, BoGo, tlsfuzzer, interop, fuzz par
composant ; registre
docs/tls-conformance.md.
Travail à reprendre
- Additifs
air-crypto(KAT-gated, ADR-034) requis pour l’interop WebPKI réelle : AES-128-GCM, ECDSA P-256/P-384, RSA-PSS (vérif.), ML-KEM-768 (hybride PQ). Sans eux, interop limitée aux pairs Ed25519/X25519/AES-256/ChaCha20. - Registre
docs/tls-conformance.md(modèlelibc-conformance.md). air-network(substrat L2) :air-tlss’y branche (connexion/listener/ path-awareness) — à spécifier en parallèle (noteapi-reseau-strategie-fr.md).- DTLS : hors périmètre (TLS sur flux uniquement ; DTLS = datagramme, décision ultérieure si besoin QUIC-indépendant).
- ABI C d’
air-tls: différée (commeair-crypto). - Décision de granularité
no_stddu cœur sans-IO (aujourd’huistd; cibleno_std + alloccohérente note §2.1).
Licence du document : MPL 2.0
Statut : Spécification de contingence air-tls maison (couche 2), TLS 1.3 pur
Rust. Instruit l’option gardée ouverte par ADR-042 §Contingence ; ne remplace pas la
décision production rustls + aws-lc-rs. Toute bascule production = RFC (ADR-015).
Spec couche 2 — air-network (substrat de connexion ≈ Network.framework)
Spécification technique — Version 0.1 (proposition). Couche 2 « Frameworks système ».
Cadrage.
air-networkvise la couverture fonctionnelle des API réseau d’Apple (Network.framework) sans jamais copier leur API — surface en vocabulaire Air idiomatique (Rust), leur doc servant de checklist et de nord de conception (note de stratégie, décision BDFL 2026-06-13). Les équivalents Apple sont cités entre parenthèses au seul titre de checklist de couverture.
Position et méthode
air-network est le substrat de connexion de couche 2 : le framework qui
compose transport (air-socket, L1), sécurité (air-tls, L2) et transports
avancés (air-quic, L2, différé) au-dessus du runtime async io_uring
(air-runtime, L1, ADR-038), et sur lequel se branchent les protocoles
applicatifs (air-http, air-ssh). C’est le point réservé par air-socket
(spec air-socket : « air-network, réservé au
framework réseau couche 2 ») et le « substrat » de la
note réseau §2.5.
Ce que air-network n’est PAS. Ce n’est pas un protocole filaire : il n’a
pas de Framer/Codec/StateMachine propres (ceux-ci vivent dans les crates de
protocole — air-tls, air-quic — selon le patron sans-IO 9-composants). air-network
orchestre : il déclare une pile, résout un endpoint, pilote l’I/O (le seul étage
qui touche sockets + horloge), surveille le chemin réseau, et expose une connexion
sémantique de haut niveau. Il consomme les cœurs sans-IO ; il n’en réimplémente
aucun.
Périmètre fonctionnel (checklist Network.framework, vocabulaire Air)
| Concept Air | ≈ Apple | Réalisation |
|---|---|---|
| Connexion sémantique bidirectionnelle | NWConnection | AirConnection (async, sur air-runtime) |
| Écouteur de connexions entrantes | NWListener | AirListener |
| Découverte de service | NWBrowser / Bonjour | AirBrowser (sur air-mdns, différé) |
| Conscience du chemin réseau | NWPath / NWPathMonitor | AirNetworkPath / AirNetworkPathMonitor (netlink + sysfs) |
| Paramètres + pile de protocoles déclaratifs | NWParameters / NWProtocolStack | AirParameters / AirProtocolStack |
| TLS intégré par défaut | (défaut Apple) | AirParameters::secure() (sécurité-d’abord) |
| Endpoint (hôte/port/service/unix) | NWEndpoint | AirEndpoint |
Dépendances
- Crates tierces externes : ZÉRO hors l’exception TLS déléguée à
air-tls(ADR-042 :rustls+aws-lc-rsen production, ou la contingence maison pur Rust).air-networklui-même n’ajoute aucune dépendance C ni tierce → son proprecheck-c-surfaceest vert. - Crates Air (couches inférieures) :
air-socket(L1) — transports TCP/UDP/Unix + résolveur de noms enfichable ;air-tls(L2) — sécurité de session (composée par défaut) ;air-quic(L2, différé) — transport QUIC (débloque HTTP/3) ;air-runtime(L1) — exécution async io_uring (buffers possédés, ADR-038) ;air-device(L1) — énumération des interfaces réseau (sysfs) pourAirNetworkPath;air-mdns(L2, différé) — découverte de service pourAirBrowser;air-base-core(L1) —AirError/AirResult(ADR-019),AirDuration.
- Zéro
unsafeexposé ; parsing/validation défensifs (Principe 3/4).
Dépendance — surveillance du chemin (
AirNetworkPathMonitor). Observer les changements de lien/route/adresse exige rtnetlink (RTNLGRP_LINK/IPV4_IFADDR/IPV6_IFADDR,RTM_NEWROUTE…). Tranché (ADR-079, décision BDFL 2026-07-07) : une crateair-netlink(couche 1) — transport netlink générique sans-IO (spec) — fournitrtnetlink;AirNetworkPathMonitorconsomme sonRtnetlinkClient. Requiert l’additif couche 0AF_NETLINK/sockaddr_nl(couche-0-v1.11).
Articulation avec le patron sans-IO (où air-network se place)
La note réseau §2.5 grave l’empilement :
air-http (h3) → air-quic → air-tls 1.3 → air-socket (UDP) [async: air-runtime]
air-http (h2) → air-tls 1.3 → air-socket (TCP)
air-http (h1) → air-socket (TCP)
air-network est le substrat transverse où ces piles se déclarent et se pilotent :
il détient le pilote I/O commun (composant §2.1 de la note) qui câble les cœurs
sans-IO empilés au transport réel air-socket + à l’horloge réelle air-runtime.
Un cœur N consomme l’API du cœur N-1, pas son I/O ; le seul I/O réel vit ici, au bas
de la pile.
Inventaire des objets à créer — ~20 objets
A. Surface publique — 16 objets
| # | Objet Air | ≈ Apple | Nature |
|---|---|---|---|
| 1 | AirParameters | NWParameters | pile déclarée (transport + sécurité + options) |
| 2 | AirParametersBuilder | — | builder (secure()/plaintext()/with_tls…) |
| 3 | AirProtocolStack | NWProtocolStack | pile ordonnée transport→sécurité→app |
| 4 | AirTransport | (choix transport) | enum Tcp / Udp / Quic |
| 5 | AirEndpoint | NWEndpoint | enum HostPort / Service / Unix |
| 6 | AirConnection | NWConnection | connexion async (état, send/receive) |
| 7 | AirConnectionState | NWConnection.State | enum Setup/Preparing/Ready/Failed/Cancelled |
| 8 | AirContentContext | NWConnection.ContentContext | métadonnées de message (final, expedited…) |
| 9 | AirListener | NWListener | accepteur de connexions entrantes |
| 10 | AirBrowser | NWBrowser | découverte de service (mDNS) |
| 11 | AirDiscoveredService | NWBrowser.Result | service découvert (nom, endpoint, txt) |
| 12 | AirServiceType | (_http._tcp…) | newtype type de service DNS-SD |
| 13 | AirNetworkPath | NWPath | instantané du chemin réseau |
| 14 | AirNetworkPathStatus | NWPath.Status | enum Satisfied/Unsatisfied/RequiresConnection |
| 15 | AirInterfaceType | NWInterface.InterfaceType | enum Wired/WiFi/Cellular/Loopback/Vpn/Other |
| 16 | AirNetworkPathMonitor | NWPathMonitor | observe les changements de chemin |
B. Traits & valeurs — ~4 objets
| # | Objet | Rôle |
|---|---|---|
| 17 | AirNetworkError | enum d’erreur (ADR-019) — résolution, transport, sécurité, chemin |
| 18 | AirNetworkPathEvent | enum d’événement du monitor (PathChanged, BetterPathAvailable, Expensive…) |
| 19 | AirProtocolOptions | trait — options par protocole (marqueur composé dans AirProtocolStack) |
| 20 | AirConnectionGroup | (optionnel) groupe de connexions partageant des paramètres (≈ NWConnectionGroup, multicast/UDP) — différé |
Paramètres & pile déclarative
#![allow(unused)]
fn main() {
/// Déclaration d'une pile réseau. **Sécurité-d'abord** : `secure()` est le défaut
/// idiomatique ; `plaintext()` est explicite (jamais de clair par accident).
pub struct AirParameters { stack: AirProtocolStack, /* options */ }
impl AirParametersBuilder {
/// TCP + TLS (défaut recommandé). `tls` = config `air-tls` (client ou serveur).
pub fn secure(tls: Arc<air_tls::TlsClientConfig>) -> Self;
/// QUIC (TLS 1.3 intégré, RFC 9000) — débloque HTTP/3. (air-quic différé.)
pub fn quic(tls: Arc<air_tls::TlsClientConfig>) -> Self;
/// UDP/TCP en clair — usage explicite (DNS, découverte, protocoles non chiffrés).
pub fn plaintext(transport: AirTransport) -> Self;
pub fn with_alpn(self, protocols: &[air_tls::AlpnProtocol]) -> Self;
pub fn with_service_class(self, class: AirServiceClass) -> Self; // best-effort/interactive…
pub fn prohibit_expensive(self, yes: bool) -> Self; // refuse liens onéreux
pub fn prohibit_constrained(self, yes: bool) -> Self; // refuse liens restreints
pub fn require_interface_type(self, t: AirInterfaceType) -> Self; // épingle wifi/wired…
pub fn build(self) -> AirResult<AirParameters>; // valide en amont (Principe 4)
}
}
Décision — sécurité par défaut. Le constructeur idiomatique est
secure()(TLS composé). Le clair exigeplaintext()explicite. On refuse le piège Apple/POSIX du « clair par omission ». Alignéair-tls(TLS 1.3-first) + ethos Air.
Endpoint
#![allow(unused)]
fn main() {
pub enum AirEndpoint {
HostPort { host: AirHostName, port: u16 }, // résolu via air-socket (IPv6-first)
Service { name: AirServiceName, ty: AirServiceType, domain: AirDomain }, // mDNS/DNS-SD
Unix { path: air_base_core::AirPath /* chemin socket (FS) */ },
}
// AirHostName : newtype validé (IDNA/ASCII, jamais d'octets arbitraires en nom d'hôte).
}
Connexion (async, sur air-runtime)
#![allow(unused)]
fn main() {
pub enum AirConnectionState { Setup, Preparing, Ready, Failed(AirNetworkError), Cancelled }
impl AirConnection {
/// Prépare une connexion sortante (résolution + handshake transport + TLS).
/// Async : ne bloque pas ; l'état passe Setup→Preparing→Ready.
pub async fn establish(endpoint: AirEndpoint, params: AirParameters) -> AirResult<Self>;
pub fn state(&self) -> AirConnectionState;
/// Notifie les transitions d'état (Ready, Failed…) — modèle événementiel.
pub async fn await_ready(&mut self) -> AirResult<()>;
/// Envoi ordonné. `context` porte les métadonnées (message final, expedited).
pub async fn send(&mut self, data: &[u8], context: AirContentContext) -> AirResult<()>;
/// Réception (buffers possédés, ADR-028 : zéro discard silencieux, ADR-032).
pub async fn receive(&mut self, max: usize) -> AirResult<(Vec<u8>, AirContentContext)>;
/// Le chemin réseau **de cette connexion** (interface courante, coût).
pub fn current_path(&self) -> AirNetworkPath;
pub async fn shutdown(&mut self) -> AirResult<()>; // close_notify TLS + FIN
pub fn cancel(&mut self); // abandon immédiat
}
}
Neutralité sync/async (ADR-038). L’API de 1ʳᵉ classe est async (une connexion sémantique est intrinsèquement événementielle). Une façade bloquante de confort (
AirConnection::establish_blocking,send_blocking…) est fournie pour les usages one-shot/CLI, pilotée par le même cœur surair-socketsynchrone.
Écouteur & découverte
#![allow(unused)]
fn main() {
impl AirListener {
pub async fn bind(endpoint: AirEndpoint, params: AirParameters) -> AirResult<Self>;
/// Accepte la prochaine connexion entrante (déjà sécurisée si la pile le déclare).
pub async fn accept(&mut self) -> AirResult<AirConnection>;
pub fn local_endpoint(&self) -> AirEndpoint;
/// Publie ce service en mDNS/DNS-SD (≈ NWListener.service). air-mdns, différé.
pub fn advertise(&mut self, name: AirServiceName, ty: AirServiceType) -> AirResult<()>;
}
impl AirBrowser { // ≈ NWBrowser (Bonjour), air-mdns
pub async fn start(ty: AirServiceType, domain: AirDomain) -> AirResult<Self>;
pub async fn next(&mut self) -> AirResult<AirBrowseChange>; // Added/Removed service
}
}
Conscience du chemin réseau (path-awareness)
#![allow(unused)]
fn main() {
pub enum AirNetworkPathStatus { Satisfied, Unsatisfied, RequiresConnection }
pub enum AirInterfaceType { Wired, WiFi, Cellular, Loopback, Vpn, Other }
pub struct AirNetworkPath { /* snapshot immuable */ }
impl AirNetworkPath {
pub fn status(&self) -> AirNetworkPathStatus;
pub fn primary_interface(&self) -> Option<AirInterfaceType>;
pub fn supports_ipv4(&self) -> bool;
pub fn supports_ipv6(&self) -> bool;
pub fn is_expensive(&self) -> bool; // voir décision ci-dessous
pub fn is_constrained(&self) -> bool; // voir décision ci-dessous
}
impl AirNetworkPathMonitor {
pub async fn start() -> AirResult<Self>;
/// Prochain changement de chemin (up/down, nouvelle route, meilleur chemin).
pub async fn next_event(&mut self) -> AirResult<AirNetworkPathEvent>;
pub fn current(&self) -> AirNetworkPath;
}
}
Décision —
expensive/constrainedsur Linux (honnêteté d’implémentation). Linux n’a pas de bit noyau « lien onéreux/restreint » (contrairement à iOS). On les dérive :
- Type d’interface (
air-device/sysfs) :wwan/cellular⇒expensivepar défaut ;loopback/wired⇒ ni l’un ni l’autre.- Politique administrateur (
air-config) : drapeau metered par interface (override explicite).- Indices systemd-networkd / NetworkManager s’ils sont présents (best-effort).
is_expensive()/is_constrained()renvoient donc une heuristique documentée + surchargeable par politique, jamais un bit noyau. Cette dérivation est isolée et testable ; son absence de source noyau est explicite, pas masquée.
Réalisation Linux du monitor : énumération des interfaces via air-device (sysfs) +
abonnement rtnetlink aux événements lien/adresse/route (dépendance air-netlink à
résoudre, cf. supra). RequiresConnection ≈ portail captif / lien monté sans route par
défaut.
Modèle multi-thread & async
AirParameters/ configs :Send + Sync, partagésArcentre connexions (immuables aprèsbuild), commeair-tls.AirConnection/AirListener:Send,!Sync— une connexion pilotée par une tâche à la fois (aucun verrou), scalabilité par N connexions indépendantes sur un exécuteur thread-per-core (ADR-038/039, sans work-stealing). Le key schedule TLS sous-jacent l’impose déjà (air-tls).AirNetworkPathMonitor: source d’événements partageable prudemment (un lecteur) ; l’état de chemin (AirNetworkPath) est un snapshot immuableSend + Syncclonable.- Buffers possédés (ADR-028) de bout en bout ; back-pressure explicite ;
zéro discard silencieux (ADR-032) sur
receive.
Sécurité
- TLS par défaut (
secure()), clair explicite (plaintext()). - Endpoints validés en amont (
AirHostNameIDNA, ports typés) — la validation du nom d’hôte pour le certificat est portée parair-tls(ServerName, RFC 6125). prohibit_expensive/prohibit_constrained/require_interface_type: la pile peut refuser un chemin non conforme à la politique (ex. ne jamais synchroniser sur cellulaire) — décision de sécurité/coût côté appelant, exprimée déclarativement.- Aucune surface C propre ; la seule surface C possible est celle, déléguée et
nommée, d’
air-tls(ADR-042).air-quic/air-mdnsrestent maison zéro-C.
Stratégie de tests
- Couverture : couche 2, cible > 90 % (CLAUDE.md).
- Composition : tests que
AirParametersdéclare correctement la pile (TCP+TLS, QUIC, clair) et que le pilote I/O câble les bons cœurs. - Loopback réel (comme
air-socket/air-tls) :AirListener↔AirConnectionbout-en-bout, sécurisé, avec transfert + back-pressure + shutdown propre. - Path-awareness déterministe :
AirNetworkPathMonitorpiloté par des événements netlink simulés (interface up/down, route ajoutée/retirée) — virtual clock, pas de vrai réseau ; vérifierexpensive/constraineddérivés + override de politique. - Découverte :
AirBrowser/advertisesur boucle mDNS locale (quandair-mdnsatterrit). - Interop :
AirConnectionvs serveurs de référence (OpenSSL/nginx) viaair-tls.
Récapitulatif
| Famille | Objets | Rôle |
|---|---|---|
| A. Surface publique | 16 | Parameters/Stack/Endpoint/Connection/Listener/Browser/Path/Monitor |
| B. Traits & valeurs | ~4 | Error, PathEvent, ProtocolOptions, (ConnectionGroup différé) |
| Total | ~20 | substrat de composition (pas un protocole filaire) |
Décisions de fond
- Substrat de composition, pas un protocole —
air-networkorchestre et pilote l’I/O ; les cœurs sans-IO (Framer/Codec/StateMachine) vivent dansair-tls/air-quic(note réseau §2.5). Pas de 10ᵉ composant. - Couverture Apple, vocabulaire Air — Network.framework = checklist, jamais copie
d’API (note
api-reseau-strategie). - Sécurité par défaut —
secure()(TLS composé) idiomatique ; clair explicite. - Async 1ʳᵉ classe (ADR-038) sur
air-runtime, façade bloquante de confort ; connexionSend/!Sync,ConfigArcpartagé (thread-per-core). - Path-awareness honnête sur Linux —
expensive/constraineddérivés (interface + politique + indices), jamais un bit noyau inexistant ; dérivation isolée et testable. - Zéro-C propre — la seule exception C est celle, déléguée/nommée, d’
air-tls(ADR-042) ;air-networkn’en ajoute aucune.
Travail à reprendre
air-netlink(L1) — tranché (ADR-079, option B couche 1) : spec écrite (docs/specs/layer-1/air-netlink.md) ; reste l’additif couche 0AF_NETLINK(couche-0-v1.11) puis le câblage deAirNetworkPathMonitorsurRtnetlinkClient.air-quic(L2) — transport QUIC (débloque HTTP/3) ;AirParameters::quic.air-mdns(L2) — découverte/publication de service ;AirBrowser/advertise.AirConnectionGroup(multicast/UDP en groupe, ≈NWConnectionGroup) — différé.- Équivalent
URLSession(client HTTP/URL-loading haut niveau) au-dessus d’air-network— couche à confirmer (couche 4 élargie ?), cf. noteapi-reseau-strategie. - ABI C d’
air-network: différée.
Licence du document : MPL 2.0
Statut : Spécification air-network (couche 2) v0.1 — substrat de connexion,
couverture fonctionnelle Network.framework en vocabulaire Air. Compose air-socket
(L1) + air-tls (L2) + air-runtime (L1). Implémentation à suivre.
Spec couche 2 — air-quic (QUIC v1 pur Rust, patron sans-IO)
Spécification technique — Version 0.1 (proposition). Couche 2 « Frameworks système ».
Cadrage.
air-quicimplémente QUIC v1 (RFC 9000 transport + RFC 9001 TLS + RFC 9002 loss/congestion), transport deair-http(HTTP/3) et futur socle de transports Air. Maison, pur Rust, zéro-C, zérounsafe; primitives viaair-crypto, handshake TLS 1.3 viaair-tls. Suit à l’identique le patron sans-IO 9-composants (note réseau §2.2).
Position et méthode
QUIC est un protocole filaire (contrairement à air-network qui l’orchestre) :
il a donc son cœur sans-IO (les 9 composants) + un pilote I/O mince sur
air-socket (UDP) et air-runtime (async). QUIC est le premier protocole Air à
exercer les 9 composants (TLS en laissait 2 absents) : il a du flow control et du
multiplexage de streams natifs.
Empilement (note §2.5) :
air-http (h3) → air-quic → air-socket (UDP) [async: air-runtime]
└─ handshake TLS 1.3 via air-tls (CRYPTO frames), AEAD via air-crypto
Décision structurante — QUIC ≠ TLS-sur-TCP. QUIC n’utilise pas le record layer de TLS. Il transporte les messages de handshake TLS 1.3 dans ses propres frames CRYPTO, et protège ses paquets avec son propre AEAD (clés dérivées des secrets TLS via HKDF-Expand-Label, labels QUIC, RFC 9001 §5).
air-quicréutilise donc le cœur handshake d’air-tls(HandshakeStateMachine+KeySchedule+TranscriptHash), pas sonRecordCodec. ⇒ Exigence surair-tls: exposer son handshake découplé du record layer (accommodation à graver dans la specair-tls; cf. « Travail à reprendre »).
Périmètre de sécurité — moderne seul
- QUIC v1 (RFC 9000) uniquement ; négociation de version (RFC 9368) pour détecter/refuser proprement, pas pour parler des versions obsolètes.
- TLS 1.3 obligatoire (RFC 9001) — QUIC n’a pas de mode legacy ; hérite du
périmètre
air-tls(AEAD only, FS). - Retry + Address Validation (anti-amplification, RFC 9000 §8) ; Stateless Reset.
- 0-RTT : optionnel, désactivé par défaut (anti-rejeu, comme
air-tls).
Dépendances
- Tierces externes : ZÉRO hors l’exception TLS déléguée à
air-tls(ADR-042). - Crates Air :
air-tls(L2, handshake TLS 1.3),air-crypto(L1, AEAD de protection de paquet — AES-GCM/ChaCha20 + HKDF ; + header protection via le modulelowlevel—AirAesBlock/AirChaCha20, additif ADR-081),air-socket(L1, UDP),air-runtime(L1, async + timers),air-base-core(L1). - Zéro
unsafeexposé ; parsing défensif intégral (varints bornés,get()).
Anatomie : les 9 composants appliqués à QUIC v1
| # | Composant | Réalisation air-quic | Pur ? |
|---|---|---|---|
| 1 | Framer | PacketFramer — délimite un datagramme UDP en paquets QUIC (coalescés), en-têtes long/short, extraction du numéro de paquet (protégé) | ✅ |
| 2 | Codec | PacketCodec (en-têtes), FrameCodec (STREAM/ACK/CRYPTO/MAX_DATA/RESET_STREAM/CONNECTION_CLOSE/PATH_CHALLENGE/NEW_CONNECTION_ID…), TransportParamCodec — varints RFC 9000 §16 bornés | ✅ |
| 3 | StateMachine | ConnectionStateMachine — Initial → Handshaking → Established → Closing → Draining (RFC 9000 §10) | ✅ |
| 4 | Handshaker | QuicHandshake — pilote le handshake air-tls via frames CRYPTO ; dérive les clés de paquet (Initial/Handshake/1-RTT), key update | ✅ + air-tls/air-crypto |
| 5 | Flow Controller | FlowController — fenêtrage connexion (MAX_DATA) et par stream (MAX_STREAM_DATA) + limites de streams (MAX_STREAMS) | ✅ |
| 6 | Multiplexer | StreamMultiplexer — streams bidi/uni, IDs RFC 9000 §2.1 (sans collision/ABA), isolation inter-streams, ordre par stream | ✅ |
| 7 | Timer Manager | LossDetection + CongestionController — RTT, PTO, retransmission, NewReno (RFC 9002) ; idle timeout, ACK delay — horloge injectée | ✅ (horloge injectée) |
| 8 | Session Context | ConnectionSecrets/KeySchedule + PacketProtection (AEAD) + HeaderProtection ; connection IDs, transport params ; zeroize | ✅ + air-crypto |
| 9 | Extension hooks | TransportParameters + registre d’extensions (datagram RFC 9221, ack-frequency…) ; extension inconnue ignorée gracieusement | ✅ |
Inventaire des objets — ~32 objets
A. Surface publique — ~13
| # | Objet | Rôle |
|---|---|---|
| 1 | QuicClientConfig (+ Builder) | politique client (config air-tls + transport params) — Arc, immuable |
| 2 | QuicServerConfig (+ Builder) | politique serveur |
| 3 | QuicEndpoint | possède un socket UDP, démultiplexe N connexions par Connection ID |
| 4 | QuicConnection | machine à états d’une connexion (Send/!Sync) |
| 5 | QuicConnectionState | enum d’état |
| 6 | SendStream | moitié émettrice d’un stream (flow-controlée) |
| 7 | RecvStream | moitié réceptrice (réordonnancement) |
| 8 | StreamId | newtype (bidi/uni, initiateur) |
| 9 | TransportParameters (+ Builder) | idle timeout, max_data, max_streams, max_udp_payload… |
| 10 | ConnectionId | newtype (routage/migration) |
| 11 | QuicError | enum (ADR-019) — transport + applicatif |
| 12 | ConnectionClose | frame de fermeture (code + raison) |
| 13 | Datagram | RFC 9221 (datagramme non fiable) — optionnel |
B. Cœur sans-IO — ~14 (les 9 composants + sous-objets)
PacketFramer, PacketCodec, FrameCodec, TransportParamCodec,
ConnectionStateMachine, QuicHandshake, FlowController, StreamMultiplexer,
LossDetection, CongestionController, KeySchedule, PacketProtection,
HeaderProtection, ExtensionRegistry.
C. Enums wire — ~5
PacketType (Initial/0-RTT/Handshake/Retry/1-RTT), FrameType, QuicVersion,
StreamKind (bidi/uni × client/server-initiated), EncryptionLevel
(Initial/Handshake/1-RTT).
Codecs (bytes ↔ frames)
#![allow(unused)]
fn main() {
/// Lecteur borné + varints QUIC (RFC 9000 §16) — jamais d'indexation paniquante.
pub struct QuicReader<'a> { /* … */ }
impl<'a> QuicReader<'a> {
pub fn varint(&mut self) -> Result<u64, DecodeError>; // 1/2/4/8 octets préfixés
pub fn take(&mut self, n: usize) -> Result<&'a [u8], DecodeError>;
}
pub enum QuicFrame<'a> {
Padding, Ping,
Ack(AckRanges<'a>),
Crypto { offset: u64, data: &'a [u8] }, // porte le handshake TLS
Stream { id: StreamId, offset: u64, fin: bool, data: &'a [u8] },
MaxData(u64), MaxStreamData { id: StreamId, max: u64 }, MaxStreams { kind: StreamKind, max: u64 },
ResetStream { id: StreamId, code: u64, final_size: u64 },
NewConnectionId { /* … */ }, RetireConnectionId(u64),
PathChallenge([u8; 8]), PathResponse([u8; 8]),
ConnectionClose(ConnectionClose),
// OMIS : rien de legacy (QUIC v1 n'a pas d'héritage).
}
// FrameCodec::decode borne toutes les longueurs ; frame inconnue en niveau interdit → CONNECTION_CLOSE(PROTOCOL_VIOLATION).
}
Protection de paquet (RFC 9001 §5). HeaderProtection (masque dérivé via
lowlevel, ADR-081) + PacketProtection (lowlevel::AirAeadExplicitNonce, ADR-082 :
nonce = IV ⊕ numéro de paquet, déterministe — pas la façade à nonce aléatoire). Clés dérivées
par KeySchedule des secrets TLS (air-tls), labels QUIC (quic key/quic iv/
quic hp).
Machine à états — multi-thread (mono-propriétaire, sans-IO)
#![allow(unused)]
fn main() {
pub enum QuicEvent {
Connected { alpn: Option<AlpnProtocol> },
StreamOpened(StreamId), StreamData(StreamId), StreamFinished(StreamId),
DatagramReceived, ConnectionClosed(ConnectionClose), WantsTransmit,
}
impl QuicConnection {
/// SANS-IO : ingère un datagramme UDP entrant → événements + transitions.
pub fn recv_datagram(&mut self, now: AirInstant, packet: &[u8]) -> Result<Vec<QuicEvent>, QuicError>;
/// SANS-IO : récupère le prochain datagramme à émettre (respecte congestion + PMTU).
pub fn poll_transmit(&mut self, now: AirInstant) -> Option<QuicDatagram>;
/// SANS-IO : prochaine échéance (PTO, idle, ACK). Horloge injectée → test déterministe.
pub fn poll_timeout(&self) -> Option<AirInstant>;
pub fn handle_timeout(&mut self, now: AirInstant) -> Result<Vec<QuicEvent>, QuicError>;
// Streams :
pub fn open_stream(&mut self, kind: StreamKind) -> Result<StreamId, QuicError>;
pub fn send(&mut self, id: StreamId, data: &[u8], fin: bool) -> Result<usize, QuicError>; // flow-controlé
pub fn read(&mut self, id: StreamId, out: &mut [u8]) -> Result<usize, QuicError>;
pub fn close(&mut self, code: u64, reason: &[u8]);
}
}
Thread model : QuicConnection Send/!Sync (mono-propriétaire, zéro verrou) ;
QuicEndpoint route les datagrammes vers la bonne connexion par Connection ID ; les
configs sont Arc Send/Sync. Un endpoint par cœur (thread-per-core, ADR-038/039).
Pilote I/O (io/) = seul contact air-socket (UDP recvmmsg/sendmmsg) + horloge.
Décision de fond à trancher — maison intégral vs distillation quinn-proto
La note réseau §3 l’a explicitement laissée ouverte. Deux options, à arbitrer (BDFL) :
- (A) Maison intégral — cohérent avec l’ethos (zéro-C, possession totale, auditabilité, 9-composants), mais QUIC est vaste (loss recovery, congestion, multi-stream, migration) → effort important, historique de bugs subtils.
- (B) Distillation
quinn-proto—quinn-protoest déjà un cœur sans-IO Rust pur, memory-safe, éprouvé (interop large). Modèleaudit-dlmalloc: s’inspirer / distiller plutôt que vendorer en boîte noire ou réinventer. Règle des 80 % (ADR-024) à évaluer.Recommandation : cadrer par un audit
quinn-proto(comme l’audit s2n-tls pourair-tls) avant de trancher — mesurer surface, deps, part réutilisable, puis décider maison vs distillation. Cette spec décrit la cible fonctionnelle (les 9 composants, l’API) indépendamment du chemin retenu.
Stratégie de tests
- Couverture couche 2 > 90 % (viser 100 % cœur sans-IO).
- Par composant : Framer/Codec fuzzés (varints/longueurs hostiles → zéro panic) ; StateMachine property/model-based ; FlowController property (jamais dépasser la fenêtre, pas de deadlock) ; Multiplexer (IDs sans ABA, isolation) ; Timers virtual clock (PTO tire à l’instant prévu).
- Vecteurs RFC 9001 (sample packet protection) + interop vs
ngtcp2/quiche(client ET serveur), comme les KATair-crypto. - Pilote I/O : loopback UDP réel.
Récapitulatif & décisions
| Famille | Objets |
|---|---|
| A. Surface publique | ~13 |
| B. Cœur sans-IO (9 comp.) | ~14 |
| C. Enums wire | ~5 |
| Total | ~32 |
- QUIC v1 moderne seul (RFC 9000/9001/9002), TLS 1.3 obligatoire.
- Handshake TLS via
air-tls(frames CRYPTO), AEAD paquet viaair-crypto— jamais le record layer TLS. - 9 composants tous exercés (flow control + mux natifs).
- Zéro-C, zéro
unsafe, primitives déléguées ; async 1ʳᵉ classe (ADR-038), connexionSend/!Sync. - Chemin d’implémentation (maison vs
quinn-proto) à trancher après audit.
Travail à reprendre
- Audit
quinn-proto(surface/deps/80 %) → décision maison vs distillation. - Accommodation
air-tls: exposer le cœur handshake découplé du record layer (requis parair-quic) — à graver dans la specair-tls. - Congestion avancée (CUBIC/BBR) après NewReno v1.
air-http(h3) consommateur ; migration de connexion (multi-path) ; ABI C différée.
Licence du document : MPL 2.0
Statut : Spécification air-quic (couche 2) v0.1 — QUIC v1 pur Rust, patron sans-IO,
handshake via air-tls. Chemin d’implémentation à trancher (audit quinn-proto).
Implémentation à suivre.
Spec couche 2 — air-mdns (mDNS + DNS-SD pur Rust, patron sans-IO)
Spécification technique — Version 0.1 (proposition). Couche 2 « Frameworks système ».
Cadrage.
air-mdnsimplémente mDNS (RFC 6762) + DNS-SD (RFC 6763) — la découverte/publication de service en réseau local (≈ Bonjour), consommée parair-network::AirBrowser/advertise. Maison, pur Rust, zéro-C, zérounsafe. Suit le patron sans-IO 9-composants (note réseau §2.2) et réutilise le codec DNS RFC 1035 d’air-socket(déjà fuzzé) plutôt que de le réimplémenter (règle des 80 %, ADR-024).
Position et méthode
mDNS = DNS sur UDP multicast (224.0.0.251:5353 / ff02::fb) sans serveur :
chaque hôte répond pour ses propres noms. DNS-SD = une convention d’enregistrements
DNS (PTR/SRV/TXT) au-dessus. air-mdns est un protocole filaire → cœur
sans-IO + pilote I/O mince (air-socket UDP multicast, air-runtime timers).
Décision — réutilisation du codec DNS.
air-socketpossède déjà un décodeur DNS RFC 1035 fuzzé (air-socket::dns,fuzz_dns_parse). mDNS partage exactement le format de message (mêmes en-têtes, mêmes noms compressés).air-mdnsréutilise ce codec (noms/en-têtes) et n’ajoute que les types d’enregistrement DNS-SD (PTR/SRV/TXT) s’ils manquent. ⇒ Petit additif possible àair-socket: exposer publiquement le codec de noms + les record types SRV/TXT/PTR (cf. « Travail à reprendre »). Zéro duplication de parseur hostile.
Périmètre — moderne, sûr, borné
- mDNS RFC 6762 : requêtes/réponses multicast, probing + announcing (§8), résolution de conflits (§9), cache avec TTL, known-answer suppression.
- DNS-SD RFC 6763 : énumération de types (
PTR), résolution d’instance (SRV+TXT), sous-types. - Bornes anti-abus (surface hostile réseau local) : noms ≤ 255 octets,
compression suivie avec borne (jamais de boucle — comme le résolveur DNS
d’
air-socket), taille de paquet bornée, cache plafonné (anti-flood). - Omis : DNS unicast récursif (c’est
air-socket::resolver), DNSSEC (hors mDNS local), mises à jour dynamiques serveur.
Dépendances
- Tierces externes : ZÉRO. Entièrement maison (mDNS n’a pas de crypto).
- Crates Air :
air-socket(L1, UDP multicast + codec DNS réutilisé),air-runtime(L1, timers de probing/announce/cache),air-base-core(L1,AirError/AirDuration). - Zéro
unsafeexposé ; parsing défensif (noms bornés,get()).
Additifs
air-socket— INSTRUITS (ADR-080).air-mdnsrequiert deair-socket: (1) multicast UDP typé (join_multicast_*/set_multicast_*, via l’additif couche 0couche-0-v1.12setsockoptstructuré pourip_mreq) ; (2) codec DNS public + SRV/TXT/PTR (réutilise le codec de noms borné/ fuzzé, zéro duplication). Descellement additifcouche-1-v1.x.
Anatomie : les 9 composants appliqués à mDNS
| # | Composant | Réalisation air-mdns | Pur ? |
|---|---|---|---|
| 1 | Framer | // TRIVIAL : un datagramme UDP = un message DNS complet (pas de framing de flux) | ✅ (trivial) |
| 2 | Codec | DnsMessageCodec (réutilisé d’air-socket) + ResourceRecordCodec (PTR/SRV/TXT/A/AAAA) | ✅ |
| 3 | StateMachine | MdnsStateMachine — probe → announce → répondre/interroger (RFC 6762 §8) + résolution de conflit (§9) | ✅ |
| 4 | Handshaker | // NO HANDSHAKE : mDNS n'a ni auth ni accord de clé. | — (absent voulu) |
| 5 | Flow Controller | // NO FLOW CONTROL : datagrammes multicast, pas de fenêtrage. | — (absent voulu) |
| 6 | Multiplexer | // NO MULTIPLEXING : un canal multicast partagé, pas de streams. | — (absent voulu) |
| 7 | Timer Manager | MdnsTimers — délais de probing (250 ms ×3), ré-annonce, TTL du cache, intervalles de requête (backoff) ; horloge injectée | ✅ (horloge injectée) |
| 8 | Session Context | RecordCache (réponses connues + TTL) + PublishedServices (nos enregistrements) | ✅ |
| 9 | Extension hooks | // MINIMAL : sous-types DNS-SD, options futures (unicast-response bit). | ✅ (minimal) |
4 composants absents/triviaux marqués (Framer trivial ; Handshaker/Flow/Mux absents par conception) — l’absence est intentionnelle et visible (note §2.2).
Inventaire des objets — ~14 objets
A. Surface publique — ~8
| # | Objet | ≈ Apple | Rôle |
|---|---|---|---|
| 1 | MdnsBrowser | NWBrowser | découvre les instances d’un type de service |
| 2 | MdnsResponder | NWListener.service | publie/annonce nos services |
| 3 | ServiceRegistration (+ Builder) | — | ce qu’on publie (instance, type, port, TXT) |
| 4 | DiscoveredService | NWBrowser.Result | instance découverte (nom, endpoint, TXT) |
| 5 | ServiceType | (_http._tcp) | newtype type DNS-SD validé |
| 6 | ServiceInstanceName | — | newtype nom d’instance (UTF-8, ≤ 63 o par label) |
| 7 | TxtRecord | — | clés/valeurs TXT (bytes, pas UTF-8 présumé) |
| 8 | MdnsError | — | enum (ADR-019) |
B. Cœur sans-IO — ~6
DnsMessageCodec (réutilisé), ResourceRecordCodec, MdnsStateMachine, MdnsTimers,
RecordCache, PublishedServices.
Codecs (bytes ↔ enregistrements)
#![allow(unused)]
fn main() {
pub enum ResourceRecord<'a> {
Ptr { name: DnsName<'a>, target: DnsName<'a> }, // _http._tcp.local → Instance._http._tcp.local
Srv { priority: u16, weight: u16, port: u16, target: DnsName<'a> },
Txt { pairs: TxtPairs<'a> }, // clés=valeurs (bytes)
A { addr: Ipv4Addr }, Aaaa { addr: Ipv6Addr },
// OMIS : types DNS obsolètes / non pertinents en mDNS local.
}
// ResourceRecordCodec::decode borne les longueurs ; noms via le codec d'air-socket
// (compression suivie AVEC borne — jamais de boucle infinie, comme le résolveur DNS).
}
Machine à états — multi-thread (mono-propriétaire, sans-IO)
#![allow(unused)]
fn main() {
pub enum MdnsEvent {
ServiceAdded(DiscoveredService), ServiceRemoved(ServiceInstanceName),
ServiceUpdated(DiscoveredService), WantsTransmit, NameConflict(ServiceInstanceName),
}
impl MdnsBrowser {
pub fn new(service_type: ServiceType) -> Result<Self, MdnsError>;
/// SANS-IO : ingère un datagramme multicast reçu → événements de découverte.
pub fn recv(&mut self, now: AirInstant, datagram: &[u8]) -> Result<Vec<MdnsEvent>, MdnsError>;
/// SANS-IO : prochain datagramme à émettre (requête, avec known-answer suppression).
pub fn poll_transmit(&mut self, now: AirInstant) -> Option<Vec<u8>>;
pub fn poll_timeout(&self) -> Option<AirInstant>;
pub fn handle_timeout(&mut self, now: AirInstant) -> Result<Vec<MdnsEvent>, MdnsError>;
}
// MdnsResponder : symétrique côté publication (probe→announce, répond aux requêtes,
// défend ses noms, résout les conflits par renommage RFC 6762 §9).
}
Thread model : MdnsBrowser/MdnsResponder Send/!Sync (mono-propriétaires) ;
le pilote I/O (io/) joint le groupe multicast et pompe le socket. Async 1ʳᵉ classe
(air-runtime), façade bloquante de confort.
Sécurité
- Surface hostile = réseau local (n’importe qui sur le lien peut émettre). Défenses :
parsing borné (réutilise le durcissement DNS d’
air-socket), cache plafonné (anti-flood), rate-limiting des réponses (RFC 6762 §6), jamais d’exécution ni d’allocation non bornée sur un paquet reçu. - Pas de confiance implicite : une instance découverte est une donnée, pas une
autorité ; toute connexion vers un endpoint découvert passe par
air-network(TLS si la pile le déclare). mDNS ne fournit aucune authentification (par conception du protocole) — documenté clairement.
Stratégie de tests
- Couverture couche 2 > 90 %.
- Codec fuzzé (réutilise/étend
fuzz_dns_parsed’air-socket) : noms compressés hostiles, records tronqués → zéro panic, borne de compression respectée. - StateMachine property/model-based : probe/announce/conflit (deux répondeurs, même nom → renommage) ; known-answer suppression.
- Timers virtual clock : probing 3×250 ms, ré-annonce, expiration TTL déterministe.
- Interop : vs
avahi/mDNSResponder(Apple) sur boucle multicast locale. - Pilote I/O : multicast loopback réel.
Récapitulatif & décisions
| Famille | Objets |
|---|---|
| A. Surface publique | ~8 |
| B. Cœur sans-IO | ~6 |
| Total | ~14 (4 composants absents/triviaux marqués) |
- mDNS + DNS-SD moderne, borné (RFC 6762/6763) ; unicast récursif reste
air-socket::resolver. - Réutilise le codec DNS fuzzé d’
air-socket— zéro duplication de parseur. - Zéro-C, zéro
unsafe, zéro crate tierce. - Sécurité = parsing borné + cache plafonné + rate-limit ; mDNS n’authentifie
rien (par conception) → la confiance vient de la couche au-dessus (
air-network/TLS). - Async 1ʳᵉ classe (ADR-038), responder/browser
Send/!Sync.
Travail à reprendre
- Additifs
air-socket— instruits (ADR-080) : codec DNS public SRV/TXT/PTR + multicast UDP typé (couche-0-v1.12setsockoptstructuré). Prérequis d’implémentation. - Consommateur :
air-network::AirBrowser/advertise(câble mDNS). - Sous-types DNS-SD, hot-reload des services publiés ; ABI C différée.
Licence du document : MPL 2.0
Statut : Spécification air-mdns (couche 2) v0.1 — mDNS + DNS-SD pur Rust, patron
sans-IO, réutilise le codec DNS d’air-socket. Implémentation à suivre.
Spec couche 2 — air-http (HTTP/1.1 + HTTP/2 + HTTP/3, patron sans-IO)
Spécification technique — Version 0.1 (proposition). Couche 2 « Frameworks système ».
Cadrage.
air-httpimplémente HTTP/1.1 (RFC 9112), HTTP/2 (RFC 9113) et HTTP/3 (RFC 9114) — h1/h2/h3 en modules internes d’un crate unique (note réseau §4 : «air-http(h1/h2/h3 = modules internes) »), + WebSocket (RFC 6455/8441). Maison, pur Rust, zéro-C, zérounsafe; s’appuie surair-tls(h1/h2),air-quic(h3),air-network(connexion). Suit le patron sans-IO 9-composants par version.
Position et méthode
HTTP est un protocole applicatif filaire (cœur sans-IO + pilote I/O mince). Chaque
version a son cœur ; le crate expose une surface unifiée (Request/Response/
Body) au-dessus, la version étant négociée (ALPN via air-tls/air-quic).
Empilement (note §2.5) :
air-http (h3) → air-quic → air-socket (UDP)
air-http (h2) → air-tls 1.3 → air-socket (TCP)
air-http (h1) → air-tls / air-socket (TCP)
└── surface unifiée Request/Response/Body ; version choisie par ALPN
Décision — crate unique, versions internes. h1/h2/h3 partagent le modèle de message (
Request/Response/HeaderMap/Body) ; les différences (framing, compression d’en-têtes, multiplexage) sont internes. Un consommateur écrit une requête une fois, la version est un détail négocié. (vs 3 crates séparés → API fragmentée.)
Périmètre de sécurité — moderne, borné
- h1 :
Content-Length/chunked stricts (anti request-smuggling : rejet des cadrages ambigus, une seule interprétation) ; pas de HTTP/0.9 ; en-têtes bornés. - h2/h3 : compression d’en-têtes HPACK (RFC 7541) / QPACK (RFC 9204) avec tables bornées (anti HPACK-bomb / CVE-2016-6581-class) ; limites de streams, de frames (anti Rapid-Reset CVE-2023-44487 : plafond de streams annulés/RTT).
- TLS obligatoire pour h2/h3 (hérité
air-tls/air-quic) ; h1 en clair explicite. - Bornes anti-abus : taille d’en-têtes, nombre de headers, profondeur, débit de frames de contrôle — tout borné (surface hostile distante).
Dépendances
- Tierces externes : ZÉRO hors l’exception TLS déléguée (
air-tls, ADR-042). - Crates Air :
air-network(L2, connexion/ALPN),air-tls(L2, h1/h2),air-quic(L2, h3),air-runtime(L1, async),air-base-core(L1, erreurs/durées). (HTTP n’a pas de crypto propre.) - Zéro
unsafeexposé ; parsing défensif (&[u8]vs&str,get(), longueurs bornées).
Anatomie : les 9 composants par version
| # | Composant | h1 | h2 | h3 |
|---|---|---|---|---|
| 1 | Framer | H1Framer (start-line + headers + body chunked/CL) | H2FrameFramer (len/type/flags/streamid) | sur streams QUIC (framing délégué à air-quic) |
| 2 | Codec | H1Codec (champs d’en-tête) | H2FrameCodec + Hpack | H3FrameCodec + Qpack |
| 3 | StateMachine | requête/réponse + keep-alive | connexion + états de stream (RFC 9113 §5.1) | idem sur streams QUIC |
| 4 | Handshaker | // délégué : TLS via air-tls, version via ALPN | idem (+ SETTINGS échange) | // délégué : QUIC/TLS via air-quic |
| 5 | Flow Controller | // NO : TCP | H2FlowController (WINDOW_UPDATE, conn+stream) | // délégué : flow control QUIC |
| 6 | Multiplexer | // NO : 1 requête/connexion (keep-alive séquentiel) | H2StreamMux (streams sur 1 TCP) | H3StreamMux (streams QUIC) |
| 7 | Timer Manager | timeouts requête/keep-alive | + PING/keepalive, rapid-reset guard | idem |
| 8 | Session Context | connexion (keep-alive) | SETTINGS + table dynamique HPACK | SETTINGS + table dynamique QPACK |
| 9 | Extension hooks | Upgrade (WebSocket h1) | extended CONNECT (WebSocket h2, RFC 8441) | extended CONNECT (h3) |
Inventaire des objets — ~33 objets
A. Surface unifiée publique — ~14
| # | Objet | Rôle |
|---|---|---|
| 1 | HttpClient (+ HttpClientConfig/Builder) | client haut niveau (pool, ALPN, redirections) |
| 2 | HttpServer (+ HttpServerConfig/Builder) | serveur (handler par requête) |
| 3 | Request (+ RequestBuilder) | requête (méthode, URI, headers, body) |
| 4 | Response (+ ResponseBuilder) | réponse (statut, headers, body) |
| 5 | HeaderMap | multimap d’en-têtes (ordre préservé, casse-insensible) |
| 6 | HeaderName / HeaderValue | newtypes validés (HeaderValue = bytes, pas UTF-8 présumé) |
| 7 | Method | enum (GET/POST/… + extension) |
| 8 | StatusCode | newtype u16 validé (1xx–5xx) |
| 9 | HttpVersion | enum Http11/Http2/Http3 |
| 10 | Uri | URI parsé/validé (scheme/authority/path/query) |
| 11 | Body / BodyStream | corps en flux (back-pressure, ADR-028 buffers possédés) |
| 12 | HttpConnection | connexion négociée (sur air-network) — version transparente |
| 13 | WebSocket (+ WebSocketMessage) | RFC 6455/8441 (module, voir ci-dessous) |
| 14 | HttpError | enum (ADR-019) — protocole, transport, TLS |
B. Cœurs sans-IO internes — ~15
- Partagé :
HeaderCodec,BodyReader/ChunkedCodec,UriCodec. - h1 :
H1Framer,H1Codec,H1StateMachine. - h2 :
H2FrameCodec,Hpack(encoder+decoder, table dynamique bornée),H2StreamMux,H2FlowController,H2StateMachine. - h3 :
H3FrameCodec,Qpack(encoder+decoder),H3StateMachine.
C. Enums/valeurs wire — ~4
H2FrameType, H3FrameType, SettingId, WebSocketOpcode.
Codecs (bytes ↔ messages)
#![allow(unused)]
fn main() {
/// Trait de codec commun (borné, anti-panic) — décliné h1/h2/h3.
pub trait HttpCodec { /* encode(&self, &mut Vec<u8>) ; decode(&mut Reader) -> Result<…> */ }
// h1 : start-line + en-têtes CRLF + corps (chunked | content-length).
// REJET des cadrages ambigus (CL + TE, doubles CL) → HttpError (anti-smuggling).
// h2 : HPACK (RFC 7541) — table dynamique BORNÉE (SETTINGS_HEADER_TABLE_SIZE),
// décodage d'entiers/chaînes Huffman borné (anti-bomb).
// h3 : QPACK (RFC 9204) — table dynamique + flux d'encodeur/décodeur, bornés.
}
HPACK/QPACK = la surface hostile n°1 (compression d’en-têtes venant du pair) → fuzz obligatoire, tables bornées, décompression jamais non bornée.
Machine à états — multi-thread (mono-propriétaire, sans-IO)
#![allow(unused)]
fn main() {
impl HttpClient {
pub async fn send(&self, request: Request) -> Result<Response, HttpError>; // version transparente
pub async fn connect(&self, uri: &Uri) -> Result<HttpConnection, HttpError>; // ALPN choisit h1/h2/h3
}
// Cœur sans-IO par version : feed(&[u8]) -> events ; poll_transmit() -> &[u8] ;
// handle_timeout(now). Pilote I/O = air-network (connexion) + air-runtime (async).
}
Thread model : HttpConnection Send/!Sync (une connexion, une tâche ; le
multiplexage h2/h3 se fait dans la connexion, pas entre threads) ; HttpClient
partageable (Arc, pool de connexions) ; configs Arc Send/Sync. Thread-per-core
(ADR-038/039).
WebSocket (module, RFC 6455 + 8441)
#![allow(unused)]
fn main() {
impl WebSocket {
/// Sur h1 : Upgrade (RFC 6455). Sur h2/h3 : extended CONNECT (RFC 8441).
pub async fn connect(uri: &Uri, params: &HttpClientConfig) -> Result<Self, HttpError>;
pub async fn send(&mut self, msg: WebSocketMessage) -> Result<(), HttpError>; // Text/Binary/Ping/Close
pub async fn receive(&mut self) -> Result<WebSocketMessage, HttpError>;
}
// Framing WS : masquage client obligatoire, bornes de frame, close handshake propre.
}
Stratégie de tests
- Couverture couche 2 > 90 % (viser 100 % sur les codecs sans-IO).
- Par composant : Framer/Codec fuzzés (h1 smuggling, HPACK/QPACK bombs, frames h2 malformées) ; StateMachine property/model-based (états de stream) ; FlowController property (fenêtre h2) ; rapid-reset guard (plafond streams annulés/RTT).
- Conformité : suite h2spec/h3spec, vecteurs HPACK (RFC 7541 annexe) / QPACK ; interop vs curl/nginx/quiche.
- Pilote I/O : loopback réel via
air-network(h1/h2 sur TLS, h3 sur QUIC).
Récapitulatif & décisions
| Famille | Objets |
|---|---|
| A. Surface unifiée | ~14 |
| B. Cœurs sans-IO (h1/h2/h3 + partagé) | ~15 |
| C. Enums wire | ~4 |
| Total | ~33 |
- Crate unique, h1/h2/h3 modules internes ; surface unifiée
Request/Response/Body; version par ALPN. - Moderne, borné : anti-smuggling (h1), HPACK/QPACK bornés, anti rapid-reset ; TLS obligatoire h2/h3.
- Zéro-C, zéro
unsafe; composeair-tls/air-quic/air-network. - WebSocket intégré (RFC 6455 h1 + 8441 h2/h3).
- Async 1ʳᵉ classe (ADR-038), connexion
Send/!Sync, poolArc.
Travail à reprendre
- Priorité d’implémentation : h1 d’abord (sur
air-tls/air-socket), puis h2, puis h3 (attendair-quic). - Consommateur : l’équivalent
URLSession(client URL-loading haut niveau) — couche à confirmer (couche 4 élargie ?), cf. noteapi-reseau-strategie. - Cookies, cache, redirections, auth (couche cliente haut niveau) ; h2 Server Push (déprécié → omis) ; ABI C différée.
Licence du document : MPL 2.0
Statut : Spécification air-http (couche 2) v0.1 — HTTP/1.1+2+3 pur Rust, crate
unique, patron sans-IO ; compose air-tls/air-quic/air-network. Implémentation à
suivre (h1 d’abord).
Spec couche 2 — air-ssh (SSH maison, moderne-only, patron sans-IO)
Spécification technique — Version 0.1 (proposition). Couche 2 « Frameworks système ». Cadrée par ADR-043.
Cadrage.
air-sshimplémente SSH (RFC 4250-4254 + 4256, certificats) maison, memory-safe Rust, zéro-C — décision ADR-043 (pas russh : maturité insuffisante pour unsshddistant critique ; crypto = RustCrypto viaair-crypto, jamais réécrite). Client (ssh/scp/sftp) avant serveur (sshd). « Strict kex » anti-Terrapin dès le départ, moderne uniquement. Suit le patron sans-IO 9-composants.
Position et méthode
SSH est un protocole filaire (transport chiffré + auth + multiplexage de canaux) → cœur sans-IO (les 9 composants, fuzzés isolément — Handshaker/Framer = surface hostile)
- pilote I/O mince sur
air-socket(TCP) +air-runtime(async).
Rappel gouvernance (ADR-043). En incubation, Air s’appuie sur l’OpenSSH système ;
air-sshmaison est la cible. Lesshd(serveur) exige un audit externe avant toute exposition ; on livre le client d’abord. Cette spec décrit la cible ; l’ordre d’implémentation est client → serveur.
Périmètre de sécurité — moderne uniquement (comme rustls pour TLS)
RETENU :
| Domaine | Retenu | Réf. |
|---|---|---|
| Strict kex | kex-strict-c/s-v00@openssh.com obligatoire (anti-Terrapin CVE-2023-48795) | RFC 4253 durci |
| Échange de clés | curve25519-sha256, mlkem768x25519-sha256 (hybride PQ, défaut OpenSSH ≥ 9.9 — réutilise ml-kem-768 ADR-078/081), ecdh-sha2-nistp256/384 ; (sntrup761x25519 différé, ADR-081) | RFC 5656, ADR-081 |
| Clés d’hôte / signatures | ssh-ed25519, ecdsa-sha2-nistp256/384, rsa-sha2-256/512 (vérif.) | RFC 8709, 8332 |
| Chiffrement | chacha20-poly1305@openssh.com, aes256-gcm@openssh.com, aes128-gcm@openssh.com (AEAD only) | RFC 5647 |
| MAC | AEAD implicite ; sinon hmac-sha2-256/512-etm@openssh.com (encrypt-then-MAC) | — |
| Auth | publickey (Ed25519/ECDSA/rsa-sha2), keyboard-interactive, certificats OpenSSH ; password (déconseillé) | RFC 4252, 4256 |
| Ext | ext-info-c/s (RFC 8308), SFTP (subsystem) | RFC 8308 |
OMIS PAR CONCEPTION (legacy/faible) : SSHv1 ; kex diffie-hellman-group1/14-sha1,
tout SHA-1 ; ssh-rsa (SHA-1), ssh-dss ; CBC, RC4, 3DES ; hmac-md5,
hmac-sha1 ; compression zlib@openssh avant auth ; agent/X11/port-forwarding =
surface minimale, explicite, désactivée par défaut.
Dépendances
- Tierces externes : ZÉRO (contrairement à TLS, pas d’exception C — ADR-043 :
air-sshmaison +air-crypto= zéro surface C). - Crates Air :
air-crypto(L1 — X25519/Ed25519/AEAD/HKDF v1 ; additifs réutilisés ADR-078 : ECDSA, ML-KEM-768 (kexmlkem768x25519) ; additifs ADR-081 : RSA PKCS#1 v1.5 vérif. (rsa-sha2), modulelowlevel(ChaCha20 keystream + Poly1305 pourchacha20-poly1305@openssh)),air-socket(L1, TCP),air-runtime(L1, async),air-base-core(L1).air-filesystem(L1) pourknown_hosts/clés. - Zéro
unsafeexposé ; parsing défensif intégral (la surface hostile SSH est pré-authentification — classe regreSSHion éliminée par memory-safety).
Anatomie : les 9 composants appliqués à SSH
| # | Composant | Réalisation air-ssh | Pur ? |
|---|---|---|---|
| 1 | Framer | BppFramer — Binary Packet Protocol (RFC 4253 §6) : packet_length/padding/payload/MAC ; strict kex (numéros de séquence non réinitialisés, anti-Terrapin) | ✅ |
| 2 | Codec | MessageCodec — messages SSH_MSG_* (KEXINIT, KEX_ECDH_, NEWKEYS, USERAUTH_, CHANNEL_*), types name-list/mpint/string bornés | ✅ |
| 3 | StateMachine | TransportStateMachine — Version → KexInit → Kex → NewKeys → UserAuth → Connection (RFC 4253/4252) | ✅ |
| 4 | Handshaker | KeyExchange — kex (X25519/PQ), vérif. clé d’hôte (known_hosts), dérivation de clés, rekey ; consomme air-crypto | ✅ + air-crypto |
| 5 | Flow Controller | ChannelFlowController — fenêtres de canal (CHANNEL_WINDOW_ADJUST, RFC 4254 §5.2) | ✅ |
| 6 | Multiplexer | ChannelMultiplexer — canaux (session/exec/subsystem/forward), IDs sans collision, isolation | ✅ |
| 7 | Timer Manager | SshTimers — rekey (temps/volume), keepalive ; horloge injectée | ✅ (horloge injectée) |
| 8 | Session Context | SessionKeys (clés de trafic bidirectionnelles, zeroize) + algos négociés + état des canaux | ✅ |
| 9 | Extension hooks | ext-info (RFC 8308), négociation d’algos, subsystem SFTP | ✅ |
Inventaire des objets — ~30 objets
A. Surface publique (client d’abord) — ~12
| # | Objet | Rôle |
|---|---|---|
| 1 | SshClient (+ SshClientConfig/Builder) | client (ssh) — connexion + auth |
| 2 | SshSession | session établie (ouvre des canaux) |
| 3 | SshChannel | canal (exec/shell/subsystem) — I/O + fenêtrage |
| 4 | SshCommand | exécution distante (exec) → stdout/stderr/exit |
| 5 | HostKeyVerifier (trait) + KnownHosts | vérif. de la clé d’hôte (TOFU/known_hosts) |
| 6 | SshCredential | clé publique (Ed25519/ECDSA/cert), agent, mot de passe |
| 7 | SshIdentity | clé privée locale (zeroize) ou délégation agent |
| 8 | SftpClient | sous-système SFTP (RFC draft) — fichiers distants |
| 9 | ScpTransfer | copie de fichiers (sur exec/SFTP) |
| 10 | SshError | enum (ADR-019) |
| 11 | Algorithm | enums d’algos négociés (kex/cipher/mac/hostkey) |
| 12 | SshServer (+ config) | serveur sshd — DIFFÉRÉ (audit externe requis, ADR-043) |
B. Cœur sans-IO — ~13 (les 9 composants + sous-objets)
BppFramer, MessageCodec, TransportStateMachine, KeyExchange, HostKeyAuth,
UserAuthStateMachine, ChannelMultiplexer, ChannelFlowController, SshTimers,
SessionKeys/KeySchedule, ExtInfo, SftpCodec, NameListCodec.
C. Enums wire — ~5
MessageType, KexAlgorithm, CipherAlgorithm, MacAlgorithm, ChannelType.
Codecs (bytes ↔ messages)
#![allow(unused)]
fn main() {
/// Lecteur SSH borné (RFC 4251 §5 : string=len32+bytes, mpint, name-list) — anti-panic.
pub struct SshReader<'a> { /* … */ }
impl<'a> SshReader<'a> {
pub fn string(&mut self) -> Result<&'a [u8], DecodeError>; // len u32 borné
pub fn name_list(&mut self) -> Result<NameList<'a>, DecodeError>;
pub fn mpint(&mut self) -> Result<&'a [u8], DecodeError>; // grand entier
}
pub enum SshMessage<'a> {
KexInit(KexInitBody<'a>), KexEcdhInit(&'a [u8]), KexEcdhReply(KexReplyBody<'a>),
NewKeys, ServiceRequest(&'a [u8]),
UserauthRequest(UserauthBody<'a>), UserauthSuccess, UserauthFailure(NameList<'a>),
ChannelOpen(ChannelOpenBody<'a>), ChannelData { channel: u32, data: &'a [u8] },
ChannelWindowAdjust { channel: u32, bytes: u32 }, ChannelEof(u32), ChannelClose(u32),
// OMIS : messages des kex/ciphers legacy (non négociables).
}
}
Machine à états — multi-thread (mono-propriétaire, sans-IO)
#![allow(unused)]
fn main() {
impl SshClient {
pub async fn connect(endpoint: AirEndpoint, config: Arc<SshClientConfig>,
verifier: Arc<dyn HostKeyVerifier>) -> Result<SshSession, SshError>;
}
impl SshSession {
pub async fn authenticate(&mut self, identity: SshIdentity) -> Result<(), SshError>;
pub async fn open_exec(&mut self, command: &str) -> Result<SshChannel, SshError>;
pub async fn open_shell(&mut self) -> Result<SshChannel, SshError>;
pub async fn sftp(&mut self) -> Result<SftpClient, SshError>;
}
// Cœur sans-IO : feed(&[u8]) -> events ; poll_transmit() -> &[u8] ; handle_timeout(now).
}
Thread model : SshSession Send/!Sync (une connexion, une tâche ; les canaux se
multiplexent dans la session) ; config Arc Send/Sync. Thread-per-core
(ADR-038/039). Secrets zeroize.
Sécurité (le cœur de l’exigence ADR-043)
- Memory-safe ⇒ classe regreSSHion (race handler async-signal-unsafe) éliminée par construction (pas de code C, handlers signaux = doctrine ADR-064 côté runtime).
- Strict kex obligatoire (anti-Terrapin) — pas de négociation « best-effort ».
- Moderne uniquement — aucun kex/cipher/MAC faible spécifié (omission = sécurité).
- Vérif. de clé d’hôte non contournable par défaut (TOFU explicite /
known_hosts) ; jamais de « accept-any » silencieux. - Forwarding minimal, explicite, off par défaut ; auth avant compression ; surface pré-auth minimale et fuzzée.
sshd(serveur) : DIFFÉRÉ, livré après audit externe (ADR-043).
Stratégie de tests
- Couverture couche 2 > 90 % (viser 100 % cœur sans-IO).
- Par composant : BppFramer/MessageCodec fuzzés (paquets pré-auth hostiles → zéro panic) ; KeyExchange vecteurs + négatifs (downgrade, Terrapin rejeté par strict kex, mauvaise clé d’hôte) ; ChannelMux/FlowController property.
- Interop OpenSSH (client
air-ssh↔sshdOpenSSH, et vice-versa quand serveur) + conformité RFC 4250-4254/4256, comme les KATair-crypto. - Pilote I/O : loopback TCP réel.
Récapitulatif & décisions
| Famille | Objets |
|---|---|
| A. Surface publique (client d’abord) | ~12 |
| B. Cœur sans-IO (9 comp.) | ~13 |
| C. Enums wire | ~5 |
| Total | ~30 |
- Maison, memory-safe, zéro-C (ADR-043, pas russh) ; crypto via
air-crypto. - Strict kex anti-Terrapin + moderne uniquement (legacy omis par conception).
- Client avant serveur ;
sshddifféré (audit externe requis). - 9 composants exercés (flow control + mux de canaux natifs) ; SFTP en subsystem.
- Async 1ʳᵉ classe (ADR-038), session
Send/!Sync, secretszeroize.
Travail à reprendre
- Additifs
air-crypto— instruits (ADR-081) : RSA PKCS#1 v1.5 vérif. (rsa-sha2), modulelowlevel(ChaCha20/Poly1305 pourchacha20-poly1305@openssh), kex PQ via ML-KEM-768 réutilisé (ADR-078).sntrup761différé (pas de crate pure-Rust vettée). - Ordre : client (
ssh→exec/shell→sftp/scp) puis serveursshd(après audit externe). - Certificats OpenSSH (RFC-less, format OpenSSH) ; agent (protocole d’agent) ; forwarding (minimal). ABI C différée.
Licence du document : MPL 2.0
Statut : Spécification air-ssh (couche 2) v0.1 — SSH maison memory-safe, moderne-only,
strict-kex, patron sans-IO ; crypto via air-crypto (zéro-C, ADR-043). Client avant
serveur ; sshd différé (audit externe). Implémentation à suivre.
Spec couche 2 — air-wireguard (VPN WireGuard, patron sans-IO)
Spécification technique — Version 0.1 (proposition). Couche 2 « Frameworks système » (crate) + daemon couche 5.
Cadrage.
air-wireguardimplémente WireGuard (Noise IK, whitepaper Donenfeld 2017). Maison, pur Rust, zéro-C, zérounsafe; crypto viaair-crypto. Crate-lib (cœur sans-IO) + daemon.airservice(note réseau §1 : WireGuard = L2/L5, UDP, Noise). WireGuard est moderne par conception (aucun legacy à omettre).
Position et méthode
Protocole filaire → cœur sans-IO (les 9 composants) + pilote I/O (air-socket UDP +
interface TUN). Le daemon crée l’interface tunnel et route le trafic. Consomme
air-runtime, air-netlink (config interface/routes), air-base-core.
Périmètre — moderne only (par construction)
- Handshake Noise IKpsk2 :
Curve25519(ECDH),BLAKE2s(hash/MAC/KDF),ChaCha20Poly1305(AEAD transport),HKDF-via-HMAC-BLAKE2s. Un seul jeu d’algos (pas de négociation → pas de downgrade — force de WireGuard). - Résistance DoS : mécanisme cookie (MAC1/MAC2) sous charge ; replay window (compteur + fenêtre glissante).
- Rekey temporel/volumétrique ; keepalive ; 1.5-RTT handshake.
Anatomie : les 9 composants
| # | Composant | air-wireguard | Pur ? |
|---|---|---|---|
| 1 | Framer | WgMessageFramer — 4 types (Handshake Init/Response, Cookie, Transport Data) sur UDP | ✅ |
| 2 | Codec | WgMessageCodec — champs fixes bornés | ✅ |
| 3 | StateMachine | PeerStateMachine — Handshake→Transport, rekey (whitepaper §6.2) | ✅ |
| 4 | Handshaker | NoiseIk — Noise IKpsk2 (X25519+BLAKE2s), MAC1/MAC2 ; consomme air-crypto | ✅ + air-crypto |
| 5 | Flow Controller | // NO : pas de fenêtrage applicatif (VPN = paquets IP encapsulés) | — |
| 6 | Multiplexer | PeerTable — routage par clé publique de pair (cryptokey routing) | ✅ |
| 7 | Timer Manager | WgTimers — rekey, keepalive, handshake-retry (whitepaper timers) ; horloge injectée | ✅ |
| 8 | Session Context | TransportKeys (send/recv, zeroize) + ReplayWindow + compteurs | ✅ |
| 9 | Extension hooks | cookie/DoS ; // pas d'extensions négociées (par conception) | ✅ |
Inventaire — ~15 objets
Surface (~7) : WireGuardTunnel, Peer/PeerConfig (clé publique, endpoint,
allowed-ips, psk, keepalive), WireGuardConfig (clé privée, port), TunnelEvent,
WgError, AllowedIps.
Cœur sans-IO (~8) : WgMessageFramer, WgMessageCodec, PeerStateMachine,
NoiseIk, PeerTable (cryptokey routing), WgTimers, TransportKeys, ReplayWindow.
#![allow(unused)]
fn main() {
pub enum TunnelEvent { HandshakeComplete(PeerId), DecryptedPacket(PeerId), WantsTransmit, PeerExpired }
impl WireGuardTunnel {
pub fn new(config: WireGuardConfig) -> AirResult<Self>;
pub fn recv_udp(&mut self, now: AirInstant, from: SocketAddr, dg: &[u8]) -> AirResult<Vec<TunnelEvent>>;
pub fn encapsulate(&mut self, now: AirInstant, ip_packet: &[u8]) -> AirResult<Option<(SocketAddr, Vec<u8>)>>;
pub fn poll_transmit(&mut self, now: AirInstant) -> Option<(SocketAddr, Vec<u8>)>;
pub fn handle_timeout(&mut self, now: AirInstant) -> AirResult<Vec<TunnelEvent>>;
}
}
Dépendances
air-crypto (L1 — X25519 ✓, ChaCha20Poly1305 ✓ ; additif BLAKE2s ci-dessous),
air-socket (L1, UDP), air-netlink (L1, interface/routes), air-runtime/
air-base-core (L1). Interface TUN : additif couche 0 tun_set_iff (TUNSETIFF)
instruit (ADR-083).
Zéro-C, zéro tierce.
Additifs
air-crypto— INSTRUITS (ADR-082). (1) BLAKE2s (blake2, pur Rust zéro-C ≠ blake3-qui-tire-cc) — hash + MAC keyed (MAC1/MAC2) + HMAC-BLAKE2s (KDF Noise). (2)lowlevel::AirAeadExplicitNonce— le transport WireGuard chiffre avec ChaCha20-Poly1305 à nonce = compteur (déterministe), donc via l’AEAD à nonce explicite, pas la façade à nonce aléatoire. X25519/ChaCha20-Poly1305 déjà présents.
Sécurité & tests
- Replay window (anti-rejeu), cookie MAC2 (anti-DoS sous charge), constant-time
(hérité
air-crypto/subtle), secretszeroize. Pas de négociation ⇒ pas de downgrade. - Tests : Framer/Codec fuzzés ; NoiseIk vecteurs whitepaper + négatifs (MAC
invalide, replay rejeté) ; interop kernel WireGuard/
wireguard-goen loopback.
Travail à reprendre
- Additifs
air-crypto— instruits (ADR-082) : BLAKE2s (+ HMAC-BLAKE2s) +lowlevel::AirAeadExplicitNonce(transport à nonce compteur). - Interface TUN — additif couche 0
tun_set_iffinstruit (ADR-083). - Daemon
.airservice(L5) ; roaming d’endpoint ; ABI C différée.
Licence : MPL 2.0. Statut : spec air-wireguard (L2, daemon L5) v0.1 — WireGuard maison,
Noise IK, sans-IO ; crypto via air-crypto (additif BLAKE2s requis).
Spec couche 2 — air-mail (SMTP + POP3 + IMAP4, patron sans-IO)
Spécification technique — Version 0.1 (proposition). Couche 2 « Frameworks système ».
Cadrage.
air-mail= client de messagerie : SMTP (soumission, RFC 5321/ 6409), POP3 (RFC 1939), IMAP4rev2 (RFC 9051) — trois modules internes d’un crate unique (commeair-httpfédère h1/h2/h3 : codec-ligne, SASL et TLS partagés). Maison, pur Rust, zéro-C, zérounsafe. Serveur mail hors périmètre (client only). Composeair-tls+air-network.
Position et méthode
Trois protocoles filaires texte, ligne par ligne (CRLF) qui partagent : framing de
ligne (+ littéraux IMAP {n}), STARTTLS / TLS implicite, SASL AUTH. Un crate,
trois modules, un codec-ligne + SASL + TLS communs.
Décision — crate unique, modules internes. SMTP (envoi) et POP3/IMAP (réception) diffèrent par le rôle mais partagent framing/TLS/SASL → factorisés une fois (anti-duplication, règle 80 %). Surface publique par protocole ; cœur partagé.
Périmètre de sécurité — moderne, TLS d’abord
| Protocole | Retenu | Ports |
|---|---|---|
| SMTP | soumission RFC 6409, SMTPUTF8, DSN, PIPELINING ; TLS implicite (465) préféré, STARTTLS (587) sinon | 465/587 |
| POP3 | RFC 1939 + STARTTLS ; TLS implicite (995) préféré | 995/110 |
| IMAP4rev2 | RFC 9051 + STARTTLS ; TLS implicite (993) préféré ; IDLE, extensions | 993/143 |
| SASL | SCRAM-SHA-256(-PLUS), PLAIN/LOGIN uniquement sous TLS, OAUTHBEARER/XOAUTH2 | — |
- TLS obligatoire pour l’AUTH : jamais d’AUTH en clair ; anti STARTTLS-stripping
(préférer le port TLS implicite ; rejeter le downgrade). Certif. vérifié (
air-tls). - Omis : AUTH en clair sans TLS ; POP3
APOP(MD5) ; IMAP legacyLOGINsans TLS ; SMTP relay ouvert (client only).
Anatomie : les 9 composants (par module, cœur partagé)
| # | Composant | SMTP | POP3 | IMAP |
|---|---|---|---|---|
| 1 | Framer | LineFramer (CRLF) — partagé ; IMAP ajoute littéraux {n} | ||
| 2 | Codec | SmtpCodec | Pop3Codec | ImapCodec (commandes taguées, réponses) |
| 3 | StateMachine | greeting→EHLO→STARTTLS→AUTH→MAIL/RCPT/DATA | greeting→STLS→AUTH→TRANS→UPDATE | greeting→STARTTLS→AUTH→SELECT→… |
| 4 | Handshaker | STARTTLS/TLS via air-tls + SASL (partagé) | ||
| 5 | Flow Controller | // NO | // NO | // NO |
| 6 | Multiplexer | // NO | // NO | tags de commande (léger : réponses corrélées) |
| 7 | Timer Manager | timeouts | timeouts | IDLE (push), timeouts |
| 8 | Session Context | capabilities (EHLO) | état TRANS | capabilities + état de mailbox (SELECT) |
| 9 | Extension hooks | SMTPUTF8/DSN/SIZE | — | extensions IMAP (CONDSTORE, MOVE…) |
Inventaire — ~20 objets
Surface (~12) : SmtpClient, Pop3Client, ImapClient, MailAddress (validé),
OutgoingMessage/Envelope, Mailbox/MessageSummary (IMAP), SaslCredential
(SCRAM/PLAIN/OAuth), Capabilities, FetchItem (IMAP), MailError, MailAuth,
ConnectionSecurity (implicit-tls/starttls/plain-refused).
Cœur partagé (~5) : LineFramer, SaslCodec (SCRAM/PLAIN/OAUTHBEARER), HeaderCodec
(RFC 5322 en-têtes), ImecCodec (encodages MIME/UTF-8), TlsUpgrade (STARTTLS).
Cœurs par module (~3) : SmtpStateMachine, Pop3StateMachine, ImapStateMachine.
#![allow(unused)]
fn main() {
impl ImapClient {
pub async fn connect(endpoint: AirEndpoint, security: ConnectionSecurity,
tls: Arc<TlsClientConfig>) -> Result<Self, MailError>;
pub async fn authenticate(&mut self, cred: SaslCredential) -> Result<(), MailError>;
pub async fn select(&mut self, mailbox: &str) -> Result<Mailbox, MailError>;
pub async fn fetch(&mut self, set: SequenceSet, items: &[FetchItem]) -> Result<Vec<MessageSummary>, MailError>;
pub async fn idle(&mut self) -> Result<IdleHandle, MailError>; // push (RFC 2177)
}
}
Dépendances
air-network (L2, connexion), air-tls (L2, STARTTLS/implicite), air-crypto (L1 —
HMAC/SHA-256 pour SCRAM ; PBKDF2 = additif ci-dessous), air-runtime/air-base-core
(L1). Zéro-C, zéro tierce.
Additif
air-crypto— INSTRUIT (ADR-082).SCRAM-SHA-256dérive via PBKDF2-HMAC-SHA256 → additifpbkdf2(RustCrypto), pur Rust zéro-C, KAT RFC 6070. Requis si SCRAM ; sinonOAUTHBEARER/XOAUTH2(token, sans PBKDF2) couvre les fournisseurs modernes.
Sécurité & tests
- TLS avant AUTH non négociable ; anti STARTTLS-stripping ; SASL moderne ; adresses/ en-têtes RFC 5322 parsés bornés (anti-injection d’en-têtes / CRLF injection).
- Tests :
LineFramer/codecs fuzzés (littéraux IMAP, réponses hostiles) ; StateMachine property (phases + refus AUTH-avant-TLS) ; interop Postfix/Dovecot en loopback TLS.
Travail à reprendre
- Additif
air-cryptoPBKDF2 — instruit (ADR-082) (si SCRAM) ; MIME complet (parsing/multipart) ; IDLE/push ; envoi (composition MIME) ; ABI C différée.
Licence : MPL 2.0. Statut : spec air-mail (L2) v0.1 — SMTP/POP3/IMAP4 client, crate
unique modules internes, sans-IO ; TLS d’abord, SASL moderne.
Spécification air-tui — noyau 1.0
Document de spécification — couche 4
Rôle du document
Ce document précise le noyau opérationnel du framework air-tui après les
décisions d’architecture actées dans :
- ADR-008 — trois backends ;
- ADR-009 — API déclarative sans coordonnées physiques ;
- ADR-018 — modèle cellulaire pour le mode console ;
- ADR-026 — contrat 1.0 d’
air-tui.
Ce document ne fige pas encore une API Rust complète ni les signatures exactes des types publics. Il fixe en revanche trois contrats sans lesquels le framework ne peut pas être implémenté proprement :
- le modèle
Capabilities; - le modèle de focus ;
- le noyau des commandes sémantiques.
1. Périmètre
air-tui est le framework console de la couche 4. Il s’appuie sur :
- un tronc déclaratif commun avec
air-ui(air-view) ; - un renderer cellulaire (
air-ui-render-tui) ; - trois backends de transport :
- backend console local via
air-console; - backend terminal Air via protocole privé ;
- backend compatible terminal tiers/SSH via Kitty keyboard protocol ou ANSI.
- backend console local via
Le présent document couvre uniquement la sémantique commune à ces trois backends. Tout détail spécifique à un backend ne peut exister que comme capacité optionnelle ou extension interne, jamais comme hypothèse implicite du framework.
2. Modèle Capabilities
2.1 Objectif
Le type Capabilities décrit ce que l’environnement d’exécution garantit à une
application air-tui.
Il ne décrit pas :
- les préférences utilisateur ;
- les entitlements applicatifs ;
- l’état dynamique de l’application ;
- les permissions de sécurité système.
Il décrit seulement les capacités d’interaction et de rendu du backend TUI.
2.2 Principes
Le modèle suit cinq principes :
- Une capacité décrit une garantie positive ou une absence explicite, jamais une supposition implicite.
- Une capacité doit être stable sémantiquement à travers les backends.
- Une capacité doit être interrogeable au runtime.
- Une capacité peut être consommée au build pour du capability-gating statique ultérieur, mais le runtime reste la source de vérité.
- Une capacité ne doit jamais prétendre émuler honnêtement ce qu’un backend ne sait pas fournir.
2.3 Forme conceptuelle
Le modèle conceptuel attendu est :
- un profil global de backend ;
- un ensemble de drapeaux ou enums fins par famille ;
- une distinction entre
guaranteed,optional,unsupportedquand cela a du sens.
Exemple purement illustratif :
#![allow(unused)]
fn main() {
struct Capabilities {
backend_profile: BackendProfile,
keyboard: KeyboardCapabilities,
pointer: PointerCapabilities,
text: TextCapabilities,
clipboard: ClipboardCapabilities,
focus: FocusCapabilities,
viewport: ViewportCapabilities,
}
}
Les noms exacts restent ouverts ; la structure logique, non.
2.4 Profils globaux
Trois profils globaux sont normatifs.
FullConsole
Profil local premium sous air-console.
Garanties minimales :
KeyDownetKeyUpfiables ;- modifiers complets et layout-aware ;
- souris native ;
- resize fiable ;
- focus structuré ;
- couleurs riches ;
- Unicode sérieux ;
- paste fiable ;
- scroll événementiel réel.
AirTerminal
Profil transporté via terminal Air.
Garanties minimales :
- mêmes invariants applicatifs que
FullConsole; - latence et qualité proches ;
- transport indirect sans perte sémantique substantielle.
Différence clé :
- le backend n’est pas le compositeur local lui-même.
CompatibleTerminal
Profil dégradé sur terminal tiers ou SSH.
Garanties minimales :
- texte rendu en grille ;
KeyDownpour un sous-ensemble significatif du clavier ;- resize généralement disponible ;
- paste et souris possibles mais non garantis ;
KeyUppotentiellement absent ;- modifiers parfois incomplets ;
- rendu Unicode et largeur de cellules dépendants du terminal.
2.5 Familles de capacités
Le framework doit exposer au minimum les familles suivantes.
Capacités clavier
Doivent permettre de répondre explicitement aux questions suivantes :
KeyUpest-il garanti ?- les modifiers gauche/droite sont-ils distingués ?
- les touches de navigation sont-elles fiables ?
- la répétition clavier est-elle distinguée d’une nouvelle pression ?
- les combinaisons avec
Alt,Ctrl,Shift,Supersont-elles fiables ? - la composition IME est-elle disponible ?
Capacités pointeur
Doivent permettre de répondre explicitement aux questions suivantes :
- un pointeur est-il disponible ?
- les clics sont-ils disponibles ?
- le mouvement est-il disponible ?
- le scroll est-il disponible ?
- la capture/glisse continue est-elle disponible ?
Capacités texte
Doivent permettre de répondre explicitement aux questions suivantes :
- Unicode est-il pris en charge sérieusement ?
- la largeur de cellule est-elle fiable pour les caractères larges ?
- le shaping complexe est-il pris en charge ?
- le bidi est-il pris en charge ?
- la coloration riche est-elle disponible ?
Le framework ne doit pas promettre le même niveau de sophistication textuelle
entre FullConsole et CompatibleTerminal.
Capacités viewport
Doivent permettre de répondre explicitement aux questions suivantes :
- la taille de grille est-elle fiable ?
- les événements de resize sont-ils fiables ?
- les régions invalidées partielles sont-elles honorées ?
- le mode écran alternatif est-il disponible ?
Capacités clipboard / paste
Doivent distinguer au minimum :
unsupportedpaste_onlyread_write
Capacités focus
Doivent permettre de répondre explicitement aux questions suivantes :
- le framework peut-il garantir un focus unique stable ?
- les changements de focus peuvent-ils être annoncés fiablement ?
- un focus pointeur et un focus clavier coexistent-ils ou non ?
2.6 Règles de dégradation
Une application air-tui doit pouvoir :
- interroger ses capacités au démarrage ;
- réagir à leur variation si le backend le permet ;
- publier des dégradations contrôlées par composant ;
- refuser proprement un mode si une capacité indispensable est absente.
Une vue peut exiger une capacité forte, mais elle doit alors :
- le déclarer explicitement ;
- fournir un fallback ;
- ou échouer proprement avec diagnostic.
Le framework ne doit jamais transformer silencieusement une garantie absente en succédané trompeur.
2.7 API conceptuelle de Capabilities
L’API publique exacte reste à écrire, mais Capabilities doit offrir au
minimum les opérations conceptuelles suivantes.
Identification du profil
L’application doit pouvoir connaître le profil courant :
backend_profile() -> FullConsole | AirTerminal | CompatibleTerminal
Cette information sert :
- au diagnostic ;
- à la télémétrie locale éventuelle de debug ;
- aux choix de fallback de haut niveau ;
- aux tests de conformité inter-backends.
Elle ne doit pas être utilisée comme substitut aux capacités fines quand une question précise peut être posée directement.
Interrogation des familles de capacités
L’application doit pouvoir accéder à des sous-objets ou vues immuables sur :
keyboard()pointer()text()clipboard()focus()viewport()
Chaque sous-objet doit offrir des prédicats ou enums stables. Exemples :
keyboard.key_up()keyboard.left_right_modifiers()pointer.scroll()text.bidi()viewport.alternate_screen()
Le document ne fige pas la forme exacte (bool, enum, bitflags), mais fige la
nécessité d’une interrogation explicite, lisible et stable.
Vérification de prérequis
Le framework doit offrir une manière standard de vérifier qu’un ensemble de capacités minimales est satisfait.
Exemples conceptuels :
supports(requirements)missing(requirements)assert_supported(requirements)
Cette couche est importante pour :
- éviter la répétition de conditions ad hoc dans les apps ;
- produire des diagnostics homogènes ;
- documenter les prérequis d’une vue ou d’un widget.
Observation des changements
Quand le backend supporte des changements dynamiques, l’application doit pouvoir être notifiée.
Cas typiques :
- resize ;
- bascule clipboard disponible / indisponible ;
- variation du mode pointeur ;
- perte d’une capacité sur transport dégradé.
Le contrat minimal est :
- soit les capacités sont statiques pour la session ;
- soit les changements sont observables explicitement.
Un changement silencieux non observable est interdit pour une capacité exposée.
Exigences déclaratives des vues
Une vue ou un widget doit pouvoir déclarer :
- des capacités requises ;
- des capacités améliorant le rendu ou l’interaction ;
- un fallback quand les capacités requises ne sont pas remplies.
Le framework devra donc fournir un mécanisme conceptuel du type :
requires(...)prefers(...)fallback(...)
La syntaxe exacte reste ouverte, le contrat non.
Diagnostic standard
Le framework doit pouvoir produire un diagnostic lisible à partir de
Capabilities.
Ce diagnostic doit au minimum répondre à :
- quel backend suis-je en train d’utiliser ?
- quelles capacités majeures sont garanties ?
- quelles capacités manquent par rapport au mode premium ?
- pourquoi telle vue s’est-elle dégradée ?
Le diagnostic standard est un outil de debug, de support utilisateur, et de test.
3. Modèle de focus
3.1 Objectif
Le focus est un concept de premier rang dans air-tui. Il ne peut pas être un
effet de bord du rendu ni un simple index de widget actif.
Le modèle de focus sert cinq objectifs :
- navigation clavier déterministe ;
- accessibilité ;
- dispatch des commandes ;
- cohérence entre backends ;
- testabilité.
3.2 Invariants
Les invariants suivants sont normatifs.
- Une scène
air-tuipossède au plus un focus clavier primaire. - Le focus clavier primaire appartient soit à un élément focusable, soit à aucun élément.
- Un élément non focusable ne peut jamais devenir focus primaire.
- La perte du focus sur un élément doit générer une transition explicite.
- Le focus ne dépend jamais de coordonnées physiques publiques.
- L’ordre de tabulation doit être déterministe pour un arbre donné.
- Un conteneur peut déléguer le focus à un descendant, jamais le dupliquer.
3.3 Types conceptuels de focus
Le framework doit distinguer conceptuellement :
keyboard focus— destination des événements clavier et commandes ;pointer hover— élément survolé quand un pointeur existe ;active scope— sous-arbre actuellement actif pour les commandes ;modal focus— capture temporaire par dialogue, palette ou menu.
Ces concepts peuvent coexister, mais ne doivent pas être confondus.
3.4 Éléments focusables
Un élément focusable doit déclarer au minimum :
- s’il accepte le focus ;
- s’il accepte l’activation ;
- s’il participe à l’ordre de tabulation ;
- s’il expose un rôle d’accessibilité cohérent ;
- quelles commandes il consomme prioritairement.
Exemples d’éléments focusables :
- bouton ;
- champ de texte ;
- éditeur ;
- liste sélectionnable ;
- table navigable ;
- arbre navigable ;
- palette de commandes.
Exemples d’éléments non focusables par défaut :
- texte statique ;
- séparateur ;
- conteneur purement décoratif.
3.5 Navigation de focus
Le framework doit fournir un noyau de navigation indépendant du backend :
- focus suivant ;
- focus précédent ;
- focus premier ;
- focus dernier ;
- entrée dans un scope ;
- sortie d’un scope ;
- focus vers panneau voisin ;
- restauration du focus précédent lors de fermeture d’un modal.
Ces transitions deviennent ensuite des commandes sémantiques, jamais des conventions cachées.
3.6 Scopes de focus
Un focus scope est un sous-arbre déclaratif qui :
- définit une frontière de navigation ;
- peut mémoriser son dernier descendant actif ;
- peut piéger temporairement le focus ;
- peut se déclarer modal ou non modal.
Cas typiques :
- dialogue ;
- palette de commandes ;
- panneau latéral ;
- split view ;
- formulaire.
3.7 Politique clavier / souris
Le framework est keyboard-first.
Cela implique :
- un clic pointeur peut déplacer le focus si la capacité pointeur existe ;
- aucun élément critique ne doit être accessible uniquement à la souris ;
- toute action de souris importante doit avoir une équivalence clavier ;
- l’absence de pointeur ne doit pas casser l’interaction fondamentale.
3.8 API conceptuelle de FocusScope
FocusScope est la brique de structuration du focus. L’API publique exacte
reste ouverte, mais les responsabilités minimales sont fixées ici.
Création et rôle
Un scope doit pouvoir être déclaré comme :
- scope standard ;
- scope mémorisant son dernier focus descendant ;
- scope modal ;
- scope piégeant la navigation ;
- scope racine logique d’un panneau ou d’un dialogue.
Le scope n’est pas un simple conteneur visuel. C’est une frontière d’interaction.
Insertion dans l’arbre
Un scope doit pouvoir :
- contenir des descendants focusables ou d’autres scopes ;
- participer à la hiérarchie de dispatch ;
- annoncer s’il est actif, modal, ou dormant ;
- définir s’il veut une navigation séquentielle, directionnelle, ou les deux.
Opérations minimales
Le modèle conceptuel doit supporter au minimum :
contains(focus_target)is_active()is_modal()activate()deactivate()focus_first()focus_last()focus_next()focus_previous()restore_last_focus()clear_focus()
Les noms exacts restent ouverts ; les opérations, non.
Mémoire du focus
Un scope peut mémoriser son dernier descendant focalisé pour permettre :
- restauration à la réouverture d’un panneau ;
- retour cohérent après fermeture d’un modal ;
- navigation plus naturelle dans les vues fractionnées.
Cette mémoire doit être explicite, jamais implicite ou accidentelle.
Piège modal
Un scope modal doit pouvoir :
- capturer le focus primaire ;
- empêcher la fuite de navigation hors du scope ;
- relâcher le focus proprement à sa fermeture ;
- restaurer le focus précédent si possible.
Les dialogues et palettes de commandes utilisent ce comportement par défaut.
Politique de sortie
Un scope doit pouvoir préciser ce qu’il advient quand la navigation atteint sa frontière :
- boucle interne ;
- sortie vers le parent ;
- blocage explicite ;
- délégation à une politique de scène.
Cette politique doit être déterministe et testable.
Relation avec les commandes
Un scope doit pouvoir enregistrer ou résoudre des commandes locales :
- commandes de navigation internes ;
- commandes de confirmation/annulation ;
- commandes propres à un conteneur complexe.
Lors du dispatch, le scope actif constitue un niveau intermédiaire stable entre le widget focalisé et la scène.
4. Modèle d’événements bruts
4.1 Objectif
Le modèle d’événements bruts est la couche d’entrée commune à tous les
backends air-tui.
Il sert à :
- transporter les faits d’interaction observés ;
- alimenter la résolution des commandes sémantiques ;
- fournir une base de tests inter-backends ;
- exprimer honnêtement les dégradations des terminaux tiers.
Un événement brut n’est ni une commande, ni une intention applicative. C’est un fait d’entrée structuré.
4.2 Principes
Le modèle suit six principes :
- Un événement brut décrit ce qui s’est produit, pas ce que l’application doit faire.
- La sémantique d’un événement doit rester stable entre backends.
- Une information absente ne doit pas être inventée silencieusement.
- Les timestamps et métadonnées d’origine sont autorisés, mais secondaires par rapport au sens interactionnel.
- Les événements doivent être utilisables en replay de test.
- Le backend doit traduire son protocole local vers ce modèle, jamais exposer directement un flux ANSI ou propriétaire au framework.
4.3 Familles d’événements
Le noyau 1.0 doit couvrir au minimum les familles suivantes :
- clavier ;
- pointeur ;
- texte/paste ;
- focus ;
- viewport ;
- système d’interaction.
4.4 Événements clavier
Les événements clavier minimaux sont :
KeyDownKeyUpKeyRepeatModifiersChanged
Un événement clavier doit pouvoir exprimer au minimum :
- une identité de touche stable ;
- l’état des modifiers ;
- éventuellement le texte produit ;
- l’origine backend ;
- le caractère répété ou non.
Règles :
KeyRepeatne doit pas être confondu avec une succession deKeyDownsi le backend sait faire la distinction ;- en
CompatibleTerminal, l’absence deKeyUpdoit être déclarée parCapabilities, pas cachée ; - la résolution en commande n’a pas lieu à ce niveau.
4.5 Événements pointeur
Les événements pointeur minimaux sont :
MouseDownMouseUpMouseMoveScrollMouseEnterMouseLeave
Un événement pointeur doit pouvoir exprimer au minimum :
- position logique dans le viewport ;
- bouton ou direction pertinente ;
- état des modifiers ;
- granularité du scroll si connue.
Règles :
- un backend qui ne sait pas fournir
MouseEnter/MouseLeavene doit pas les simuler à l’aveugle ; - un scroll continu et un scroll discret peuvent partager la même famille d’événements tout en gardant une granularité distincte.
4.6 Événements texte et paste
Les événements minimaux sont :
PasteImePreeditsi supporté plus tardImeCommit
Règles :
Pastetransporte un payload textuel brut ;Pastene doit pas être réémis sous forme de succession artificielle deKeyDownsi le backend sait distinguer le collage ;ImeCommitdoit rester distinct de toute commande d’insertion tant qu’aucun binding n’a été résolu.
4.7 Événements de focus
Les événements minimaux sont :
FocusGainedFocusLostHoverGainedHoverLost
Règles :
FocusGained/FocusLostconcernent le focus clavier ;HoverGained/HoverLostconcernent le survol pointeur ;- le backend ne décide pas seul de la politique de focus globale ; il signale seulement les faits observables.
4.8 Événements de viewport
Les événements minimaux sont :
ResizeViewportExposedsi une exposition partielle existe
Resize doit exprimer au minimum :
- taille logique en cellules du viewport ;
- information de validité/cohérence ;
- éventuellement la cause si connue.
4.9 Événements système d’interaction
Le noyau 1.0 doit permettre au moins :
CapabilitiesChangedBackendSuspendedBackendResumed
Ces événements ne sont pas des commandes et ne doivent pas être dispatchés comme telles.
4.10 Type conceptuel RawEvent
La future API devra matérialiser un type conceptuel RawEvent couvrant
l’ensemble des familles ci-dessus.
Exigences minimales :
- somme typée ou équivalent ;
- inspectable ;
- sérialisable pour tests ;
- utilisable en replay ;
- non ambigu entre texte, clavier, focus, pointeur et système.
4.11 EventContext
La future API devra aussi matérialiser un contexte léger associé aux événements
bruts, distinct de CommandContext.
EventContext sert à fournir :
- horodatage éventuel ;
- backend d’origine ;
- viewport courant ;
- capabilities courantes ;
- métadonnées de tracing ou de replay.
Règle importante :
EventContextappartient à la couche de traitement des événements ;CommandContextappartient à la couche de dispatch des commandes ;- les deux peuvent partager des données, mais ne doivent pas être fusionnés sans nécessité.
4.12 Pipeline normative
La pipeline minimale devient :
RawEvent -> normalisation backend -> focus/hit-testing -> binding -> Command -> dispatch
Les étapes peuvent être implémentées différemment, mais aucune ne peut être supprimée conceptuellement sans réintroduire de l’ambiguïté.
4.13 Règles de tests
Le modèle d’événements doit être testable au minimum sur :
- distinction
KeyDown/KeyRepeat/KeyUpquand disponible ; - absence honnête de
KeyUpen terminal tiers ; - cohérence des événements
Resize; - distinction
Pastevs saisie clavier ; - conservation de l’ordre des événements en replay.
5. Commandes sémantiques
5.1 Objectif
Les commandes sémantiques forment le contrat d’action de air-tui.
Une commande n’est pas une touche. C’est une intention stable, rebindable, journalisable, testable, et dispatchable dans l’arbre de vues.
5.2 Pipeline
La pipeline normative est :
événement brut -> binding -> commande -> dispatch -> action
Le binding dépend :
- du backend ;
- du layout clavier ;
- du contexte de focus ;
- des préférences utilisateur ;
- du scope modal actif.
La commande produite doit être indépendante de ces détails.
5.3 Niveaux de commandes
Le framework doit distinguer au moins quatre niveaux :
- commandes d’application ;
- commandes de scène ;
- commandes de conteneur ;
- commandes de widget.
Commandes d’application
Exemples :
app.quitapp.helpapp.preferencesapp.refresh
Commandes de scène
Exemples :
scene.focus_nextscene.focus_previousscene.cancelscene.confirmscene.open_palette
Commandes de conteneur
Exemples :
pane.focus_leftpane.focus_rightsplit.resize_increasetabs.nexttabs.previous
Commandes de widget
Exemples :
list.move_nextlist.move_previouslist.page_downtree.expandtree.collapseeditor.insert_newlineeditor.delete_backwardeditor.save
5.4 Noyau minimal 1.0
Le noyau minimal de commandes garanties par le framework doit couvrir au moins les familles suivantes.
Navigation générale
scene.focus_nextscene.focus_previousscene.confirmscene.cancelscene.open_palette
Navigation directionnelle
nav.upnav.downnav.leftnav.rightnav.page_upnav.page_downnav.homenav.end
Édition de texte
text.inserttext.delete_backwardtext.delete_forwardtext.move_lefttext.move_righttext.move_word_lefttext.move_word_righttext.select_alltext.copytext.cuttext.paste
Listes / tables / arbres
collection.move_nextcollection.move_previouscollection.activatecollection.expandcollection.collapsecollection.select_toggle
Fenêtrage logique / panneaux
pane.focus_leftpane.focus_rightpane.focus_uppane.focus_downpane.close
5.5 Dispatch
Le dispatch suit l’ordre conceptuel suivant :
- widget focalisé ;
- scope de focus actif ;
- conteneurs parents ;
- scène ;
- application.
Une commande non gérée peut :
- être ignorée explicitement ;
- déclencher un feedback standard ;
- remonter jusqu’à l’application ;
- être réinterprétée par un fallback déclaré.
Le framework ne doit pas inventer de comportement caché non inspectable.
5.6 Binding par défaut
Les bindings concrets ne sont pas tous figés ici, mais les règles suivantes le sont :
- ils doivent rester cohérents avec les conventions TUI modernes ;
- ils doivent être remappables ;
- ils ne doivent pas dépendre exclusivement de touches indisponibles sur
CompatibleTerminal; - ils doivent avoir des alternatives quand une capacité manque.
Exemple :
- si
KeyUpn’existe pas, aucun binding critique ne doit en dépendre exclusivement ; - si la souris n’existe pas, aucune commande de navigation primaire ne doit lui être réservée.
6. Types publics conceptuels
Cette section introduit les types conceptuels minimaux que la future API
publique air-tui devra matérialiser. Les noms exacts et signatures exactes
restent ouverts, mais leur rôle ne l’est plus.
6.1 CapabilityRequirements
Rôle
CapabilityRequirements représente un ensemble déclaratif d’exigences qu’une
vue, un widget, ou une fonctionnalité veut imposer à son environnement
d’exécution.
Ce type sert à :
- documenter les prérequis d’un composant ;
- centraliser la logique de support/fallback ;
- produire des diagnostics homogènes ;
- éviter les branches ad hoc fondées directement sur le backend.
Contenu conceptuel minimal
CapabilityRequirements doit pouvoir exprimer :
- des capacités requises ;
- des capacités préférées ;
- des capacités explicitement non nécessaires ;
- éventuellement un niveau minimal de profil quand c’est justifié.
Le type ne doit pas être une simple liste de booléens. Il doit permettre une description structurée par famille :
- clavier ;
- pointeur ;
- texte ;
- clipboard ;
- focus ;
- viewport.
Opérations minimales
Le modèle conceptuel doit supporter au minimum :
empty()require(...)prefer(...)merge(...)is_satisfied_by(capabilities)missing_from(capabilities)
Résultat de vérification
La vérification d’un CapabilityRequirements ne doit pas se limiter à un
booléen quand un diagnostic plus riche est possible.
Le framework doit pouvoir produire au minimum :
- satisfait / non satisfait ;
- liste des capacités manquantes ;
- liste des capacités seulement dégradées ;
- raison normalisée du fallback.
Règles
- Un widget du noyau 1.0 ne doit pas exiger un backend nominal si une exigence plus fine suffit.
- Une exigence doit être formulée en termes de capacité, pas de marqueur d’implémentation.
- Les exigences doivent être composables entre vues parentes et enfants.
6.2 FocusTarget
Rôle
FocusTarget représente une cible focusable adressable par le système de
focus.
Il n’est pas nécessairement un widget visible autonome ; il peut aussi représenter :
- un sous-élément focusable d’un widget complexe ;
- un point d’entrée logique dans un scope ;
- une cible virtuelle restaurable.
Invariants
Un FocusTarget doit être :
- stable pour la durée de vie logique de son élément ;
- comparable pour déterminer l’égalité de cible ;
- utilisable dans les diagnostics et tests ;
- indépendant de coordonnées physiques publiques.
Propriétés conceptuelles minimales
Un FocusTarget doit pouvoir exposer ou permettre d’obtenir :
- son scope parent ;
- sa focusability actuelle ;
- son rôle d’interaction ;
- son ordre relatif de navigation ;
- son état activable ou non.
Opérations minimales
Le modèle conceptuel doit supporter au minimum :
is_focusable()scope()role()activation_kind()
Un FocusTarget n’a pas à muter directement le focus lui-même ; cette
responsabilité appartient au moteur de focus et aux scopes.
Cas des widgets complexes
Un widget complexe comme Table, Tree, Editor, ou CommandPalette peut
choisir entre deux stratégies :
- un focus target unique avec curseur interne ;
- plusieurs focus targets descendants explicites.
La stratégie choisie doit rester cohérente avec :
- l’accessibilité ;
- la testabilité ;
- la stabilité des commandes ;
- la simplicité de navigation.
6.3 Command
Rôle
Command représente une intention sémantique dispatchable.
Il ne représente ni :
- une touche ;
- un événement brut ;
- une callback liée à un widget particulier.
Structure conceptuelle minimale
Un Command doit pouvoir exprimer au minimum :
- une identité stable ;
- une catégorie ;
- éventuellement une payload typée ou contextualisée ;
- une origine ou cause logique si utile au debug.
Exemples :
- commande sans payload :
scene.cancel - commande paramétrée :
text.insert(character) - commande contextualisée :
collection.activate(current_selection)
Identité
L’identité d’une commande doit être :
- stable dans le temps ;
- sérialisable pour debug/tests ;
- utilisable dans les diagnostics, la palette de commandes et les bindings.
Le document ne fige pas encore si cette identité est :
- une enum fermée ;
- un identifiant hiérarchique string-like ;
- un couple catégorie + verbe ;
- ou une combinaison de ces approches.
Mais il fige qu’elle doit être stable et inspectable.
Opérations minimales
Le modèle conceptuel doit supporter au minimum :
id()category()payload()is_repeatable()is_text_input()
Ces opérations n’imposent pas la forme finale, seulement la sémantique attendue.
Règles
- Une commande du noyau 1.0 doit pouvoir être remappée.
- Une commande critique doit avoir un sens stable entre backends.
- Une commande ne doit pas dépendre d’un widget concret pour exister.
6.4 CommandContext
Rôle
CommandContext représente l’environnement sémantique dans lequel une commande
est évaluée et dispatchée.
Il sert à éviter que les widgets ou l’application aillent puiser directement des informations globales dispersées.
Contenu conceptuel minimal
Un CommandContext doit fournir au minimum l’accès à :
Capabilitiesactives ;- focus primaire courant ;
- scope actif ;
- informations de viewport utiles ;
- état modal éventuel ;
- métadonnées d’origine de la commande ;
- canal de feedback ou de rejet standard.
Opérations minimales
Le modèle conceptuel doit supporter au minimum :
capabilities()focused_target()active_scope()viewport()origin()reject(reason)emit_feedback(feedback)
Origine de commande
Le contexte doit distinguer conceptuellement l’origine d’une commande :
- clavier ;
- pointeur ;
- paste ;
- accessibilité ;
- automation/test ;
- commande programmatiquement injectée.
Cette distinction est utile pour :
- diagnostiquer des comportements ;
- adapter certains feedbacks ;
- tester la cohérence inter-backends.
Règles
CommandContextne doit pas devenir un objet fourre-tout applicatif.- Il transporte le strict nécessaire au dispatch et au feedback.
- Toute donnée métier applicative doit rester hors de ce type.
6.5 Relations entre les types
Les relations minimales suivantes sont normatives :
CapabilityRequirementss’évalue contreCapabilities.FocusTargetvit dans unFocusScope.Commandest dispatchée à partir d’unFocusTargetet d’unCommandContext.CommandContextexpose leFocusTargetfocalisé et lesCapabilitiesactives.
Schéma conceptuel :
Capabilities ----> CapabilityRequirements
|
v
CommandContext ---> FocusScope ---> FocusTarget
|
v
Command
6.6 Non-objectifs à ce stade
Cette section ne fige pas encore :
- les traits Rust exacts ;
- le niveau de généricité ;
- la sérialisation wire de ces types ;
- la séparation exacte entre types
air-viewcommuns et typesair-tuispécifiques.
Ces points viendront plus tard. Le but ici est de verrouiller la sémantique, pas la syntaxe API.
7. Relations avec les widgets 1.0
Le noyau de widgets défini par ADR-026 impose déjà quelques attentes.
Widgets devant obligatoirement participer au focus
InputEditorListTableTreeTabsDialogCommandPalette
Widgets pouvant être focusables selon configuration
Block/PanelStatusBarProgressquand interactif
Widgets non focusables par défaut
TextSpanChartpurement passif
8. Premier lot de widgets 1.0
Cette section précise le premier lot de widgets à considérer comme noyau
implémentable d’air-tui. Le but n’est pas d’épuiser le futur widget set, mais
de fixer un socle cohérent avec Capabilities, FocusScope et les commandes
sémantiques.
Pour chaque widget, quatre dimensions comptent :
- rôle principal ;
- contrat de focus ;
- commandes minimales ;
- dégradations acceptées.
8.1 Text
Rôle. Affichage de texte statique ou riche en spans stylés.
Focus. Non focusable par défaut.
Commandes minimales. Aucune commande d’interaction obligatoire.
Dégradations acceptées.
- réduction de richesse colorimétrique ;
- wrapping différent selon backend ;
- shaping/bidi réduit en
CompatibleTerminal.
Dégradations interdites en silence.
- texte tronqué sans signalement quand une vue exige explicitement l’intégrité ;
- largeur de cellule incohérente cassant la mise en page.
8.2 Block / Panel
Rôle. Conteneur logique et visuel.
Focus. Non focusable par défaut ; peut devenir focusable s’il représente un panneau navigable ou sélectionnable.
Commandes minimales.
- aucune en mode passif ;
scene.focus_next/scene.focus_previoussi focusable ;pane.focus_*quand utilisé comme panneau d’une vue fractionnée.
Dégradations acceptées.
- bordures simplifiées ;
- styles de box-drawing réduits ;
- titres plus sobres.
8.3 Input
Rôle. Champ texte mono-ligne.
Focus. Toujours focusable.
Commandes minimales.
scene.confirmscene.canceltext.inserttext.delete_backwardtext.delete_forwardtext.move_lefttext.move_righttext.move_word_lefttext.move_word_righttext.select_alltext.copytext.cuttext.paste
Dégradations acceptées.
- sélection visuelle plus simple ;
- clipboard limité ;
- IME absent sur backend dégradé.
Dégradations interdites en silence.
- perte d’édition clavier de base ;
- focus instable ;
- paste destructif ou ambigu sans signalement.
Machine d’état minimale.
idle: champ non focalisé.focused: champ focalisé, curseur visible.editing: insertion/suppression active.selecting: sélection active si supportée.disabled: visible mais non éditable.
Le passage focused -> editing peut être implicite dès la première entrée
texte. Le passage focused -> disabled n’est autorisé que sur changement
d’état explicite de l’application.
Modèle de focus.
- un
Inputcorrespond par défaut à un uniqueFocusTarget; - l’entrée dans le focus place le curseur selon la politique du composant : début, fin, ou dernière position mémorisée ;
- la sortie du focus doit figer proprement la sélection et le curseur ;
- un
Inputdans unDialogne doit pas pouvoir perdre le focus hors du scope modal sans commande explicite.
Sémantique de commandes.
text.insertajoute le contenu au point d’insertion courant ;text.delete_backwardettext.delete_forwardopèrent de manière déterministe sur sélection ou curseur ;scene.confirmvalide le champ si le contexte l’autorise, sinon remonte ;scene.cancelannule l’édition en cours seulement si le composant le supporte explicitement ; sinon la commande remonte.
Feedback minimal.
- focus visible ;
- curseur visible ;
- état désactivé perceptible ;
- erreur de validation perceptible mais non bloquante par défaut.
Pré-requis de capacités.
- requis :
keyboard,focus,viewport.resizefiable ; - préférés :
clipboard,text.bidi,ime.
8.4 Editor
Rôle. Édition texte multi-ligne.
Focus. Toujours focusable ; constitue généralement son propre scope d’interaction.
Commandes minimales.
- toutes celles de
Input; nav.upnav.downnav.page_upnav.page_downnav.homenav.endeditor.insert_newlineeditor.savesi l’éditeur expose cette action
Dégradations acceptées.
- rendu de sélection simplifié ;
- gouttière ou numéros de ligne absents ;
- shortcuts avancés indisponibles sur backend dégradé.
Dégradations interdites en silence.
- désynchronisation curseur / texte ;
- perte de navigation fondamentale ;
- modifications de texte non déterministes.
Machine d’état minimale.
idle: éditeur non focalisé ;focused: éditeur focalisé ;editing: insertion/suppression active ;selecting: sélection active ;navigating: déplacement sans mutation ;readonly: navigation autorisée, mutation interdite ;disabled: ni édition ni navigation active.
Un éditeur ne doit jamais mélanger silencieusement readonly et disabled.
Stratégie de focus.
Deux stratégies sont permises :
- un unique
FocusTargetavec curseur interne ; - plusieurs cibles de focus internes si l’éditeur expose des sous-zones distinctes.
Pour le noyau 1.0, la stratégie recommandée est le focus target unique avec curseur interne, plus simple et plus stable pour les commandes.
Sémantique de navigation.
nav.up/nav.downdéplacent le curseur verticalement ;nav.page_up/nav.page_downdéplacent le viewport logique ;nav.home/nav.endagissent au minimum sur la ligne courante ;- le maintien d’une colonne désirée lors des déplacements verticaux doit être déterministe.
Sémantique d’édition.
text.inserteteditor.insert_newlinedoivent produire des mutations ordonnées et reproductibles ;- les suppressions doivent avoir une sémantique définie sur sélection, caractère, ou unité logique future ;
editor.saven’est disponible que si le composant annonce explicitement cette action ; sinon la commande remonte.
Feedback minimal.
- curseur visible ;
- viewport cohérent autour du curseur ;
- état
readonlyperceptible ; - sélection perceptible quand elle est supportée ;
- erreur ou rejet de commande signalé via feedback standard.
Pré-requis de capacités.
- requis :
keyboard,focus,viewport.resize; - préférés :
clipboard,text.wide_cell_width,text.bidi,ime.
8.5 List
Rôle. Collection linéaire navigable.
Focus. Focusable si interactive.
Commandes minimales.
collection.move_nextcollection.move_previousnav.page_upnav.page_downnav.homenav.endcollection.activatecollection.select_togglesi multi-sélection
Dégradations acceptées.
- marqueurs de sélection simplifiés ;
- hover absent ;
- scroll moins fin.
Machine d’état minimale.
idle: liste non focalisée ;focused: liste focalisée ;navigating: déplacement de l’élément actif ;selecting: changement de sélection ;empty: liste vide ;disabled: liste visible mais non interactive.
Une liste peut être focused et empty ; elle doit alors rester navigable au
sens du focus global, sans action de collection disponible.
Modèle interne minimal.
Une List interactive doit distinguer explicitement :
- l’élément actif ;
- l’ensemble sélectionné si la multi-sélection existe ;
- l’ancre de scroll logique.
L’élément actif et la sélection ne doivent pas être confondus.
Stratégie de focus.
Pour le noyau 1.0, la stratégie recommandée est :
- un unique
FocusTargetpour la liste ; - un curseur/élément actif interne.
La stratégie “un focus target par ligne” est autorisée plus tard, mais alourdit fortement l’accessibilité, le dispatch et les tests.
Sémantique de commandes.
collection.move_next/collection.move_previousdéplacent l’élément actif sans ambiguïté ;collection.activateactive l’élément courant ;collection.select_togglemodifie la sélection sans casser l’élément actif ;nav.page_up/nav.page_downdéplacent au moins l’ancre visible.
Feedback minimal.
- élément actif perceptible ;
- sélection perceptible si présente ;
- état vide perceptible ;
- impossibilité d’activation signalée proprement.
Pré-requis de capacités.
- requis :
keyboard,focus; - préférés :
pointer,viewport.resize,pointer.scroll.
8.6 Table
Rôle. Données tabulaires navigables.
Focus. Focusable si interactive.
Commandes minimales.
- toutes les commandes de
List; nav.leftnav.right
Dégradations acceptées.
- colonnes tronquées ou repliées ;
- absence de resize interactif des colonnes ;
- styles de séparation simplifiés.
Dégradations interdites en silence.
- ambiguïté non signalée sur cellule active ;
- navigation incohérente ligne/colonne.
Machine d’état minimale.
idle: table non focalisée ;focused: table focalisée ;navigating_rows: déplacement vertical actif ;navigating_columns: déplacement horizontal actif ;selecting: changement de sélection si supporté ;empty: table vide ;disabled: table visible mais non interactive.
Modèle interne minimal.
Une Table interactive doit distinguer explicitement :
- la ligne active ;
- la colonne active ou cellule active selon le mode ;
- la sélection courante ;
- l’ancre de scroll verticale ;
- l’ancre de scroll horizontale.
Le modèle “ligne active” et le modèle “cellule active” sont tous deux permis, mais le composant doit en choisir un explicitement et le maintenir de façon cohérente.
Stratégie de focus.
Pour le noyau 1.0, la stratégie recommandée est :
- un unique
FocusTargetpour la table ; - une cellule ou ligne active gérée en interne.
Sémantique de commandes.
collection.move_next/collection.move_previousdéplacent au minimum la ligne active ;nav.left/nav.rightdéplacent la colonne ou cellule active ;collection.activateagit sur l’élément logique actuellement actif ;nav.page_up/nav.page_downdéplacent la fenêtre visible de façon déterministe.
Feedback minimal.
- ligne ou cellule active perceptible ;
- séparation visuelle suffisante des colonnes ;
- état vide perceptible ;
- ambiguïté de navigation signalée si le mode change.
Pré-requis de capacités.
- requis :
keyboard,focus,viewport.resize; - préférés :
pointer,pointer.scroll,text.wide_cell_width.
8.7 Tree
Rôle. Hiérarchie expandable/collapsible.
Focus. Focusable si interactive.
Commandes minimales.
collection.move_nextcollection.move_previouscollection.expandcollection.collapsecollection.activate
Dégradations acceptées.
- glyphes d’expansion simplifiés ;
- indentation plus sobre ;
- absence d’animation d’ouverture/fermeture.
Machine d’état minimale.
idle: arbre non focalisé ;focused: arbre focalisé ;navigating: déplacement du nœud actif ;expanding: ouverture logique d’un nœud ;collapsing: fermeture logique d’un nœud ;empty: arbre vide ;disabled: arbre visible mais non interactif.
Modèle interne minimal.
Un Tree interactive doit distinguer explicitement :
- le nœud actif ;
- l’état ouvert/fermé de chaque nœud concerné ;
- la profondeur logique ;
- l’ancre de scroll visible ;
- la sélection si elle existe.
Stratégie de focus.
Comme pour List, la stratégie recommandée est :
- un unique
FocusTargetpour l’arbre ; - un nœud actif interne.
Sémantique de commandes.
collection.move_next/collection.move_previousnaviguent dans l’ordre visible actuel ;collection.expandouvre le nœud actif s’il est ouvrable ;collection.collapseferme le nœud actif s’il est ouvert, ou remonte au parent selon la politique du composant ;collection.activateactive le nœud courant.
Feedback minimal.
- nœud actif perceptible ;
- état ouvert/fermé perceptible ;
- hiérarchie perceptible malgré les dégradations visuelles ;
- impossibilité d’expansion/collapse signalée proprement.
Pré-requis de capacités.
- requis :
keyboard,focus; - préférés :
pointer,pointer.scroll,viewport.resize.
8.8 ScrollView
Rôle. Fenêtre défilante sur un contenu plus grand que le viewport.
Focus. Peut être focusable ou déléguer le focus à son contenu.
Commandes minimales.
nav.upnav.downnav.page_upnav.page_downnav.homenav.end
Dégradations acceptées.
- scroll seulement par page ;
- indicateurs de scroll absents ;
- inertie inexistante.
Machine d’état minimale.
idle: non focalisée si le composant prend le focus ;focused: focalisée ;scrolling: déplacement du viewport logique ;delegating: laisse le focus ou les commandes au contenu ;disabled: visible mais non interactive.
Rôle de focus.
Deux modes sont normatifs :
focus owner: laScrollViewreçoit le focus et interprète les commandes de scroll ;focus delegator: le contenu reçoit le focus, laScrollViewne fait que clipper et translater.
Le composant doit déclarer explicitement quel mode il adopte.
Sémantique de commandes.
nav.up/nav.downdéplacent le viewport ou sont déléguées au contenu selon le mode ;nav.page_up/nav.page_downdéplacent au minimum d’un viewport logique ;nav.home/nav.enddéplacent à l’origine ou à l’extrémité logique.
Feedback minimal.
- position de scroll perceptible si possible ;
- début et fin de contenu détectables ;
- impossibilité de scroller davantage signalée proprement.
Pré-requis de capacités.
- requis :
keyboardsi focus owner ; - préférés :
pointer.scroll,viewport.resize.
8.9 Tabs
Rôle. Sélection entre plusieurs panneaux mutuellement exclusifs.
Focus. Focusable ; constitue souvent un scope de focus avec restauration du dernier focus par onglet.
Commandes minimales.
tabs.nexttabs.previousscene.confirm
Dégradations acceptées.
- apparence de tab simplifiée ;
- raccourcis directs aux onglets absents ;
- overflow transformé en liste ou palette.
Machine d’état minimale.
idle: onglets non focalisés ;focused: barrette ou sélecteur d’onglets focalisé ;switching: transition logique entre deux onglets ;disabled: visible mais non interactive.
Modèle interne minimal.
Un Tabs doit distinguer explicitement :
- l’onglet sélectionné ;
- l’onglet focalisé si distinct ;
- le dernier focus descendant mémorisé pour chaque panneau quand cette fonctionnalité est activée.
Stratégie de focus.
Le modèle recommandé est :
- un
FocusTargetpour la barrette d’onglets ; - un
FocusScopepour le panneau actif ; - restauration du dernier focus du panneau lors du retour sur un onglet.
Sémantique de commandes.
tabs.next/tabs.previouschangent l’onglet sélectionné ;scene.confirmpeut entrer dans le panneau actif si la barrette a le focus ;scene.focus_next/scene.focus_previouspeuvent alterner entre barrette et contenu selon la politique du composant.
Feedback minimal.
- onglet sélectionné perceptible ;
- onglet focalisé perceptible s’il diffère ;
- overflow perceptible et navigable.
Pré-requis de capacités.
- requis :
keyboard,focus; - préférés :
viewport.resize,pointer.
8.10 StatusBar
Rôle. Affichage d’état, de hints de commandes, ou de messages contextuels.
Focus. Non focusable par défaut ; peut devenir focusable si interactive.
Commandes minimales.
- aucune en mode passif ;
scene.confirmou commande dédiée si elle expose des actions.
8.11 Dialog
Rôle. Interaction modale courte : confirmation, alerte, petit formulaire.
Focus. Toujours encapsulé dans un FocusScope modal.
Commandes minimales.
scene.confirmscene.cancelscene.focus_nextscene.focus_previous
Dégradations acceptées.
- bordure ou ombrage simplifiés ;
- réduction du chrome visuel ;
- mise en page plus compacte.
Dégradations interdites en silence.
- fuite du focus hors du modal ;
- impossibilité de confirmer ou d’annuler au clavier.
Machine d’état minimale.
opening: apparition logique du dialogue ;active: dialogue modal actif ;submitting: validation en cours si l’action n’est pas immédiate ;closing: fermeture logique ;closed: hors scène active.
Même sans animation visuelle, ces états logiques doivent exister pour cadrer le focus et les commandes.
Scope modal.
Un Dialog crée par défaut un FocusScope modal qui :
- capture le focus primaire à l’ouverture ;
- mémorise le focus précédent ;
- piège
scene.focus_next/scene.focus_previousà l’intérieur ; - restaure le focus précédent à la fermeture si la cible existe encore.
Structure interne minimale.
Un Dialog 1.0 peut contenir au minimum :
- un titre optionnel ;
- un contenu textuel ou un petit arbre de widgets ;
- une zone d’actions explicites ;
- éventuellement un widget focalisable primaire (
Input,List, etc.).
Sémantique de commandes.
scene.confirmdéclenche l’action par défaut si elle existe ;scene.cancelferme le dialogue ou remonte un rejet explicite ;scene.focus_next/scene.focus_previousnaviguent dans le scope modal ;- les commandes non locales remontent seulement si la politique du dialogue le permet explicitement.
Feedback minimal.
- frontière modale perceptible ;
- action par défaut identifiable ;
- fermeture ou rejet perceptible ;
- erreur de validation ou d’envoi perceptible.
Pré-requis de capacités.
- requis :
keyboard,focus; - préférés :
pointer,viewport.resize.
8.12 CommandPalette
Rôle. Point d’entrée universel des commandes et de la navigation globale.
Focus. Scope modal dédié.
Commandes minimales.
scene.cancelscene.confirmcollection.move_nextcollection.move_previoustext.inserttext.delete_backward
Dégradations acceptées.
- matching plus simple ;
- absence de preview ;
- rendu de liste simplifié.
Dégradations interdites en silence.
- impossibilité d’invoquer la palette ;
- résultats activables uniquement à la souris.
Machine d’état minimale.
closed: hors scène active ;opening: entrée logique dans la modalité ;querying: saisie active de la requête ;browsing: navigation dans les résultats ;executing: exécution logique de la commande choisie ;closing: fermeture logique ;
Scope modal.
La CommandPalette crée un FocusScope modal par défaut, au même titre qu’un
Dialog, avec capture du focus primaire et restauration du focus précédent à
la fermeture.
Structure interne minimale.
La palette contient au minimum :
- un champ de requête ;
- une liste de résultats ;
- une sélection active ;
- un mécanisme d’activation de résultat.
Le champ et la liste peuvent être modélisés comme sous-composants internes, mais le widget doit rester cohérent comme unité d’interaction unique.
Sémantique de commandes.
text.insertettext.delete_backwardmodifient la requête ;collection.move_next/collection.move_previousdéplacent le résultat actif ;scene.confirmexécute le résultat actif ;scene.cancelferme la palette sans exécution.
Feedback minimal.
- requête visible ;
- résultat actif perceptible ;
- absence de résultat perceptible ;
- exécution ou rejet perceptible.
Pré-requis de capacités.
- requis :
keyboard,focus; - préférés :
viewport.resize,clipboard,pointer.
8.13 Progress
Rôle. Indication d’avancement déterminé ou indéterminé.
Focus. Non focusable par défaut ; focusable seulement si l’élément expose des actions.
Commandes minimales. Aucune en mode passif.
Dégradations acceptées.
- style de barre simplifié ;
- mode indéterminé réduit à indicateur textuel ;
- absence d’animation.
8.14 Chart
Rôle. Visualisation textuelle simple : barres, sparklines, jauges, petits indicateurs.
Focus. Non focusable par défaut ; peut devenir focusable si des points ou séries sont inspectables.
Commandes minimales.
- aucune en mode passif ;
nav.left/nav.right/scene.confirmsi inspectable.
Dégradations acceptées.
- résolution visuelle réduite ;
- couleurs simplifiées ;
- légende tronquée ou repliée.
9. Contrats transverses du lot 1.0
Tous les widgets 1.0 interactifs doivent respecter les règles suivantes.
9.1 Clavier intégral
Toute action critique doit être réalisable au clavier.
9.2 Focus visible
Un widget focusable doit avoir un état focalisé perceptible dans tous les profils supportés, même si la richesse visuelle varie.
9.3 Commandes inspectables
Les commandes consommées par un widget doivent pouvoir être documentées et, à terme, introspectées par le framework.
9.4 Dégradation explicable
Quand un widget se dégrade, le framework doit pouvoir expliquer :
- quelle capacité manque ;
- quelle variante a été choisie ;
- si cette variante reste pleinement fonctionnelle ou non.
10. Patterns composites 1.0
Les widgets de base ne suffisent pas à décrire une application air-tui
crédible. Le framework doit fournir ou au minimum normaliser quelques patterns
composites de haut niveau, récurrents dans air-base.
Un pattern composite :
- assemble plusieurs widgets du noyau ;
- impose une politique de focus cohérente ;
- expose un petit contrat de commandes ;
- définit ses dégradations acceptées.
10.1 Form
Rôle. Saisie structurée de plusieurs champs, sections et actions.
Composition minimale.
- texte d’aide ou labels ;
Inputet/ouEditor;- actions
confirm/cancel; - sections optionnelles.
Focus.
- scope dédié ;
- navigation séquentielle stable ;
- possibilité de mémoriser le dernier champ invalide ou focalisé.
Commandes minimales.
scene.focus_nextscene.focus_previousscene.confirmscene.cancel
Dégradations acceptées.
- layout plus compact ;
- aides inline déplacées en dessous ;
- indicateurs visuels de validation simplifiés.
Machine d’état minimale.
idle: formulaire présent mais non focalisé ;focused: un champ ou une action interne détient le focus ;editing: au moins un champ est en interaction active ;validating: validation locale ou globale en cours ;submitting: soumission logique en cours ;invalid: au moins une contrainte empêche la soumission ;disabled: formulaire visible mais non interactif.
Le formulaire peut être focused et invalid en même temps. L’état invalid
ne doit jamais être implicite : il doit pouvoir être dérivé d’erreurs de
validation explicites.
Modèle interne minimal.
Un Form doit distinguer explicitement :
- l’ordre de tabulation des champs ;
- les sections éventuelles ;
- le premier champ invalide ;
- les actions primaires et secondaires ;
- l’état sale (
dirty) si la vue choisit de l’exposer.
Focus et validation.
- le focus se déplace séquentiellement entre champs et actions ;
scene.confirmsur un champ n’implique pas automatiquement soumission globale si le composant ne l’annonce pas ;- à échec de validation, le focus doit pouvoir être déplacé de manière déterministe vers le premier champ invalide ou rester sur le champ courant selon la politique du formulaire ;
- un formulaire dans un
Dialoghérite du scope modal parent sans dupliquer sa logique.
Sémantique de commandes.
scene.focus_next/scene.focus_previousnaviguent entre champs et actions ;scene.confirmsoumet si le contexte l’autorise et si la validation passe ;scene.cancelannule ou remonte selon la politique du conteneur ;- les commandes widget-level (
text.*,editor.*) sont traitées par le champ focalisé avant toute logique de formulaire.
Feedback minimal.
- champ focalisé perceptible ;
- champs invalides perceptibles ;
- état de soumission ou de validation perceptible ;
- action primaire identifiable.
Pré-requis de capacités.
- requis :
keyboard,focus; - préférés :
viewport.resize,clipboard,ime,text.bidi.
10.2 NavigationSplit
Rôle. Navigation maître/détail en mode texte.
Composition minimale.
- un panneau latéral de navigation (
ListouTree) ; - un panneau de détail (
Text,Form,Editor, etc.) ; - éventuellement une barre de séparation logique.
Focus.
- deux scopes principaux ;
- commandes
pane.focus_left/pane.focus_rightnormatives ; - restauration du dernier focus dans chaque panneau recommandée.
Dégradations acceptées.
- collapse du panneau latéral ;
- passage temporaire en navigation empilée ;
- séparation visuelle simplifiée.
Machine d’état minimale.
idle: composite visible mais aucun panneau focalisé ;master_focused: panneau de navigation focalisé ;detail_focused: panneau de détail focalisé ;collapsed_master: panneau maître replié ;disabled: composite visible mais non interactif.
Modèle interne minimal.
Un NavigationSplit doit distinguer explicitement :
- le panneau maître ;
- le panneau détail ;
- le dernier focus interne de chaque panneau ;
- l’item de navigation actuellement sélectionné ;
- la politique de collapse du panneau maître.
Politique de focus.
pane.focus_leftetpane.focus_rightsont des commandes normatives ;- le changement de sélection dans le panneau maître peut remplacer le contenu du détail sans déplacer automatiquement le focus ;
- quand le focus revient dans un panneau, la restauration du dernier focus interne est recommandée ;
- en mode
collapsed_master, le panneau maître doit rester accessible via une commande explicite.
Sémantique de commandes.
- les commandes
collection.*dans le panneau maître s’appliquent à la vue de navigation ; - les commandes du panneau détail sont résolues localement tant que ce panneau garde le focus ;
scene.confirmdans le panneau maître peut soit activer l’élément, soit déplacer le focus au détail selon la politique du composant.
Feedback minimal.
- panneau focalisé perceptible ;
- item maître sélectionné perceptible ;
- état collapsed / expanded perceptible ;
- séparation logique entre navigation et détail perceptible même en dégradé.
Pré-requis de capacités.
- requis :
keyboard,focus; - préférés :
viewport.resize,pointer.
10.3 SettingsView
Rôle. Présentation ordonnée de préférences et réglages.
Composition minimale.
- groupes ou sections ;
Form;- aides contextuelles ;
- actions de validation si nécessaire.
Focus.
- scope principal ;
- navigation séquentielle et éventuellement par sections.
Commandes minimales.
- celles de
Form; - navigation directionnelle si panneau latéral de catégories.
Machine d’état minimale.
browsing_categories: catégorie active en navigation ;editing_section: section de réglages active ;validating: validation locale ou globale ;submitting: application ou enregistrement en cours.
Politique de focus.
- si une liste de catégories existe, elle forme un scope latéral analogue à
NavigationSplit; - sinon, la vue se comporte comme un
Formétendu.
Dégradations acceptées.
- catégories repliées en liste simple ;
- aide contextuelle déplacée en dessous ;
- validation différée moins riche visuellement.
10.4 HelpView
Rôle. Aide textuelle structurée, raccourcis, documentation in-app.
Composition minimale.
Textriche ;ScrollView;- éventuellement
TabsouListde rubriques.
Focus.
- peut être passive ou interactive ;
- si interactive, focus owner généralement au niveau
ScrollViewou de la liste de rubriques.
Dégradations acceptées.
- styles typographiques simplifiés ;
- table des matières repliée ;
- recherche absente en backend dégradé.
Machine d’état minimale.
reading: lecture du contenu principal ;navigating_index: navigation dans la table des matières si présente ;searching: recherche active si le composant la supporte.
Politique de focus.
- en version passive, aucun focus propre hors conteneur parent ;
- en version interactive, le focus alterne entre index, recherche et contenu scrollable selon la structure choisie.
Commandes minimales.
nav.upnav.downnav.page_upnav.page_downscene.focus_nextscene.focus_previous
10.5 CommandBrowser
Rôle. Variante persistante ou non modale de la CommandPalette pour
explorer les actions disponibles.
Composition minimale.
Inputde filtre optionnel ;ListouTablede commandes ;- panneau d’aide ou description optionnel.
Focus.
- un ou plusieurs scopes selon la mise en page ;
- la stratégie recommandée est un focus principal sur la liste avec filtre accessible par commande dédiée.
10.6 Règles communes aux composites
- un composite ne doit pas introduire de commandes cachées incohérentes avec le noyau ;
- un composite doit définir sa politique de focus en termes de
FocusScopeetFocusTarget; - un composite doit pouvoir expliquer ses dégradations via
Capabilities.
11. Architecture des crates air-tui
Cette section traduit les contrats précédents en découpage d’implémentation. Elle ne fige pas encore tous les noms exacts des modules internes, mais fixe les responsabilités de chaque crate et leurs relations de dépendance.
Le principe directeur est simple :
- séparer sémantique, rendu et transport ;
- ne pas laisser un backend dicter l’API publique ;
- garder les types de contrat au-dessus des détails de protocole ;
- permettre des tests croisés du même comportement sur plusieurs backends.
11.1 Vue d’ensemble
Le découpage cible est le suivant :
air-view
|
+--> air-tui
| |
| +--> air-tui-widgets
| +--> air-ui-render-tui
| +--> air-tui-runtime
|
+--> air-tui-backend-console
+--> air-tui-backend-terminal
+--> air-tui-backend-ansi
Ce schéma doit se lire ainsi :
air-viewporte le tronc déclaratif commun ;air-tuiexpose la façade framework console ;air-tui-widgetsporte le widget set TUI ;air-ui-render-tuiporte le renderer cellulaire ;air-tui-runtimeporte la boucle d’input, la normalisation d’événements, le binding et le dispatch ;- les crates
air-tui-backend-*adaptent chacune un environnement d’exécution concret.
11.2 air-tui
Rôle. Façade publique du framework console.
La crate air-tui doit réexporter :
- les widgets TUI publics ;
- les types de contrat publics (
Capabilities,CapabilityRequirements,Command,CommandContext,FocusScope,FocusTarget,RawEventselon la répartition retenue) ; - les points d’entrée de runtime nécessaires à une application ;
- le minimum de traits ou fonctions de bootstrap nécessaires à l’exécution.
air-tui ne doit pas :
- contenir directement un backend concret ;
- dépendre d’un terminal ANSI concret ;
- contenir de logique de protocole
air-console; - exposer des détails de transport comme surface publique principale.
11.3 air-tui-widgets
Rôle. Implémentation du widget set TUI standard.
Cette crate porte au minimum :
- les widgets du lot 1.0 ;
- les patterns composites 1.0 ;
- leurs machines d’état ;
- leur logique de focus locale ;
- leur traduction des
Commanden mutations d’état.
Elle dépend conceptuellement de :
air-viewpour la composition ;air-tui-runtimepour les contrats d’interaction ;air-ui-render-tuiseulement via interfaces stables, jamais via couplage au backend concret.
air-tui-widgets ne doit pas embarquer directement de logique spécifique à
air-console, au terminal Air, ou à l’ANSI.
11.4 air-ui-render-tui
Rôle. Renderer cellulaire.
Cette crate reçoit un arbre de vues ou une représentation normalisée et produit une scène cellulaire rendable par les backends.
Responsabilités minimales :
- layout cellulaire ;
- glyphes, attributs et styles ;
- clipping ;
- dirty regions ;
- scroll logique ;
- calcul de hit-testing logique si cette responsabilité n’est pas ailleurs.
Sortie conceptuelle :
- grille de cellules ;
- métadonnées de focus/hover si nécessaires au debug ;
- informations de diff pour éviter le repaint complet.
air-ui-render-tui ne doit pas :
- parser un flux terminal ;
- parler AirCom directement ;
- gérer un PTY ;
- décider de la politique de binding clavier.
11.5 air-tui-runtime
Rôle. Cœur interactionnel du framework console.
C’est la crate la plus structurante après air-tui lui-même. Elle doit porter
le pipeline suivant :
- réception des événements bruts ;
- normalisation backend ;
- mise à jour des
Capabilities; - focus et scopes ;
- hit-testing logique ;
- binding événement -> commande ;
- dispatch de commande ;
- feedback et rejet standard.
air-tui-runtime est le bon lieu pour matérialiser ou héberger conceptuellement
les types suivants, sous une forme ou une autre :
RawEventEventContextCommandCommandContextFocusScopeFocusTargetCapabilities
Cette crate ne doit pas dépendre d’un backend concret. Les backends doivent dépendre d’elle, ou implémenter les interfaces qu’elle expose.
11.6 air-tui-backend-console
Rôle. Adaptateur du mode plein bypass via air-console.
Responsabilités minimales :
- connexion au protocole privé d’
air-console; - réception des événements riches natifs ;
- émission de la grille cellulaire ou de son diff ;
- signalement fidèle des capacités
FullConsole.
Ce backend est la référence premium pour le mode texte Air.
Il ne doit pas contenir :
- la logique de widget ;
- la politique de focus globale ;
- des fallbacks ANSI.
11.7 air-tui-backend-terminal
Rôle. Adaptateur du terminal Air riche, hors air-console direct.
Responsabilités minimales :
- parler le protocole privé du terminal Air ;
- traduire les événements et capacités en profil
AirTerminal; - transmettre la sortie du renderer.
Ce backend doit fournir une sémantique applicative proche de
air-tui-backend-console, avec seule différence notable : la couche de
transport.
11.8 air-tui-backend-ansi
Rôle. Adaptateur du terminal tiers ou SSH.
Responsabilités minimales :
- parler ANSI/VT et Kitty keyboard protocol quand disponible ;
- détecter honnêtement les capacités réellement présentes ;
- produire des
RawEventdégradés mais cohérents ; - transmettre la sortie rendue sous une forme acceptable par le terminal tiers.
Ce backend est explicitement un backend de compatibilité, pas le centre de gravité du design.
11.9 Dépendances et direction du flux
Les dépendances conceptuelles doivent respecter au minimum les règles suivantes :
air-tuidépend deair-view,air-tui-runtime,air-tui-widgets,air-ui-render-tui;air-tui-widgetsdépend deair-viewetair-tui-runtime;air-ui-render-tuine dépend d’aucun backend concret ;- chaque
air-tui-backend-*dépend deair-tui-runtimeet consomme la sortie du renderer ; - aucun backend ne doit être requis pour compiler les types centraux du framework.
Flux conceptuel d’exécution :
backend -> RawEvent -> air-tui-runtime -> Command -> widgets/state
widgets/state -> air-ui-render-tui -> cell grid -> backend
11.10 Testabilité du découpage
Ce découpage est choisi aussi pour ses propriétés de test :
- tests unitaires de
air-tui-runtimesans backend réel ; - tests de widgets sans protocole terminal ;
- tests du renderer sur snapshots de grilles ;
- tests d’adaptateurs backend avec fixtures de flux d’entrée/sortie ;
- tests end-to-end identiques sur
console,terminal,ansi.
11.11 Points laissés ouverts
Restent ouverts à ce stade :
- l’existence ou non d’une crate
air-tui-typesdédiée ; - la séparation exacte entre runtime, focus et commandes en sous-crates ;
- la place exacte de certains types communs dans
air-viewvsair-tui-runtime; - la stratégie de réexport depuis la façade
air-tui.
12. Architecture interne de air-tui-runtime
Cette section descend d’un cran sous le niveau crate. Elle décrit les
sous-systèmes conceptuels de air-tui-runtime, leur ordre de responsabilité,
et les points où ils s’articulent avec les widgets, le renderer et les
backends.
Principe directeur :
- normaliser tôt ;
- décider tard ;
- ne jamais mélanger transport, focus, binding et rendu ;
- rendre chaque étape testable isolément.
12.1 Vue d’ensemble
Le runtime peut se lire comme une pipeline en anneau :
backend adapter
-> event ingress
-> normalizer
-> capability tracker
-> focus engine
-> hit-testing / target resolution
-> binding engine
-> command dispatcher
-> state mutation / feedback
-> invalidation scheduler
-> renderer bridge
-> backend adapter
Toutes ces étapes peuvent cohabiter dans une seule crate, mais elles doivent rester séparables conceptuellement et testables individuellement.
12.2 event ingress
Rôle. Point d’entrée des événements provenant d’un backend.
Responsabilités minimales :
- recevoir des événements source dans l’ordre ;
- préserver l’ordre logique ;
- associer un
EventContextminimal ; - rejeter les événements manifestement invalides ;
- alimenter la phase de normalisation.
event ingress ne doit pas :
- résoudre des commandes ;
- décider du focus global ;
- déclencher du rendu directement.
12.3 normalizer
Rôle. Convertir les événements backend-spécifiques en RawEvent
canoniques.
Responsabilités minimales :
- transformer les protocoles de backend en événements normés ;
- distinguer honnêtement
KeyDown,KeyRepeat,KeyUp,Paste, etc. ; - enrichir les événements avec les métadonnées stables disponibles ;
- éliminer les ambiguïtés de transport sans inventer d’information absente.
Le normalizer est le point où se matérialise le contrat “les backends ne
dictent pas l’API”.
12.4 capability tracker
Rôle. Maintenir l’état courant de Capabilities.
Responsabilités minimales :
- exposer le profil courant ;
- suivre les capacités stables et dynamiques ;
- émettre
CapabilitiesChangedsi nécessaire ; - fournir des snapshots cohérents au reste du runtime.
Le capability tracker doit être la source de vérité interne pour toute
question du type “peut-on faire X sur ce backend maintenant ?”.
12.5 focus engine
Rôle. Maintenir et faire évoluer l’état de focus.
Responsabilités minimales :
- conserver le focus primaire ;
- gérer les
FocusScope; - appliquer les politiques d’entrée/sortie de scope ;
- restaurer les focus mémorisés ;
- arbitrer les transitions
FocusGained/FocusLost.
Le focus engine ne doit pas être un effet secondaire du renderer. Il est un
sous-système autonome.
12.6 hit-testing / target resolution
Rôle. Résoudre les cibles logiques concernées par un événement ou une commande.
Responsabilités minimales :
- associer un événement pointeur à un
FocusTargetou sous-arbre logique ; - déterminer le widget focalisé ou survolé ;
- coopérer avec le renderer pour les positions logiques si nécessaire ;
- fournir au binding et au dispatch un contexte de cible stable.
Cette étape peut dépendre de métadonnées calculées au rendu, mais ne doit pas vivre dans le backend.
12.7 binding engine
Rôle. Transformer RawEvent + contexte en Command.
Responsabilités minimales :
- appliquer les bindings par défaut ;
- respecter les remappings utilisateur ;
- distinguer insertion de texte, navigation, activation, annulation ;
- tenir compte du scope modal actif ;
- produire zéro, une, ou plusieurs commandes si le modèle le permet.
Le binding engine ne doit pas muter directement l’état des widgets. Il produit
des intentions, pas des effets.
12.8 command dispatcher
Rôle. Acheminer une Command vers le bon niveau de l’arbre.
Responsabilités minimales :
- construire le
CommandContext; - envoyer d’abord la commande au widget focalisé ;
- remonter au scope actif, aux parents, à la scène, puis à l’application ;
- recueillir rejet, consommation, ou remapping ;
- déclencher le feedback standard quand rien ne gère la commande.
Le command dispatcher est le gardien de la cohérence de propagation.
12.9 state mutation / feedback
Rôle. Appliquer les mutations d’état résultant du dispatch et produire les signaux de retour.
Responsabilités minimales :
- muter les machines d’état des widgets concernés ;
- produire des rejets standardisés ;
- produire les feedbacks visuels ou sonores abstraits ;
- marquer les zones ou sous-arbres invalidés.
Le runtime ne doit pas imposer la logique métier d’application, mais il doit fournir le cadre pour que les mutations d’UI restent cohérentes.
12.10 invalidation scheduler
Rôle. Décider quoi rerendre, quand, et avec quel niveau de granularité.
Responsabilités minimales :
- agréger les invalidations ;
- distinguer invalidation locale et rerendu complet ;
- coopérer avec les dirty regions du renderer ;
- éviter les rerenders inutiles ;
- garantir qu’une mutation visible mène à un rendu cohérent.
L’invalidation est un sous-système important parce que le mode texte riche peut facilement dégénérer en rafraîchissements complets coûteux si elle n’est pas structurée.
12.11 renderer bridge
Rôle. Frontière entre runtime et air-ui-render-tui.
Responsabilités minimales :
- préparer l’arbre ou snapshot à rendre ;
- transmettre les contraintes de viewport ;
- récupérer la grille ou son diff ;
- transmettre au runtime les métadonnées utiles au hit-testing et au debug.
Cette frontière doit rester assez mince pour éviter le couplage fort entre runtime et renderer.
12.12 Boucle d’exécution
La boucle d’exécution conceptuelle d’air-tui-runtime est :
- recevoir un événement backend ;
- le normaliser en
RawEvent; - mettre à jour les capacités si nécessaire ;
- résoudre focus et cible ;
- produire une ou plusieurs commandes ;
- dispatcher les commandes ;
- appliquer mutations et feedback ;
- planifier l’invalidation ;
- demander un rendu si nécessaire ;
- pousser la sortie vers le backend.
Cette boucle peut être synchrone ou intégrée à un runtime async plus large, mais sa sémantique doit rester stable.
12.13 États internes minimaux
Le runtime doit maintenir au minimum :
- snapshot courant de
Capabilities; - focus primaire et scopes actifs ;
- état de hover si pertinent ;
- queue ou flux d’événements entrants ;
- invalidations en attente ;
- dernier snapshot rendu ou diff de référence si nécessaire ;
- configuration de bindings active.
12.14 Interfaces attendues entre sous-systèmes
Les interfaces conceptuelles minimales sont :
- backend ->
event ingress: émission d’événements source ordonnés ; normalizer->capability tracker: métadonnées de capacité observées ;focus engine<->hit-testing: cibles et transitions ;binding engine->command dispatcher:Command+ origine ;state mutation->invalidation scheduler: sous-arbres ou régions sales ;renderer bridge-> backend : grille ou diff rendus.
12.15 Testabilité interne
Chaque sous-système doit être testable séparément :
normalizeravec fixtures de backend ;capability trackeravec transitions de profil ;focus engineavec arbres et scopes synthétiques ;binding engineavec matrices d’événements et de remappings ;command dispatcheravec arbres de widgets factices ;invalidation scheduleravec scénarios de mutations locales.
12.16 Points laissés ouverts
Restent ouverts à ce stade :
- architecture mono-crate interne vs sous-modules très marqués ;
- éventuelle scission future en
air-tui-focus,air-tui-command, etc. ; - politique exacte de buffering des événements ;
- articulation précise avec le runtime async de couche 1/2 ;
- place exacte du hit-testing entre runtime et renderer.
13. Tests attendus
L’implémentation future doit être testée au minimum selon quatre axes.
Tests de capacités
- capacité correctement détectée par backend ;
- absence de fausse déclaration ;
- fallback correct d’une vue selon capacité manquante.
Tests de focus
- unicité du focus primaire ;
- ordre de tabulation déterministe ;
- restauration correcte après fermeture d’un modal ;
- navigation cohérente entre scopes.
Tests de commandes
- binding produit la bonne commande ;
- dispatch remonte correctement dans l’arbre ;
- commande critique toujours accessible dans les profils supportés.
Tests inter-backends
Une même app de test doit être exercée sur :
FullConsoleAirTerminalCompatibleTerminal
avec vérification que :
- le comportement critique reste cohérent ;
- seules les dégradations explicitement autorisées apparaissent.
14. Questions laissées ouvertes
Les points suivants restent à instruire plus tard sans remettre en cause cette spec :
- noms exacts des types publics ;
- sérialisation précise de l’arbre logique pour debug/tests ;
- stratégie IME détaillée en mode console ;
- politique de shaping/bidi exacte selon backend ;
- modèle de thèmes et de styles textuels ;
- widgets additionnels après le noyau 1.0.
Licence du document : MPL 2.0 Statut : Spécification de design pré-implémentation. Édition directe autorisée en phase de design pré-ouverture publique ; après ouverture publique, évolution via RFC si impact contractuel.
Spécification d’API air-tui-runtime
Document de spécification — couche 4
Rôle du document
Ce document décrit la surface d’API conceptuelle de air-tui-runtime.
Il complète air-tui-core.md, qui fixe :
- les contrats d’interaction ;
- le modèle d’événements ;
- les commandes ;
- les widgets ;
- l’architecture des crates ;
- l’architecture interne de
air-tui-runtime.
Le présent document change de focale : il s’adresse d’abord à un développeur qui veut comprendre ce que le runtime lui offre pour construire des applications ou des composants de framework, sans devoir relire toute l’architecture interne.
L’objectif n’est pas encore de figer mot pour mot une API Rust stable 1.0. L’objectif est de définir :
- les points d’entrée attendus ;
- les types publics majeurs ;
- les responsabilités de chaque objet ;
- le style d’usage attendu ;
- les invariants ergonomiques de l’API.
1. Principes d’API
L’API publique de air-tui-runtime doit respecter les principes suivants.
1.1 Développeur-first
Un développeur d’application ne doit pas avoir à comprendre :
- un protocole backend ;
- un format terminal ;
- une boucle de focus implicite ;
- une mécanique d’invalidation artisanale ;
- un système de dispatch caché.
Ces détails existent, mais le runtime doit les absorber.
1.2 Contrats explicites
Les capacités, le focus, les commandes, les événements et le feedback doivent être exposés par des types nommés et des opérations explicites.
Pas de magie cachée.
1.3 Lecture simple, puissance graduelle
L’API doit être praticable à trois niveaux :
- usage simple pour une application standard ;
- usage intermédiaire pour des composants personnalisés ;
- usage avancé pour des widgets ou conteneurs complexes.
Le développeur doit pouvoir commencer simple sans être enfermé.
1.4 Stabilité sémantique
Même si les signatures exactes évolueront avant 1.0, les concepts suivants doivent rester stables :
CapabilitiesCapabilityRequirementsRawEventCommandCommandContextFocusScopeFocusTarget
2. Modèle mental de l’API
Le développeur doit pouvoir raisonner ainsi :
- Le backend livre des événements.
- Le runtime les normalise.
- Le runtime maintient les capacités et le focus.
- Le runtime résout des commandes sémantiques.
- Les vues/widgets réagissent aux commandes.
- Le runtime programme le rerendu.
Formule compacte :
events in -> commands resolved -> state updated -> render scheduled
3. Points d’entrée publics
L’API conceptuelle doit exposer au minimum les points d’entrée suivants.
3.1 Runtime
Runtime est le point d’entrée principal.
Rôle :
- posséder la boucle d’exécution ;
- recevoir des événements ;
- maintenir le focus et les capacités ;
- dispatcher les commandes ;
- programmer le rendu.
Attentes conceptuelles minimales :
- création simple ;
- configuration lisible ;
- accès aux capacités ;
- injection d’événements ;
- exécution d’un cycle ;
- accès au rendu produit.
Exemple conceptuel :
#![allow(unused)]
fn main() {
let mut runtime = Runtime::new(config);
runtime.mount(app_view);
runtime.push_event(raw_event);
runtime.tick();
let frame = runtime.take_frame();
}
Les noms exacts sont ouverts ; ce style d’usage est l’attendu.
3.2 RuntimeConfig
RuntimeConfig décrit les choix globaux du runtime.
Doit pouvoir couvrir au minimum :
- stratégie de bindings ;
- options de focus ;
- politique de feedback ;
- budget de rendu ou d’invalidation ;
- politique de diagnostics.
Il ne doit pas devenir une poubelle de flags hétérogènes.
3.3 MountedView ou équivalent
Le runtime doit pouvoir monter un arbre racine de vue ou un composant racine.
Le type exact peut varier, mais la sémantique suivante doit exister :
- une racine unique logique ;
- une durée de vie claire ;
- un canal de mise à jour de l’état ;
- un point de démontage propre.
4. Types publics centraux
4.1 Capabilities
Déjà défini conceptuellement dans air-tui-core.md.
Attentes API :
- lecture simple ;
- interrogation par famille ;
- diagnostic standard ;
- vérification contre
CapabilityRequirements.
Exemple conceptuel :
#![allow(unused)]
fn main() {
let caps = runtime.capabilities();
if caps.keyboard().key_up().is_guaranteed() {
// comportement premium
}
}
4.2 CapabilityRequirements
Usage attendu :
- déclaration par widgets et composites ;
- vérification par le runtime ;
- fallback documenté.
Exemple conceptuel :
#![allow(unused)]
fn main() {
let req = CapabilityRequirements::new()
.require(KeyboardCapability::KeyDown)
.prefer(PointerCapability::Scroll)
.prefer(TextCapability::Bidi);
}
4.3 RawEvent
RawEvent est la surface d’entrée stable des interactions.
L’application standard ne manipule pas forcément RawEvent souvent, mais :
- les composants avancés peuvent l’observer ;
- les tests en ont besoin ;
- les backends doivent le produire ;
- le runtime doit le consommer.
4.4 Command
Command est l’unité d’action stable.
Attentes API :
- identité stable ;
- catégorie lisible ;
- payload éventuelle ;
- sérialisation utile au debug/tests.
Exemple conceptuel :
#![allow(unused)]
fn main() {
match command.id() {
CommandId::SceneConfirm => { /* ... */ }
CommandId::CollectionMoveNext => { /* ... */ }
_ => {}
}
}
4.5 CommandContext
CommandContext est la fenêtre d’information disponible au moment du dispatch.
Il doit être :
- léger ;
- lisible ;
- centré sur l’interaction ;
- sans pollution métier.
4.6 FocusScope et FocusTarget
Le développeur n’a pas toujours besoin de construire des scopes explicitement. Mais dès qu’il écrit :
- un conteneur complexe ;
- un modal ;
- un éditeur ;
- une navigation maître/détail ;
il doit disposer d’objets stables pour raisonner sur le focus.
5. Objets et rôles de haut niveau
L’API publique doit permettre de distinguer au moins les objets suivants.
5.1 Runtime
Orchestre l’ensemble.
5.2 EventSource ou équivalent
Abstraction sur une source d’événements backend.
Usage :
- backend console ;
- backend terminal Air ;
- backend ANSI ;
- tests synthétiques.
5.3 CommandHandler ou équivalent
Abstraction pour les composants qui consomment des commandes.
Le nom exact peut changer, mais le runtime doit pouvoir appeler un point d’entrée cohérent de traitement des commandes.
5.4 RenderOutput ou équivalent
Représente la sortie rendue ou le diff prêt à être envoyé au backend.
Le développeur d’application n’a pas toujours à le manipuler, mais :
- les intégrateurs backend, si ;
- les tests snapshot, si ;
- le debug outillé, si.
6. Style d’API attendu
6.1 API lisible
Le style attendu est plus proche de :
#![allow(unused)]
fn main() {
runtime.capabilities().pointer().scroll().is_supported()
}
que de :
#![allow(unused)]
fn main() {
runtime.flags() & 0x0400 != 0
}
6.2 API orientée types
Préférer :
- enums ;
- nouveaux types ;
- objets contextuels explicites ;
- méthodes nommées ;
à :
- chaînes magiques ;
- entiers nus ;
- drapeaux opaques ;
- callbacks anonymes partout.
6.3 API centrée commandes, pas touches
L’API applicative ne doit pas pousser les développeurs vers :
- inspection de séquences clavier brutes ;
- branches par backend ;
- logique de keycodes dans chaque widget.
Le bon chemin doit être :
- déclarer ou consommer des commandes sémantiques.
7. Parcours développeur attendu
7.1 Application simple
Un développeur doit pouvoir :
- créer un runtime ;
- monter une vue racine ;
- brancher un backend ;
- laisser le runtime faire le reste.
Exemple conceptuel :
#![allow(unused)]
fn main() {
let mut runtime = Runtime::new(RuntimeConfig::default());
runtime.mount(root_view());
while let Some(event) = backend.next_event() {
runtime.push_event(event);
runtime.tick();
if let Some(frame) = runtime.take_frame() {
backend.render(frame);
}
}
}
7.2 Composant personnalisé
Un développeur de composant doit pouvoir :
- déclarer ses
CapabilityRequirements; - déclarer sa politique de focus ;
- consommer des
Command; - demander une invalidation propre.
Exemple conceptuel :
#![allow(unused)]
fn main() {
impl CommandHandler for MyWidget {
fn requirements(&self) -> CapabilityRequirements { /* ... */ }
fn handle_command(
&mut self,
command: &Command,
cx: &mut CommandContext,
) -> CommandResult {
// ...
}
}
}
7.3 Intégrateur backend
Un développeur backend doit pouvoir :
- produire des
RawEventcohérents ; - déclarer les capacités ;
- consommer la sortie rendue ;
- ne jamais avoir à reconstituer la logique de focus ou de commandes.
8. Feedback et diagnostics
Le runtime doit offrir un canal uniforme pour :
- rejeter une commande ;
- signaler une capacité manquante ;
- expliquer un fallback ;
- inspecter l’état courant du focus ;
- journaliser la suite d’événements/commandes utile au debug.
L’objectif explicite est de produire une future documentation développeur crédible et agréable, pas seulement un moteur techniquement juste.
9. Exemples de surface d’API conceptuelle
Les snippets suivants ne figent pas la syntaxe finale, mais illustrent le style attendu.
9.1 Lecture des capacités
#![allow(unused)]
fn main() {
let caps = runtime.capabilities();
if caps.text().bidi().is_supported() {
render_rich_help();
} else {
render_fallback_help();
}
}
9.2 Déclaration de prérequis
#![allow(unused)]
fn main() {
fn requirements() -> CapabilityRequirements {
CapabilityRequirements::new()
.require(KeyboardCapability::KeyDown)
.require(FocusCapability::PrimaryFocus)
.prefer(PointerCapability::Scroll)
}
}
9.3 Dispatch d’une commande
#![allow(unused)]
fn main() {
runtime.dispatch(Command::scene_confirm());
}
9.4 Inspection du focus
#![allow(unused)]
fn main() {
let focused = runtime.focus().primary_target();
let scope = runtime.focus().active_scope();
}
10. Anti-objectifs
La future API publique ne doit pas ressembler à :
- un simple wrapper d’événements terminal bruts ;
- une API centrée sur les backends ;
- une collection de callbacks sans contrat ;
- une hiérarchie opaque de services globaux ;
- un framework où chaque widget reprogramme le focus et le binding.
11. Relations avec la future documentation développeur
Ce document doit servir plus tard de base à trois familles de documents :
- guide “démarrage rapide” développeur ;
- guide “écrire un widget personnalisé” ;
- guide “écrire un backend TUI”.
Autrement dit, cette spec n’est pas seulement une note d’architecture interne. Elle prépare directement la qualité perçue du SDK Air.
12. Points laissés ouverts
- noms exacts des traits et structs publics ;
- politique de lifetime/ownership des arbres montés ;
- style exact de configuration (
builder, struct simple, traits) ; - séparation précise entre API application et API backend ;
- niveau de réexport depuis
air-tuivsair-tui-runtime.
Licence du document : MPL 2.0 Statut : Spécification d’API conceptuelle pré-implémentation. Édition directe autorisée en phase de design pré-ouverture publique ; après ouverture publique, évolution via RFC si impact contractuel.
Spec couche 4 — air-url (chargement d’URL haut niveau ≈ URLSession)
Spécification technique — Version 0.1 (proposition). Couche 4 « Frameworks applicatifs » — fondation (couche + nom ratifiés par ADR-084).
Cadrage.
air-urlvise la couverture fonctionnelle du client URL-loading d’Apple (URLSession, dans Foundation) sans copier l’API — surface en vocabulaire Air idiomatique (noteapi-reseau-strategie). Bâti au-dessus d’air-http(h1/h2/h3) etair-network. Maison, pur Rust, zéro-C, zérounsafe. Commodité applicative, pas un protocole filaire.
Décisions RATIFIÉES (ADR-084).
- Couche 4 élargie — le périmètre de la couche 4 passe de « frameworks UI » à « frameworks développeur : UI + fondation ».
air-urlest une commodité de fondation (commeURLSessiondans Foundation chez Apple), au-dessus d’air-http/air-network(L2). Alternative (haut de couche 2) rejetée (mélange de registres).- Nom
air-url— descriptif, vocabulaire Air, jamais « URLSession ». Alternativesair-web/air-fetchrévisables par le BDFL sans rouvrir la couche.
Position et méthode
air-url orchestre des requêtes HTTP(S) de haut niveau : sessions, tâches, cache,
cookies, défis d’authentification, transferts en arrière-plan, WebSocket. Il compose
air-http (le protocole) + air-network (connexion/path-awareness) ; il n’a pas de
cœur sans-IO (ce n’est pas un protocole filaire — comme air-network, c’est un
substrat, mais applicatif).
Périmètre fonctionnel (checklist URLSession, vocabulaire Air)
| Concept Air | ≈ Apple | Réalisation |
|---|---|---|
| Session (config partagée, pool) | URLSession | AirUrlSession |
| Tâche données | URLSessionDataTask | AirDataTask |
| Tâche téléchargement (vers fichier) | URLSessionDownloadTask | AirDownloadTask |
| Tâche envoi (upload) | URLSessionUploadTask | AirUploadTask |
| WebSocket | URLSessionWebSocketTask | AirWebSocketTask (sur air-http WS) |
| Transferts en arrière-plan | background sessions | AirBackgroundSession |
| Cache HTTP | URLCache | AirHttpCache (RFC 9111) |
| Cookies | HTTPCookieStorage | AirCookieStore (RFC 6265, SameSite/Secure/HttpOnly) |
| Défi d’auth | auth challenge | AirAuthChallenge (Basic/Bearer/cert pinning) |
| Requête/Réponse | URLRequest/URLResponse | AirUrlRequest/AirUrlResponse |
Inventaire — ~16 objets
Surface (~14) : AirUrlSession (+ AirUrlSessionConfig/Builder), AirUrlRequest,
AirUrlResponse, AirDataTask, AirDownloadTask, AirUploadTask, AirWebSocketTask,
AirBackgroundSession, AirCachePolicy, AirHttpCache, AirCookieStore,
AirAuthChallenge, AirUrlError.
Support (~2) : AirTaskState (enum Suspended/Running/Completed/Cancelled),
AirRedirectPolicy.
#![allow(unused)]
fn main() {
impl AirUrlSession {
pub fn new(config: Arc<AirUrlSessionConfig>) -> Self; // config Arc, Send+Sync
pub async fn data(&self, request: AirUrlRequest) -> Result<(AirUrlResponse, Bytes), AirUrlError>;
pub fn download(&self, request: AirUrlRequest) -> AirDownloadTask; // → fichier, reprenable
pub fn upload(&self, request: AirUrlRequest, body: AirBodySource) -> AirUploadTask;
pub async fn websocket(&self, request: AirUrlRequest) -> Result<AirWebSocketTask, AirUrlError>;
}
}
Dépendances
air-http (L2, HTTP/1-2-3 + WebSocket), air-network (L2, connexion/path-awareness),
air-runtime (L1, async), air-filesystem (L1, download/cache sur disque),
air-config (L1, cookies/cache persistants), air-base-core (L1). Zéro-C (la seule
surface C possible = celle, déléguée, d’air-tls sous air-http).
Sécurité
- TLS par défaut (via
air-http/air-tls) ; downgrade http:// explicite. - Cookies sûrs :
Secure/HttpOnly/SameSiterespectés ; isolation par origine ; pas d’envoi cross-site par défaut. - Cache correct (RFC 9111) : jamais de réutilisation d’une réponse non cachable /
no-store; revalidationETag/Last-Modified. - Cert pinning (hook
AirAuthChallenge) ; redirections validées (pas de fuite de credentials cross-origin ; limite de sauts). - Transferts en arrière-plan : persistés (
air-config/air-filesystem), reprenables.
Stratégie de tests
- Couverture couche 4 (cible selon nature, > 90 % logique).
- Cache : conformité RFC 9111 (fraîcheur, revalidation,
no-store) ; cookies : RFC 6265 (SameSite, expiration, domaine/chemin) ; redirections (limite, credential-stripping). - Interop : vs serveurs réels via
air-http(loopback nginx) ; reprise de download.
Récapitulatif & décisions
- Commodité applicative (couche 4 élargie, à ratifier) — compose
air-http, pas un protocole. - Couverture URLSession, vocabulaire Air — jamais de copie d’API ; nom à figer.
- Sécurité par défaut : TLS, cookies sûrs, cache correct, pinning, redirections validées.
- Async 1ʳᵉ classe ; session
ArcSend/Sync, tâches indépendantes.
Travail à reprendre
- Couche + nom : ratifiés (ADR-084 — couche 4 fondation, nom
air-url). - Cache/cookies persistants (schéma
air-config) ; politiques de retry/timeout ; ABI C différée.
Licence : MPL 2.0. Statut : spec air-url (couche 4, à confirmer) v0.1 — chargement
d’URL haut niveau ≈ URLSession, sur air-http. Nom + couche à ratifier.
Spec couche 5 — air-dhcp (client DHCPv4/DHCPv6, patron sans-IO)
Spécification technique — Version 0.1 (proposition). Couche 5 « Services système ».
Cadrage.
air-dhcp= client de configuration réseau automatique : DHCPv4 (RFC 2131), DHCPv6 (RFC 8415), et RA/SLAAC IPv6 (RFC 4861/4862). Maison, pur Rust, zéro-C, zérounsafe. Crate-lib (cœur sans-IO) + daemon.airservice(note réseau §1 : DHCP = L5, UDP broadcast). Serveur DHCP hors périmètre (client only).
Position et méthode
Protocole filaire → cœur sans-IO (les 9 composants) + pilote I/O (air-socket UDP
broadcast/multicast). Le daemon applique le bail obtenu : adresses/routes via
air-netlink (rtnetlink), DNS via air-config (resolv.conf). Consomme
air-runtime (async/timers), air-base-core.
Périmètre — moderne, borné
- DHCPv4 (DISCOVER/OFFER/REQUEST/ACK, RENEW/REBIND) ; DHCPv6 (SOLICIT/ADVERTISE/ REQUEST/REPLY, IA_NA/IA_PD) ; RA/SLAAC (autoconfiguration IPv6 sans état).
- Options bornées (surface hostile : le serveur DHCP est sur le LAN) — jamais d’exécution, jamais d’alloc non bornée ; option 43/121 (routes) parsées prudemment.
- Omis : serveur DHCP ; options legacy inutiles (NIS…) ; BOOTP legacy.
Anatomie : les 9 composants
| # | Composant | air-dhcp | Pur ? |
|---|---|---|---|
| 1 | Framer | // TRIVIAL : un datagramme UDP = un message DHCP | ✅ triv. |
| 2 | Codec | DhcpV4Codec/DhcpV6Codec — en-tête fixe + options TLV bornées | ✅ |
| 3 | StateMachine | LeaseStateMachine — DISCOVER→…→BOUND, RENEW/REBIND/EXPIRE (RFC 2131 §4.4) | ✅ |
| 4 | Handshaker | // NO : pas d'auth (DHCP authentication RFC 3118 = hors périmètre) | — |
| 5 | Flow Controller | // NO | — |
| 6 | Multiplexer | // NO | — |
| 7 | Timer Manager | LeaseTimers — T1/T2/expiry, retransmit backoff (RFC 2131 §4.1) ; horloge injectée | ✅ |
| 8 | Session Context | Lease — adresse, masque, routeurs, DNS, durée, xid | ✅ |
| 9 | Extension hooks | options connues (SLAAC/RA en variante) | ✅ |
Inventaire — ~12 objets
Surface (~8) : DhcpV4Client, DhcpV6Client, SlaacClient, Lease (+ LeaseV6),
DhcpOption, DhcpEvent (Bound/Renewed/Expired/Lost), DhcpConfig, DhcpError.
Cœur sans-IO (~4) : DhcpV4Codec, DhcpV6Codec, LeaseStateMachine, LeaseTimers.
#![allow(unused)]
fn main() {
pub enum DhcpEvent { Bound(Lease), Renewed(Lease), Expired, Lost, WantsTransmit }
impl DhcpV4Client {
pub fn new(interface: AirInterfaceRef) -> AirResult<Self>;
pub fn recv(&mut self, now: AirInstant, datagram: &[u8]) -> AirResult<Vec<DhcpEvent>>;
pub fn poll_transmit(&mut self, now: AirInstant) -> Option<Vec<u8>>; // broadcast
pub fn handle_timeout(&mut self, now: AirInstant) -> AirResult<Vec<DhcpEvent>>;
}
}
Dépendances
air-socket (L1, UDP broadcast/multicast — additif SO_BROADCAST typé si besoin),
air-netlink (L1, appliquer adresses/routes), air-config (L1, DNS/resolv.conf),
air-runtime/air-base-core (L1). Zéro-C, zéro tierce. ICMPv6 (RA/SLAAC) : le
socket brut est déjà possible en couche 0 (SocketType::Raw + socket générique) ;
il faut seulement un wrapper air-socket typé (Raw/Ipv6 + ICMP6_FILTER via
setsockopt_bytes, ADR-080) — pas d’additif couche 0 (ADR-083).
Sécurité & tests
- Parsing d’options borné + fuzzé (LAN hostile) ; bail plafonné ; jamais de route/
DNS appliqués sans validation. Rogue-DHCP : hors scope crypto (DHCP n’authentifie
pas), le daemon peut exiger une politique (
air-config). - Tests : codec fuzzé, StateMachine property (transitions bail), timers virtual clock,
interop
isc-dhcp/dnsmasqen loopback.
Travail à reprendre
- Wrappers
air-socket(couche 1) : ICMPv6 brut typé (SLAAC — socle couche 0 déjà présent, ADR-083) +SO_BROADCASTtypé (sockopt entier existant, ADR-071). Aucun additif couche 0. - Daemon
.airservice(L5) : intégrationair-config/air-netlink, politique. - DHCPv6 IA_PD (délégation de préfixe) ; ABI C différée.
Licence : MPL 2.0. Statut : spec air-dhcp (L5) v0.1 — client DHCPv4/v6 + SLAAC, sans-IO.
Spec couche 5 — air-ntp (client NTP/SNTP + NTS, patron sans-IO)
Spécification technique — Version 0.1 (proposition). Couche 5 « Services système ».
Cadrage.
air-ntp= client de synchronisation d’horloge : NTPv4/SNTP (RFC 5905) + NTS (Network Time Security, RFC 8915 — temps authentifié). Maison, pur Rust, zéro-C, zérounsafe. Crate-lib + daemon.airservice(note réseau §1 : NTP = L5, UDP).
Position et méthode
Protocole filaire → cœur sans-IO + pilote I/O (air-socket UDP). Le daemon discipline
l’horloge système via une interface temps privilégiée (couche 0 clock_settime/
adjtimex — additif à instruire). Consomme air-runtime, air-base-core, et
air-tls/air-crypto pour NTS.
Périmètre — moderne, sûr
- SNTP client (RFC 4330/5905) : offset/délai, filtrage, sélection.
- NTS (RFC 8915) — recommandé : établissement de clés par TLS 1.3 (
air-tls, ALPNntske/1) puis NTP authentifié (AEAD viaair-crypto, cookies NTS). Défend contre le spoofing (NTP nu est non authentifié). - Discipline d’horloge bornée : jamais de saut d’horloge géant silencieux (bornes de sanité, step vs slew) ; refus des serveurs incohérents (falsetickers).
- Omis : mode serveur ; NTP symétrique/broadcast ; autokey (déprécié, remplacé par NTS).
Anatomie : les 9 composants
| # | Composant | air-ntp | Pur ? |
|---|---|---|---|
| 1 | Framer | // TRIVIAL : datagramme UDP = 1 paquet NTP (48 o + extensions) | ✅ triv. |
| 2 | Codec | NtpPacketCodec — en-tête 48 o + champs d’extension NTS bornés | ✅ |
| 3 | StateMachine | SyncStateMachine — poll, filtre, algorithme de discipline (RFC 5905 §11) | ✅ |
| 4 | Handshaker | NTS-KE via air-tls (établit cookies+clés) ; sinon // NO (NTP nu) | ✅ + air-tls |
| 5 | Flow Controller | // NO | — |
| 6 | Multiplexer | // NO | — |
| 7 | Timer Manager | PollTimers — intervalle de poll adaptatif (minpoll/maxpoll) ; horloge injectée | ✅ |
| 8 | Session Context | NtsSession (cookies+clés AEAD) + état de discipline (offset/jitter) | ✅ |
| 9 | Extension hooks | champs d’extension NTS (RFC 8915) | ✅ |
Inventaire — ~10 objets
Surface (~6) : NtpClient, NtsConfig, TimeSample (offset/delay/dispersion),
ClockDiscipline (step/slew), NtpEvent (Synchronized/Stepped/Unsynced), NtpError.
Cœur sans-IO (~4) : NtpPacketCodec, SyncStateMachine, PollTimers, NtsSession.
Dépendances
air-socket (L1, UDP), air-tls (L2, NTS-KE), air-crypto (L1, AEAD NTS),
air-runtime/air-base-core (L1). Réglage d’horloge : clock_settime (step)
déjà présent en couche 0 ; additif adjtimex (slew, discipline fine) —
instruit (ADR-083).
Zéro-C, zéro tierce.
Sécurité & tests
- NTS préféré (temps authentifié) ; NTP nu marqué non authentifié (avertissement).
- Bornes de sanité sur l’offset (anti-saut) ; sélection multi-serveurs (anti-falseticker).
- Tests : codec fuzzé (paquets + extensions NTS), discipline property (offset borné),
timers virtual clock, interop
chrony/ntpd/serveur NTS (Cloudflaretime.cloudflare.com).
Travail à reprendre
- Additif couche 0 :
adjtimex/clock_adjtime(slew) — instruit (ADR-083) ;clock_settime(step) déjà présent. - Daemon
.airservice(L5) ; sources multiples + sélection ; ABI C différée.
Licence : MPL 2.0. Statut : spec air-ntp (L5) v0.1 — client SNTP + NTS, sans-IO.
Guide ABI C — libair-base.so (vue d’ensemble)
Public : développeurs C (et, à terme, Swift / Python / Ruby via les bindings) qui consomment Air sans écrire de Rust. Ces pages expliquent comment utiliser la bibliothèque et quelles obligations pèsent sur l’appelant — pas seulement quoi appeler.
air-base-capi expose la couche 1 air-base-lib (Rust pur) en ABI C stable,
produisant libair-base.so. C’est la première passe ABI C du projet : le banc
d’essai de la méthodologie de toute la future « libc Air » (userland Rust, contrats
redessinés, propriété mémoire explicite). Décisions de contrat :
- Source unique de vérité : la doc par-symbole naît des commentaires
///du code Rust, reportés parcbindgendans le header committéinclude/air_base.h, puis rendus par Doxygen (HTML + pagesman). Aucune doc C rédigée à la main, donc zéro dérive (ADR-027). - Stabilité : le header est un contrat ABI garanti 10 ans, committé et diffé en CI (ADR-012).
- Erreurs :
AirStatusin-band, sanserrno,panic = "abort"(ADR-045).
Pourquoi une ABI C, et pas seulement du Rust
Le Rust est l’implémentation ; l’ABI C est le contrat universel. Tout langage
sait appeler du C. En figeant la frontière en C — types #[repr(C)], statut in-band,
propriété explicite — Air devient polyglotte dès le départ sans maintenir N
documentations parallèles : un seul header, un seul jeu de garanties.
Les pages de ce guide
- Mise en route — inclure le header, lier la lib, versionnement/ABI.
- Modèle d’erreurs (
AirStatus) — tester, lire, propager ; pourquoi aucunepanicne traverse l’ABI ; pourquoi pas d’errno. - Propriété mémoire & cycle de vie — LE point critique : qui alloue, qui libère, avec quelle fonction. Tableau par symbole.
- Thread-safety par type — ce qui est appelable depuis quel thread, avec les pièges concrets.
- Exemples C compilables — UUID, logging, mesure du temps, compilés contre le header committé.
Les trois règles à retenir avant tout
- Tester chaque retour. Toute fonction rend un
AirStatus(ou un pointeur non-NULLpourair_status_message). Un*outn’est écrit que surAIR_STATUS_OK. Voir Modèle d’erreurs. - Règle de propriété uniforme : +1 au RETOUR, +0 en ARGUMENT. Un handle
retourné (
air_log_open,air_log_fields_new) est possédé par l’appelant → il doit le libérer. Un type passé en argument est emprunté → l’appelé ne le libère ni ne le conserve. Voir Propriété mémoire. - Les sorties chaînes utilisent VOTRE buffer. Aucune chaîne n’est allouée par
Air en T1/T3 : vous fournissez le buffer (tailles
AIR_UUID_HYPHENATED_LEN/AIR_ID128_HEX_LEN) ; trop petit ⇒AIR_STATUS_BUFFER_TOO_SMALL(jamais de troncature silencieuse). Rien à libérer.
Version anglaise : voir C ABI — overview.
Mise en route (ABI C)
Cette page montre comment inclure, lier et versionner libair-base.so
depuis un programme C. Pour le modèle d’erreurs, voir
Modèle d’erreurs ; pour la propriété mémoire,
Propriété mémoire.
1. Inclure le header
Un seul header, committé et faisant autorité :
#include "air_base.h"
Le header est généré depuis le code Rust par cbindgen et committé dans le
dépôt (crates/air-base-capi/include/air_base.h). Il porte un avertissement « NE PAS
ÉDITER À LA MAIN » : c’est un artefact de contrat, pas un fichier à retoucher.
Toute la documentation par-symbole (clauses de propriété, thread-safety, valeurs de
retour) y vit déjà en commentaires /// ; Doxygen la rend en HTML + pages man.
Il est #pragma once et compatible C++ (gardes extern "C") : utilisable tel
quel depuis un .c comme depuis un .cpp.
2. Lier la bibliothèque
La crate air-base-capi produit une cdylib nommée libair_base.so (artefact
de build), liée par -lair_base :
# Depuis la racine du dépôt, après avoir bâti la cdylib :
cargo build -p air-base-capi # → target/debug/libair_base.so
cc mon_programme.c \
-I crates/air-base-capi/include \
-L target/debug -lair_base \
-Wl,-rpath,target/debug \
-o mon_programme
-I …/include: pour que#include "air_base.h"soit trouvé.-L … -lair_base:-lair_baserésoutlibair_base.soà l’édition de liens.-Wl,-rpath,…: pour qu’à l’exécution le chargeur trouve la lib sansLD_LIBRARY_PATH(pratique en développement ; en production, la lib est installée dans un répertoire standard et porte le sonamelibair-base.so).
Note de nommage. L’artefact de build est
libair_base.so(souligné, ⇒-lair_base). Le soname installé visé estlibair-base.so(tiret), forme publique du projet. Les deux désignent la même bibliothèque ; ne mélangez pas les deux à l’édition de liens (utilisez-lair_basesur l’arbre de build).
3. Versionnement & ABI (ADR-012)
Cette ABI est un contrat stable, garanti 10 ans :
- Les discriminants d’enum (
AirStatus,AirLogLevel) sont explicites et committés :AIR_STATUS_OK == 0aujourd’hui le restera. Vous pouvez câbler ces valeurs en dur en confiance. - Le header committé est diffé en CI : aucune dérive d’ABI ne peut passer
silencieusement (outillage
air-abi-check/air-symver). Un changement incompatible exigerait une nouvelle version majeure de soname. - Les tailles de buffer sont des constantes du header
(
AIR_UUID_HYPHENATED_LEN,AIR_ID128_HEX_LEN) : utilisez toujours la constante, jamais un littéral, pour rester correct si le format évoluait.
4. Surface disponible (T1 → T3)
| Domaine | Fonctions | Détail |
|---|---|---|
| Identifiants | air_uuid_new_v7/v4, air_uuid_to_hyphenated, air_uuid_parse, air_id128_machine_id, air_id128_to_hex, air_monotonic_id_next | POD + buffers appelant |
| Statut | air_status_message | chaîne statique (ne pas libérer) |
| Logging | air_log_open/close, air_log_emit, air_log_lost_count, air_log_fields_new/free/add/add_bytes | handles opaques (+1 au retour) |
| Temps | air_instant_now, air_instant_elapsed, air_instant_duration_since, air_duration_from_secs/millis/nanos, air_duration_as_secs_f64 | POD, réentrant |
Prochaine étape
Avant d’écrire la moindre ligne « réelle », lisez les deux pages qui conditionnent la correction de votre code :
- Modèle d’erreurs — comment tester chaque appel.
- Propriété mémoire & cycle de vie — quoi libérer, et avec quelle fonction.
English version: Getting started.
Modèle d’erreurs (AirStatus)
Air rend ses erreurs in-band : la valeur de retour porte le statut. Pas
d’errno, pas d’exception, aucune panic qui traverse l’ABI. Cette page
explique comment tester, lire et propager ces statuts — et pourquoi
ce modèle a été choisi (ADR-045).
La règle de base
Presque toute fonction rend un enum AirStatus. AIR_STATUS_OK == 0 est le seul
cas de succès, et le seul cas où un *out est écrit :
AirUuid u;
AirStatus st = air_uuid_new_v7(&u);
if (st != AIR_STATUS_OK) {
/* `u` n'a PAS été écrit — ne pas le lire. */
fprintf(stderr, "échec : %s\n", air_status_message(st));
return 1;
}
/* Ici seulement, `u` est valide. */
Piège. Ne lisez jamais un paramètre de sortie avant d’avoir vérifié
AIR_STATUS_OK. Sur erreur,*outest laissé intact (non initialisé si vous ne l’aviez pas initialisé). C’est volontaire : zéro écriture partielle.
Lire un statut : air_status_message
const char *msg = air_status_message(st); /* jamais NULL */
La chaîne est statique (strerror-like, sans locale ni état global) : valide
pour toute la durée du programme, à ne pas libérer. C’est un message technique
en anglais, destiné aux logs/diagnostics — pas un texte localisé pour l’utilisateur
final.
Le catalogue des statuts
Deux familles, aux discriminants committés (ADR-012) :
| Plage | Origine | Exemples |
|---|---|---|
0 | succès | AIR_STATUS_OK |
1..=13 | miroir de AirErrorKind (couche 1) | AIR_STATUS_NOT_FOUND, AIR_STATUS_INVALID_DATA, AIR_STATUS_IO… |
100, 101 | propres à la frontière C | AIR_STATUS_NULL_ARGUMENT, AIR_STATUS_BUFFER_TOO_SMALL |
Les deux statuts de frontière sont ceux que vous pouvez déclencher par un mauvais usage de l’API :
AIR_STATUS_NULL_ARGUMENT(100) — un pointeur requis valaitNULL. Détecté par validation amont, sans déréférencement : passerNULLne provoque jamais de segfault côté Air, juste ce statut. (Les fonctions de libérationair_log_close/air_log_fields_freefont exception :NULLy est un no-op documenté, pas une erreur.)AIR_STATUS_BUFFER_TOO_SMALL(101) — votre buffer de sortie est trop petit. Rien n’est écrit (pas de troncature silencieuse, ADR-032). Agrandissez le buffer aux constantes du header et rappelez.
Pourquoi pas d’errno ?
errno est un état global par thread, fragile : facile à écraser entre l’appel
et sa lecture, invisible dans la signature, source de bugs classiques en C. Le statut
in-band est au contraire :
- explicite dans le type de retour (impossible de l’ignorer par mégarde) ;
- local (aucun état partagé, donc trivialement thread-safe) ;
- stable (discriminants committés).
Pourquoi aucune panic ne traverse l’ABI
La crate est compilée en panic = "abort". Conséquence : une panic Rust ne se
propage pas à travers la frontière C (ce serait un comportement indéfini) — elle
avorte le processus immédiatement (fail-fast). Autrement dit :
- une
panic= un bug dans Air, jamais une condition d’erreur normale ; - les conditions attendues (entrée invalide, ressource absente, buffer court) sont
toutes rendues via
AirStatus, jamais parpanic; - vous n’avez aucun
catchà écrire côté C, et aucunAIR_PANICà gérer : le contrat est « soitAirStatus, soit le processus s’arrête ».
C’est un choix de sûreté : mieux vaut un abort net et diagnosticable qu’un déroulement de pile qui corromprait l’état d’un appelant C.
Propager proprement
Comme AirStatus est une valeur, la propagation est triviale : relayez le statut, ou
mappez-le sur votre propre modèle d’erreurs. Schéma typique :
AirStatus do_work(AirLog *log) {
AirInstant t0;
AirStatus st = air_instant_now(&t0);
if (st != AIR_STATUS_OK) {
return st; /* remonte tel quel */
}
/* … */
return AIR_STATUS_OK;
}
English version: Error model.
Propriété mémoire & cycle de vie
LE point critique de l’ABI C. En C, rien ne vous rappelle de libérer — le compilateur ne suit pas la propriété. Cette page énonce, sans ambiguïté et pour chaque symbole, qui alloue, qui libère, et avec quelle fonction. Un développeur C doit toujours savoir s’il faut libérer un type, et comment.
La doctrine de propriété n’est pas un guide à part qui dériverait : elle naît du
commentaire /// de chaque symbole (reporté dans le header, rendu par Doxygen)
et est synthétisée ici (ADR-027 §B).
La règle uniforme : +1 au RETOUR, +0 en ARGUMENT
Une seule règle gouverne tout le modèle d’objet Air :
- Un type complexe RETOURNÉ est possédé par l’appelant (+1). Vous en êtes responsable → vous devez le libérer avec sa fonction de libération dédiée.
- Un type complexe passé en ARGUMENT est emprunté (+0). Vous gardez la
propriété ; l’appelé ne le libère pas et ne conserve aucune référence au-delà
de l’appel (sauf clause
///explicite — il n’y en a aucune en T1/T3).
Tout le reste découle de cette règle. Les trois catégories de types ci-dessous précisent comment elle s’applique concrètement.
Catégorie 1 — POD passés par valeur (rien à libérer)
AirUuid, AirId128, AirInstant, AirDuration, AirLogLevel, AirStatus sont
des POD (Plain Old Data, #[repr(C)]) trivialement copiables. Ils vivent là
où vous les déclarez (pile, struct, tableau) : aucune allocation Air, aucune
fonction de libération.
AirUuid u; /* sur la pile de l'appelant */
air_uuid_new_v7(&u); /* `&u` emprunté (+0), écrit sur OK */
/* `u` disparaît à la fin du scope — rien à faire. */
Quand un POD est passé par pointeur (const AirUuid *uuid, AirDuration *out),
c’est uniquement pour éviter une copie ou écrire une sortie : la mémoire reste la
vôtre (+0).
Catégorie 2 — Sorties chaînes par buffer appelant (rien à libérer)
Les fonctions de formatage (air_uuid_to_hyphenated, air_id128_to_hex) n’allouent
rien. Vous fournissez le buffer ; Air y écrit :
char buf[AIR_UUID_HYPHENATED_LEN]; /* 37, NUL inclus */
AirStatus st = air_uuid_to_hyphenated(&u, buf, sizeof buf);
/* `buf` est à vous (+0). Trop petit ⇒ AIR_STATUS_BUFFER_TOO_SMALL, rien écrit. */
- Dimensionnez avec les constantes du header, jamais un littéral :
AIR_UUID_HYPHENATED_LEN(37),AIR_ID128_HEX_LEN(33). LeNULfinal est inclus. - Buffer trop court ⇒
AIR_STATUS_BUFFER_TOO_SMALL, aucune écriture partielle (ADR-032). - Aucune
air_*_free: la mémoire est la vôtre, sa durée de vie est la vôtre.
Cas particulier — air_status_message renvoie un const char * vers une chaîne
statique : elle n’est jamais libérée, jamais NULL, valide pour toute la
durée du programme. Ne la libérez pas (ce serait une faute).
Catégorie 3 — Handles opaques (+1 au retour → vous DEVEZ libérer)
AirLog et AirLogFields sont des handles opaques : des typedef struct …
jamais déréférencés côté C. Ils sont alloués par Air et rendus possédés (+1) ;
chacun a une fonction de libération dédiée :
| Type | Créé par (+1) | Libéré par |
|---|---|---|
AirLog | air_log_open | air_log_close |
AirLogFields | air_log_fields_new | air_log_fields_free |
AirLog *log = NULL;
if (air_log_open(NULL, &log) == AIR_STATUS_OK) { /* +1 : log possédé */
/* … usage … */
air_log_close(log); /* libération obligatoire */
}
Règles de cycle de vie des handles :
- Écrits sur
AIR_STATUS_OKseul. Sur erreur,*outreste inchangé : il n’y a rien à libérer (n’appelez pasclose/freesur un handle non obtenu). close/freeconsomment le handle (+1 → libéré). Après l’appel, le pointeur est invalide : ne le réutilisez plus, et ne le libérez pas deux fois (double-free interdit).NULLest un no-op pourair_log_close/air_log_fields_free: libérer unNULLest sûr (pratique pour les chemins d’erreur).- Le handle passé à
air_log_emit(const AirLog *,const AirLogFields *) est emprunté (+0) :emitne le libère pas et n’en garde pas de référence. C’est toujours à vous d’appelerclose/free.
Tableau récapitulatif par symbole
| Symbole | Retour | Argument(s) | Fonction de libération |
|---|---|---|---|
air_uuid_new_v7 / _v4 | AirStatus | out emprunté +0 (POD, écrit sur OK) | — |
air_uuid_to_hyphenated | AirStatus | uuid +0 ; buf appelant +0 | — |
air_uuid_parse | AirStatus | s +0 (C-string) ; out +0 | — |
air_id128_machine_id | AirStatus | out +0 (POD) | — |
air_id128_to_hex | AirStatus | id +0 ; buf appelant +0 | — |
air_monotonic_id_next | AirStatus | out +0 (uint64_t) | — |
air_status_message | const char * statique | status (par valeur) | ne pas libérer |
air_log_open | AirStatus | namespace_ +0 ; *out possédé +1 | air_log_close |
air_log_close | void | consomme log (NULL = no-op) | (c’est la libération) |
air_log_emit | AirStatus | log / message / fields empruntés +0 | — |
air_log_lost_count | AirStatus | log +0 ; out +0 | — |
air_log_fields_new | AirStatus | *out possédé +1 | air_log_fields_free |
air_log_fields_free | void | consomme fields (NULL = no-op) | (c’est la libération) |
air_log_fields_add | AirStatus | fields emprunté-mut +0 ; key/value +0 | — |
air_log_fields_add_bytes | AirStatus | fields +0 ; key +0 ; value +0 (len octets) | — |
air_instant_now | AirStatus | out +0 (POD) | — |
air_instant_elapsed | AirStatus | since +0 ; out +0 | — |
air_instant_duration_since | AirStatus | later/earlier +0 ; out +0 | — |
air_duration_from_secs/millis/nanos | AirStatus | out +0 (POD) | — |
air_duration_as_secs_f64 | AirStatus | d +0 ; out +0 (double) | — |
Lecture du tableau : une ligne avec une fonction de libération non vide est la seule où vous avez quelque chose à libérer. Toutes les autres : rien.
Encodage des chaînes & buffers
- Les
const char *d’entrée sont des chaînes CNUL-terminées attendues en UTF-8 ; un contenu non-UTF-8 rendAIR_STATUS_INVALID_DATA(jamais de déréférencement d’unNULL: passerNULLrendAIR_STATUS_NULL_ARGUMENT). air_log_fields_add_bytesaccepte une valeur binaire (lenoctets, sauts de ligne etNULautorisés) : c’est le seul cas « octets bruts », emprunté +0 le temps de l’appel.- Les buffers de sortie sont toujours fournis et possédés par vous (catégorie 2).
Secrets & refcompté
En T1/T3, aucun type ne transporte de secret et aucun n’expose de
retain/release : la règle « +1 retour / +0 argument » suffit. Quand la libc Air
introduira des objets refcomptés ou porteurs de secrets, leur clause /// dédiée le
précisera explicitement (paire retain/release, effacement zeroize à la charge de
qui) — toujours selon le même principe : la propriété est gravée dans le commentaire
de chaque symbole.
English version: Memory ownership & lifecycle.
Thread-safety par type
L’ABI C déclare, par type, ce qui est appelable depuis quel thread (ADR-027 §B.4). En C, le compilateur ne vérifie rien : respecter ces politiques est votre responsabilité. Cette page les énonce avec les pièges concrets.
Tableau des politiques
| Type / fonctions | Politique | Ce que cela autorise |
|---|---|---|
AirLog (air_log_emit, air_log_lost_count) | ThreadSafe | même handle partagé entre plusieurs threads |
AirLog (air_log_close) | consomme | fermeture sans usage concurrent |
AirLogFields (air_log_fields_*) | NON thread-safe | construire/muter sur un seul thread |
Temps & identifiants (air_instant_*, air_duration_*, air_uuid_*, air_id128_*, air_monotonic_id_next, air_status_message) | réentrant / sans état partagé | appel concurrent libre |
AirLog = ThreadSafe
Un même handle AirLog peut être partagé entre plusieurs threads : son cœur est
un Arc<JournalSink> et l’émission est un envoi de datagramme atomique. Plusieurs
threads peuvent donc appeler air_log_emit / air_log_lost_count sur le même
handle, en parallèle, sans verrou de votre part :
/* thread A et thread B, même `log` — OK */
air_log_emit(log, AIR_LOG_LEVEL_INFO, "depuis A", NULL);
air_log_emit(log, AIR_LOG_LEVEL_ERROR, "depuis B", NULL);
Piège — la fermeture, elle, n’est pas concurrente.
air_log_closeconsomme le handle (il le libère). Vous devez garantir qu’aucun autre thread n’utiliselogau moment duclose, et qu’il n’est appelé qu’une fois. Schéma sûr : faire travailler tous les threads, les joindre, puisclosedepuis un seul thread.
AirLogFields = NON thread-safe
AirLogFields est un builder mutable : chaque air_log_fields_add[_bytes] le
modifie. Il n’est protégé par aucun verrou. N’y touchez jamais depuis deux threads à
la fois.
Le modèle d’usage prévu — et sûr — est « construire mono-thread, puis lire » :
/* 1. Un SEUL thread construit le builder. */
AirLogFields *f = NULL;
air_log_fields_new(&f);
air_log_fields_add(f, "REQUEST_ID", "abc-123");
air_log_fields_add(f, "USER", "alice");
/* 2. Passé en lecture (+0) à emit : sûr tant que personne ne le mute en parallèle. */
air_log_emit(log, AIR_LOG_LEVEL_INFO, "requête traitée", f);
air_log_fields_free(f); /* libéré par le thread propriétaire */
Le danger concret. Deux threads appelant
air_log_fields_addsur le mêmeAirLogFields= course de données (corruption de la structure interne, undefined behavior). Si plusieurs threads doivent enrichir un log, donnez à chacun son propreAirLogFields, ou sérialisez lesaddderrière votre mutex.
Pourquoi ce choix ? Un builder est par nature un objet de travail local : le
rendre thread-safe imposerait un verrou interne coûteux à tous, pour un besoin rare.
La politique « un thread le construit, plusieurs peuvent ensuite logger via un AirLog
partagé » couvre le cas réel sans surcoût (Principe 5 : ne pas payer pour ce qu’on
n’utilise pas).
Temps & identifiants = réentrants / sans état partagé
- Toutes les fonctions de temps (
air_instant_now,air_instant_elapsed,air_instant_duration_since,air_duration_from_*,air_duration_as_secs_f64) et d’identifiants (air_uuid_*,air_id128_*,air_status_message) sont réentrantes - elles ne font qu’une lecture d’horloge kernel et/ou un calcul sur des POD locaux à
l’appelant, sans état partagé mutable. Vous pouvez les appeler concurremment
depuis autant de threads que vous voulez, chacun avec ses propres
out:
/* N threads, chacun avec son AirInstant local — aucun partage, aucun verrou */
AirInstant t;
air_instant_now(&t);
air_monotonic_id_next est le seul à toucher un compteur global au processus,
mais l’incrément est atomique : appel concurrent sûr, chaque thread reçoit une
valeur distincte et strictement croissante. (La monotonie est garantie au sein
d’un processus ; ce n’est pas un identifiant inter-processus.)
Règle mnémotechnique
- Logger (
AirLog) : partageable entre threads pour émettre ; fermez-le seul, une fois, sans usage concurrent. - Champs (
AirLogFields) : un thread, un builder. Jamais de mutation concurrente. - Temps & IDs : sans souci, partout, en parallèle.
English version: Thread-safety by type.
Exemples C compilables
Trois programmes complets, compilés contre le header committé et liés à
libair_base.so. Les sources autonomes vivent dans
docs/guides/abi-c/examples/
et sont compilées (advisory) par scripts/build-docs.sh quand cc et la cdylib
sont présents — preuve que le header et la lib s’emploient exactement comme décrit.
Compiler & lier (rappel)
cargo build -p air-base-capi # → target/debug/libair_base.so
cc docs/guides/abi-c/examples/<exemple>.c \
-I crates/air-base-capi/include \
-L target/debug -lair_base \
-Wl,-rpath,target/debug \
-o <exemple>
Voir Mise en route pour le détail des options.
(a) UUID : new_v7 → to_hyphenated → parse
Round-trip complet sur un POD ; rien à libérer (buffer sur la pile).
#include <stdio.h>
#include "air_base.h"
int main(void) {
AirUuid u;
if (air_uuid_new_v7(&u) != AIR_STATUS_OK) return 1; /* `&u` emprunté +0 */
char text[AIR_UUID_HYPHENATED_LEN]; /* buffer appelant (37) */
if (air_uuid_to_hyphenated(&u, text, sizeof text) != AIR_STATUS_OK) return 1;
printf("uuid v7 = %s\n", text);
AirUuid back;
if (air_uuid_parse(text, &back) != AIR_STATUS_OK) return 1;
return 0;
}
Points clés : text est votre buffer (catégorie 2) ; trop petit ⇒
AIR_STATUS_BUFFER_TOO_SMALL, rien écrit. Aucune air_*_free.
(b) Logging : open → fields → emit → close
Le seul exemple avec des handles possédés (+1) : il doit libérer dans l’ordre inverse de l’acquisition.
#include <stdio.h>
#include "air_base.h"
int main(void) {
AirLog *log = NULL;
AirStatus st = air_log_open(NULL /* journal par défaut */, &log); /* +1 */
if (st != AIR_STATUS_OK) {
fprintf(stderr, "air_log_open: %s\n", air_status_message(st));
return 1;
}
AirLogFields *fields = NULL;
if (air_log_fields_new(&fields) != AIR_STATUS_OK) { /* +1 */
air_log_close(log); /* libère l'acquis */
return 1;
}
air_log_fields_add(fields, "REQUEST_ID", "abc-123"); /* fields emprunté-mut +0 */
air_log_emit(log, AIR_LOG_LEVEL_INFO, "hello depuis C", fields); /* tout +0 */
air_log_fields_free(fields); /* libère le builder */
air_log_close(log); /* libère le logger */
return 0;
}
Points clés : open/new rendent des handles possédés → libération obligatoire
(close/free). emit emprunte tout (+0). Sur l’échec de fields_new, on libère
le log déjà acquis avant de sortir (pas de fuite). AirLogFields est mono-thread
(cf. Thread-safety).
(c) Temps : now → elapsed → as_secs_f64
Mesure d’écoulement monotone sur des POD ; réentrant, rien à libérer.
#include <stdio.h>
#include "air_base.h"
int main(void) {
AirInstant start;
if (air_instant_now(&start) != AIR_STATUS_OK) return 1; /* valeur absolue opaque */
/* ... travail à mesurer ... */
AirDuration d;
if (air_instant_elapsed(&start, &d) != AIR_STATUS_OK) return 1; /* saturé à 0 */
double secs;
if (air_duration_as_secs_f64(&d, &secs) != AIR_STATUS_OK) return 1;
printf("écoulé = %.9f s (%lld s + %u ns)\n",
secs, (long long)d.seconds, d.nanoseconds);
return 0;
}
Points clés : la valeur absolue d’un AirInstant est opaque — seules les
différences ont un sens. air_instant_elapsed est saturé à zéro si start
est dans le futur (jamais de durée négative). Tout est POD local, donc réentrant.
Et si cc est absent ?
Ces exemples sont fournis en bloc ci-dessus (lisibles sans rien compiler) ; la
compilation par build-docs.sh est advisory : son absence ne casse jamais le
build de la doc. La conformité « comme un vrai consommateur C » est par ailleurs
vérifiée par le test ABI de la crate (tests/abi/conformance.c).
English version: Compilable C examples.
C ABI guide — libair-base.so (overview)
Audience: C developers (and, eventually, Swift / Python / Ruby via bindings) consuming Air without writing Rust. These pages explain how to use the library and what obligations fall on the caller — not just what to call.
air-base-capi exposes the layer-1 air-base-lib (pure Rust) as a stable C ABI,
producing libair-base.so. This is the project’s first C-ABI pass: the proving
ground for the methodology of the whole future “Air libc” (Rust userland, redrawn
contracts, explicit memory ownership). Contract decisions:
- Single source of truth: per-symbol docs are born from the Rust
///comments, carried bycbindgeninto the committed headerinclude/air_base.h, then rendered by Doxygen (HTML +manpages). No hand-written C docs, hence zero drift (ADR-027). - Stability: the header is a C-ABI contract guaranteed for 10 years, committed and diffed in CI (ADR-012).
- Errors:
AirStatusreturned in-band, noerrno,panic = "abort"(ADR-045).
Why a C ABI, not just Rust
Rust is the implementation; the C ABI is the universal contract. Every language
can call C. By freezing the boundary in C — #[repr(C)] types, in-band status,
explicit ownership — Air becomes polyglot from day one without maintaining N
parallel docs: one header, one set of guarantees.
The pages in this guide
- Getting started — include the header, link the lib, versioning/ABI.
- Error model (
AirStatus) — test, read, propagate; why nopaniccrosses the ABI; why noerrno. - Memory ownership & lifecycle — the critical point: who allocates, who frees, with which function. Per-symbol table.
- Thread-safety by type — what is callable from which thread, with concrete pitfalls.
- Compilable C examples — UUID, logging, time measurement, compiled against the committed header.
The three rules to remember above all
- Check every return. Every function returns an
AirStatus(or a non-NULLpointer forair_status_message). An*outis written only onAIR_STATUS_OK. See Error model. - Uniform ownership rule: +1 on RETURN, +0 on ARGUMENT. A returned handle
(
air_log_open,air_log_fields_new) is owned by the caller → you must free it. A type passed as an argument is borrowed → the callee neither frees it nor retains it. See Memory ownership. - String outputs use YOUR buffer. No string is allocated by Air in T1/T3: you
provide the buffer (sizes
AIR_UUID_HYPHENATED_LEN/AIR_ID128_HEX_LEN); too small ⇒AIR_STATUS_BUFFER_TOO_SMALL(never silent truncation). Nothing to free.
Version française : voir Guide ABI C — vue d’ensemble.
Getting started (C ABI)
This page shows how to include, link, and version libair-base.so from a
C program. For the error model see Error model; for memory
ownership see Memory ownership.
1. Include the header
A single committed, authoritative header:
#include "air_base.h"
The header is generated from the Rust code by cbindgen and committed to the
repository (crates/air-base-capi/include/air_base.h). It carries a “DO NOT EDIT BY
HAND” warning: it is a contract artifact, not a file to tweak. All per-symbol docs
(ownership clauses, thread-safety, return values) already live there as ///
comments; Doxygen renders them to HTML + man pages.
It is #pragma once and C++-compatible (extern "C" guards): usable as-is from a
.c or a .cpp.
2. Link the library
The air-base-capi crate produces a cdylib named libair_base.so (build
artifact), linked with -lair_base:
# From the repo root, after building the cdylib:
cargo build -p air-base-capi # → target/debug/libair_base.so
cc my_program.c \
-I crates/air-base-capi/include \
-L target/debug -lair_base \
-Wl,-rpath,target/debug \
-o my_program
-I …/include: so#include "air_base.h"is found.-L … -lair_base:-lair_baseresolveslibair_base.soat link time.-Wl,-rpath,…: so the loader finds the lib at runtime withoutLD_LIBRARY_PATH(handy in development; in production the lib is installed in a standard directory and carries the sonamelibair-base.so).
Naming note. The build artifact is
libair_base.so(underscore, ⇒-lair_base). The intended installed soname islibair-base.so(hyphen), the project’s public form. Both name the same library; don’t mix the two when linking (use-lair_baseagainst the build tree).
3. Versioning & ABI (ADR-012)
This ABI is a stable contract, guaranteed for 10 years:
- Enum discriminants (
AirStatus,AirLogLevel) are explicit and committed:AIR_STATUS_OK == 0today will stay so. You can hard-wire these values with confidence. - The committed header is diffed in CI: no ABI drift can slip through silently
(
air-abi-check/air-symvertooling). An incompatible change would require a new major soname version. - Buffer sizes are header constants (
AIR_UUID_HYPHENATED_LEN,AIR_ID128_HEX_LEN): always use the constant, never a literal, to stay correct if the format ever changes.
4. Available surface (T1 → T3)
| Domain | Functions | Note |
|---|---|---|
| Identifiers | air_uuid_new_v7/v4, air_uuid_to_hyphenated, air_uuid_parse, air_id128_machine_id, air_id128_to_hex, air_monotonic_id_next | POD + caller buffers |
| Status | air_status_message | static string (do not free) |
| Logging | air_log_open/close, air_log_emit, air_log_lost_count, air_log_fields_new/free/add/add_bytes | opaque handles (+1 on return) |
| Time | air_instant_now, air_instant_elapsed, air_instant_duration_since, air_duration_from_secs/millis/nanos, air_duration_as_secs_f64 | POD, reentrant |
Next step
Before writing any “real” line, read the two pages that govern the correctness of your code:
- Error model — how to check every call.
- Memory ownership & lifecycle — what to free, and with which function.
Version française : Mise en route.
Error model (AirStatus)
Air returns errors in-band: the return value carries the status. No errno, no
exceptions, no panic crosses the ABI. This page explains how to check,
read, and propagate these statuses — and why this model was chosen
(ADR-045).
The basic rule
Almost every function returns an enum AirStatus. AIR_STATUS_OK == 0 is the only
success case, and the only case where an *out is written:
AirUuid u;
AirStatus st = air_uuid_new_v7(&u);
if (st != AIR_STATUS_OK) {
/* `u` was NOT written — do not read it. */
fprintf(stderr, "failed: %s\n", air_status_message(st));
return 1;
}
/* Only here is `u` valid. */
Pitfall. Never read an output parameter before checking for
AIR_STATUS_OK. On error,*outis left untouched (uninitialized if you didn’t initialize it). This is deliberate: zero partial writes.
Reading a status: air_status_message
const char *msg = air_status_message(st); /* never NULL */
The string is static (strerror-like, no locale, no global state): valid for the
whole program lifetime, must not be freed. It is a technical English message for
logs/diagnostics — not a localized end-user string.
The status catalogue
Two families, with committed discriminants (ADR-012):
| Range | Origin | Examples |
|---|---|---|
0 | success | AIR_STATUS_OK |
1..=13 | mirror of AirErrorKind (layer 1) | AIR_STATUS_NOT_FOUND, AIR_STATUS_INVALID_DATA, AIR_STATUS_IO… |
100, 101 | C-boundary specific | AIR_STATUS_NULL_ARGUMENT, AIR_STATUS_BUFFER_TOO_SMALL |
The two boundary statuses are the ones you can trigger by misusing the API:
AIR_STATUS_NULL_ARGUMENT(100) — a required pointer wasNULL. Detected by upfront validation, without dereferencing: passingNULLnever segfaults on Air’s side, you just get this status. (The release functionsair_log_close/air_log_fields_freeare an exception:NULLthere is a documented no-op, not an error.)AIR_STATUS_BUFFER_TOO_SMALL(101) — your output buffer is too small. Nothing is written (no silent truncation, ADR-032). Grow the buffer to the header constants and call again.
Why no errno?
errno is per-thread global state, and brittle: easy to clobber between the call
and its read, invisible in the signature, a source of classic C bugs. The in-band
status is, by contrast:
- explicit in the return type (impossible to ignore by accident);
- local (no shared state, so trivially thread-safe);
- stable (committed discriminants).
Why no panic crosses the ABI
The crate is built with panic = "abort". As a result, a Rust panic does not
propagate across the C boundary (that would be undefined behavior) — it aborts the
process immediately (fail-fast). In other words:
- a
panic= a bug in Air, never a normal error condition; - expected conditions (invalid input, missing resource, short buffer) are all
reported via
AirStatus, never viapanic; - you have no
catchto write on the C side, and noAIR_PANICto handle: the contract is “eitherAirStatus, or the process stops”.
This is a safety choice: a clean, diagnosable abort beats a stack unwind that would corrupt a C caller’s state.
Propagate cleanly
Since AirStatus is a value, propagation is trivial: relay the status, or map it onto
your own error model. Typical pattern:
AirStatus do_work(AirLog *log) {
AirInstant t0;
AirStatus st = air_instant_now(&t0);
if (st != AIR_STATUS_OK) {
return st; /* bubble up as-is */
}
/* … */
return AIR_STATUS_OK;
}
Version française : Modèle d’erreurs.
Memory ownership & lifecycle
The critical point of the C ABI. In C, nothing reminds you to free — the compiler does not track ownership. This page states, unambiguously and for every symbol, who allocates, who frees, and with which function. A C developer must always know whether to free a type, and how.
The ownership doctrine is not a separate guide that could drift: it is born from
the /// comment on each symbol (carried into the header, rendered by Doxygen)
and synthesized here (ADR-027 §B).
The uniform rule: +1 on RETURN, +0 on ARGUMENT
A single rule governs the whole Air object model:
- A complex type RETURNED is owned by the caller (+1). You are responsible for it → you must free it with its dedicated release function.
- A complex type passed as an ARGUMENT is borrowed (+0). You keep ownership;
the callee does not free it and retains no reference beyond the call (unless an
explicit
///clause says otherwise — there is none in T1/T3).
Everything else follows from this rule. The three categories below show how it applies concretely.
Category 1 — POD passed by value (nothing to free)
AirUuid, AirId128, AirInstant, AirDuration, AirLogLevel, AirStatus are
POD (Plain Old Data, #[repr(C)]), trivially copyable. They live where you
declare them (stack, struct, array): no Air allocation, no release function.
AirUuid u; /* on the caller's stack */
air_uuid_new_v7(&u); /* `&u` borrowed (+0), written on OK */
/* `u` vanishes at end of scope — nothing to do. */
When a POD is passed by pointer (const AirUuid *uuid, AirDuration *out), it is
only to avoid a copy or to write an output: the memory stays yours (+0).
Category 2 — String outputs via caller buffer (nothing to free)
The formatting functions (air_uuid_to_hyphenated, air_id128_to_hex) allocate
nothing. You provide the buffer; Air writes into it:
char buf[AIR_UUID_HYPHENATED_LEN]; /* 37, NUL included */
AirStatus st = air_uuid_to_hyphenated(&u, buf, sizeof buf);
/* `buf` is yours (+0). Too small ⇒ AIR_STATUS_BUFFER_TOO_SMALL, nothing written. */
- Size with the header constants, never a literal:
AIR_UUID_HYPHENATED_LEN(37),AIR_ID128_HEX_LEN(33). The trailingNULis included. - A short buffer ⇒
AIR_STATUS_BUFFER_TOO_SMALL, no partial write (ADR-032). - No
air_*_free: the memory is yours, its lifetime is yours.
Special case — air_status_message returns a const char * to a static
string: it is never freed, never NULL, valid for the whole program lifetime. Do
not free it (that would be a fault).
Category 3 — Opaque handles (+1 on return → you MUST free)
AirLog and AirLogFields are opaque handles: typedef struct … never
dereferenced on the C side. They are allocated by Air and returned owned (+1);
each has one dedicated release function:
| Type | Created by (+1) | Freed by |
|---|---|---|
AirLog | air_log_open | air_log_close |
AirLogFields | air_log_fields_new | air_log_fields_free |
AirLog *log = NULL;
if (air_log_open(NULL, &log) == AIR_STATUS_OK) { /* +1: log owned */
/* … use … */
air_log_close(log); /* mandatory release */
}
Handle lifecycle rules:
- Written on
AIR_STATUS_OKonly. On error,*outis unchanged: there is nothing to free (don’t callclose/freeon a handle you didn’t obtain). close/freeconsume the handle (+1 → freed). After the call the pointer is invalid: don’t reuse it, and don’t free it twice (double-free forbidden).NULLis a no-op forair_log_close/air_log_fields_free: freeing aNULLis safe (handy for error paths).- The handle passed to
air_log_emit(const AirLog *,const AirLogFields *) is borrowed (+0):emitdoes not free it and keeps no reference. It is always up to you to callclose/free.
Per-symbol summary table
| Symbol | Return | Argument(s) | Release function |
|---|---|---|---|
air_uuid_new_v7 / _v4 | AirStatus | out borrowed +0 (POD, written on OK) | — |
air_uuid_to_hyphenated | AirStatus | uuid +0 ; buf caller +0 | — |
air_uuid_parse | AirStatus | s +0 (C-string) ; out +0 | — |
air_id128_machine_id | AirStatus | out +0 (POD) | — |
air_id128_to_hex | AirStatus | id +0 ; buf caller +0 | — |
air_monotonic_id_next | AirStatus | out +0 (uint64_t) | — |
air_status_message | const char * static | status (by value) | do not free |
air_log_open | AirStatus | namespace_ +0 ; *out owned +1 | air_log_close |
air_log_close | void | consumes log (NULL = no-op) | (this is the release) |
air_log_emit | AirStatus | log / message / fields borrowed +0 | — |
air_log_lost_count | AirStatus | log +0 ; out +0 | — |
air_log_fields_new | AirStatus | *out owned +1 | air_log_fields_free |
air_log_fields_free | void | consumes fields (NULL = no-op) | (this is the release) |
air_log_fields_add | AirStatus | fields borrowed-mut +0 ; key/value +0 | — |
air_log_fields_add_bytes | AirStatus | fields +0 ; key +0 ; value +0 (len bytes) | — |
air_instant_now | AirStatus | out +0 (POD) | — |
air_instant_elapsed | AirStatus | since +0 ; out +0 | — |
air_instant_duration_since | AirStatus | later/earlier +0 ; out +0 | — |
air_duration_from_secs/millis/nanos | AirStatus | out +0 (POD) | — |
air_duration_as_secs_f64 | AirStatus | d +0 ; out +0 (double) | — |
Reading the table: a row with a non-empty release function is the only kind where you have something to free. All others: nothing.
String & buffer encoding
- Input
const char *are NUL-terminated C strings expected as UTF-8; non-UTF-8 content returnsAIR_STATUS_INVALID_DATA(never aNULLdereference: passingNULLreturnsAIR_STATUS_NULL_ARGUMENT). air_log_fields_add_bytesaccepts a binary value (lenbytes, newlines andNULallowed): the only “raw bytes” case, borrowed +0 for the duration of the call.- Output buffers are always provided and owned by you (category 2).
Secrets & reference counting
In T1/T3, no type carries a secret and none exposes retain/release: the
“+1 return / +0 argument” rule suffices. When the Air libc introduces reference-counted
or secret-bearing objects, their dedicated /// clause will state it explicitly
(paired retain/release, whose responsibility the zeroize wipe is) — always under
the same principle: ownership is carved into each symbol’s comment.
Version française : Propriété mémoire & cycle de vie.
Thread-safety by type
The C ABI declares, per type, what is callable from which thread (ADR-027 §B.4). In C the compiler checks nothing: honoring these policies is your responsibility. This page states them with the concrete pitfalls.
Policy table
| Type / functions | Policy | What it allows |
|---|---|---|
AirLog (air_log_emit, air_log_lost_count) | ThreadSafe | same handle shared across multiple threads |
AirLog (air_log_close) | consumes | close with no concurrent use |
AirLogFields (air_log_fields_*) | NOT thread-safe | build/mutate on one thread |
Time & identifiers (air_instant_*, air_duration_*, air_uuid_*, air_id128_*, air_monotonic_id_next, air_status_message) | reentrant / no shared state | free concurrent calls |
AirLog = ThreadSafe
A single AirLog handle may be shared across multiple threads: its core is an
Arc<JournalSink> and emission is an atomic datagram send. Multiple threads may
therefore call air_log_emit / air_log_lost_count on the same handle, in
parallel, with no locking on your part:
/* thread A and thread B, same `log` — OK */
air_log_emit(log, AIR_LOG_LEVEL_INFO, "from A", NULL);
air_log_emit(log, AIR_LOG_LEVEL_ERROR, "from B", NULL);
Pitfall — closing, however, is not concurrent.
air_log_closeconsumes the handle (it frees it). You must guarantee that no other thread is usinglogat the moment ofclose, and that it is called exactly once. Safe pattern: let all threads work, join them, thenclosefrom a single thread.
AirLogFields = NOT thread-safe
AirLogFields is a mutable builder: each air_log_fields_add[_bytes] modifies it.
It is protected by no lock. Never touch it from two threads at once.
The intended — and safe — usage model is “build single-threaded, then read”:
/* 1. A SINGLE thread builds the builder. */
AirLogFields *f = NULL;
air_log_fields_new(&f);
air_log_fields_add(f, "REQUEST_ID", "abc-123");
air_log_fields_add(f, "USER", "alice");
/* 2. Passed read-only (+0) to emit: safe as long as nobody mutates it in parallel. */
air_log_emit(log, AIR_LOG_LEVEL_INFO, "request handled", f);
air_log_fields_free(f); /* freed by the owning thread */
The concrete danger. Two threads calling
air_log_fields_addon the sameAirLogFields= a data race (corruption of the internal structure, undefined behavior). If several threads must enrich a log, give each its ownAirLogFields, or serialize theadds behind your mutex.
Why this choice? A builder is by nature a thread-local working object: making it
thread-safe would impose a costly internal lock on everyone, for a rare need. The
policy “one thread builds it, several can then log via a shared AirLog” covers the
real case at no cost (Principle 5: don’t pay for what you don’t use).
Time & identifiers = reentrant / no shared state
All time functions (air_instant_now, air_instant_elapsed,
air_instant_duration_since, air_duration_from_*, air_duration_as_secs_f64) and
identifier functions (air_uuid_*, air_id128_*, air_status_message) are
reentrant: they do only a kernel clock read and/or a computation on caller-local
POD, with no mutable shared state. You may call them concurrently from as many
threads as you like, each with its own out:
/* N threads, each with its own local AirInstant — no sharing, no lock */
AirInstant t;
air_instant_now(&t);
air_monotonic_id_next is the only one touching a process-global counter, but the
increment is atomic: safe to call concurrently, each thread gets a distinct and
strictly increasing value. (Monotonicity is guaranteed within a process; it is
not an inter-process identifier.)
Mnemonic rule
- Logger (
AirLog): shareable across threads to emit; close it alone, once, with no concurrent use. - Fields (
AirLogFields): one thread, one builder. Never mutate concurrently. - Time & IDs: worry-free, everywhere, in parallel.
Version française : Thread-safety par type.
Compilable C examples
Three complete programs, compiled against the committed header and linked to
libair_base.so. The standalone sources live in
docs/guides/abi-c/examples/
and are compiled (advisory) by scripts/build-docs.sh when cc and the cdylib
are present — proof that the header and the lib are used exactly as described.
Compile & link (reminder)
cargo build -p air-base-capi # → target/debug/libair_base.so
cc docs/guides/abi-c/examples/<example>.c \
-I crates/air-base-capi/include \
-L target/debug -lair_base \
-Wl,-rpath,target/debug \
-o <example>
See Getting started for the option details.
(a) UUID: new_v7 → to_hyphenated → parse
A full round-trip on a POD; nothing to free (stack buffer).
#include <stdio.h>
#include "air_base.h"
int main(void) {
AirUuid u;
if (air_uuid_new_v7(&u) != AIR_STATUS_OK) return 1; /* `&u` borrowed +0 */
char text[AIR_UUID_HYPHENATED_LEN]; /* caller buffer (37) */
if (air_uuid_to_hyphenated(&u, text, sizeof text) != AIR_STATUS_OK) return 1;
printf("uuid v7 = %s\n", text);
AirUuid back;
if (air_uuid_parse(text, &back) != AIR_STATUS_OK) return 1;
return 0;
}
Key points: text is your buffer (category 2); too small ⇒
AIR_STATUS_BUFFER_TOO_SMALL, nothing written. No air_*_free.
(b) Logging: open → fields → emit → close
The only example with owned handles (+1): it must free in the reverse order of acquisition.
#include <stdio.h>
#include "air_base.h"
int main(void) {
AirLog *log = NULL;
AirStatus st = air_log_open(NULL /* default journal */, &log); /* +1 */
if (st != AIR_STATUS_OK) {
fprintf(stderr, "air_log_open: %s\n", air_status_message(st));
return 1;
}
AirLogFields *fields = NULL;
if (air_log_fields_new(&fields) != AIR_STATUS_OK) { /* +1 */
air_log_close(log); /* free what was acquired */
return 1;
}
air_log_fields_add(fields, "REQUEST_ID", "abc-123"); /* fields borrowed-mut +0 */
air_log_emit(log, AIR_LOG_LEVEL_INFO, "hello from C", fields); /* all +0 */
air_log_fields_free(fields); /* free the builder */
air_log_close(log); /* free the logger */
return 0;
}
Key points: open/new return owned handles → mandatory release
(close/free). emit borrows everything (+0). On fields_new failure, the
already-acquired log is freed before returning (no leak). AirLogFields is
single-threaded (see Thread-safety).
(c) Time: now → elapsed → as_secs_f64
Monotonic elapsed-time measurement on POD; reentrant, nothing to free.
#include <stdio.h>
#include "air_base.h"
int main(void) {
AirInstant start;
if (air_instant_now(&start) != AIR_STATUS_OK) return 1; /* absolute value opaque */
/* ... work to be measured ... */
AirDuration d;
if (air_instant_elapsed(&start, &d) != AIR_STATUS_OK) return 1; /* saturated at 0 */
double secs;
if (air_duration_as_secs_f64(&d, &secs) != AIR_STATUS_OK) return 1;
printf("elapsed = %.9f s (%lld s + %u ns)\n",
secs, (long long)d.seconds, d.nanoseconds);
return 0;
}
Key points: an AirInstant’s absolute value is opaque — only differences
matter. air_instant_elapsed is saturated at zero if start is in the future
(never a negative duration). Everything is local POD, hence reentrant.
What if cc is absent?
These examples are provided inline above (readable without compiling anything); the
compilation by build-docs.sh is advisory: its absence never breaks the doc build.
Conformance “like a real C consumer” is additionally verified by the crate’s ABI test
(tests/abi/conformance.c).
Version française : Exemples C compilables.
Phase A — Décisions techniques restantes
Document de setup phase 0 — Version 1.0
Vue d’ensemble
La Phase A couvre les décisions techniques structurantes qui n’ont pas été tranchées dans les ADRs fondateurs et qui sont nécessaires avant le démarrage du code. Cette phase formalise les choix concernant :
- Le runtime asynchrone.
- La cryptographie.
- L’outillage de fuzzing.
- La gestion des erreurs en couches supérieures.
- Le workflow de gestion des dépendances.
Deux ADRs ont été émis suite à cette phase :
- ADR-023 : Runtime asynchrone Air sur io_uring.
- ADR-024 : Workflow de gestion des dépendances.
Les autres décisions sont des conventions documentées sans ADR formel.
1. Runtime asynchrone
Décision : runtime maison construit progressivement, en parallèle de la phase 1.
Voir ADR-023 pour la justification complète et le périmètre.
Justifications résumées :
- Alignement parfait avec io_uring (la couche 0 est io_uring-native).
- Principe d’ingénierie 6 (règle des 80%) : Tokio est massif, Air en utiliserait 20-30%.
- Principe d’ingénierie 7 (verbosité au service de la clarté) : pas de magie cachée.
- Leçon de l’expérience Tokio : éviter les écueils déjà identifiés.
- Cohérence verticale du stack Air.
Phasage proposé. Le runtime maison se construit en couche 1, en parallèle du modèle d’objet C-ABI. Phase 0 du projet : couche 0 + types. Phase 1 : runtime async maison + modèle d’objet C-ABI.
Nom et structure. Le runtime s’appelle air-runtime, organisé en plusieurs sous-crates :
air-runtime-core: scheduler, tâches, primitives async.air-runtime-io: intégration io_uring.air-runtime-time: timers basés sur timerfd.air-runtime-signal: gestion des signaux async via signalfd.air-runtime-sync: mutex async, channels, notify.air-runtime: façade qui réexporte les modules nécessaires.
2. Cryptographie
Décision : RustCrypto pour les primitives, avec audit explicite par crate utilisée. Différé à la phase 1 ou 2.
Justifications :
- Pur Rust, pas de dépendance C.
- Modulaire : Air n’inclut que les primitives dont il a réellement besoin.
- Audité : les crates principales (
sha2,ed25519-dalek,x25519-dalek) ont des audits publics. - Maintenu activement.
Coût de désengagement. Moyen. L’API des primitives crypto est suffisamment stable que la migration serait possible.
Décision opérationnelle. Pour la phase 0, on n’a pas besoin de crypto. Le sujet est différé à la phase 1 ou phase 2 quand on construira les premières fonctionnalités qui en ont besoin. À ce moment-là, audit explicite sous Principe 6 pour chaque crate ajoutée.
Pas d’ADR formel à ce stade ; la décision sera prise au moment où le besoin émerge.
3. Outillage de fuzzing
Décision : cargo-fuzz comme outillage principal.
Justifications :
- Standard de l’écosystème Rust.
- Bien intégré à Cargo.
- Documentation extensive.
- Communauté large.
Pas d’ADR formel. Convention par défaut de l’écosystème Rust.
D’autres outils (afl.rs, honggfuzz) peuvent être ajoutés pour des cibles spécifiques si nécessaire.
4. Gestion des erreurs en couches supérieures
Décision : thiserror comme dépendance, avec coût de désengagement faible documenté.
Justifications :
- C’est essentiellement une macro. Pas de logique runtime.
- Standard de facto pour les bibliothèques Rust.
- Principe 6 respecté (usage concentré à ~100% par site d’utilisation).
- Élimine le boilerplate.
Convention. Tout Error Air dérive thiserror::Error. Pas d’ADR formel, mais à mentionner dans les conventions de code (Phase B).
Voir aussi ADR-019 pour le modèle d’erreurs hybride à deux niveaux.
5. Workflow de gestion des dépendances
Décision : workflow explicite avec DEPENDENCIES.md par crate et EXCEPTIONS.md au workspace.
Voir ADR-024 pour la spécification complète.
Points clés :
- Format standardisé de
DEPENDENCIES.md(nom, version, % API utilisée, justification, coût de désengagement, statut d’audit). - Format de
EXCEPTIONS.mdpour les exceptions à la règle des 80%. - Processus d’ajout encadré par revue de mainteneur.
- Audit régulier en fin de phase.
- Outillage CI :
cargo audit,cargo deny,cargo machete, script Air maison.
Récapitulatif Phase A
Décisions techniques tranchées :
| Sujet | Décision | ADR |
|---|---|---|
| Runtime async | Maison, construit en phase 1 sur io_uring | ADR-023 |
| Cryptographie | Différé à la phase 1 ou 2 (RustCrypto envisagé) | Pas d’ADR maintenant |
| Fuzzing | cargo-fuzz (convention écosystème) | Pas d’ADR |
| Erreurs couches sup | thiserror (faible coût désengagement) | Convention en Phase B |
| Workflow dépendances | DEPENDENCIES.md + EXCEPTIONS.md par crate | ADR-024 |
Licence du document : MPL 2.0 Statut : Document de setup phase 0.
Phase B1 — Structure physique et style guide général
Document de setup phase 0 — Version 1.0
Vue d’ensemble
La Phase B1 couvre comment le code Air est organisé physiquement et comment il est écrit. Ce sont les conventions qu’un contributeur rencontre dès son premier patch.
L’objectif est d’avoir un projet où un développeur découvrant n’importe quel fichier reconnaît immédiatement les conventions qui s’appliquent.
Section 1 : Structure physique du workspace
Mono-repo Cargo workspace
Air est organisé en un mono-repo unique contenant un workspace Cargo. Ce choix offre plusieurs avantages :
- Builds atomiques.
- Refactorings transverses faciles.
- Tests d’intégration triviaux entre crates.
- Cargo.lock unique partagé.
C’est la convention de facto pour les projets Rust de taille industrielle.
Structure des dossiers à la racine
air/
├── Cargo.toml # Manifest du workspace
├── Cargo.lock # Lockfile partagé
├── rust-toolchain.toml # Version Rust pinnée
├── rustfmt.toml # Configuration de formatage
├── .clippy.toml # Configuration de lints
├── deny.toml # Configuration cargo-deny
├── EXCEPTIONS.md # Exceptions au Principe 6
├── README.md # Présentation publique
├── LICENSE # MPL 2.0
├── CHARTER.md # Lien vers la Charte
├── ENGINEERING_PRINCIPLES.md # Lien vers les Principes
├── CODE_OF_CONDUCT.md # Contributor Covenant
├── CONTRIBUTING.md # Guide de contribution
├── SECURITY.md # Politique de sécurité
│
├── docs/ # Documentation projet
│ ├── adrs/ # Architecture Decision Records
│ ├── vision/ # Vision document (FR + EN)
│ ├── specs/ # Specifications techniques
│ └── guides/ # Guides développeur
│
├── crates/ # Toutes les crates Air
│ ├── air-sys-types/
│ ├── air-sys-syscall/
│ ├── air-runtime-core/
│ ├── air-runtime-io/
│ └── ...
│
├── tools/ # Outils internes
│ ├── air-abi-check/
│ ├── air-symver/
│ └── air-deprecation-tracker/
│
├── tests/ # Tests d'intégration cross-crate
├── benches/ # Benchmarks transverses
├── .github/ # Configuration GitHub
│ ├── workflows/ # CI
│ └── PULL_REQUEST_TEMPLATE.md
└── scripts/ # Scripts de build, release, etc.
Conventions de nommage des crates
Préfixe air-. Toutes les crates Air commencent par air-.
Hiérarchie par modules logiques.
-
air-sys-*: couche 0 (système).air-sys-types: types fondamentaux.air-sys-syscall: wrappers de syscalls.
-
air-runtime-*: couche 1, runtime async.air-runtime-core,air-runtime-io,air-runtime-time, etc.
-
air-object-*: couche 2, modèle d’objet.air-object-core,air-object-derive.
-
air-aircom-*: couche 2, IPC.air-aircom-protocol,air-aircom-transport.
-
air-compositor-*: couche 3. -
air-tui-*,air-ui-*: couche 4. -
air-launcher-*: couche 5.
Crate façade par couche. Chaque couche peut avoir une crate façade qui réexporte les éléments principaux.
Visibilité des crates.
- Publiques : destinées à crates.io.
- Internes :
publish = falseou registre restreint.
Configuration globale du workspace
[workspace]
resolver = "2"
members = [
"crates/*",
"tools/*",
]
[workspace.package]
version = "0.1.0"
edition = "2024"
rust-version = "1.96.0"
license = "MPL-2.0"
authors = ["Air contributors"]
repository = "https://github.com/air-desktop-project/air"
[workspace.dependencies]
bitflags = "2.4"
thiserror = "1.0"
# Pas de `rustix` : la couche 0 appelle les syscalls directement via
# `core::arch::asm!`, sans dépendance de bindings externe (cf. EXCEPTIONS.md
# et la décision de socle consignée dans macro-architecture-fr.md).
[workspace.lints.rust]
unsafe_op_in_unsafe_fn = "deny"
unused_must_use = "deny"
unreachable_pub = "warn"
[workspace.lints.clippy]
all = "warn"
pedantic = "warn"
cast_possible_truncation = "deny"
cast_sign_loss = "deny"
cast_lossless = "deny"
arithmetic_side_effects = "warn"
undocumented_unsafe_blocks = "deny"
missing_safety_doc = "deny"
missing_errors_doc = "warn"
missing_panics_doc = "warn"
Fichier rust-toolchain.toml
[toolchain]
channel = "1.96.0"
components = ["rustfmt", "clippy", "rust-src", "llvm-tools-preview"]
targets = ["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"]
profile = "minimal"
La version Rust est pinnée explicitement.
Section 2 : Style guide général
Nommage
- Crates :
kebab-caseavec préfixeair-. - Modules :
snake_case. - Types :
UpperCamelCase. - Traits :
UpperCamelCase. - Fonctions, méthodes, variables :
snake_case. - Constantes :
SCREAMING_SNAKE_CASE. - Lifetimes : single lowercase letter ou nom court explicite.
Structure des fichiers source
Ordre des éléments :
- Module-level doc comment (
//!). - Imports (
use). - Constantes publiques.
- Constantes privées.
- Types publics.
- Types privés.
- Implémentations (
implblocks). - Fonctions publiques libres.
- Fonctions privées libres.
- Tests (
#[cfg(test)] mod tests).
Imports
Trois groupes séparés par une ligne vide :
- Imports de
coreetstd. - Imports de dépendances externes.
- Imports de crates Air.
#![allow(unused)]
fn main() {
use core::ffi::CStr;
use std::time::Duration;
use bitflags::bitflags;
use thiserror::Error;
use air_sys_types::{OwnedFd, Pid};
use air_sys_syscall::process;
}
Pas de use *. Toujours nommer les imports explicitement.
Visibilités
pub: exposé hors de la crate. Soigneusement choisi.pub(crate): utilisable dans la crate mais pas exposé.pub(super): utilisable dans le module parent.- Privé : utilisable uniquement dans le module.
Tout symbole pub engage la stabilité ABI selon l’ADR-012.
Conventions sur les modules
- Un module par concept.
- Réexports en
lib.rs. - Pas de modules « util » génériques.
Conventions sur les types
- Newtypes systématiques pour les identifiants.
- Préférence pour les enums sur les booléens multiples.
#[non_exhaustive]sur les enums publics.#[must_use]sur les types qui doivent être consommés.
Conventions sur les fonctions
- Fonctions courtes (typiquement < 50 lignes).
- Pas plus de 5-7 paramètres.
- Préférer les références aux ownerships sauf nécessité.
- Pas de paramètres
bool.
Conventions sur les erreurs
- Tout
Resultretourné a un type d’erreur précis. thiserrorpour les types d’erreur enrichis.- Les conversions d’erreur sont explicites via
From. - Pas de
panic!dans le code de production sauf invariant interne. - Pas de
unwrap()ouexpect()dans le code de production.
Conventions rustdoc
Structure d’une docstring :
- Résumé en une ligne.
- Description détaillée.
- Section
# Examples(recommandée). - Section
# Errors(obligatoire pourResult). - Section
# Panics(obligatoire si peut paniquer). - Section
# Safety(obligatoire pourunsafe).
Les exemples doivent compiler. Le CI exécute cargo test --doc.
Section 3 : Conventions sur unsafe
Discipline générale
- Pas d’
unsafeau-dessus de la couche 1 sauf justification exceptionnelle. - Les besoins éventuels en
unsafesont concentrés dans des modules dédiés et audités.
Format des commentaires SAFETY
Chaque bloc unsafe est précédé d’un commentaire // SAFETY: qui explique pourquoi l’invariant est respecté :
#![allow(unused)]
fn main() {
pub fn pid(&self) -> Pid {
// SAFETY: getpid never fails and always returns a positive PID per POSIX.
unsafe { Pid::new_unchecked(libc::getpid() as i32) }
}
}
Le lint undocumented_unsafe_blocks (configuré en deny) refuse les blocs unsafe sans commentaire.
Encapsulation des unsafe
Un bloc unsafe doit être le plus court possible, idéalement une seule ligne.
Revue obligatoire
Tout PR qui introduit ou modifie un bloc unsafe demande une revue par un mainteneur senior.
Lints associés
unsafe_op_in_unsafe_fn:deny.undocumented_unsafe_blocks:deny.missing_safety_doc:deny.
Récapitulatif Phase B1
| Domaine | Convention principale |
|---|---|
| Repo | Mono-repo Cargo workspace |
| Structure | crates/, tools/, docs/, tests/, benches/ |
| Nommage crates | Préfixe air-, hiérarchie par couche |
| Nommage Rust | Conventions Rust standard, newtypes systématiques |
| Imports | Trois groupes (core/std, externe, Air) |
| Visibilités | Explicites, pub engage la stabilité |
| Rustdoc | Sections structurées, exemples qui compilent |
unsafe | Commentaires SAFETY obligatoires, encadrement strict |
Licence du document : MPL 2.0 Statut : Document de setup phase 0.
Phase B2 — Conventions opérationnelles
Document de setup phase 0 — Version 1.0
Vue d’ensemble
La Phase B2 couvre les conventions qu’un contributeur rencontre à chaque PR : comment tester, comment gérer les erreurs en couches supérieures, comment formater les commits et les pull requests.
Section 1 : Conventions de tests
Organisation physique
Trois types de tests coexistent dans Air :
Tests unitaires : dans le même fichier que le code testé, dans un module #[cfg(test)] mod tests. Testent les fonctions privées et les détails d’implémentation.
Tests d’intégration de crate : dans le dossier tests/ à côté de src/. Testent l’API publique.
Tests cross-crate : dans le dossier tests/ à la racine du workspace. Testent les interactions entre plusieurs crates Air.
Nommage des tests
Convention subject_action_expectation.
#![allow(unused)]
fn main() {
#[test]
fn open_existing_file_returns_owned_fd() { /* ... */ }
#[test]
fn open_nonexistent_file_returns_enoent() { /* ... */ }
}
Catégories de tests à couvrir
Pour les couches 0 et 1 (Principe 1 : test exhaustif), chaque fonction publique a au minimum :
- Test du cas nominal.
- Tests des chemins d’erreur (un par variant documenté).
- Tests des limites.
- Tests des invariants.
Pour les fonctions complexes, s’ajoutent :
- Property-based tests (via
proptestouquickcheck). - Tests adversariaux.
- Tests de concurrence (via
loomsi nécessaire). - Fuzzing via
cargo-fuzz.
Harnais spécifiques
Harnais subprocess : pour les tests qui modifient l’état du processus (seccomp, prctl, etc.). Fork un sous-processus, exécute la fonction, propage le résultat.
Harnais tempdir : pour les tests filesystem. Chaque test obtient son propre dossier temporaire.
Harnais io_uring_test_ring : pour les tests io_uring. Crée un ring de test, gère le cleanup.
Harnais signal_isolation : pour les tests qui manipulent les signaux. Restaure le state après.
Tests qui demandent des privilèges
#![allow(unused)]
fn main() {
#[test]
#[cfg_attr(not(privileged_test), ignore = "requires CAP_SYS_ADMIN")]
fn unshare_creates_new_namespace() { /* ... */ }
}
Le CI exécute ces tests dans un environnement privilégié séparé.
Tests bench-comme
Outillage : criterion. Les benchmarks vivent dans benches/. Le CI les exécute sur les machines de référence (ADR-014) et détecte les régressions :
- Augmentation > 10% : alerte.
- Augmentation > 25% : échec CI.
Couverture de tests
100% sur les couches 0 et 1. Outillage : cargo-tarpaulin ou cargo-llvm-cov.
Une baisse de couverture sur les couches 0 et 1 fait échouer le CI.
Section 2 : Conventions de gestion des erreurs en couches supérieures
Application de l’ADR-019
Couche 0 : Errno minimal. Couches 1+ : type Error enrichi par crate, via thiserror.
Définition d’un type Error par crate
#![allow(unused)]
fn main() {
use thiserror::Error;
use air_sys_types::Errno;
#[derive(Debug, Error)]
pub enum IoError {
#[error("syscall failed: {source}")]
Syscall {
#[from]
source: Errno,
},
#[error("io_uring submission failed: queue full")]
SubmissionQueueFull,
#[error("invalid operation parameters: {reason}")]
InvalidParameters { reason: &'static str },
#[error("operation not supported on this kernel: {0:?}")]
OperationNotSupported(IoUringOpcode),
#[error("internal runtime error: {0}")]
Internal(InternalError),
}
}
Conventions :
- Un variant par catégorie d’erreur logique.
#[from]pour les conversions automatiques.- Messages d’erreur informatifs.
- Pas de
Stringdans les messages sauf nécessité (préférer&'static str).
Attachement de contexte
Le contexte est attaché au moment de la propagation :
#![allow(unused)]
fn main() {
fn open_config(path: &Path) -> Result<Config, ConfigError> {
let fd = openat2(DirFd::Cwd, path.as_cstr(), OpenHow::read_only())
.map_err(|errno| ConfigError::OpenFailed {
path: path.to_path_buf(),
source: errno,
})?;
// ...
}
}
Hiérarchie des erreurs
Les erreurs se propagent en chaîne via source() :
#![allow(unused)]
fn main() {
#[derive(Debug, Error)]
pub enum TransportError {
#[error("io error: {0}")]
Io(#[from] air_runtime_io::IoError),
#[error("connection refused by peer")]
ConnectionRefused,
#[error("invalid wire format: {reason}")]
ProtocolViolation { reason: &'static str },
}
}
Politique sur les panic
Panic acceptable : violations d’invariants internes (bugs Air).
Panic inacceptable : input utilisateur invalide, condition normale d’erreur, erreur kernel.
Documentation des erreurs
La section # Errors de chaque fonction publique liste les variants d’erreur possibles avec leur contexte.
Section 3 : Templates de PR et de commits
Format des messages de commit
Variation simplifiée de Conventional Commits :
<type>(<scope>): <short description>
<optional body>
<optional footer>
Types reconnus : feat, fix, docs, style, refactor, perf, test, chore, adr.
Scope : nom de la crate ou du module touché.
Description courte : mode impératif, présent, sans point final, minuscule.
Exemple :
feat(air-sys-syscall): add pidfd_open wrapper
Implements the pidfd_open syscall as part of the process management
family. Includes RAII handle (PidFd), error mapping, and full test
coverage including post-mortem usage.
Resolves: AIR-127
Pas de commits “wip” ou “fix typo”
Les commits sont des unités logiques cohérentes. Squashés ou réordonnés avant le merge.
Template de PR
## Summary
<!-- One or two sentences describing what this PR does. -->
## Motivation
<!-- Why is this change needed? Link to issues or RFCs. -->
## Changes
-
## Testing
-
## Documentation
-
## Dependencies
<!-- Did this PR add, remove, or modify dependencies?
Link to DEPENDENCIES.md changes per ADR-024. -->
## Breaking changes
<!-- Note that breaking changes on air-stable symbols
require a major version bump per ADR-012. -->
## Engineering Principles checklist
- [ ] Test coverage maintained (100% for layers 0 and 1, > 90% elsewhere)
- [ ] Defensive arithmetic conventions followed
- [ ] String/buffer/encoding handling explicit
- [ ] Inputs validated upfront, invariants documented
- [ ] No unsafe code added without SAFETY comments and review
- [ ] If new dependency added: DEPENDENCIES.md updated per ADR-024
- [ ] If new public API: rustdoc complete
- [ ] ABI stability respected on `air-stable` symbols
## Related ADRs
<!-- List any ADRs that motivated or relate to this change. -->
Workflow de revue
- Auto-vérification par le contributeur (checklist).
- Revue par CI automatique.
- Revue humaine par un mainteneur.
- Revue spécialisée si nécessaire :
unsafemodifié : revue par mainteneur senior systèmes.- ADR ajouté : revue par BDFL/TC.
- ABI publique modifiée : revue ABI dédiée.
- Approbation et merge.
Pas de merge sans approbation explicite. Pas d’auto-merge.
Préférer squash-and-merge pour PR avec plusieurs commits intermédiaires.
Branches
Trunk-based development.
- Une seule branche stable :
main. - Les PRs sont mergées dans
maindirectement. - Pas de branches
develop,release, ou autres. - Les releases sont des tags Git sur des commits de
main. - Les backports vers les versions majeures supportées (cf. ADR-012) se font sur des branches
release/N.0.
Section 4 : Documents de référence à créer
CONTRIBUTING.md: guide général.STYLE_GUIDE.md: conventions de code (Phase B1).TESTING_GUIDE.md: conventions de tests (Phase B2 section 1).ERROR_HANDLING.md: conventions d’erreurs (Phase B2 section 2).COMMIT_CONVENTIONS.md: format des messages de commit.PR_REVIEW_GUIDE.md: workflow de revue.
Récapitulatif Phase B2
| Domaine | Convention principale |
|---|---|
| Tests unitaires | Dans src/, #[cfg(test)] mod tests |
| Tests intégration | Dans tests/ au niveau crate |
| Tests cross-crate | Dans tests/ au niveau workspace |
| Harnais spéciaux | subprocess, tempdir, io_uring_test_ring, etc. |
| Erreurs couches sup | Type par crate via thiserror, contexte au point d’attachement |
| Format commits | Variation Conventional Commits, scope par crate |
| Template PR | Checklist Principes d’ingénierie obligatoire |
| Branches | Trunk-based, main unique |
| Documents | Plusieurs guides à la racine |
Licence du document : MPL 2.0 Statut : Document de setup phase 0.
Phase C — Infrastructure technique
Document de setup phase 0 — Version 1.0
Vue d’ensemble
La Phase C couvre tout ce qui transforme une spec en projet opérationnel : où héberger le code, comment exécuter le CI, comment publier des releases.
Cette phase est particulièrement importante pour Air parce que l’ADR-014 impose une validation continue sur des machines de référence diverses (Raspberry Pi 4, Mac Intel) dès la phase 0.
Section 1 : Plateforme et workflow Git
Choix de la plateforme
Décision : démarrage sur GitHub avec migration prévue vers Forgejo self-hosted à la création de la fondation.
Justifications :
- Phase d’incubation : GitHub maximise la visibilité et l’accès aux contributeurs Rust.
- Engagement de migration : dès la création de la fondation Air, migration vers Forgejo self-hosté.
- Préparation dès maintenant : utiliser des fonctionnalités compatibles, éviter les workflows GitHub-spécifiques.
- Mirroring automatique : un mirror Codeberg existe dès le début, garantissant que le code reste accessible.
Identité du projet :
- Domaine :
air-desktop.org - Organisation GitHub :
air-desktop-project - Repo principal :
github.com/air-desktop-project/air
Modèle de branches
Trunk-based development. Confirmé en Phase B2.
Politique de protection des branches
main est protégée :
- Pas de push direct, uniquement via PR.
- CI doit passer entièrement avant merge.
- Au moins une revue approuvée par un mainteneur.
- Signatures Git obligatoires (GPG ou SSH) pour les commits sur
main. - Pas de force-push, jamais.
Les branches de support release/N.0 ont les mêmes protections.
Politique de signature
Tous les commits sur main sont signés (GPG ou SSH key).
DCO appliqué via Signed-off-by dans le message de commit. Vérifié en CI.
Section 2 : Infrastructure CI
Plateforme CI
GitHub Actions comme plateforme CI principale (puis Forgejo Actions après migration, syntaxe compatible).
Architecture des runners
Runners hébergés cloud : pour x86_64 standard, tests rapides, lints.
Runners self-hosted sur machines de référence :
- Raspberry Pi 4 (4 Go et 8 Go) : runners ARM64 natifs.
- Mac mini Intel i5 8 Go : runner x86_64 sur hardware Mac.
- MacBook Pro Intel i7 16 Go : runner x86_64 laptop Mac.
Au démarrage, hébergés par le mainteneur principal. À terme, par la fondation Air.
Alternative partielle : émulation QEMU pour ARM64 sur runners x86_64. Acceptable en phase 0 si les Pi physiques ne sont pas immédiatement opérationnels.
Pipelines CI
Pipeline pr-quick : à chaque push sur PR. Feedback en quelques minutes.
cargo fmt --checkcargo clippy -- -D warningscargo check --workspace --all-targetscargo test --workspace --lib
Durée cible : < 5 minutes. Sur runners x86_64 cloud.
Pipeline pr-full : à chaque push sur PR. 20-30 minutes.
- Tout
pr-quickplus : cargo test --workspace --testscargo test --workspace --doccargo llvm-covavec seuil 100% (couches 0 et 1)cargo audit,cargo deny check- Vérification DEPENDENCIES.md (script Air maison)
- Build sur ARM64 (QEMU initialement, runners natifs ensuite)
Pipeline pr-hardware : conditionnel (déclenchable via label).
- Tests d’intégration sur Raspberry Pi 4.
- Tests d’intégration sur Mac Intel.
- Benchmarks sur machines de référence (comparaison baseline).
Obligatoire avant merge pour les PRs qui touchent la couche 0 ou les couches sensibles aux ressources.
Pipeline main-nightly : chaque nuit sur main.
- Tout
pr-fullplus : - Fuzzing étendu : 1 heure par cible.
- Tests ABI (compatibilité avec versions précédentes).
- Tests stress concurrent.
- Tests sur toutes les machines de référence.
Pipeline release : déclenché par un tag Git.
- Tout
pr-full+pr-hardware. - Build des artefacts de release.
- Génération de la documentation.
- Publication sur les channels de distribution.
- Signature des artefacts.
Outillage CI obligatoire
cargo fmt --checkcargo clippycargo testcargo llvm-covcargo auditcargo denycargo machetecargo doc --no-depscargo-fuzz- Script
check-dependencies(vérifie cohérence Cargo.toml ↔ DEPENDENCIES.md) - Script
check-dco(vérifie Signed-off-by) - Script
check-safety-comments(vérifie commentaires SAFETY sur unsafe)
Outillage ABI (air-abi-check, air-symver, air-deprecation-tracker) ajouté dès qu’il existe.
Configuration des règles strictes
.clippy.toml :
cognitive-complexity-threshold = 25
too-many-arguments-threshold = 7
type-complexity-threshold = 250
deny.toml :
[graph]
targets = [
{ triple = "x86_64-unknown-linux-gnu" },
{ triple = "aarch64-unknown-linux-gnu" },
]
[advisories]
vulnerability = "deny"
unmaintained = "warn"
unsound = "deny"
yanked = "deny"
[licenses]
allow = ["MPL-2.0", "Apache-2.0", "MIT", "BSD-3-Clause", "ISC", "Unicode-DFS-2016"]
deny = ["GPL-2.0", "GPL-3.0", "AGPL-3.0"]
[bans]
multiple-versions = "warn"
deny = []
Métriques et observabilité
Le CI expose :
- Couverture par crate, agrégée sur le workspace.
- Temps de build et de test (tendance).
- Résultats de benchmarks comparés à la baseline.
- Nombre de dépendances et leur fraicheur.
- Couverture de fuzzing.
Tableau de bord public.
Section 3 : Stratégie de release et de packaging
Versions et tags
Air suit le versionnement défini par ADR-012 :
MAJEUR.MINEUR.PATCHsemver.- Majeur : tous les 5-10 ans.
- Mineur : ajoute des APIs sans casse.
- Patch : bug fixes.
Période 0.x exploratoire (12-24 mois après air-base 1.0).
Format des tags Git
vMAJOR.MINOR.PATCH. Exemple :v0.1.0,v1.0.0.- Pré-release :
vMAJOR.MINOR.PATCH-PRE. Exemple :v1.0.0-rc1. - Chaque tag est signé GPG.
Artefacts de release
Pour chaque release :
- Sources : archive tarball
.tar.xzsignée. - Documentation HTML : générée via
cargo doc. - Documentation utilisateur : mdbook.
- Binaires de référence : pour les outils Air (x86_64 et ARM64 Linux).
- Checksums et signatures : SHA-256 + signatures GPG.
- Notes de version : extraites du
CHANGELOG.md.
Publication sur crates.io
Workflow :
- Tag de release créé et signé.
- CI déclenche le pipeline
release. - Validation finale.
- Publication sur crates.io dans l’ordre des dépendances.
- Génération et déploiement des autres artefacts.
- Annonce sur les channels.
Pas d’auto-publication. Déclenchée manuellement par un mainteneur senior.
Changelog
CHANGELOG.md suivant le format Keep a Changelog. Entrée ajoutée à chaque PR significatif.
Politique de pré-release
Avant air-base 1.0 :
- ABI non garantie stable.
- API peut évoluer entre versions mineures.
Après air-base 1.0, période exploratoire de 12-24 mois (cf. ADR-012).
Distribution
Conformément à ADR-013, phases de distribution :
Phase d’incubation :
- Repository officiel Air ajoutable aux distributions existantes.
- Image OS de référence basée sur Debian stable.
- Crates publiées sur crates.io.
Phase pré-grand-public : construction d’Air OS dédié.
Phase grand public (Air OS 1.0) : téléchargement direct, installation guidée.
Builds reproductibles
Objectif structurant aligné avec les valeurs Air :
- Versions de toolchain pinnées.
- Dépendances pinnées.
- Variables d’environnement contrôlées.
- Timestamps déterministes.
L’objectif : un même tag Git produit des artefacts bit-pour-bit identiques sur n’importe quelle machine de référence.
À consigner dans un ADR-025 dédié.
Récapitulatif Phase C
| Domaine | Décision |
|---|---|
| Plateforme Git | GitHub au démarrage, migration vers Forgejo à la création de la fondation |
| Modèle de branches | Trunk-based, main unique |
| Signatures | GPG/SSH obligatoires, DCO via Signed-off-by |
| Plateforme CI | GitHub Actions (puis Forgejo Actions) |
| Runners | Cloud x86_64 + self-hosted ARM64 (Pi 4) + Mac Intel |
| Pipelines | pr-quick, pr-full, pr-hardware, main-nightly, release |
| Outillage CI | fmt, clippy, test, llvm-cov, audit, deny, machete, fuzz, scripts Air |
| Releases | Tags signés, semver, période 0.x exploratoire |
| Artefacts | Sources, docs HTML, mdbook, binaires, checksums, signatures |
| Distribution | crates.io initialement, registre Air à terme |
| Reproductibilité | Objectif structurant, ADR-025 dédié |
Documents associés à créer :
.github/workflows/*.yml: configurations CI.RELEASE_PROCESS.md: guide opérationnel.INFRASTRUCTURE.md: description de l’infrastructure CI.CHANGELOG.md: historique.
Licence du document : MPL 2.0 Statut : Document de setup phase 0.
Phase D — Communauté et documentation
Document de setup phase 0 — Version 1.0
Vue d’ensemble
La Phase D couvre l’aspect humain du projet : comment les gens trouvent Air, apprennent à l’utiliser, contribuent, communiquent. Moins technique que les phases précédentes mais aussi structurant pour la durée de vie du projet.
Section 1 : Documentation utilisateur, développeur, API
Trois types de documentation
Documentation utilisateur : pour les utilisateurs finaux d’Air OS. Pas de jargon technique inutile. Minimale pendant la phase d’incubation.
Documentation développeur : pour les développeurs qui écrivent des applications Air ou comprennent l’architecture. Couvre concepts, guides pratiques, exemples annotés.
Documentation API : référence générée depuis le code (rustdoc).
Outillage
- Rustdoc pour la référence API.
- mdbook pour les guides longue forme.
- Site web statique (Hugo, Zola ou Astro).
Structure proposée du site Air
air-desktop.org/
├── / # Page d'accueil
├── /vision # Vision du projet
├── /charter # Charte
├── /principles # Principes d'ingénierie
├── /guides/ # mdbook : guides développeur
├── /api/ # rustdoc généré
│ ├── 0.1.0/
│ ├── 0.2.0/
│ └── latest/
├── /adrs/ # ADRs publiés
├── /blog/ # Annonces et articles
├── /community/ # Comment participer
├── /downloads/ # Artefacts de release
└── /trademark/ # Politique de marque
Multilingue
Stratégie progressive (cf. ADR-016) :
Phase d’incubation : français et anglais britannique uniquement.
Phase pré-publique à publique : ajout progressif des autres langues prioritaires.
Traductions contribuées par la communauté.
Documentation API : conventions étendues
- Chaque crate a une page d’accueil substantielle (500-1500 mots dans
lib.rs). - Exemples qui compilent (
cargo test --docen CI). - Versions documentées historiquement.
Documentation des ADRs
Publiés sur le site, rendus en HTML depuis leurs sources Markdown. Le registre des ADRs est l’index principal.
Un ADR fondateur ne change pas. Les amendements se font par nouveaux ADRs.
Section 2 : Communication communautaire
Canaux de communication
- Mailing list
announce@air-desktop.org: faible volume, annonces officielles. Modérée. - Mailing list
dev@air-desktop.org: discussions techniques, RFCs. - Mailing list
users@air-desktop.org: questions d’usage. - Chat Matrix :
#air-desktop:matrix.org. Plusieurs salles. Logs publics archivés. - Forum web : optionnel, à évaluer plus tard.
- Issue tracker : sur la plateforme Git.
- Blog officiel : sur le site Air. Articles longs, faible volume, qualité élevée.
Politique de modération
Contributor Covenant 2.1 (cf. ADR-015).
Modération assurée par un groupe distinct du mainteneur technique (séparation des rôles).
Politique transparente :
- Sanctions publiques (avec anonymisation si nécessaire).
- Décisions appellables.
- Modérateurs ne modèrent pas leurs propres conflits.
Stratégie d’annonce
Pour les annonces importantes (release majeure, RFC structurant) :
- Article de blog officiel.
- Annonce sur la mailing list
announce. - Notification dans le chat Matrix.
- Si pertinent : post sur r/rust, This Week in Rust.
- Si très pertinent : annonce sur Phoronix, LWN.
Air ne pratique pas de “marketing actif” sur les réseaux sociaux. Pas de stratégie de buzz.
Site web : ton et style
- Ton sobre. Pas de superlatifs.
- Honnêteté sur le statut.
- Pas de tracking utilisateur (pas de Google Analytics, pas de cookies tiers).
- Design simple. Lisibilité prioritaire.
- Accessibilité WCAG 2.2 AA (ADR-017).
Politique de réponse
Objectifs (pas engagements contractuels) :
- Pull requests : réponse initiale sous 7 jours.
- Issues : triage initial sous 14 jours.
- Questions sur listes ou chat : quelques jours.
- RFCs : période de commentaires 2-4 semaines (ADR-015).
Section 3 : Documentation pour les premiers contributeurs
README.md à la racine
La première chose qu’un visiteur voit. Dit en quelques minutes :
- Ce qu’Air est (et n’est pas).
- Le statut actuel.
- Comment construire le projet.
- Comment exécuter les tests.
- Comment contribuer.
- Comment trouver de l’aide.
- La licence.
CONTRIBUTING.md
Guide de contribution couvrant :
- Quel type de contributions sont bienvenues.
- Comment trouver un premier ticket (“good first issue”).
- Le workflow Git complet (fork, branche, PR, revue).
- Conventions de code, de commits, de tests.
- DCO et signatures.
- Processus RFC.
- Où poser des questions.
Ton accueillant et concret.
CODE_OF_CONDUCT.md
Adoption du Contributor Covenant 2.1.
SECURITY.md
Politique de divulgation responsable :
- Comment signaler (
security@air-desktop.org, clé GPG). - Délais de réponse attendus.
- Processus de patch et de divulgation.
- Politique sur CVE et advisories.
Important dès le démarrage.
Guide du premier patch
Document séparé docs/guides/first-contribution.md :
- Comprendre la structure du repo.
- Choisir un ticket “good first issue”.
- Mettre en place l’environnement.
- Faire le changement.
- Exécuter les tests.
- Préparer le commit (avec SIGNED-OFF-BY).
- Soumettre le PR.
- Réagir aux retours de revue.
Labels sur les issues
- Type :
bug,feature,documentation,refactor. - Layer :
layer-0,layer-1, etc. - Difficulty :
good-first-issue,intermediate,advanced. - Status :
needs-triage,in-progress,blocked,ready-for-review. - Component :
air-sys-syscall,air-runtime, etc.
Documentation interne pour les mainteneurs
Dossier docs/maintainers/ :
- Procédure de release.
- Procédure de modération.
- Procédure d’onboarding d’un nouveau mainteneur.
- Procédure de gestion des CVEs.
- Procédure de mise à jour de la version Rust supportée.
Section 4 : Synthèse documentaire
Documents à la racine du projet :
air/
├── README.md
├── LICENSE
├── CHANGELOG.md
├── CONTRIBUTING.md
├── CODE_OF_CONDUCT.md
├── SECURITY.md
├── STYLE_GUIDE.md
├── TESTING_GUIDE.md
├── ERROR_HANDLING.md
├── COMMIT_CONVENTIONS.md
├── PR_REVIEW_GUIDE.md
├── DEPENDENCIES.md # par crate
├── EXCEPTIONS.md # workspace
├── RELEASE_PROCESS.md
├── INFRASTRUCTURE.md
└── docs/
├── vision/
├── charte/
├── principes-ingenierie/
├── adrs/
├── specs/
├── guides/
│ ├── first-contribution.md
│ ├── getting-started/
│ └── ...
├── maintainers/
└── STATUS.md
Récapitulatif Phase D
| Domaine | Décision |
|---|---|
| Doc API | rustdoc + mdbook + site statique |
| Multilingue | FR + EN au démarrage, autres langues progressives |
| Mailing lists | announce / dev / users sur air-desktop.org |
| Chat | Matrix, plusieurs salles, logs publics |
| Modération | Contributor Covenant 2.1, modérateurs distincts |
| Blog | Articles longs, faible volume |
| Ton communication | Sobre, factuel, sans buzz |
| README | Court, factuel, statut clair |
| CONTRIBUTING | Workflow complet, accueillant |
| Guide premier patch | Document dédié pas-à-pas |
| Labels issues | Type, layer, difficulty, status, component |
| Doc mainteneurs | Dossier séparé docs/maintainers/ |
Aucun ADR formel en Phase D (conventions opérationnelles documentaires). Les décisions structurantes sont déjà couvertes par les ADRs existants (016 pour les langues, 015 pour le code de conduite et la marque).
Licence du document : MPL 2.0 Statut : Document de setup phase 0.
Intégration continue (CI)
Ce document décrit la CI du dépôt Air : les workflows, les runners, et les seuils
de couverture. La CI gate les pull requests et les push sur main.
Workflows
| Workflow | Fichier | Déclencheurs | Rôle |
|---|---|---|---|
ci | .github/workflows/ci.yml | pull_request, push sur main | Tests + clippy + couverture sur l’arche ARM (raspi) + chaîne d’approvisionnement. x86 validé hors CI (barrière pré-merge, ADR-037) |
docs | .github/workflows/docs.yml | pull_request, push sur main | Génération du docbook mdBook + référence API rustdoc (GitHub-hosted) |
Runners self-hosted
Les crates de la couche 0 sont Linux-only et doivent être validées sur
x86_64 ET aarch64 (ADR-014). Depuis
ADR-037, ce découpage est réparti ainsi :
la CI ne tourne que sur le runner ARM (raspi-srv-2), et l’x86 est validé
hors CI par la barrière pré-merge (cargo xtask barrier) sur speedy ET
carbon. Les runners sont enregistrés en service persistant (svc.sh,
démarrage auto au reboot) :
| Runner | Machine | Arche | Labels | Rôle |
|---|---|---|---|---|
raspi-srv-2 | Raspberry Pi 4 (Ubuntu) | aarch64 | self-hosted, Linux, ARM64, air-aarch64 | Seul runner CI (ARM) |
speedy | Mac mini Intel (Ubuntu) | x86_64 | self-hosted, Linux, X64, air-x86_64 | Enregistré, hors workflows — réactivation à l’ouverture publique |
Pourquoi. raspi-srv-2 est lent → réservé au seul rôle CI ARM. speedy et carbon (x86_64 ; carbon 8 c/16 Go, speedy 4 c/8 Go) sont pilotés comme exécuteurs de tâches ; la barrière x86 pré-merge tourne sur les deux (couverture x86 sur deux micro-architectures). Cf. ADR-037.
Les toolchains (stable 1.96.0 via rust-toolchain.toml, nightly,
cargo-llvm-cov, cargo-audit, cargo-deny, cargo-machete) persistent sur les
runners. Le workflow les vérifie/installe de façon idempotente (le 1er run après
une mise à jour de toolchain peut être plus lent, surtout sur le Pi).
Note sécurité. Un runner self-hosted exécute le code des PRs. Tant que le dépôt est privé et mono-mainteneur, le risque est faible. À réévaluer à l’arrivée de contributeurs externes (isolation, runners éphémères, ou bascule vers des runners GitHub-hosted pour les PRs externes).
Validation x86 pré-merge (ADR-037)
L’x86 n’étant plus dans la CI, avant tout merge sur main la barrière
complète est exécutée sur speedy ET carbon via cargo xtask barrier :
fmt (stable + nightly) · clippy -D warnings · cargo test --workspace ·
couverture lignes ≥ 96 / branches ≥ 78 en root (ADR-031) · cargo audit /
deny / machete · // SAFETY: · cohérence Cargo.toml ↔ DEPENDENCIES.md ·
check-c-surface. Ce sont exactement les contrôles de l’ancien runner x86.
Un merge n’a lieu que si les deux machines x86 sont vertes et la CI ARM est verte — préservant l’invariant ADR-014 (x86_64 ET aarch64). À l’ouverture publique, un gate CI x86 sera rebranché (Décision 3 d’ADR-037).
Jobs
test-coverage (aarch64 seul — ADR-037)
Sur l’arche ARM — le x86 suit la même séquence hors CI via cargo xtask barrier (ADR-037) —, dans l’ordre :
cargo fmt --all -- --checkcargo clippy --all-targets --all-features -- -D warnings(un lint peut être arch-spécifique, p. ex.cast_losslessobservé sur aarch64).cargo test --workspace(lib + intégration + doctests).- Couverture lignes :
cargo llvm-cov --workspace --fail-under-lines 96. - Couverture branches :
cargo +nightly llvm-cov --workspace --lib --branch --summary-only, puis extraction du % de la ligneTOTALet application du plancher (78) en shell —cargo-llvm-covn’expose pas de--fail-under-branches(seuls--fail-under-lines/-functions/-regions).
supply-chain (runner ARM)
cargo audit · cargo deny check · cargo machete · cargo xtask check-syscalls · cargo xtask check-c-surface · cargo xtask audit-exceptions.
Les gates xtask sont gravés en outillage (cf.
outillage-xtask.md) : check-syscalls compare les numéros
de syscalls déclarés dans air-sys-syscall aux headers uapi des deux arches
(la classe d’erreur qui nous a piégés) ; audit-exceptions valide le format /
la taxonomie ADR-035 du registre COVERAGE-EXCEPTIONS.md et recompte son
récapitulatif (anti-dérive du décompte). Rapides, sans root, exit ≠ 0 sur
violation.
Politique « zéro surface C/C++ » (audit 082)
Air est aujourd’hui 100 % Rust pur — aucune dépendance ne compile ou ne lie du C/C++. Cet état est figé en invariant CI, par deux verrous complémentaires :
deny.toml[bans]: ban nominatif des vecteurs C connus —cc,cmake,bindgen,pkg-config,aws-lc-sys,ring. (cargo-denyne sait pas globber*-sys, d’où le second verrou.)cargo xtask check-c-surface: garde-fou générique — échoue si une crate dont le nom finit par-sys(convention des bindings FFI vers une lib C) entre dans l’arbre résolu (cargo tree -e normal,build). Parseur testé sur fixtures ; faux positif évité sur les crates Airair-sys-types/air-sys-syscall(suffixes-types/-syscall, pas-sys). Câblé ici (jobsupply-chain) et danscargo xtask barrier.
Une réintroduction future de surface C (p. ex. ring si/quand air-tls
adopte un provider crypto) devra être une exception NOMMÉE dans
docs/EXCEPTIONS.md — justification + plan de sortie —
suivant la discipline ADR-024/ADR-034. Aucune entrée EXCEPTIONS.md
aujourd’hui : Air est zéro-C.
Cache de compilation — sccache
Le job test-coverage repart d’un target/ propre à chaque run
(actions/checkout clean: true sur les runners persistants). Sans cache, la
couche 0 scellée (couche-0-v1) est recompilée intégralement à chaque
PR — coûteux, surtout sur le Pi. On branche sccache comme RUSTC_WRAPPER :
son store vit hors de target/ (SCCACHE_DIR=$HOME/.cache/sccache), donc
survit au git clean et repeuple en cache-hits les crates inchangées.
- Installation (
Ensure sccache, idempotente) : binaire pré-bâti officiel (musl, SHA256 épinglé —v0.10.0) pour l’arche du runner, sans compilation (important sur le Pi) ; fallbackcargo install sccache --lockedsignalé (::warning::) si le téléchargement/checksum échoue. - Périmètre : seules les étapes non-root (
fmt/clippy/test) sont cachées (RUSTC_WRAPPER=sccache,SCCACHE_CACHE_SIZE=5G). C’est là qu’est le gain « recompilation ». - Étapes de couverture en root — choix (b) : NON cachées. Les passes
llvm-covtournent ensudo(ADR-031) ; sous root, sccache viserait le cache de root (misses / permissions), et les builds-C instrument-coveragese cachent mal. On désactive donc explicitement le wrapper sur ces étapes (env … "RUSTC_WRAPPER=" cargo llvm-cov …). La mécanique de déterminisme de couverture (purge.profraw, restauration de propriété detarget/) est inchangée : le store sccache est distinct detarget/. (La re-mesure de la couche 0 scellée sera traitée séparément — ADR-036 filtrage par chemin.) - Mesure : une étape finale
sccache --show-stats(--zero-statsen début de run) chiffre le taux de hit.
Sain vis-à-vis d’ADR-025. Un cache = mémoïsation « mêmes sources + même
toolchain → mêmes octets », vérifiée par hachage des entrées : exactement les
mêmes builds, plus vite. Garde-fou repro : le gate cargo xtask repro
(preuve de déterminisme par double-build) neutralise tout RUSTC_WRAPPER
(RUSTC_WRAPPER=/RUSTC_WORKSPACE_WRAPPER= dans son environnement de build) —
un cache-hit ne peut donc pas masquer une non-reproductibilité. repro
n’est par ailleurs câblé dans aucun job CI.
Filtrage par chemin de la re-vérification d’une couche scellée (ADR-036)
La couche 0 est scellée (couche-0-v1) : ses sources sont gelées. Re-mesurer
intégralement sa couverture (lignes + branches, en root, coûteux surtout sur le
Pi) à chaque PR couche 1 n’achète rien — sauf face à la dérive de
toolchain (un bump nightly/stable peut changer clippy/llvm-cov à sources
identiques). ADR-036
grave la politique ; la CI l’implémente ainsi :
- Job
changes: calcule un booléencouche0viagit diff --name-onlycontre la base de la PR (zéro nouvelle dépendance d’action, Principe 6). Globs conservateurs (sur-déclencher plutôt que sous-déclencher) :crates/air-sys-types/**,crates/air-sys-syscall/**,rust-toolchain.toml,Cargo.lock,Cargo.toml(racine),.github/workflows/**,deny.toml,docs/COVERAGE-EXCEPTIONS.md. - Re-vérification COMPLÈTE (étapes couverture lignes/branches en root,
inchangées) si et seulement si — variable de job
FULL:couche0 == true(PR touchant la couche 0 / toolchain / CI / politique) ;push/merge versmain— garde-fou (b) :mainn’est JAMAIS dans un état non vérifié ; une régression de dérive de toolchain est bloquée au merge, pas seulement signalée ;- planifié hebdomadaire (
schedule: cron '0 4 * * 1') — garde-fou (c) : détecte une dérive d’environnement runner sans activité de PR.
- Sinon (PR « couche 1 pure ») :
fmt,clippyetcargo test --workspaces’exécutent toujours (la couche 0 est bâtie + ses tests unitaires/doctests exécutés, juste pas re-mesurée) ; les étapes de couverture et leur mécanique de déterminisme (purge.profraw,sudo -n, restauration de propriété detarget/) sont sautées. Le job n’est donc pas vert « à vide ».
Aucun job fuzz n’existe en CI aujourd’hui (le fuzzing reste un cargo +nightly fuzz run manuel) ; la politique vaut pour la couverture. Un rouge
sur l’une des exécutions complètes rouvre le sceau (traitement prioritaire).
Le tag couche-0-v1 reste la référence d’autorité.
Couverture : seuils et mesure
Le standard de la couche 0 est 100 % lignes + branches hors exceptions. Cet
invariant n’est pas mécaniquement vérifiable : les exceptions légitimes
(privilège, feature kernel, enfant forké, EFAULT-safe, structurel,
valeur-impossible) sont documentées en prose dans
COVERAGE-EXCEPTIONS.md et revues humainement à chaque
PR.
La CI applique donc un plancher anti-régression sur la couverture brute (elle échoue si la couverture passe sous le seuil), pas un test d’égalité à 100 %.
Mesure en root (ADR-031). Les étapes
llvm-covdu jobtest-coverages’exécutent en root (sudo -n) afin que les tests d’intégration privilégiés (bpf()/CAP_BPF,uinput, perf, cgroup, bpffs) s’exécutent réellement sous instrumentation. La familleebpf, quasi-tout-privilégiée, plafonnerait sinon ~74 % en non-root. Prérequis :NOPASSWDsur les deux runners self-hosted ; une étape finale rétablit la propriété detarget/. Modèle de confiance : dépôt privé / mono-mainteneur (cf. ADR-031).
--exclude xtask. La crate d’outillagextask/(dev-tooling, membre du workspace pour l’aliascargo xtask) n’est pas du code Air livré et n’est pas soumise au 100 % : les étapesllvm-covl’excluent (--workspace --exclude xtask). Voir outillage-xtask.md. (La crate couche 1air-base-libétait exclue tant qu’elle n’était qu’un squelette Passe 1 ; depuis son implémentation Passe 2, elle est mesurée à 100 % comme couche fondatrice — exclusion retirée.) (Idemair-crypto: exclusion Passe 1 retirée depuis son implémentation Passe 2 — mesurée comme couche fondatrice.) (Idemair-filesystem: exclusion Passe 1 retirée depuis son implémentation Passe 2 — mesurée comme couche fondatrice ; les rares bras non couverts sont des gardes défensives STRUCTUREL/ENV documentées in-code, ADR-035.) (Idemair-memory: exclusion Passe 1 retirée depuis son implémentation Passe 2 —tracker/arena/pool/slab/backingmesurés ; aucune fonctionunsafeexposée, l’uniqueunsafede l’arène prouvé sans UB sous Miri.)
air-runtime— MESURÉ (étape 6 phase B), TARGET-ONLY par fichier. La crate couche 1air-runtime(runtimeAirRuntime: TCB/TLS/relocation/environnement/ spawn/fork, ADR-052) est réalisée et mesurée : la façadeAirRuntime(errno/set_errno/current_tid) et les modules host-testables (args/env/reloc-parser/fork/lib) sont mesurés à 100 %. L’exclusion en bloc--exclude air-runtimea été retirée (amendement ADR-035 § TARGET-ONLY). Seuls ses 4 fichiers TARGET-ONLY —thread_control_block.rs,thread_local_storage.rs,thread.rs,start.rs— sound uniquement sur*-linux-air(registre TLS, TCB, spawnCLONE_SETTLS, bootstrapcrt0) sont ignorés par fichier (--ignore-filename-regex, précédent_capnp.rs) ; les quelques lignes target-only résiduelles dereloc.rs/fork.rssont consignées au registre COVERAGE-EXCEPTIONS.md (catégorie TARGET-ONLY). Preuve on-target : selftestrt/crates/airrt-selftest(exit 42, 2 arches).
- Lignes :
cargo llvm-cov --workspace --exclude xtask(tous les targets ;air-runtimemesuré, 4 fichiers TARGET-ONLY ignorés par regex). - Branches :
cargo +nightly llvm-cov --workspace --exclude xtask --lib --branch. Le--libest obligatoire : sans lui, le binaire de test d’intégration lie la lib sans#[cfg(test)]et colle une région fantôme[True:0, False:0]sur chaque branche (artefact prouvé, pas une vraie branche source).
Baseline et seuils
Baseline mesurée le 2026-06-01 (HEAD f046603) :
| Arche | Lignes | Branches |
|---|---|---|
| speedy (x86_64) | 96.61 % | 78.79 % |
| raspi-srv-2 (aarch64) | 96.49 % | 78.97 % |
Les deux arches diffèrent légèrement (divergence d’environnement documentée :
mlockall réussit sur speedy, échoue EPERM sur le Pi). Plancher commun retenu,
un cran sous la plus basse valeur de chaque métrique :
- lignes :
cargo llvm-cov --workspace --fail-under-lines 96(flag natif). - branches : plancher 78 appliqué en shell sur le %
TOTALdecargo +nightly llvm-cov --workspace --lib --branch --summary-only(pas de--fail-under-branchesnatif).
Le plancher lignes a été recalibré à 96 sur mesure empirique
(2026-07-01, post-loom) : 5 passes de cargo llvm-cov --workspace donnent
96.65–96.66 % de lignes couvertes, variance 0.01 %. La variance
run-to-run qui avait motivé la descente à 94 (#189 — 95.89 % vs 96.12 %
observés à l’époque, ~0 % de marge sous 96) a disparu ; 96 laisse ~0.65 % de
marge sous le min observé pour une variance négligeable. On rétablit donc un
filet anti-régression serré (une régression réelle coûte plusieurs points)
sans risque de flake. La vraie garantie reste la couverture per-crate 100 % des
couches 0/1 (réconciliation déterministe couvrable-vide contre
COVERAGE-EXCEPTIONS.md, inchangée) ; ce plancher global n’est qu’un filet
grossier (ADR-031).
Relever encore ce seuil quand la couverture brute monte durablement (p. ex. après ajout de tests) — en gardant une petite marge sous le min observé.
Depuis ADR-037, la CI applique ces planchers sur aarch64 ; les mêmes planchers sont vérifiés sur x86_64 par la barrière pré-merge (
cargo xtask barrier) sur speedy et carbon.
Déterminisme : un gate qui ne rougit que sur une vraie régression
Un gate de couverture qui échoue au hasard est pire qu’aucun gate. Deux
sources de non-déterminisme ont été traitées (branche fix/ci-flaky-pidfd-coverage) :
1. Course de réutilisation de fd dans les tests pidfd_* (corrigée)
Trois tests d’air-sys-syscall (pidfd_drop_closes_underlying_fd,
pidfd_send_signal_on_closed_pidfd_returns_ebadf,
pidfd_getfd_on_closed_pidfd_returns_ebadf) prouvent que le Drop du wrapper
RAII ferme le fd sous-jacent, en ré-observant le numéro fermé : le kernel
doit répondre EBADF. Comme cargo test exécute en parallèle, un autre
thread peut allouer un fd qui réutilise ce numéro entre le drop et
l’observation → le fd redevient valide → faux négatif. L’instrumentation de
couverture élargit la fenêtre, ce qui faisait rougir cargo llvm-cov par
intermittence (rare en mesure naturelle, mais reproductible sous amplification :
~11 % d’observations « réutilisées » avec 8 threads de churn et une fenêtre
élargie).
Il s’agit d’un défaut d’observation de test, pas d’une course en
production : le contrat RAII (close(fd) au Drop) est correct ; PidFd est
un PidFd(OwnedFd) sans Drop propre. La correction est côté test, sans
dépendance externe ni --test-threads=1, par double défense par construction :
- fd HAUT non réutilisable : on duplique le pidfd (
dup_fd) vers un numéro proche deRLIMIT_NOFILE(min(soft - 1, 4096)) et on observe la fermeture de CE numéro. Une allocation concurrente prend toujours le plus petit fd libre, jamais ce numéro haut → réutilisation par les autres tests impossible. - verrou
FD_OBSERVATION_LOCK: sérialise les trois tests-frères entre eux, pour qu’ils ne visent pas le même numéro haut au même instant (sinon la course se déplacerait simplement sur le fd haut).
2. Bruit .profraw (purge + résidu bénin documenté)
- Le step Clean coverage artifacts (
cargo llvm-cov clean --workspace) purge les.profraw/.profdatarésiduels avant chaque mesure (le workspace persiste entre runs sur les runners self-hosted). - Des
LLVM Profile Error: Permission deniedpeuvent subsister, émis par les enfants forkés des tests landlock/seccomp qui restreignent leur propre accès FS avant le flush du profil (cf.flush_child_coverage, et les exceptionsCHILD-EXITdeCOVERAGE-EXCEPTIONS.md). Ces messages sont bénins : ils polluent éventuellement la sortie mais ne font pas échouer le step (les profils utiles sont flushés avant la restriction). On ne cherche pas à les masquer par un contournement fragile.
Dette connue — enforcement des status checks
Sur GitHub Free pour un dépôt privé d’organisation, l’API de protection de branche (rendre ces checks « required » au merge) peut être indisponible (cf. JOURNAL, dette « protections de branche »). Conséquence : la CI tourne et affiche son statut sur chaque PR, mais le blocage dur du merge en cas d’échec n’est pas garanti côté GitHub tant que le dépôt est Free/privé.
Mitigation actuelle : discipline du mainteneur (ne pas merger une PR rouge). Résolution prévue : à l’ouverture publique (dépôt public Free débloque les protections) ou via un plan payant.
Outillage cargo xtask — gates qualité gravés
Ce document décrit la crate d’outillage xtask : elle grave en outillage
déterministe et réutilisable les gates qualité qu’on vérifiait à la main —
en particulier ceux qui nous ont mordus (numéros de syscalls par arche,
invariant « couvrable vide », dérive du décompte d’exceptions).
Statut.
xtaskest de l’outillage de développement, pas du code Air livré. Il est membre du workspace (pour quecargo xtask <gate>se résolve via l’alias.cargo/config.toml), mais exclu de la mesure de couverture (--exclude xtask) et non soumis au 100 % des couches 0/1 (carve-out esprit ADR-030). Il reste linté (clippy --workspace -D warnings), testé (cargo test --workspaceexécute ses tests de parseurs) et audité (deny/audit/machete). Il n’introduit aucune dépendance externe (std seulement) : leCargo.lockdu produit et la reproductibilité (ADR-025) restent intacts.
Invocation
cargo xtask barrier [--no-coverage] # toute la barrière, un seul point d'entrée
cargo xtask couvrable-vide [--strict] [--no-root]
cargo xtask check-syscalls
cargo xtask check-layers
cargo xtask audit-exceptions [--strict]
cargo xtask repro [--keep]
Chaque sous-commande accepte --json (sortie machine) et rend un code de
sortie : 0 = vert (ou signalements non bloquants), 1 = violation, 2 =
erreur d’usage / interne. Conçu pour la CI comme pour Claude Code.
Les cinq gates
1. barrier — un seul point d’entrée pour la barrière
Enchaîne et agrège : fmt (stable et nightly), clippy --workspace --all-targets -D warnings, cargo test --workspace, couverture lignes +
branches en root (planchers ADR-031 :
lignes ≥ 96, branches ≥ 78), cargo audit, cargo deny check, cargo machete,
présence d’un // SAFETY: sur chaque bloc unsafe { … } du socle, cohérence
Cargo.toml ↔ DEPENDENCIES.md. Récapitulatif structuré pass/fail + chiffres.
--no-coverage saute les étapes root (boucle de dev rapide sans sudo).
2. couvrable-vide — anti-gaming (le plus important)
Lance llvm-cov (root), parse les lignes non couvertes du code production et
les croise avec COVERAGE-EXCEPTIONS.md.
- Gate DUR (mécaniquement décidable) : le plancher de couverture (lignes ≥ 96, branches ≥ 78). C’est le filet anti-gaming central — la couverture brute ne peut pas chuter en douce.
- Assist (advisory /
--strict) : l’invariant « couvrable VIDE » (toute ligne rouge atteignable ⊆ registre) n’est pas mécaniquement vérifiable — la reachability est une décision humaine (cf. CI.md) et lesfichier:lignedu registre dérivent. La réconciliation se fait donc par symbole (fonction englobante mentionnée au registre) ou par proximité de ligne (±40, pour absorber la dérive). Les lignes non rapprochées sont signalées ;--strictles durcit en violations (et fait échouer aussi sur un fichier production non couvert sans aucune section au registre). Les références du registre au-delà du fichier sont signalées comme orphelines.
--no-root mesure sans sudo (couverture partielle, ebpf plafonné — diagnostic
seulement).
3. check-syscalls — numéros déclarés vs headers uapi (ce qui nous a piégés)
Extrait les numéros de syscalls déclarés dans air-sys-syscall (commentaires
// SAFETY: SYS_<nom> (<arche> = <N>) et constantes d’arche const SYS_… /
const NR_IO_URING_…) et les compare, pour chaque arche, aux headers
uapi :
| Arche | Header (overridable) |
|---|---|
| x86_64 | /usr/include/x86_64-linux-gnu/asm/unistd_64.h (XTASK_UNISTD_X86_64) |
| aarch64 | /usr/include/asm-generic/unistd.h (XTASK_UNISTD_AARCH64) — aarch64 utilise la table générique |
Le header générique est présent sur les deux exécuteurs, donc les deux
arches sont vérifiables depuis n’importe lequel. Tout écart ⇒ VIOLATION
(exit ≠ 0), avec le détail syscall : code_déclaré vs header. Si un header
manque pour une arche, c’est signalé clairement (et ignoré), jamais un échec
silencieux. La résolution des indirections (__NR_mmap → __NR3264_mmap → 222)
est gérée.
4. audit-exceptions — format/taxonomie ADR-035 du registre
Valide chaque entrée de COVERAGE-EXCEPTIONS.md :
- catégorie ∈ {
STRUCTURAL,PRIVILEGE,FEATURE-KERNEL,CHILD-EXIT,DEFERRED-TOOLING} ; aucunTEST-HARNESSau registre production (ADR-035 §2) ; - justification non vide ;
fichier:ligneexistant : tout.rscité doit exister, toute ligne citée ≤ la taille du fichier, et tout symbole entre backticks doit être présent dans le fichier cité (anti-dérive — résout la dette de re-sync notée au sceau) ;- récapitulatif recomputé à partir des rangées et confronté au
récapitulatif affiché — toute divergence est une VIOLATION (anti-dérive du
décompte, le travers dénoncé par l’audit
053).
Les entrées DEFERRED-TOOLING sont signalées (dette à couvrir). --strict
durcit les orphelines fichier:ligne / symbole en violations.
5. repro — double-build bit-pour-bit (ADR-025)
Construit le même arbre deux fois en environnement contrôlé (toolchain
pinné, SOURCE_DATE_EPOCH fixe, --remap-path-prefix sur la source / le
target de chaque build / CARGO_HOME), dans deux target distincts remappés
vers le même préfixe logique, puis diffe bit-pour-bit l’ensemble des
*.rlib du socle. Toute divergence ⇒ exit ≠ 0, avec le détail des fichiers
non déterministes (premier octet divergent). C’est l’attestation de
reproductibilité visée par l’ADR-025 (le sceau couche 0 n’avait qu’un constat
same-host non outillé). --keep conserve target/repro pour inspection.
check-layers — doctrine de couche mécanisée (ADR-052 / ADR-077)
La doctrine de couche — « seule la couche 1 consomme la couche 0 » ; « les toits
C-ABI (air-libc-*, *-capi) bindent la couche 1, jamais la 0 en direct »
(ADR-077) ; air-sys-types transverse
(ADR-052 D6) — était tenue à la main
/ en revue. Ce gate la mécanise : chaque crate air-* déclare sa couche
dans [package.metadata.air] (layer = 0|1|2), zone ignorée par Cargo (même
précédent que [package.metadata.cargo-machete]) donc inerte ; le gate lit ces
déclarations et prouve que le graphe de dépendances les respecte :
- (a) toute crate
air-*déclarelayer(sinon « crate sans couche ») ; - (b) pas de dépendance montante (
M ≤ N) ; - (c) pas de saut (
M ≥ N-1) : un toitlayer=2ne touche pas la couche 0 en direct — il passe par la couche 1.
air-sys-types est exclu de (b)/(c) (transverse). Les arêtes air-*
cible-only ([target.'cfg(…)'.dependencies]) qui violeraient les règles — p.ex.
l’escape hatch syscall de la couche 0 cadré par
ADR-087, absent du graphe hôte livré —
sont signalées en advisory (non bloquant), pas rouge. Branché dans barrier.
Intégration CI
Le job supply-chain du workflow ci appelle cargo xtask check-syscalls et
cargo xtask audit-exceptions (rapides, sans root) en source unique des
gates correspondants. La couverture reste mesurée par les étapes llvm-cov
existantes du job test-coverage (en root, ADR-031),
inchangées hormis l’ajout de --exclude xtask. couvrable-vide --strict,
repro et barrier sont disponibles localement / à la demande (ils
rejouent la couverture en root et ne sont pas câblés en dur pour ne pas
déstabiliser la CI ; ils pourront l’être quand le registre sera ré-ancré par
symbole).
Pourquoi un gate buggé est pire que pas de gate
Un gate qui rougit au hasard (faux positif) ou qui passe à tort (faux négatif)
donne une fausse confiance. C’est pourquoi les parseurs de xtask
(couvrable-vide, check-syscalls, audit-exceptions, et les helpers de
barrier/repro) sont testés sur entrées synthétiques (cargo test --workspace), et pourquoi la frontière entre gate dur (mécaniquement
décidable : planchers, numéros de syscalls, taxonomie, cohérence du décompte) et
assist advisory (reachability, dérive fichier:ligne) est explicite.
Licence : MPL 2.0
EXCEPTIONS.md — Exceptions explicites à la règle des 80 %
Document de référence — Version 0.1
Rôle de ce document
Le Principe d’ingénierie 6 (cf. docs/principes-ingenierie/principes-ingenierie-fr.md)
et l’ADR-024 (cf. docs/adrs/ADR-024-workflow-dependances-fr.md) imposent une
règle stricte : une dépendance externe n’est admise que si au moins 80 % du
code de la crate candidate est effectivement utilisé par Air. Sous ce seuil,
la dépendance est refusée et le code nécessaire est intégré en sous-module Air.
Le ratio se mesure sur le code de la crate, pas sur la « surface d’API utile ». Une crate qui n’expose qu’une seule API publique mais qui émet pour chaque usage des dizaines de méthodes et de traits dérivés peut très bien échouer au critère même si « tout ce qu’on appelle » est utilisé : ce qui compte, c’est le volume de code embarqué dans nos binaires.
Certaines dépendances sont néanmoins structurantes : leur réimplémentation serait disproportionnée, leur adoption massive dans l’écosystème en fait des références de fait, leurs garanties (sécurité, maintenance, audit) seraient impossibles à reproduire en interne. Pour ces cas, le Principe 6 prévoit une exception explicitement nommée, jamais implicite. Ce fichier consigne ces exceptions.
Toute exception doit :
- Être motivée par un besoin transverse à Air (pas un usage ponctuel d’une seule crate Air).
- Lister les critères de dérogation (réimplémentation déraisonnable, adoption massive, garanties non reproductibles, etc.).
- Préciser les mitigations (audit de sécurité, pin de version, suivi).
- Référencer l’ADR ou la décision qui a tranché.
Exceptions actives
bitflags
| Champ | Valeur |
|---|---|
| Crate | bitflags |
| Version pinnée | =2.11.1 (pin strict couches 0/1, cf. Cargo.toml racine) |
| Licence | Apache-2.0 OR MIT (liste blanche, cf. CLAUDE.md) |
| Dépendances transitives | aucune en v2 |
| Adoptée par | air-sys-types (et toutes les crates couche 0 ultérieures) |
| Tranchée par | session 2026-05-22 (suite : clone3/waitid) — cf. docs/JOURNAL.md |
Motif. Crate macro structurante : son macro bitflags! génère pour
chaque type de drapeau un ensemble complet de traits (BitOr, BitAnd,
BitXor, Not, Sub, Extend, FromIterator, Debug, Hash,
constructeurs empty/from_bits/from_bits_truncate/from_bits_retain,
itérateurs, etc.). Mesuré sur le code émis, Air n’en utilise qu’une
fraction par type (typiquement bits, contains, from_bits_truncate,
empty, plus un ou deux opérateurs binaires) — la crate échoue donc
strictement au seuil des 80 % du Principe 6.
Pourquoi l’exception est justifiée.
-
Volume structurant. Les specs
docs/specs/layer-0/family-*.mdidentifient plus de 30 typesbitflagsdans la seule couche 0 (CloneFlags,WaitOptions,OpenFlags,MapFlags,ProtectionFlags,MessageFlags,RenameFlags,StatxMask,StatxFlags,SignalFdFlags,SeccompFilterFlags,LandlockAccessFs,TimerFdFlags,MlockFlags,AcceptFlags, …). Réimplémenter manuellement les traits associés à chacun serait plusieurs centaines de lignes de boilerplate fragile, dupliqué. -
Reproduction déraisonnable. Une réimplémentation interne couvrirait à peu près le même code émis (les traits sont mécaniques), pour une moindre qualité de tests et de revue. Le bénéfice « moins de code dans la crate externe » est annulé par le coût « plus de code maison ».
-
Adoption massive.
bitflagsest de facto le standard de l’écosystème Rust pour ce besoin. Les autres briques bas niveau de l’écosystème (nix,libc,rustix, …) l’utilisent déjà — ce sont des points de comparaison externes, pas des dépendances d’Air : la couche 0 d’Air appelle les syscalls directement viacore::arch::asm!et n’utilise pasrustix. La maintenance debitflagsest portée parrust-lang/bitflags. -
Zéro dépendance transitive en v2 : la surface d’exposition externe se limite à
bitflagslui-même.
Mitigations.
- Pin strict
=2.11.1dans[workspace.dependencies]. Deux protections complémentaires : (a) la reproductibilité du build est assurée par leCargo.lockcommitté (ADR-025) ; (b) la reproductibilité de la résolution est ajoutée par le pin strict — si jamais le lock est supprimé ou qu’une re-résolution est forcée (ajout d’une autre dép qui contraint le solver,cargo generate-lockfile), une borne^2.4pourrait glisser vers la plus récente 2.x compatible ;=2.11.1ne glisse pas. Chaque montée de version reste ainsi une décision explicite tracée dans la diff deCargo.toml, revue en PR. - Pas de politique transversale dans cette PR : la question
« =strict partout vs ^caret au-delà de la couche 1 » mérite un ADR ou
un
DEPENDENCY_POLICY.mddédié, à instruire séparément. cargo auditdans la chaîne CI (à activer dès qu’elle existe).cargo denyvérifie la licence et l’absence de dépendances transitives non autorisées (à activer dès qu’elle existe).- Audit ponctuel à chaque montée de version majeure ; consigné dans le JOURNAL et, si non trivial, dans un ADR.
Ce que cette exception n’autorise pas.
- Ajouter d’autres crates similaires sans nouvel examen (chaque crate macro est jugée sur ses propres mérites).
- Substituer
bitflagsà un besoin couche 1+ sans réévaluer le contexte (les couches supérieures peuvent avoir des contraintes différentes). - Faire évoluer l’usage au-delà de la macro
bitflags!et de ses traits générés (par exemple, vers des fonctionnalités exotiques) sans amender cette entrée.
icu4x (composants icu_*)
| Champ | Valeur |
|---|---|
| Crates | icu_normalizer, icu_casemap, icu_segmenter, icu_collator, icu_locale_core, icu_calendar (composants à la carte) |
| Versions pinnées | =2.2.0 (sauf icu_calendar =2.2.1), default-features = false + feature compiled_data (+ auto pour icu_segmenter) — réglage gravé ADR-059 §4 (coupe transitivement std), cf. [workspace.dependencies] racine |
| Licence | Unicode-3.0 (ajoutée à la liste blanche deny.toml ; icu4x 2.x a quitté Unicode-DFS-2016) |
| Adoptée par | air-base-lib (couche 1) ; surface Unicode/i18n du cœur |
| Tranchée par | ADR-016 (i18n) ; câblage rapport 073 (couche 1, Passe 2) ; ADR-059 (stratégie Option V : Air utilise icu4x, pas de réimplémentation) |
| Nature technique | Rust pur no_std, PAS ICU4C (réimplémentation from-scratch d’unicode-org, zéro binding C, zéro FFI). Repose sur core + alloc (alloue pour les sorties ; zéro IO/syscall/horloge/thread ; allocateur cible = air-alloc). Runtime = tables const baked (compiled_data, en .rodata) + fonctions pures ; std mécaniquement exclu sur la cible (build-std core+alloc). Vérifié empiriquement : la fermeture (air-base-lib + icu + socle) compile pour x86_64/aarch64-unknown-linux-air (--release + default-features=false + build-std=core,alloc, ADR-059 ; roadmap §2.A.2). Conforme « zéro surface C/C++ » (gate check-c-surface). |
Motif. icu4x est la base Unicode/i18n d’Air (ADR-016). Chaque composant
expose une API très large (normalisation, casing, segmentation, collation,
calendriers, locales) dont air-base-lib n’utilise qu’une fraction (estimée
10–20 % par composant, cf. crates/air-base-lib/DEPENDENCIES.md). Mesuré sur le
code embarqué (données Unicode baked-in comprises), l’ensemble échoue donc
strictement au seuil des 80 % du Principe 6 — d’où l’exception structurante.
Pourquoi l’exception est justifiée.
- Réimplémentation déraisonnable et dangereuse. Réécrire une normalisation Unicode (UAX #15), une segmentation par graphèmes/mots/phrases (UAX #29), une collation (UTS #10) ou les arithmétiques de calendriers serait des milliers de lignes, avec un risque élevé de bugs de sécurité (confusion d’encodage, troncatures) — précisément ce que le Principe 3 cherche à éviter.
- Données Unicode à jour. icu4x embarque les tables Unicode officielles, versionnées et maintenues par le consortium ICU/Unicode — non reproductible en interne sans dette de maintenance permanente.
- Modularité native. icu4x est conçu en composants tirables à la carte :
on n’embarque que les domaines du contrat (pas de runtime data provider grâce
à
compiled_data), ce qui borne la surface réelle. - Frontière confinée. L’usage est isolé dans
air-base-lib(couche 1) ; le formatage culturel (couche 2) viendra plus tard, sans élargir cette entrée.
Mitigations.
- Pins stricts (
=2.2.0/=2.2.1) dans[workspace.dependencies]: la montée de version reste une décision explicite tracée en PR (repro ADR-025). compiled_data(données baked-in) : aucun chargement runtime, surface d’attaque réduite, build reproductible.cargo denyvérifie la licenceUnicode-3.0(liste blanche) et les transitives ;cargo auditcouvre les avis de sécurité.- % d’API par composant documenté et daté dans
crates/air-base-lib/DEPENDENCIES.md; ré-audit à chaque montée majeure. - Composant écarté (
icu_time) : retiré faute d’usage réel (conversionCLOCK_REALTIME → ISOdérivée viaicu_calendar::RataDie).
Ce que cette exception n’autorise pas.
- Ajouter d’autres composants
icu_*sans mesurer leur usage réel et amenderDEPENDENCIES.md+ cette entrée. - Activer un data provider runtime ou des features élargissant la surface sans
réévaluation (rester sur
compiled_data). - Étendre l’usage d’icu4x à d’autres crates sans réexaminer le contexte de couche.
air-crypto — crypto auditée (RustCrypto, dalek, zeroize)
| Champ | Valeur |
|---|---|
| Crates | sha2, sha3, hmac, aes-gcm, chacha20poly1305, hkdf, argon2, zeroize (RustCrypto) ; ed25519-dalek, x25519-dalek (dalek) ; subtle (transitif, constant-time) |
| Versions pinnées | cf. [workspace.dependencies] racine + crates/air-crypto/DEPENDENCIES.md. no_std-strict (roadmap §2.E.2) : default-features = false sur sha2/sha3/aes-gcm/chacha20poly1305/argon2/ed25519-dalek (coupe la fuite std transitive ; features réactivées = strict nécessaire, cf. DEPENDENCIES.md) |
| Licences | MIT OR Apache-2.0 (RustCrypto, zeroize) ; BSD-3-Clause (dalek) — toutes en liste blanche deny.toml |
| Adoptée par | air-crypto (couche 1) |
| Tranchée par | ADR-034 (discipline crypto) ; câblage prompt 086 (Passe 2) ; no_std-strict roadmap §2.E.2 |
Motif. « On ne réimplémente JAMAIS de crypto. » C’est la crate où la
discipline « minimiser les deps » est délibérément renversée (cf. spec
docs/specs/layer-1/air-crypto.md) : rouler sa propre crypto est dangereux
(attaques temporelles, bugs subtils, zéro audit). La sécurité vient de
l’adoption massive et auditée. Chaque crate n’expose qu’une fraction de son
API générique utilisée par Air (~10–40 %, cf. DEPENDENCIES.md) — elles
échouent donc à la règle des 80 %, d’où l’exception structurante (analogie
icu4x, ADR-016).
Pourquoi justifiée.
- Réimplémentation déraisonnable et dangereuse — AES-GCM, ChaCha20-Poly1305, Ed25519, X25519, Argon2, SHA-2/3 sont des primitives où la moindre erreur (non constant-time, débordement) est une faille. RustCrypto/dalek sont audités, constant-time, et corrigent vite (fast-lane ADR-034).
- Pur Rust, zéro C/C++ — toutes ces crates respectent la politique zéro-C
(#81) :
cargo xtask check-c-surfaceet le bancc/*-sysrestent verts. (BLAKE3 a été différé précisément parce queblake3tirecc— cf.DEPENDENCIES.mdet la doc du modulehash.) (Réserveaarch64(roadmap §2.E.2) : la fermetureno_stdcompile surx86_64-unknown-linux-airsans aucunlibc, mais suraarch64le cratecpufeatures— dép non-optionnelle pour la détection ARMv8 — lielibc(getauxval), qui n’a pas de profilenv=air. Manque de support cible / surface C à lever par un cheminlibc-freeair_auxval(AT_HWCAP)— chantier dédié, cf.DEPENDENCIES.md§« Fermetureno_std-strict ».) - API anti-mésusage — Air enrobe ces crates d’une surface qui rend les
erreurs courantes difficiles (clés
zeroize, AEAD à nonce géré, vérifs constant-time), sans toucher au cœur crypto.
Additifs planifiés (descellement couche-1-v1.x, RFC — support air-tls).
Pour l’interop WebPKI de la contingence air-tls maison (ADR-042 §Contingence ;
docs/specs/layer-2/air-tls.md ; instruits en docs/specs/layer-1/air-crypto.md
§« Additifs planifiés ») : p256/p384 (ECDSA + ECDH NIST, RFC 6979 déterministe),
rsa (RSA-PSS vérification seule — aucune opération privée RSA → hors
RUSTSEC-2023-0071 « Marvin »), ml-kem (FIPS 203, hybride X25519MLKEM768 only),
et la variante AES-128-GCM de l’aes-gcm déjà présent. Toutes pur Rust, zéro-C
(check-c-surface vert — à re-vérifier au bump, réserve aarch64/cpufeatures ci-dessus).
Chaque entrée : RFC de descellement (modèle ADR-065/066/067) + KAT-gate (ADR-034)
- ajout à
DEPENDENCIES.md. Non actives tant que le descellement n’est pas ratifié.
Additifs air-quic/air-ssh (ADR-081). Révélés par les specs QUIC (header
protection) et SSH (signatures/cipher) : aes/chacha20/poly1305 — déjà
transitifs (via aes-gcm/chacha20poly1305), promus dépendances directes pour un
module air_crypto::lowlevel (bloc AES single-block, keystream ChaCha20, Poly1305
one-shot — primitives nues opt-in expert-only, la façade AEAD restant le défaut) ;
rsa (déjà ci-dessus) étendu à RSASSA-PKCS#1 v1.5 vérification (hors Marvin,
opération publique). Kex PQ SSH = ml-kem réutilisé (mlkem768x25519) ; sntrup761
différé. Pur Rust, zéro-C, ratification BDFL + KAT-gate.
Additifs air-wireguard/air-mail + AEAD nonce explicite (ADR-082). (1) AEAD à
nonce explicite dans lowlevel (AirAeadExplicitNonce, backends aes-gcm/
chacha20poly1305 déjà présents — seule l’API à nonce fourni s’ajoute) : requis par
record/packet TLS/QUIC/SSH/WireGuard (nonce déterministe IV⊕compteur, ≠ nonce
aléatoire de la façade). (2) blake2 (nouveau, RustCrypto, pur Rust zéro-C ≠
blake3-qui-tire-cc) : BLAKE2s hash/MAC/HMAC pour le handshake Noise de WireGuard. (3)
pbkdf2 (nouveau, RustCrypto) : SCRAM-SHA-256 de air-mail. Ratification BDFL +
KAT-gate.
Mitigations.
- Pins stricts (versions
=) ; bumps KAT-gated (ADR-034) ;cargo audit(fast-lane sécurité) +cargo deny(licences permissives) en CI. - Vecteurs officiels (KAT) RFC/NIST en tests + property-based + fuzzing.
- Discipline régie par ADR-034 (veille upstream, exemption de vendoring).
Ce que cette exception n’autorise pas.
- Réintroduire une surface C/C++ (p. ex.
ring,aws-lc,blake3aveccc) — interdit par la politique #81 ; nécessiterait sa propre exception nommée + plan de sortie. - Écrire de la crypto maison ou utiliser un PRNG userspace pour l’aléa
(
AirRandomreste le CSPRNG kernel). - Ajouter d’autres crates crypto sans audit ADR-034 + entrée ici.
capnp — runtime Cap’n Proto (partagé air-config ↔ AirCom)
| Champ | Valeur |
|---|---|
| Crate | capnp |
| Version pinnée | =0.26.0 (pin strict couche 1, cf. Cargo.toml racine) |
| Licence | MIT (liste blanche, cf. CLAUDE.md) |
| Dépendances transitives | aucune (features quickcheck/embedded-io non activées ; embedded-io?/… non tirée) |
| Adoptée par | air-config-schema, air-config-compile, air-config (lecture zéro-copie de l’artefact mappé) et AirCom (air-com-schema : schéma v1 du wire format ; air-com-proto : Codec du control plane), ADR-001 |
| Tranchée par | ADR-040 (2026-06-25, BDFL) |
Motif. L’artefact binaire de configuration d’Air est encodé en Cap’n
Proto (ADR-040), choisi pour la lecture zéro-copie/mmap (budget runtime au
boot, Principe 9) et la cohérence projet : AirCom (ADR-001) s’engage déjà
sur Cap’n Proto. Air n’utilise qu’une fraction de l’API du crate capnp
(encodage + réflexion dynamique de schéma) — la crate échoue donc au seuil des
80 % du Principe 6, d’où l’exception structurante.
Pourquoi l’exception est justifiée.
- Audit mutualisé (décisif). La dépendance Cap’n Proto est de toute façon
justifiée pour AirCom ;
air-configla réutilise sans coût d’exception supplémentaire — un seul langage de schéma, une seule chaîne de codegen, une seule dépendance à auditer sur tout le stack (ADR-040 §Conséquences). - Pur Rust, zéro C/C++ au build. Le crate runtime
capnpa zéro dépendance transitive et n’introduit aucune surface C/C++ :cargo xtask check-c-surfaceet le bancc/*-sysrestent verts. Le tool C++capnp(front-end de schéma) est confiné à la régénération mainteneur du code généré committé (ADR-040 addendum), hors build/CI. - Réimplémentation déraisonnable. Un format schema-first zéro-copie à évolution intégrée n’est pas reproductible en interne (horizon 20 ans).
Mitigations.
- Pin strict
=0.26.0; code généré committé → build pur cargo, reproductible (ADR-025) ; tool C++capnpversion pinnée 1.1.0, hors CI. cargo deny(licence MIT permissive) +cargo auditen CI.
Ce que cette exception n’autorise pas.
- Réintroduire une surface C/C++ (le tool C++ reste hors build ; aucun
capnp-sys/cc). - Tirer d’autres crates de l’écosystème Cap’n Proto sans entrée ici.
libm — math flottante no_std (air-libm)
| Champ | Valeur |
|---|---|
| Crate | libm (rust-lang) |
| Version pinnée | =0.2.16 (pin strict couche 1, cf. Cargo.toml racine) |
| Licence | MIT (liste blanche deny.toml ; l’amont rust-lang/compiler-builtins est dual MIT OR Apache-2.0) |
| Dépendances transitives | aucune (pur Rust no_std) |
| Adoptée par | air-libm (couche 1) — surface math no_std d’Air |
| Tranchée par | ADR-057 (2026-07-01, par délégation d’autonomie BDFL ; companion ADR-053/ADR-024) |
Motif. L’audit icu4x (docs/notes/audit-icu4x-rust-pur-fr.md, §3/§8) a établi que le seul
primitif système qui manque à la réimplémentation i18n / libc d’Air est une bibliothèque
mathématique flottante no_std — un libm. Deux consommateurs incontournables en dépendent :
calendrical_calculations (math calendaire, via icu_calendar — non contournable) et le modèle
LSTM d’icu_segmenter, tous deux par la façade core_maths sur libm. Air vendore la crate
libm (rust-lang) et l’expose via la crate couche 1 air-libm (pub use libm::*;). Le % d’API
« consommé » est aujourd’hui nul (aucun consommateur encore — c’est une fondation posée pour la
libc/i18n future, ADR-053) ; la crate échoue donc trivialement au seuil des 80 % du Principe 6, d’où
l’exception structurante (modèle icu4x, ADR-016).
Pourquoi l’exception est justifiée.
- Réimplémentation déraisonnable et risquée. Réécrire un
libmcorrect from scratch serait des dizaines de fonctions transcendantes à précision ULP, avec cas limites NaN/inf/dénormaux — un risque de bugs numériques précisément là où Air ne peut pas se permettre l’à-peu-près. Le bénéfice « moins de code externe » serait annulé par le coût « plus de code maison moins testé ». - Qualité
std, maintenu par le projet Rust.libmest fusionné danscompiler-builtins: c’est lelibmque lastdRust utilise elle-même sur les ciblesno_std/wasm. Sa correction est éprouvée et continûment testée par l’amontrust-lang. - Pur Rust
no_std, zéro dépendance transitive. Portage defdlibmen Rust pur, sans aucune dép runtime ni surface C/C++ (cargo xtask check-c-surfacereste vert ; aucun*-sys, aucuncc). Compatible avec la cible*-linux-air(prouvé par crate-sonde, ADR-057).
Mitigations.
- Pin strict
=0.2.16dans[workspace.dependencies]: la montée de version reste une décision explicite tracée en PR (repro ADR-025). cargo denyvérifie la licenceMIT(liste blanche) et l’absence de transitives ;cargo auditcouvre les avis de sécurité.- Frontière confinée dans
air-libm(couche 1) ; aucune autre crate ne tirelibmdirectement. - % d’API et audit consignés/datés dans
crates/air-libm/DEPENDENCIES.md; ré-audit à chaque montée.
Ce que cette exception n’autorise pas.
- Tirer
libmdirectement depuis une autre crate sans passer parair-libm(frontière unique). - Substituer une autre crate math (
core_maths,micromath, …) sans nouvelle entrée ici. - Faire évoluer
air-libmau-delà du ré-export (logique math propre) sans réévaluer le contexte.
syn / quote / proc-macro2 — outillage proc-macro (air-object-macros, politique proc-macro d’Air)
| Champ | Valeur |
|---|---|
| Crates | syn, quote, proc-macro2 (+ transitif unicode-ident) |
| Versions pinnées | syn =2.0.117 (default-features = false, features derive/parsing/printing/clone-impls/proc-macro), quote =1.0.45, proc-macro2 =1.0.106 — cf. [workspace.dependencies] racine |
| Licences | MIT OR Apache-2.0 (les trois) ; unicode-ident (MIT OR Apache-2.0) AND Unicode-DFS-2016 — toutes en liste blanche deny.toml |
| Adoptées par | air-object-macros (couche 2, premier crate proc-macro d’Air) — l’attribut #[air_class] (spec docs/specs/layer-2/air-object.md §4 ; ADR-002) |
| Tranchée par | spec air-object.md §4 « Décisions ratifiées » n°5 (BDFL 2026-06-25) : exception 80 % proc-macro acceptée → « fixe la politique proc-macro d’Air » |
| Nature technique | Pur Rust, ZÉRO surface C/C++ (cargo xtask check-c-surface reste vert ; aucun *-sys, aucun cc). Build-time only : une proc-macro ([lib] proc-macro = true) compile pour l’HÔTE et est exécutée par rustc au build des consommateurs — elle n’est JAMAIS liée dans un artefact cible (rien n’entre dans libair-object.so ni dans aucune .so/binaire livré). |
Motif. Une libc/un modèle d’objet à surface stable a besoin de génération de
code à la compilation (#[air_class] synthétise la table de descripteurs const
d’AO.3 — « zéro glue par classe »). Le seul écosystème crédible pour parser/émettre
du Rust dans une proc-macro est le trio syn (parseur), quote (émetteur),
proc-macro2 (couche TokenStream neutre). Chaque crate expose une API très large
(syn couvre toute la grammaire Rust) dont air-object-macros n’utilise qu’une
fraction (parsing de DeriveInput/attribut, quote!) : mesuré sur le code
embarqué, l’ensemble échoue au seuil des 80 % du Principe 6 — d’où l’exception
structurante (modèle bitflags/icu4x).
Pourquoi l’exception est justifiée.
- Réimplémentation déraisonnable. Réécrire un parseur de la grammaire Rust (suivant l’évolution du langage) serait des dizaines de milliers de lignes, pour une moindre qualité — précisément le cas où le Principe 6 prévoit l’exception nommée.
- Adoption massive et de facto.
syn/quote/proc-macro2(rust-lang / dtolnay) sont l’outillage standard de tout l’écosystème proc-macro Rust (serde, thiserror, clap… et la plupart des*-derive). Maintenance et audit portés en amont. - Rien dans l’artefact livré (décisif). Contrairement aux deps runtime, une
proc-macro ne survit pas au build :
rustcl’exécute puis la jette. Lalibair-object.socible tier-1 ne contient aucun octet desyn/quote— le critère « code embarqué dans nos binaires » vaut zéro ici. C’est l’exception la moins coûteuse du registre. - Zéro surface C/C++ (
check-c-surfacevert) et zéro fork (ADR-090) : une proc-macro compile pour l’hôte, elle échappe à la fermetureno_std/*-linux-airde la cible.
Mitigations.
- Pins stricts (
=) dans[workspace.dependencies], alignés sur leCargo.lockdéjà résolu : toute montée reste une décision explicite tracée en PR (repro ADR-025). default-features = falsesursyn(features tirées à la carte : pas defull, pas d’extra-traits) : surface réduite au strict nécessaire.cargo deny(licences permissives) +cargo audit(avis de sécurité) en CI ;cargo machetevérifie que les trois deps sont réellement utilisées.% d'APIet audit consignés/datés danscrates/air-object-macros/DEPENDENCIES.md.- Frontière confinée : seul
air-object-macrostire ces crates ; toute future crate*-macrosréutilise cette politique (ci-nommée) sans nouvelle exception, tant qu’elle reste build-time only et pur Rust.
Ce que cette exception n’autorise pas.
- Tirer
syn/quote/proc-macro2depuis une crate runtime (liée dans un artefact) — l’exception est bornée au build-time only ([lib] proc-macro = true). - Ajouter une feature élargissant la surface (
syn/full,extra-traits…) sans amender cette entrée +DEPENDENCIES.md. - Réintroduire une surface C/C++ dans la chaîne proc-macro (aucun
*-sys/cc).
syscall générique (raw_syscall) — escape hatch, entorse ADR-021 + « libc binde couche 1 » — À RETIRER
| Champ | Valeur |
|---|---|
| Nature | Fonction Rust air_sys_syscall::raw_syscall::raw_syscall(number, a1..a6) (asm brut 2 arches, couche 0) + shim libc cible-only air_libc_capi::syscall qui la binde directement. |
| Politiques dérogées | (1) ADR-021 conv. 3 — « pas de wrapper générique pour syscalls multiplexés ; chaque opération = une fonction dédiée typée ». (2) « la libc binde la couche 1, jamais la couche 0 » — le shim syscall est le seul point de la libc tapant la couche 0. |
| Adoptée par | air-libc-capi (module syscall, #[cfg(target_vendor = "air")]) sur air-sys-syscall (dép cible-only). |
| Tranchée par | Décision BDFL — 2026-07-09 (M5, Lot D) : « implémenter syscall comme std l’attend (générique, tape la couche 0 s’il faut), mais noter l’exception et prendre contact avec l’équipe Rust pour la solutionner — à retirer le plus vite possible ». |
| Statut | ⚠️ TEMPORAIRE — à retirer. |
Motif (l’impossibilité). La libc C-ABI doit fournir long syscall(long number, ...) : le pal unix de Rust std l’appelle en direct pour deux services sans fonction libc typée — futex (aucune fonction libc n’a jamais existé ; base des Mutex/Condvar/Once/park de std sur Linux) et gettid (pas de wrapper garanti avant glibc 2.30). syscall(2) est par nature une trappe non typée : on ne peut pas la rendre typée sans la dénaturer, ni la router proprement (les flags d’opération futex de std rendent tout routage fragile).
Pourquoi c’est borné.
- Un seul point, cible-only.
raw_syscalln’a qu’un consommateur (le shimsyscall), gatétarget_vendor = "air"; il n’existe pas dans l’artefact hôte. - Aucun code Air ne l’utilise. Tout le reste d’Air passe par des wrappers typés (
futex_wait,gettid, …) ;raw_syscallest exclusivement la trappe pour le Cstdqui contourne les API typées. - Périmètre M5 quasi nul au runtime.
hello-stdétant mono-thread,futexn’est jamais appelé (contention uniquement) — le symbole doit juste exister pour lier.
Plan de retrait (action item amont — équipe Rust). Faire que le pal unix de std n’ait pas besoin d’un syscall générique sur *-linux-air : router futex/gettid vers des fonctions typées Air, ou fournir un pal Air dédié. Dès que l’amont est résolu, raw_syscall et le shim syscall disparaissent (et cette exception avec).
Shims C va_arg variadiques (printf… + open/openat/fcntl) — exception nommée à « zéro C »
| Champ | Valeur |
|---|---|
| Nature | Code C interne (≈ 190 + ≈ 90 lignes), PAS une dépendance Cargo. Fichiers crates/air-libc-c/csrc/printf_shim.c (+ header privé air_fmt_glue.h) et crates/air-libc-c/csrc/fileio_shim.c, compilés par crates/air-libc-c/build.rs et liés dans libair_c.so. |
| Politique dérogée | « zéro surface C/C++ » (audit 082 / prompt 083 ; gate check-c-surface, ban cc/*-sys de deny.toml). C’est une exception à la politique zéro-C, distincte de la règle des 80 % — consignée ici, le registre des exceptions nommées. |
| Adoptée par | air-libc-c (artefact libair_c.so, host-only). |
| Tranchée par | Décision BDFL — roadmap libc §6 : M1-d « frontière variadique printf = option (A) » ; M2 l’étend à open/openat/fcntl (mêmes contraintes : fonctions variadiques, mode/arg conditionnel — cf. roadmap §6 M2 « open… variadique → shim C fait le va_arg »). |
Motif (l’impossibilité technique). Une libc C-ABI doit fournir printf/
fprintf/snprintf/… et open/openat/fcntl — des fonctions C variadiques
(const char *fmt, ... ; int flags, ... /* mode_t */).
Or Rust-stable ne peut pas définir de fonction variadique : extern "C" fn f(…, ...) exige la feature c_variadic, nightly, et la libc d’Air reste sur
stable (ADR-025, barrier, couverture 100 %). Il faut donc un point de contact
C pour le seul mécanisme que Rust-stable ne sait pas exprimer : le va_arg.
Pourquoi l’exception est justifiée (périmètre minimal, zéro surface d’attaque).
- Le shim ne fait QUE
va_arg+ délégation. Il ne parse rien : aucune boucle de format, aucun calcul de largeur/précision, aucune interprétation de%. Pour chaque fonction, il (a) demande le plan des types d’args à la glue Rust (air_fmt_arg_kinds), (b) fait leva_arg(ap, <type>)mécanique dicté par le plan (en respectant les promotions C), (c) re-délègue au moteur de rendu Rust (air_fmt_render_buf/_stream). C’est trivialement auditable. - La surface d’attaque N°1 reste en Rust fuzzé. La chaîne de format — la
surface « format-string » d’une libc — est parsée et rendue entièrement par
air-libc-fmt(Rust pur, sansunsafe, sans allocation, fuzzé :fuzz_libc_format), via la glue mesuréeair-libc-printf(fuzz_libc_printf). Le plan et le rendu passent par le même parseur (air_libc_fmt::format) ⇒ ils ne peuvent pas diverger ; le C ne voit jamais un%. %nrejeté de bout en bout. Le moteur rend%nlittéralement et ne tire aucun argument ⇒ le plan n’émet aucun kind, le shim ne fait aucunva_arg, jamais d’écriture-arrière (vecteur format-string classique neutralisé).- Aucune dépendance Cargo, aucune surface
*-sys.build.rsinvoquecc/$CCdirectement (std::process::Command), jamais la cratecc(bannie).cargo tree,check-c-surfaceetcargo denysont inchangés/verts (le compilateur C est un outil hôte, comme cbindgen/Doxygen/capnp).
Mitigations.
- Périmètre gelé : les shims n’implémentent que (a)
printf/fprintf/sprintf/snprintf+ variantesv*et (b)open/openat/fcntl— dans les deux cas le seulva_argmécanique + délégation à un point d’entrée Rust non-variadique (air_fmt_*/air_libc_{open,openat,fcntl}).fileio_shim.cne fait aucun syscall, aucune interprétation de drapeau (la décision «O_CREAT⇒ liremode» est un simple test de bit) ; toute la sémantique (openat2, CLOEXEC, EINTR) vit en Rust. Tout autre élargissement exige d’amender cette entrée. - Garde ABI de la représentation d’argument partagée (
FmtArgValue) : verrou_Static_assertcôté C et testsize_of/offset_ofcôté Rust (une dérive de layout casse la compilation — défense en profondeur, sécurité #1). - Reproductibilité (ADR-025) : mêmes sources + même
$CC⇒ même objet ; options figées dansbuild.rs. - Couverture : la glue Rust (
air-libc-printf) est mesurée (viser 100 %) ; le shim.c(insondable parllvm-cov, comme les shims#[no_mangle]) est couvert par le harnais hello-world C réel (air-libc-c/tests/hello_world.rs) + les tests de conformité ABI. - Preuve d’exécution :
nm -D libair_c.somontreprintf… ; le hello-world C imprimeHello, Air 42.
Portée. Host maintenant (cc/clang de l’hôte de dev/CI). Le portage
on-target (*-linux-air, toolchain clang-air) est hors périmètre M1-d — la
crate d’artefact est host-only. Le shim y sera recompilé par le toolchain cible le
moment venu (aucun changement de conception : c’est le même va_arg).
Ce que cette exception n’autorise pas.
- Mettre quelque logique de format que ce soit en C (parsing, largeur/précision,
%n$positionnels, locale) — tout cela reste en Rust. - Réintroduire une dépendance Cargo C (
cc,*-sys) — l’invocation directe decc/$CCest le seul vecteur autorisé. - Étendre le C au-delà des familles
printfetopen/openat/fcntl(p. ex.scanf/syscall/ioctlvariadiques) sans une nouvelle décision BDFL + mise à jour de cette entrée.
Licence du document : MPL 2.0 Statut : registre vivant des exceptions explicites au Principe d’ingénierie 6.
COVERAGE-EXCEPTIONS.md
Exceptions de couverture légitimes pour la couche 0 d’Air.
Chaque entrée correspond à une branche ou à des lignes de production qui ne peuvent pas être atteintes via l’API safe ou en environnement CI non-root, et qui ne constituent pas une dette de qualité.
Taxonomie gravée par ADR-035 (companion d’ADR-031) :
- STRUCTURAL : branche logiquement inatteignable par construction (invariant
garanti : longueur ≤ borne, sémantique kernel per-process, free-list,
bid < count…). Inclut l’ancienneEFAULT-SAFE(brasEFAULT/EBADFexclu car buffers/sorties valides par construction — le mot-clé « EFAULT-SAFE » reste dans les justifications) et l’ancienneVALUE-UNREACHABLEpermanente (joker#[non_exhaustive], garde défensive permanente). - PRIVILEGE : nécessite une capability / condition de privilège (
CAP_*,RLIMIT_*, Yama…) absente sur le runner. - FEATURE-KERNEL : feature/option kernel présente ⇒ le bras de repli (kernel ancien / feature absente) est mort.
- CHILD-EXIT : code exécuté dans un enfant forké mais profil LLVM non
flushable (priv-drop / seccomp-strict / landlock-lock /
abort) — limite d’instrumentation du modèle fork+flush d’ADR-031 ; preuve par le code de sortie observé viawaitid. - DEFERRED-TOOLING : branche réellement atteignable non encore outillée — à couvrir, jamais exemption permanente ; chaque entrée nomme l’outillage requis.
- TARGET-ONLY (amendement ADR-035, 2026-06-29 ; couche 1) : code sound
uniquement sur
*-linux-air(registre TLS, TCB, relocation static-PIE, spawnCLONE_SETTLS) — ni exécuté ni mesuré par le gate (*-linux-gnu, glibc : unsound, #151). Granularité fichier (--ignore-filename-regex) ; preuve par le selftestrt/on-target (exit code, 2 arches). Exception légitime, pas une dette. Cf. section « air-runtime — couche 1 » ci-dessous. Hors décompte du récapitulatif production couche 0.
Règle (ADR-035) : les bras de code de test (court-circuit d’
assert!, gardes de harnais) ne figurent PAS au registre production — voir l’annexe en fin de fichier. Invariant : l’ensemble « couvrable » (branches atteignables non couvertes hors exception légitime) reste VIDE.
Application de la taxonomie (
055, 2026-06-14).EFAULT-SAFEetVALUE-UNREACHABLE(permanente) reclassées en STRUCTURAL ;TEST-HARNESSsorti du registre production (→ annexe) ; 3 candidates « atteignables-mais- dures » désormais COUVERTES par de vrais tests, leurs exceptions supprimées (fs::try_lockcontention,net::get_so_errorasync,process::pidfd_send_signalSome) ⇒DEFERRED-TOOLINGdéfini mais VIDE.
Méthodologie de mesure des branches — artefact [True:0, False:0] (élucidé)
cargo +nightly llvm-cov --workspace --branch (tous targets) colle, sur des lignes
pourtant couvertes, des régions de branche fantômes Branch (L:C): [True: 0, False: 0]
au coordonnées identiques à une branche réelle couverte. Investigation (session 3f) :
- Cause prouvée. Elles proviennent exclusivement du binaire de test d’intégration
(
crates/air-sys-syscall/tests/main_thread_invariant.rs), qui lie la lib sans#[cfg(test)]et n’exécute quegetpid/gettid. Son instanciation (hash de coverage-map différent de celle compiléecfg(test)pour les tests unitaires) contribue un[0,0]pour chaque branche qu’il n’exécute pas ; llvm-cov ne peut pas le fusionner avec les compteurs réels et liste les deux. - Preuve reproductible.
--workspace→ 36 shadows surprocess.rs;--workspace --lib(exclut le binaire d’intégration) → 0 shadow, 60 régions réelles. Chaque shadow[0,0]partageait sa coordonnée exacte avec une branche couverte (36/36) — une branche source réelle ne peut pas être à la fois couverte et jamais atteinte. - Décision de mesure. La barrière branches se mesure
cargo +nightly llvm-cov --workspace --lib --branch(artefact-free). Les lignes se mesurent--workspace(les compteurs de lignes fusionnent correctement, pas de shadow). Ce ne sont donc pas des entrées d’exception à énumérer : c’est une correction de commande de mesure. Le binaire d’intégration garde sa propre couverture (getpid/gettid) hors métrique de branche.
Note — cohabitation toolchain (exceptions CHILD-EXIT temporaires)
Plusieurs familles (process, signal, security) exercent des wrappers couche 0
uniquement dans des enfants forkés (clone3). Le profil LLVM de ces enfants
n’est plus flushé : flush_child_coverage est désormais vide. Raison :
un enfant forké hérite d’un espace d’adressage copié, et
__llvm_profile_write_file alloue — si un thread frère tenait le lock interne
de malloc au moment du clone3, ce lock est copié verrouillé (détenteur
absent dans l’enfant) → le malloc de l’enfant deadlocke (flaky couche 0 qui
faisait pendre le gate). L’enfant doit donc rester async-signal-safe. Les
lignes qu’il exécute sont des exceptions CHILD-EXIT, prouvées par le code de
sortie observé par le parent via waitid.
C’est un artefact de la cohabitation entre le code bas-niveau Air — qui forke
via clone3 brut (couche 0, kernel = bible, sans la danse atfork d’une libc)
— et glibc, dont l’allocateur sert au runtime de couverture ; cohabitation
imposée par une toolchain rustc/LLVM qui n’est pas Air-compatible. Le fork()
d’une libc, lui, ferait l’atfork (verrouiller l’allocateur avant le clone, le
réinitialiser dans l’enfant) ; clone3 brut, non.
Réactivable plus tard. Quand la libc Air sera prête et qu’on aura recompilé une toolchain Rust/LLVM/clang/clang++ sur la libc Air (probablement avec d’autres bibliothèques que la seule libc — à voir le moment venu), Air maîtrisera l’allocateur tiré en couverture (fork-safe, ou flush async-signal-safe) → la couverture de ces lignes pourra être rétablie. Donc : exception maintenant, couverture totale plus tard. (cf. mémoire projet « coverage CHILD-EXIT cohabitation toolchain ».)
fs — famille filesystem
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
open_by_handle_at — corps complet (chemin succès, l.2453-2495) | PRIVILEGE | CAP_DAC_READ_SEARCH requis ; absent en CI non-root. Le chemin erreur (EPERM) est couvert par name_to_handle_at_enoent. |
openat2 — repli return openat(...) sur ENOSYS (l.620) et branche if err == ENOSYS (l.655) | FEATURE-KERNEL | openat2(2) (Linux 5.6+) est présent sur les 2 exécuteurs → le repli vers openat n’est jamais pris. Atteignable seulement sur un kernel < 5.6. |
close — branche d’erreur if ret < 0 => return Err (l.725) | STRUCTURAL | close d’un OwnedFd valide consommé : seuls EINTR (non retenté, convention 2 ADR-021) ou EBADF (impossible, fd possédé valide) ; non provoquable via l’API safe. |
readlinkat — branche if n < cap côté faux + chemin de croissance du buffer (l.1642, 1646-1657) | STRUCTURAL | Le buffer initial fait PATH_MAX (4096) ; un kernel Linux refuse de créer un symlink dont la cible dépasse PATH_MAX-1 (4095). Donc readlinkat retourne toujours n ≤ 4095 < 4096 → la troncature/croissance est inatteignable par construction sur les FS cibles (ext4/tmpfs). |
name_to_handle_at_ok_or_eopnotsupp — bras Err(EOPNOTSUPP)/Err(e) => panic! (l.3507-3511) et 2ᵉ/3ᵉ opérandes du `assert!(… | … | |
Garde de skip if ret < 0 { return; } du test seals_* quand memfd_create/seals indisponible (l.3467) | FEATURE-KERNEL | Skip pris uniquement si memfd/F_SEAL absents ; présents sur les exécuteurs. |
fs — extension inotify (fs::inotify)
Décodeur pur (air-sys-types::fs : InotifyEvents/InotifyEvent) couvert à
100 % lignes + branches, incl. property-based + fuzz (fuzz_inotify_parse).
Wrappers (fs::inotify) : intégration kernel réelle (CREATE/MODIFY/MOVED_FROM↔
MOVED_TO corrélés par cookie/DELETE/IGNORED, bon name ; remove_watch ;
EAGAIN NONBLOCK ; add_watch ENOENT ; remove_watch EINVAL). Résidus :
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
inotify_init — bras d’erreur if ret < 0 => return Err | STRUCTURAL | N’échoue qu’en épuisement de ressources (EMFILE/ENFILE/ENOMEM, max_user_instances) — condition système non provoquable de façon déterministe en CI (cf. spec §Tests). Le chemin succès est couvert ; le décodage d’errno est testé par ailleurs. |
Bras Err des i32::try_from(ret).map_err(EINVAL) de inotify_init (fd) et add_watch (wd) | STRUCTURAL | Conversion défensive i64 → i32 (Principe 2, pas d’as nu) : ret ≥ 0 provient d’un FD/wd kernel, toujours ≤ i32::MAX → le bras d’erreur est inatteignable par construction. |
ipc — famille IPC
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
Branches d’erreur (if ret < 0 => return Err) de eventfd2 (l.194) et pipe2 (l.243) | STRUCTURAL | Avec des EventFdFlags/PipeFlags typés et valides, les seuls échecs possibles sont EFAULT (exclu, aucun pointeur user pour eventfd2) ou l’épuisement de fd (EMFILE/ENFILE, condition de ressource, pas un défaut de code). Les branches d’erreur atteignables sont testées : EventFd::read→EAGAIN, EventFd::write(u64::MAX)→EINVAL, vmsplice fd invalide→EBADF. |
Garde de skip if test_memfd_create(...) < 0 { return; } du test tee_einval_on_non_pipe | FEATURE-KERNEL | Le bras return (skip) n’est pris que si memfd_create est indisponible. Sur les 2 exécuteurs cibles memfd_create est présent → seul le bras « continuer » est exercé ; le skip relève d’un kernel sans memfd. |
mem — famille mémoire
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
Mapping::drop / munmap — branche d’erreur if ret < 0 (l.133-134) | STRUCTURAL | raw_munmap n’échoue (EINVAL) que sur une adresse/longueur invalide ; un Mapping/munmap(Mapping) part toujours d’un mapping vivant valide → inatteignable via l’API safe. Le chemin succès est couvert (munmap_explicit_succeeds). |
munlockall — branche d’erreur if ret < 0 (l.670) | STRUCTURAL | munlockall(2) ne prend aucun pointeur utilisateur et ne nécessite aucun privilège sur Linux moderne (≥ 2.6.9) : il réussit toujours. Le chemin succès est couvert (munlockall_succeeds_or_eperm). |
memfd_secret — branche d’erreur if ret < 0 (l.757, ENOSYS) | FEATURE-KERNEL | CONFIG_SECRETMEM présent sur les 2 exécuteurs → memfd_secret réussit, l’arme ENOSYS n’est prise que sur un kernel < 5.14 sans secretmem. Test memfd_secret_ok_or_enosys (les deux issues). |
mmap_fixed — chemin succès et bras fd = Some | — (COUVERT) | mmap_fixed_over_memfd_succeeds (MAP_FIXED par-dessus un memfd) et mmap_fixed_noreplace_eexist_on_occupied_addr (chemin d’erreur ? → EEXIST). |
mlockall — corps de retour succès Ok(()) (l.651) | PRIVILEGE | Pris seulement si CAP_IPC_LOCK / RLIMIT_MEMLOCK l’autorise. Divergence d’environnement constatée : couvert sur speedy (mlockall réussit), non pris sur raspi-srv-2 (mlockall → EPERM, hôte plus restreint). L’arme d’erreur est couverte sur les 2 arches par mlockall_einval_on_empty_flags (EINVAL). |
Tests « Ok ou restreint » (mlock_munlock_pair, mlock2_onfault, mlockall_current_or_eperm, munlockall_succeeds_or_eperm, memfd_secret_ok_or_enosys, process_vm_readv/writev_own_memory) : le bras non pris selon le privilège de l’hôte (Ok sur hôte permissif, Err(EPERM/ENOMEM/EACCES/ENOSYS) sur hôte restreint) | PRIVILEGE | Ces tests prennent exactement un bras selon l’environnement (CAP_IPC_LOCK, RLIMIT_MEMLOCK, Yama ptrace_scope, secretmem). L’autre bras est donc structurellement mort sur cet hôte. Divergence attendue : sur speedy le bras Ok est pris (l’Err est mort), sur raspi plusieurs Err sont pris (l’Ok est mort). Le cœur de chaque test (l’opération elle-même) est exercé dans les deux cas. |
net — famille réseau
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
raw_to_socket_addr — branche if len < 3 (AF_UNIX, l.164) | STRUCTURAL | Inatteignable : len < 2 (l.150) et len == 2 (l.161) sont déjà traités au-dessus → en ce point len ≥ 3 toujours. Les gardes de troncature len < 16 (IPv4) et len < 28 (IPv6) sont testées (raw_to_socket_addr_rejects_truncated_*). |
parse_scm_rights — 2ᵉ borne du while interne d’extraction des FDs && fd_off+4 <= cmsg_buf.len() (l.278) | STRUCTURAL | Défense en profondeur contre un cmsg malformé annonçant plus de FDs que le buffer n’en contient. Le kernel fournit toujours des données de contrôle bien formées (end ≤ cmsg_buf.len()), donc la 1ʳᵉ borne (fd_off+4 <= end) sort de la boucle avant. Les autres branches du parseur sont couvertes (type/level/len ≠ SCM_RIGHTS, aligned == 0, dépassement de buffer). |
get_so_error — bras None (closure) de NonZeroI32::new(val).ok_or_else(…) (net.rs) | STRUCTURAL | Défense en profondeur (Principe 5) : dans cette closure, val != 0 est déjà garanti par le else du if val == 0 — NonZeroI32::new(val) est donc toujours Some, la closure d’erreur EINVAL est inatteignable par construction. (Le bras Some(erreur) — erreur socket asynchrone — est désormais couvert par un vrai test : connect non bloquant vers un port loopback fermé → ECONNREFUSED via SO_ERROR, boucle bornée — voir get_so_error_some_on_refused_nonblocking_connect.) |
Tests « variante connue » : if let SocketAddr::Ipv4/Ipv6/Unix(..) = getsockname(..) (l.2403/2426/2443/2478) | STRUCTURAL | getsockname sur un socket lié en IPv4/IPv6/Unix retourne déterministement la variante correspondante ; le bras else { panic } est inatteignable par construction. |
Test connect_ipv4_econnrefused — 2ᵉ opérande de err == ECONNREFUSED || err == EACCES (l.2505) | STRUCTURAL | Sur les 2 exécuteurs, connect(127.0.0.1:1) → ECONNREFUSED (1ᵉʳ opérande vrai → court-circuit) ; le || EACCES couvre un hôte au pare-feu plus strict. |
Test sockopt_ipv6_v6only_set_on_ipv6_socket — if let Ok(fd) = socket(Ipv6, ..) (l.2349) | FEATURE-KERNEL | Le bras else (création de socket IPv6 échoue) n’est pris que sur un hôte sans IPv6. Présent sur les 2 exécuteurs → seul le bras Ok est exercé. |
Test accept4_eagain_on_nonblocking_without_connections — garde de skip if ret < 0 (fcntl F_SETFL) (l.2585) | FEATURE-KERNEL | Le skip n’est pris que si fcntl(F_SETFL) échoue (jamais sur les kernels cibles). Le cœur du test (accept4 non-bloquant → EAGAIN) est exercé. |
getpeername — closure None (EAFNOSUPPORT) de raw_to_socket_addr(..).ok_or_else(..) (net.rs, l.1060) | STRUCTURAL | Inatteignable par construction : getpeername(2) réussit sur un socket créé via l’API safe, dont le domaine ∈ {Unix(1), Ipv4(2), Ipv6(10)} (SocketDomain) — les trois familles que raw_to_socket_addr décode toujours en Some. Les gardes de troncature internes (len < 16/< 28) sont testées séparément (raw_to_socket_addr_rejects_truncated_*). |
get_so_bindtodevice — closure map_err (EINVAL) de CString::new(&buffer[..nul_pos]) (net.rs, l.1390) | STRUCTURAL | Inatteignable par construction : nul_pos est la position du premier NUL (ou length à défaut), donc buffer[..nul_pos] ne contient aucun NUL interne → CString::new retourne toujours Ok. Le chemin succès est couvert (get_so_bindtodevice_unbound_returns_empty). |
security — famille sécurité
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
Corps des wrappers exécutés dans l’enfant : raw_syscall_landlock_restrict_self (l.236-253), LandlockRuleset::restrict_self (l.420-424), seccomp_set_mode_strict (l.620-627), helper de test raw_exit | CHILD-EXIT | Exécutés dans un enfant forké. Le profil LLVM de l’enfant n’est plus flushé (flush_child_coverage est vide — async-signal-safety, § Note cohabitation toolchain). Auparavant un flush était tenté avant le verrou (le verrou interdit ensuite tout open/write), mais ce flush alloue → risque de deadlock après clone3. Ces lignes restent prouvées par le code de sortie observé via waitid. Couverture réactivable avec une toolchain Air-compatible. |
Cohabitation toolchain — setup des wrappers seccomp/landlock exercé uniquement dans des enfants forkés (avant le verrou), désormais non flushé : seccomp_set_mode_filter, raw_syscall_seccomp, landlock_create_ruleset, landlock_add_rule, add_rule_path_beneath, raw_syscall_landlock_create_ruleset, raw_syscall_landlock_add_rule, run_in_forked_child | CHILD-EXIT | Exécutés dans un enfant forké (clone3) ; flush_child_coverage est vide (async-signal-safety — § Note cohabitation toolchain). Preuve d’exécution = code de sortie via waitid. Couverture réactivable avec une toolchain Air-compatible. |
Bras défensifs / d’échec des fermetures enfant des tests fork (set_no_new_privs().is_err() => return, path_fd < 0 => return 14, Err(_) => code) et bras parent correspondants Exited { code: ≠0 } / other => panic! | CHILD-EXIT | Ne sont pris que si la séquence échoue côté enfant — jamais sur un noyau sain. Le return N est le canal de communication enfant→parent (un panic/expect dans l’enfant forké corromprait le protocole waitid), ils ne peuvent donc pas être remplacés. |
Bras Err(Errno::ENOSYS) => 42 (+ Exited { code: 42 }) des tests fork seccomp/landlock | FEATURE-KERNEL | Pris uniquement si seccomp/Landlock est absent du noyau. Sur les noyaux cibles Air (Landlock + seccomp activés, vérifié sur les 2 exécuteurs) ils ne s’exécutent pas. Les sondes read-only (landlock_supported_abi, landlock_create_ruleset, as_fd) assument, elles, la capacité présente (pas de bras mort). |
Branches CLONE_NEWUSER dans clone3 | FEATURE-KERNEL | kernel.apparmor_restrict_unprivileged_userns=1 sur Ubuntu 24.04+. Le code production de clone3 ne contient pas de branchement sur les bits CLONE_NEW* — seul le test environnement-dépendant est en lecture seule. |
Note couverture (historique). Un spike avait introduit un flush explicite du profil LLVM de l’enfant (
security.rs40,8 %/10 % → 84,9 %/60 %), couvrant le setup des wrappers (create_ruleset, add_rule, filtre allow-all, no_new_privs). Ce flush a été retiré (flush_child_coveragevide) car il alloue dans un enfant forké → deadlock possible (§ Note cohabitation toolchain). Ce setup est donc redevenu CHILD-EXIT (entrée « Cohabitation toolchain » ci-dessus), prouvé par le code de sortie. Couverture réactivable avec une toolchain Air-compatible.
process — famille processus
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
Branches d’erreur des wrappers prctl simples — set_parent_death_signal/get_parent_death_signal, get_no_new_privs, set_thread_name/get_thread_name, set_dumpable/get_dumpable, set_keep_caps/get_keep_caps, set_timer_slack, cap_ambient_is_set/cap_ambient_raise/cap_ambient_lower/cap_ambient_clear_all, capget (noms complets cités pour le rapprochement mécanique du gate) | FEATURE-KERNEL | Ces appels ne peuvent pas échouer sur un kernel sain : l’entrée est validée en amont par les newtypes typés (Signal, DumpableMode, Capability, bool) → pas d’EINVAL ; aucun pointeur user pour les setters scalaires → pas d’EFAULT. La logique de décodage Errno elle-même est couverte via le helper commun errno_from_negative_syscall_ret testé en isolation. |
Bras child_exit(N) d’échec/alternatif des fermetures enfant des tests fork (codes ≠ 0), gardes défensives (is_err() => …) et branches « impossibles » associées (ex. if new_sid == child_pid) | CHILD-EXIT | Le profil LLVM de l’enfant n’est plus flushé (flush_child_coverage est vide — async-signal-safety, cf. § Note cohabitation toolchain ; le setup enfant est désormais CHILD-EXIT, cf. l’entrée « Cohabitation toolchain » ci-dessous). Restent ici les bras d’échec/alternatifs, jamais pris sur un environnement sain — et qui ne peuvent pas être remplacés par panic/expect (corromprait le protocole de sortie enfant→parent via waitid). |
Cohabitation toolchain — corps des wrappers exercés uniquement dans des enfants forkés (privsep / sessions / cwd), désormais non flushés : setsid, raw_syscall_setsid, raw_syscall_setresuid, raw_syscall_setresgid, raw_syscall_setgroups, get_groups, chdir, fchdir | CHILD-EXIT | Exécutés dans un enfant forké (clone3) ; flush_child_coverage est vide (async-signal-safety — § Note cohabitation toolchain). Preuve d’exécution = code de sortie via waitid. Couverture réactivable avec une toolchain Air-compatible. |
exit_group (l.448) et raw_syscall_exit_group (l.457-466) | CHILD-EXIT | Primitive de sortie appelée par child_exit après le flush du profil (et ne retournant jamais : -> !) ; ses lignes ne peuvent donc pas être comptabilisées. Exécution prouvée par le code de sortie observé via waitid. |
Ligne std::process::abort() côté enfant (test waitid_decodes_child_killed_by_signal) | CHILD-EXIT | abort() lève SIGABRT et bypasse l’atexit LLVM ; impossible de flusher avant (c’est précisément ce que le test provoque). |
Branches de skip if is_root() { … } des tests de capabilities | PRIVILEGE | Le bras root n’est pris que si le runner est root (scénario EPERM inapplicable) ; non pris sur les exécuteurs CI non-root. |
cap_ambient_raise chemin succès | PRIVILEGE | CAP_SETPCAP + la capability visée requises. Le chemin erreur EPERM est couvert. |
capset chemin succès | PRIVILEGE | CAP_SETPCAP/CAP_SETUID requis. Le chemin erreur EPERM est couvert. |
debug_assert!(ret < 0 && ret > -4096) de errno_from_negative_syscall_ret (l.1939) | — (COUVERT) | Les deux côtés faux sont désormais couverts par 2 tests #[should_panic] (ret = 0 ; ret = -5000). Plus une exception. |
Branches de détection d’environnement des helpers de test : if apparmor_restricts_userns() (l.2354), if yama_blocks_ptrace_on_own_child() (l.2697), let Ok(s) = read_to_string("/proc/sys/kernel/…") (l.2529) | FEATURE-KERNEL | Côté non pris = configuration kernel opposée (AppArmor désactivé / Yama bloquant / sysctl absent). Sur les 2 exécuteurs cibles (Ubuntu, AppArmor userns=1, Yama=1, sysctls présents) un seul côté est atteignable. L’autre relève d’un kernel différemment configuré. |
Gardes structurelles des harnais de test dont un seul côté est logiquement atteignable : match … Killed { signal, .. } if signal.as_raw() == SIGKILL (l.2264, 2689), if self_pid != pgid (l.2805), Some(m) if m > 0 => Finite(m) (l.3133), if !touches_infinity (l.3250) | CHILD-EXIT | Bras d’assertion d’un harnais : le côté « faux » correspond à un résultat que le test garantit ne pas se produire (signal ≠ SIGKILL après un kill(SIGKILL), pgid déjà égal, rlim ≤ 0, valeur infinie inattendue). Le remplacer par un assert perdrait l’aiguillage de validation ; il n’est pas réductible sans réécrire le test. |
Garde du matches! de test CloneResult::Parent { … } if child_pid.as_raw() == 1234 (air-sys-types/process, l.768) | STRUCTURAL | Bras d’assertion : le côté faux (PID ≠ 1234) ne se produit pas dans le test qui fixe le PID à 1234. Pid/Tid/Signal::try_from_raw (gardes > 0 / ≤ NSIG) sont par ailleurs testés explicitement (valeurs négatives, nulles, hors borne). |
privsep — bras erreur if ret < 0 { return Err(…) } de set_groups (l.1111), set_resgid (l.1177), set_resuid (l.1205) | PRIVILEGE | L’erreur de ces setters est EPERM, atteignable uniquement pour un appelant non privilégié (après réduction). Elle est exercée fonctionnellement par l’enfant forké privsep_drop_is_irreversible (regain root/gid → EPERM, et réajout de groupe → EPERM), prouvée par le code de sortie observé via waitid — mais cet enfant a largué ses privilèges, donc son profil LLVM n’est pas flushable (écriture du .profraw refusée à un non-root). Les chemins succès sont exercés par l’enfant privsep_setters_succeed_as_root mais, le flush enfant ayant été retiré (async-signal-safety — § Note cohabitation toolchain), ils sont désormais CHILD-EXIT (cf. l’entrée « Cohabitation toolchain » de cette famille), prouvés par le code de sortie. Réactivable avec une toolchain Air-compatible. |
privsep — bras erreur if ret < 0 { return Err(…) } de get_resgid (l.1232), get_resuid (l.1261) | STRUCTURAL | getresgid/getresuid ne peuvent échouer qu’avec EFAULT (pointeur invalide) ; les trois sorties sont des variables de pile vivantes et alignées → l’échec est inatteignable par construction. Le chemin succès est couvert (privsep_getters_read_own_credentials, en processus). |
privsep — corps des enfants privsep_drop_is_irreversible / privsep_resuid_none_changes_only_effective (sans branche locale : conditions combinées par &) ; lignes de skip if !is_root() { return } des 3 tests privilégiés ; bras other => panic! de run_child | CHILD-EXIT / PRIVILEGE | Les enfants qui larguent leurs privilèges ne peuvent pas flusher leur profil (écriture .profraw refusée à un non-root) ; leurs lignes ne sont donc pas comptabilisées bien qu’exécutées (correction prouvée par le code de sortie). Le return de skip n’est pris que hors root (le gate de couverture tourne en root). Le bras panic de run_child n’est pris que si waitid rend autre chose qu’Exited — jamais sur un fork sain. |
getrusage — bras d’erreur if ret < 0 { return Err(…) } (l.3669) | STRUCTURAL | getrusage(2) ne peut échouer que sur who invalide (EINVAL) ou pointeur invalide (EFAULT) — exclus par construction : RusageWho est un enum typé (3 valeurs valides) et le buffer usage est une variable de pile vivante et alignée (taille struct rusage). Le bras d’erreur est donc inatteignable via l’API safe ; le chemin succès est couvert (getrusage_self_succeeds_and_fills + variantes Children/Thread). Le décodage d’errno (errno_from_negative_syscall_ret) est couvert par ailleurs. |
signal — famille signaux
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
Défense en profondeur SignalMask::{contains,with,without} — branches None => false/self (air-sys-types, l.330/346/360) | STRUCTURAL | Inatteignables tant que Signal::try_from_raw borne à [1, 64]. Gardées par Principe 5 (défense en profondeur). |
unwrap_nz — bras None => panic!("unreachable…") (air-sys-types, l.142) et garde if n <= 0 || n > NSIG | STRUCTURAL | Const fn de construction des constantes Signal::SIGxxx : après la garde [1, NSIG], NonZeroI32::new(n) est toujours Some → le bras None est inatteignable (présent pour l’exhaustivité du match const). Les valeurs hors borne sont rejetées à la compilation ; les tests runtime #[should_panic] (n = 0, n = 65/100) couvrent la garde côté panic. |
SignalMask::from_signals — bras None de 1_u64.checked_shl(bit_index) (air-sys-types, l.307) | STRUCTURAL | bit_index = signal - 1 ≤ NSIG - 1 = 63 (signal borné [1, 64] par try_from_raw) → checked_shl(≤ 63) est toujours Some ; le décalage ne déborde jamais. |
Branches d’erreur (if ret < 0 => return Err) de SignalFd::update_mask (l.107), signalfd_create (l.143), rt_sigprocmask (l.244), install_fatal_handler (l.509), restore_handler (l.539) | STRUCTURAL | Avec un fd OwnedFd valide, un mask/pointeur valides, sigsetsize=8, des flags typés et un FatalSignal typé (Segv/Bus/Fpe/Ill), les seuls échecs possibles seraient EFAULT (pointeur invalide, exclu par construction), EBADF (fd possédé valide, exclu) ou l’épuisement de fd (EMFILE/ENFILE pour signalfd_create, condition de ressource, pas un défaut de code). Le chemin succès est couvert ; le décodage Errno lui-même l’est via errno_from_negative_syscall_ret (tests dédiés). Les branches d’erreur réellement atteignables sans privilège sont testées : SignalFd::read→EAGAIN, tgkill/kill/rt_sigqueueinfo→ESRCH. |
async_handler (ADR-066) — bras d’erreur if ret < 0 => return Err de async_handler::restore ; bras d’erreur if ret < 0 de rt_sigpending | STRUCTURAL | restore repasse au noyau une disposition valide capturée par un install réussi sur le même signal → pas d’EINVAL/EFAULT (pointeur local vivant). rt_sigpending écrit un sigset_t dans un buffer local vivant avec sigsetsize=8 fixé → seul EFAULT (exclu). Le chemin succès des deux est couvert en thread (async_handler::install réel + SIG_DFL/SIG_IGN via auto-tgkill, pas de fork ; rt_sigpending : SIGUSR1 bloqué+généré → pendant, drainé via signalfd) ; le bras d’erreur d’async_handler::install est, lui, testé (install(SIGKILL) → EINVAL). Décodage Errno couvert via errno_from_negative_syscall_ret. |
Bras défensifs/alternatifs des fermetures enfant des tests fork (if …is_err() => child_exit(N), gardes de match Ok(e) if e.signal == … => child_exit(0) dont le côté faux n’est jamais pris) — kill/rt_sigqueueinfo/install+restore en enfant forké | CHILD-EXIT | Le profil LLVM de l’enfant n’est plus flushé (flush_child_coverage est vide — async-signal-safety, § Note cohabitation toolchain ; le setup enfant est désormais CHILD-EXIT, cf. l’entrée « Cohabitation toolchain » ci-dessous). Restent ici les bras d’échec/alternatifs, jamais pris sur un noyau sain, et qui ne peuvent pas être remplacés par panic/expect (corromprait le protocole de sortie enfant→parent via waitid). |
Cohabitation toolchain — wrappers signaux exercés uniquement dans des enfants forkés, désormais non flushés : current_signal_mask, block_signals, unblock_signals, set_signal_mask, signalfd_create, wait_for_signal, kill, tgkill, rt_sigqueueinfo, install_fatal_handler, restore_handler, raw_syscall_rt_sigprocmask, raw_syscall_rt_sigaction, raw_syscall_kill, raw_syscall_tgkill, raw_syscall_rt_sigqueueinfo, raw_syscall_signalfd4, raw_syscall_read | CHILD-EXIT | Exécutés dans un enfant forké (clone3) ; flush_child_coverage est vide (async-signal-safety — § Note cohabitation toolchain). Preuve d’exécution = code de sortie via waitid. Couverture réactivable avec une toolchain Air-compatible. |
system — famille système
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
sethostname chemin succès | PRIVILEGE | CAP_SYS_ADMIN requis. Le chemin erreur EPERM est couvert par sethostname_without_cap_sys_admin_returns_eperm. |
uname — branche ret < 0 (l.219/220) | STRUCTURAL | uname(2) ne peut échouer qu’avec EFAULT sur un pointeur invalide ; l’API safe fournit toujours une &mut KernelUtsname valide. Branche inatteignable par construction. |
sysinfo — branche ret < 0 (l.265/266) | STRUCTURAL | idem uname : seul EFAULT possible, exclu par construction. |
getrandom — branche ret < 0 (l.174/175) | STRUCTURAL | Avec une slice valide et des GetrandomFlags typés, les seuls échecs seraient EFAULT (exclu par construction) ou EAGAIN/EINTR lors d’un blocage sur pool d’entropie non initialisé — ne se produit pas sur un système démarré (et jamais avec GRND_INSECURE). |
time — famille temps
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
clock_settime chemin succès avec modification effective | PRIVILEGE | CAP_SYS_TIME requis. Le chemin erreur EPERM est couvert par clock_settime_realtime_current_time_is_ok_or_eperm. |
io_uring — famille io_uring (Temps 1, cœur)
Le cœur Temps 1 ne soumet qu’une opération : NOP (mono-shot, traitée
inline par le kernel — le CQE est posté avant le retour de io_uring_enter).
Plusieurs chemins de production ne sont donc atteignables qu’avec des opérations
à complétion asynchrone (POLL/READ/TIMEOUT) ou multishot, dont la
soumission relève de Temps ultérieurs (3d multishot, 3f sandbox). Les chemins
correspondants sont prouvés au niveau unitaire là où c’est possible (logique
slab, décodage CQE) ; leur câblage de bout en bout viendra avec l’API qui les
rend atteignables.
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
map_rings/ring_sizes — bras else du layout deux mmaps (mod.rs l.313 ; Some(mmap_file(…)) l.338-344) | FEATURE-KERNEL | IORING_FEAT_SINGLE_MMAP (Linux ≥ 5.4) est présent sur les 2 exécuteurs (kernel 6.12) → single_mmap est toujours vrai, le layout hérité à deux mmaps (SQ et CQ séparés) n’est jamais pris. Atteignable seulement sur un kernel < 5.4. |
Attente bloquante bornée — chemin succès : wait_bounded Ok(()) (mod.rs l.536-537), wait_completion reboucle après enter réussi (l.863), wait_completion_timeout Ok(self.harvest_ready()) (l.915-916) | STRUCTURAL | Ces bras ne sont pris que si l’io_uring_enter(GETEVENTS, min_complete=1) réussit alors qu’aucune complétion non périmée n’était déjà moissonnée. En Temps 1 le seul op soumissible (NOP) est synchrone : son CQE est déjà présent au premier harvest_ready, avant tout enter d’attente. Atteignable avec un op à complétion asynchrone (POLL/READ/TIMEOUT), soumissible en Temps ≥ 3. Les chemins d’erreur (EINTR/ETIME/EAGAIN) sont couverts par le simulateur. |
harvest_ready — bras SlotOutcome::More (mod.rs l.709-716) | STRUCTURAL | Le CQE IORING_CQE_F_MORE n’est produit que par une opération multishot ; sa soumission relève du Temps 3d (multishot). La logique slab sous-jacente (slot maintenu vivant sur has_more) est couverte par slab::tests::multishot_completion_keeps_slot_alive. |
opcode_number — bras _ => u8::MAX (mod.rs l.1057) | STRUCTURAL | IoUringOpcode est #[non_exhaustive] : les 55 variantes actuelles sont toutes mappées explicitement (couvert par opcode_number_maps_all_retained_opcodes). Le joker ne garde que d’éventuelles variantes futures — value-unreachable aujourd’hui. |
SubmissionRing::prepare — garde if space_left() == 0 { return None } (ring.rs l.191) | STRUCTURAL | submit_nop vérifie self.sq.space_left() == 0 (→ EBUSY) avant d’appeler prepare (couvert par submit_nop_on_full_sq_returns_ebusy), puis dénote le retour par .expect("place SQ vérifiée"). La garde None de prepare est donc inatteignable via l’API : aucun appelant n’invoque prepare sans pré-vérifier la place. |
InflightSlab::reserve_inner — bras else => unreachable!() du let Free { next_free } = slot.state (slab.rs) | STRUCTURAL | Invariant : free_head ne référence que des slots Free (maintenu par reserve/reserve_multishot/complete/release, qui n’y remettent un slot qu’en l’état Free). Le else ne peut être pris sans violer l’invariant de la free-list — inatteignable par construction. (Cœur commun reserve_inner introduit au Temps 3d ; même invariant.) |
io_uring — Temps 2a (opérations filesystem)
23 façades FS livrées (fs_ops.rs), couvertes de bout en bout en root par les
round-trips d’intégration (kernel 6.12) + property-based + Miri (ownership). Les
accesseurs de Completion (into_vectored_result/into_statx/into_xattr_result/
opened_fd) sont couverts chemins succès et erreur et bras défensifs.
Résidu unique :
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
Gardes de débordement u32 sur la longueur de buffer : len_u32(buffer.len())? de submit_read/submit_write et len_u32(value.len())? de submit_setxattr/submit_getxattr/submit_fsetxattr/submit_fgetxattr — bras Err(EINVAL) (fs_ops.rs) | STRUCTURAL | Le bras d’erreur n’est pris que pour un buffer > 4 GiB (usize > u32::MAX), non allouable de façon déterministe en CI (RAM + temps). La validation est nécessaire (le champ len du SQE est un u32, Principe 4) ; son chemin succès est couvert par tous les round-trips. Les autres gardes de longueur sont couvertes car testables sans allocation géante : sync_file_range/fadvise (u32::try_from(nbytes/length) sur un u64 > u32::MAX) et readv/writev (buffers.len() > IOV_MAX), par length_overflow_validations_reject_before_submission. |
io_uring — Temps 2b (opérations réseau)
12 façades réseau livrées (net_ops.rs), couvertes en root par les round-trips
d’intégration (AF_UNIX socketpair + TCP loopback) : send/recv, passage de FD
SCM_RIGHTS (même-objet vérifié), MSG_CTRUNC sans fuite, socket/bind/listen/
connect/accept, cycle zero-copy à deux complétions (résultat→NOTIF) + échec
non supporté, proptest, Miri (ownership zero-copy + msghdr). Les 5 accesseurs
(into_socket_fd/accepted_fd/into_accept_result/into_receive_message_result/
into_zero_copy_buffer, + zero_copy_copied) sont couverts succès + erreur +
bras défensifs. Résidu unique :
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
Gardes de débordement u32 sur la longueur de buffer : len_u32(buffer.len())? de submit_send/submit_receive/submit_send_zero_copy — bras Err(EINVAL) (net_ops.rs l.272/299/420) | STRUCTURAL | Identique au résidu 2a : pris seulement pour un buffer > 4 GiB (usize > u32::MAX), non allouable en CI. Le champ len du SQE est un u32 (Principe 4) ; le chemin succès est couvert par tous les round-trips send/recv et le property-based. Aucune autre branche/direction d’erreur de production 2b n’est rouge (ensemble « couvrable » vide) : EBUSY, validations, control.is_empty(), et les directions res<0 des 5 accesseurs sont toutes exercées. |
io_uring — Temps 3a (registration : ressources fixes)
21 register opcodes + 3 variantes « direct » + read/write fixe + fixed_fd_install
livrés (registration.rs), couverts en root par les round-trips d’intégration
(kernel 6.17 : FixedFdTable sparse + openat2/socket/accept direct + read/write
via FixedSlot + fixed_fd_install, RegisteredBuffers register/mmap/update/
clone_from + read/write fixed round-trip, eventfd notification, personality,
io-wq affinité/max-workers, horloge, ring fd enregistré), le simulateur
(tous les bras ? Err des façades register), les bornes (set/clear/update/
slice/set_alloc_range hors borne), et les doctests compile_fail (un
FixedSlot/RegisteredBufferSlice ne survit pas au désenregistrement).
Branches : 100 % (ensemble couvrable vide). Résidu lignes unique :
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
register_napi — corps succès Ok(NapiConfig { … }) et unregister_napi — Ok(()) (registration.rs), + le bras Ok du test register_napi_round_trip_when_available | FEATURE-KERNEL | IORING_REGISTER_NAPI (busy-poll réseau, CONFIG_NET_RX_BUSY_POLL) rend EINVAL sur les exécuteurs (pas de NAPI exposé au ring) → seul le bras Err(EINVAL) est pris. Le chemin d’erreur est couvert (intégration + simulateur). Le chemin succès n’est atteignable que sur un hôte où NAPI est exposé. Le test prend exactement un bras selon l’environnement. |
Écart spec/kernel signalé (pas une exception de couverture).
register_clock(ClockSource::Realtime)rendEINVAL: io_uring 6.12/6.17 n’accepte queMONOTONIC/BOOTTIMEpour l’horloge des timeouts. Le typeClockSource::Realtimereste exposé (surface validée non modifiée) ; son rejet est testé (register_clock_all_sources) et documenté, jamais corrigé en silence (ADR-032).
io_uring — Temps 3b (buffers fournis ring-mapped)
Sous-module provided.rs : ProvidedBufferRing (register/unregister/status,
modes app et kernel_mmap), submit_receive_provided/submit_read_provided
(+ bundle), guard RAII ProvidedBuffer (réappro à la libération), mode
incrémental (BUF_MORE ⇒ pas de réappro). Couvert en root par les round-trips
d’intégration (recv loopback + id/octets, pénurie -ENOBUFS + réappro +
resoumission, kernel_mmap, status(), read_provided sur pipe, bundle), des
Completion synthétiques déterministes (branches BUF_MORE/réappro/None
sans dépendre du timing kernel), un test property-style (256 cycles
checkout→return : available ≤ count, id rendu == id demandé, aucun buffer
perdu), le simulateur (erreurs register/status/unregister + count non
puissance de 2), et un doctest compile_fail (désenregistrement interdit sous
un ProvidedBuffer vivant). Lignes 99,6 % / couvrable vide. Résidu :
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
Bras Err des ? sur MmapRegion::new_anonymous (anneau app) et mmap_file (anneau kernel) dans register (provided.rs) | STRUCTURAL | Un mmap(MAP_ANONYMOUS) / mmap du fd ring à l’offset IORING_OFF_PBUF_RING d’une petite région valide ne peut échouer que par ENOMEM (ressource, pas un défaut de code) — non provoquable de façon déterministe en CI. Le simulateur de syscalls ne couvre que setup/enter/register (pas mmap). Le chemin succès est couvert (modes app et kernel testés). |
Bras défensifs is_some_and(…) == false de checkout/return_buffer (bid ≥ count ⇒ out_flags.get_mut rend None) (provided.rs) | STRUCTURAL | Le kernel ne sélectionne que des bid < count (id d’un buffer du groupe) ; un bid hors borne est inatteignable via l’API (l’id vient toujours d’un cqe->flags du même groupe). Garde défensive conservée (Principe 5), jamais prise. |
io_uring — Temps 3c (opérations liées)
Sous-module linked.rs : LinkedChainBuilder (first/then/then_hard/
with_link_timeout/submit), staging + réservation atomique + publication
unique. Lignes de production 100 % (--lib --branch --show-missing-lines
vide). Couvert en root par : chaîne de nops (ordre), soft link rompu sur erreur
(-ECANCELED), piège du short read rompant une chaîne soft (confirmé sur
kernel réel), hard link survivant à l’erreur, link_timeout expirant
(maillon annulé) et non expirant (timeout annulé), skip_cqe_on_success (seule
la finale arrive), atomicité (slab plein ⇒ EBUSY sans publication, rollback),
erreur de staging du LINK_TIMEOUT, with_link_timeout sans maillon (EINVAL),
property-style (longueurs 1..6, ordre = soumission), accesseurs ChainTokens
(sous Miri). Résidu :
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
SubmissionRing::prepare — garde if space_left()==0 { return None } (ring.rs) | STRUCTURAL | Pré-existant (déjà tabulé section io_uring Temps 1) : tout appelant pré-vérifie space_left avant prepare (EBUSY), puis dénote par .expect. Inatteignable via l’API ; resurfacé par l’ajout des helpers de staging. |
io_uring — Temps 3d (opérations multishot)
Sous-module multishot.rs : submit_accept_multishot[_direct], submit_receive_multishot,
submit_read_multishot, submit_poll_multishot, submit_timeout_multishot,
cancel_multishot. multishot.rs : lignes 100 % / branches 100 % — ensemble
couvrable VIDE. Couvert en root par : accept multishot (flux F_MORE de N
connexions + cancel terminal -ECANCELED, slot vivant tant que F_MORE puis
libéré), accept multishot direct (connexion dans un slot fixe auto-alloué),
recv multishot (flux de buffers fournis), pénurie -ENOBUFS terminante +
réappro + resoumission, read multishot (pipe + buffers fournis), poll multishot
(événement + cancel), timeout multishot (ticks + cancel), back-pressure
EBUSY (SQ pleine), op mono-coup → multishot_token() == None. Le cycle de
vie S1 multishot (slot survit aux F_MORE, libéré à la terminale, génération
anti-CQE-tardif) est prouvé sous Miri par le test pur du slab
(multishot_slot_survives_until_final_then_filters_stale).
Aucune exception propre au sous-module. Le seul résidu de slab.rs
(reserve_inner unreachable!) est pré-existant (cf. section io_uring
Temps 1, STRUCTURAL).
io_uring — Temps 3e (usage multi-thread)
Sous-module shared.rs : LockedIoUring (verrou, Send + Sync), RingPool
(ATTACH_WQ + ring fds enregistrés + routage msg_ring par RingHandle),
SqpollIoUring (SETUP_SQPOLL, réveil NEED_WAKEUP auto). Sûreté prouvée à
trois niveaux : IoUring: !Sync (doctest compile_fail) + LockedIoUring: Send + Sync (doctest compile-pass + assertion statique) ; loom (mod loom_proof, --cfg loom) sur la discipline de verrou (exclusion mutuelle,
aucune mise à jour perdue, aucun accès gardé hors verrou) ; tests d’intégration
concurrents réels (4 threads sur LockedIoUring, thread-per-core, routage
inter-ring msg_ring, SQPOLL réveil + rafale éveillée + propagation d’erreur
enter). IoUring reçoit son unsafe impl Send (ADR-022 D6, justifié
// SAFETY:). Les deux bras d’erreur d’io_uring_enter de SqpollIoUring
(réveil SQ_WAKEUP dans submit, attente GETEVENTS dans submit_and_wait)
sont couverts par le simulateur de syscalls : submit/submit_and_wait
empruntent la même couture instrumentable syscall::enter que les autres
syscalls io_uring, sur laquelle on injecte -EINTR ; l’errno transite par le
même errno_from_negative_syscall_ret que le vrai kernel et remonte tel quel
(ADR-021 conv. 2). L’ancienne exception EFAULT-SAFE posée sur ces bras est donc
retirée. shared.rs lignes 95,6 %, branches 100 %. Résidus :
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
Bras Err(e) => { assert!(matches!(e, EPERM)); return } des 5 tests SqpollIoUring (fallback si SETUP_SQPOLL refusé) | PRIVILEGE | SQPOLL est autorisé sans privilège sur les exécuteurs (kernel ≥ 6.17, depuis 5.13) → seul le bras Ok est pris ; le bras Err (EPERM) relève d’un hôte/cgroup restreignant SQPOLL. Prend exactement un bras selon l’environnement. |
io_uring — Temps 3f (confinement / sandbox)
Sous-module sandbox.rs : RestrictionSet (liste blanche default-deny),
RegisterOp, SqeFlagSet ; flux restrict → enable (S3) câblé dans
mod.rs::apply_restrictions/encode_restriction. sandbox.rs 100 % lignes +
branches (aucune branche ; matchs exhaustifs couverts). La preuve de
sécurité (openat2 refusé sur un ring « réseau seul » alors que le process a le
droit d’ouvrir le fichier) est verte, ainsi que : opcode autorisé qui passe,
default-deny, require_sqe_flags imposé, soumission avant enable refusée
(EBADFD), immuabilité après enable (refus kernel), propagation d’erreur
REGISTER_RESTRICTIONS (simulateur), property-based (tout sous-ensemble encode
exactement ses opcodes). Résidu unique :
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
Bras Err du u32::try_from(encoded.len()).map_err(EINVAL)? dans apply_restrictions (mod.rs) | STRUCTURAL | Conversion défensive usize → u32 du nombre de restrictions (Principe 2, pas d’as nu). Une liste blanche ne peut pas approcher 2³² entrées (chaque io_uring_restriction fait 16 o ⇒ 64 Gio) : le bras d’erreur est inatteignable par construction. Le chemin succès et le bras if ret < 0 (échec REGISTER_RESTRICTIONS) sont couverts (simulateur). |
io_uring — Temps 4 (accès brut niveau 1)
Sous-module raw.rs (surface publique : RawSubmissionQueueEntry/
RawCompletionQueueEntry/RawOpcode/RAW_USER_DATA_TAG + accesseurs ; méthodes
raw_get_submission_queue_entry/raw_peek_completion_queue_entry/
raw_advance_completion_queue + capacités, dans mod.rs). Couverture : 100 %
lignes + branches sur le code de production / couvrable vide — à un unique
artefact d’instrumentation près (ligne ci-dessous). Les chemins unsafe sont exercés par des tests d’intégration kernel
(NOP brut → raw_peek/raw_advance ; op brute vs wrapper niveau 2 ; coexistence
slab + brut sans collision ; SQ pleine → None ; raw_advance borné ; moisson
gérée qui s’efface devant une brute en tête). La règle du tag bit 63 repose sur
le slab dont la génération est désormais bornée à 31 bits (GENERATION_MASK,
slab.rs) — le bit 63 du user_data géré est donc toujours nul (asserté par
les tests de coexistence). Le debug_assert! du tag manquant est couvert par un
test #[should_panic]. Le décodage brut de données kernel externes est fuzzé
(fuzz_api::decode_raw_cqe, cible io_uring_4_raw_cqe, sous #[cfg(fuzzing)]).
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
debug_assert_raw_tags — } fermant if cfg!(debug_assertions) (mod.rs, l.1013) | STRUCTURAL | Artefact d’instrumentation LLVM : ce } porte une région à count == 0 alors que le corps de la boucle est prouvé exécuté (mesure : 308 itérations via les tests raw_nop_*). Région dégénérée du if cfg!-constant, analogue aux ombres [True:0, False:0] documentées en tête de registre — non couvrable par aucun test (la purge raw_pending.clear() qui suit, l.1014, est couverte). |
Comme tout le module, l’intégration io_uring n’est pas modélisable par Miri (
#[cfg_attr(miri, ignore)]) ; Miri valide les chemins purs (layout, accesseurs, slab/génération). Les seuls résidus de branches deraw.rs(≈ 80 % sur le fichier) sont des bras d’assert!/should_panicde tests, jamais du code de production (qui n’a aucune branche dansraw.rs).
device — famille périphériques
Le décodage pur (uevent, input_event) est couvert à 100 % (lignes +
branches), incl. property-based. Les chemins de succès des ioctls EVIOC* et de
recvmsg (réception d’un vrai uevent) sont couverts par le test d’intégration
uinput (uinput_evdev_and_uevent_round_trip) exécuté en root sur les
exécuteurs. Résidus :
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
uevent_socket_open — branche d’erreur if ret < 0 de socket (l.187) | STRUCTURAL | socket(AF_NETLINK, SOCK_RAW, NETLINK_KOBJECT_UEVENT) ne requiert aucun privilège et n’échoue qu’en épuisement de ressources (EMFILE/ENOMEM) non provoquable de façon déterministe en test. Le chemin succès est couvert ; le helper errno_from_negative_syscall_ret est testé séparément. |
uevent_socket_open — branche d’erreur if ret < 0 de setsockopt(SO_PASSCRED) (l.211) | STRUCTURAL | setsockopt(SOL_SOCKET, SO_PASSCRED, 1) sur un socket netlink valide ne peut échouer (option universellement supportée) ; branche d’erreur non provoquable. Chemin succès couvert par uevent_open_userspace_sets_passcred. |
uevent_socket_open — branche d’erreur if ret < 0 de bind (l.230) | STRUCTURAL | bind sur un sockaddr_nl bien formé (groupes KERNEL/USERSPACE) réussit ; l’échec exigerait un nl_groups réservé/privilégié non émis par l’API. Chemin succès couvert. |
read_u32_native / credential_uid — bras None des try_into().ok()? sur des tranches déjà bornées | STRUCTURAL | La tranche passée à try_into::<[u8;4]> provient d’un get(off..off+4) réussi : la longueur est exactement 4 par construction, le Err de try_into est inatteignable. Défense en profondeur (Principe 5). |
ebpf — famille eBPF / perf
air-sys-types::ebpf couvert à 100 % (lignes + branches), incl. proptest
(round-trip des miroirs BpfInstruction/PerfEventAttr, repli Other).
air-sys-syscall::ebpf couvert à 100 % hors exceptions ci-dessous : un
harnais d’intégration privilégié réel (exécuté en root sur speedy) exerce
les chemins de succès — création/élément/batch/gel de cartes, chargement de
programme (valide + invalide → log du vérifieur, + champs optionnels
fentry/freplace), test_run, programme compteur assemblé à la main avec
BPF_PSEUDO_MAP_FD attaché via bpf_raw_tracepoint_open (compteur vérifié),
attache/détache cgroup v2 + bpf_link_create/update/detach + prog_query,
épinglage bpffs pin/get, btf_load + carte typée par BTF,
introspection *_get_next_id/*_get_fd_by_id (prog/map/btf/link),
perf_event_open (software/tracepoint/matériel + portées
ProcessOnCpu/AllProcessesOnCpu/Cgroup), tous les ioctls perf dont le pont
perf_event_set_bpf_program et query_bpf. Fuzzing : cible fuzz_bpf_obj_info
(décode borné des champs réécrits par le kernel dans bpf_attr).
Les fausses exceptions « PRIVILEGE / dette de test » de la première version ont été retirées : les chemins qu’elles couvraient sont désormais exercés par le harnais. Ne restent ci-dessous que des exceptions structurelles réelles.
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
Attr::put_u32/put_u64/put_name et perf_event_query_bpf — bras None des if let Some(slot)/(Some, Some) (l.171, 178, 196, 1203) ; Attr::read_u32 bras None => 0 (l.187) | STRUCTURAL | Défense en profondeur (Principe 5) : tous les offsets utilisés en production sont des constantes ≤ 188 dans un tampon de 192 octets ; get_mut/get ne retournent jamais None. Inatteignable par construction (mais conservé : un futur offset erroné est borné, jamais d’OOB). Le bras None de read_u32 est prouvé sûr par le fuzz fuzz_bpf_obj_info (offset hors borne → 0). |
*_get_next_id (prog/map/btf/link) — bras return Err(err) du cas non-ENOENT (l.766-767, branche if err == ENOENT côté faux l.764) | STRUCTURAL | *_GET_NEXT_ID ne peut retourner que ENOENT (fin d’itération, → Ok(None), couvert) ou, uniquement en non-privilégié, EPERM. Sur l’exécuteur root, seul ENOENT survient ; le bras d’erreur générique n’est atteignable que sans CAP_BPF (couvert sur l’arche non-root raspi). Conservé par robustesse. |
poll — famille poll
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
ppoll — bras d’erreur if ret < 0 => return Err(errno_from_negative_syscall_ret(ret)) (poll.rs, l.107) | STRUCTURAL | ppoll(2) ne retourne < 0 que sur EINTR (signal pendant l’attente — non injectable de façon déterministe en CI, même précédent que waitid), EFAULT (pointeur invalide — exclu : la slice PollFd et le mot sigset proviennent de références vivantes) ou EINVAL (timespec invalide — exclu par la conversion défensive interne : tv_nsec < 1e9, tv_sec ≥ 0 clampé). Un fd invalide n’erre pas : le kernel pose POLLNVAL dans revents et compte l’entrée. Les chemins succès (fd prêt / POLLIN), expiration (Ok(0)), sondage (Duration::ZERO), multi-fd, masque non-null et timeout NULL sont couverts ; le décodage d’errno l’est via errno_from_negative_syscall_ret_maps_eintr. |
futex — famille futex (ADR-048, re-sceau couche-0-v1.6)
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
futex_wake — bras d’erreur if ret < 0 => return Err(errno_from_negative_syscall_ret(ret)) (futex.rs, l.168) | STRUCTURAL | FUTEX_WAKE sur un &AtomicU32 valide ne peut échouer que par EFAULT (pointeur invalide — exclu par construction : word est une référence vivante) : FUTEX_WAKE n’utilise que l’adresse comme clé de file, sans lire/écrire le contenu du mot, et ne prend aucun timeout/uaddr2/val3. Le bras d’erreur est donc inatteignable via l’API safe. Le chemin succès (compte de réveils, dont 0 sans waiter) est couvert ; le décodage d’errno l’est via errno_from_negative_syscall_ret_maps_known. (futex_wait n’a PAS d’exception : son bras ret < 0 est couvert par EAGAIN (valeur divergente) et ETIMEDOUT (échéance courte) — déterministes. EINTR partagerait ce bras mais n’est pas injectable sans trampoline de restorer de signal x86_64 ; il est documenté ici sans test dédié, même précédent que ppoll.) |
futex_wake — bras Err du u32::try_from(ret).map_err(|_| Errno::EINVAL) (futex.rs, l.175) | STRUCTURAL | Conversion défensive i64 → u32 (Principe 2, pas d’as nu) : ret ≥ 0 est le nombre de waiters réveillés, toujours ≤ INT_MAX (FutexWakeCount borne le val à INT_MAX par construction) → le bras d’erreur est inatteignable. Le chemin succès est couvert (test multi-thread futex_wake_releases_one_waiter). |
arch — famille architecture (ADR-051, re-sceau couche-0-v1.7)
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
get_fs — bras d’erreur if ret < 0 { return Err(…) } (arch.rs, l.110) | STRUCTURAL | arch_prctl(ARCH_GET_FS, &mut base) ne peut échouer que par EFAULT (pointeur invalide — exclu : base est une variable de pile vivante et alignée) sur un kernel x86_64 conforme. Le bras d’erreur est inatteignable via l’API safe. Le chemin succès est couvert (get_fs_returns_nonzero_base) ; le décodage d’errno (errno_from_negative_syscall_ret) l’est par le bras d’erreur atteignable de set_fs (set_fs_non_canonical_is_rejected_without_changing_fs, adresse non canonique → EINVAL/EPERM). (arch_prctl est x86_64-only ; sur aarch64 le module arch est vide — aucune ligne à couvrir.) |
terminal — famille terminal (ADR-060, termios/tty)
Le groupe Session / job-control (tranche 5) exige, pour son chemin
nominal, un terminal de contrôle — que le kernel n’accorde qu’à un
leader de session. Le cycle complet est donc validé dans un enfant forké
setsid (test session_job_control_full_cycle_in_forked_child), qui encode
chaque étape dans son code de sortie (0 = tout le cycle a réussi). Toutes
les branches d’erreur (if result < 0 => Err) sont couvertes dans le
parent sur /dev/null (ENOTTY) ; le bras Ok(None) de tcgetpgrp est
lui aussi couvert dans le parent (un maître /dev/ptmx neuf n’a aucun groupe
de premier plan → TIOCGPGRP rend 0 → Ok(None)) — donc aucune exception
pour tcgetpgrp. Résidu :
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
Bras succès (Ok(…) + if result < 0 côté faux) de tcsetpgrp (l.558), session_id (l.586), set_ctty (l.611), clear_ctty (l.628) — 4 lignes + 4 branches | CHILD-EXIT | Chemin nominal exécuté uniquement dans l’enfant forké setsid (le kernel refuse ces ioctls hors terminal de contrôle → ENOTTY côté parent, branche Err couverte). Validé par le code de sortie de l’enfant (fork_run, test session_job_control_full_cycle_in_forked_child) : setsid → set_ctty → session_id == SID → tcsetpgrp → tcgetpgrp == Some(SID) → clear_ctty. Le profil LLVM de l’enfant n’est pas flushé (async-signal-safety — § Note cohabitation toolchain). Couverture réactivable avec une toolchain Air-compatible. |
air-runtime — couche 1, TARGET-ONLY (ADR-035 § amendement 2026-06-29)
Le runtime userland air-runtime (objet couche 1, ADR-052 /
ADR-049) est mesuré depuis l’étape 6 phase B
(retrait de --exclude air-runtime). Ses 4 fichiers TARGET-ONLY —
thread_control_block.rs, thread_local_storage.rs, thread.rs, start.rs — sont
retirés par fichier (--ignore-filename-regex, précédent _capnp.rs) car sound
uniquement sur *-linux-air (programmation du registre TLS, self-pointer du TCB,
spawn CLONE_SETTLS, bootstrap crt0). Les modules host-testables
(args.rs, env.rs, lib.rs façade AirRuntime, parseur de reloc.rs, machinerie
d’fork.rs) sont mesurés à 100 %.
Restent, dans reloc.rs et fork.rs (fichiers mesurés, NON ignorés), quelques
lignes isolées TARGET-ONLY : orchestrations qui parcourent l’auxv/pile réel·le
du kernel ou écrivent en mémoire de l’image — non exerçables sur l’hôte du gate
(*-linux-gnu) sans corrompre la GOT/le TLS (unsoundness #151). Toute la logique de
parsing/arithmétique en amont est extraite en helpers couverts à 100 % sur images
ELF synthétiques ; ne restent target-only que les corps d’orchestration ci-dessous.
Preuve on-target : le selftest rt/crates/airrt-selftest (exit 42, 2 arches —
carbon x86_64 + raspi aarch64). Hors décompte du récapitulatif production couche 0.
| Branche / Lignes | Catégorie | Justification |
|---|---|---|
apply_relative_relocations — reloc.rs corps (l.415-427) | TARGET-ONLY | L’écriture de relocation (target as *mut usize).write(value) ne peut pas s’exécuter sur l’hôte : le binaire de test est lié à glibc et déjà relocalisé par ld.so → ré-écrire la GOT/.data.rel.ro corromprait le processus. Les couples (cible, valeur) rendus par RelativeRelocations sont couverts à 100 % sur image synthétique ; seule l’écriture est target-only (prouvée on-target, selftest rt/). |
relocate_self — reloc.rs corps (l.438-473) | TARGET-ONLY | Orchestration static-PIE : parcourt l’auxv/pile réel·le du kernel (AT_PHDR/PT_DYNAMIC/.rela.dyn) puis applique les relocations. L’appeler sur l’hôte corromprait la GOT (cf. ci-dessus) ; le module de test ne l’exécute jamais. Tous ses helpers (auxiliary_vector_address, read_program_header_table, scan_program_headers, read_relocation_table, RelativeRelocations) sont couverts à 100 %. Prouvée on-target (selftest rt/, exit 42). |
protect_relro — reloc.rs corps (l.581-617) | TARGET-ONLY | Durcissement RELRO : parcourt l’auxv réel (PT_GNU_RELRO, AT_PAGESZ) puis appelle air_memory::raw_mapping::protect_range (mprotect) sur la plage chargée — non exerçable sur l’hôte (reprotéger des pages de l’image de test = SIGSEGV). Le calcul de plage (scan_relro_segment/read_page_size) est couvert à 100 % ; seul l’appel mprotect final est target-only. Prouvée on-target (selftest rt/). |
run_child — fork.rs corps (l.194-201) | TARGET-ONLY | Hook enfant post-fork/clone3 : invoque les hooks child (testés via invoke_child_handlers) puis enchaîne sur reset_after_fork_in_child (reset bas niveau TARGET-ONLY ci-dessous). N’a de sens que dans l’enfant mono-thread async-signal-safe sur *-linux-air ; l’exécuter sur le gate mêlerait les TLS Air/glibc (#151). Prouvée on-target (selftest rt/). |
reset_after_fork_in_child — fork.rs corps (l.221-263) | TARGET-ONLY | Reset post-fork : relit le tid de l’enfant (gettid, host-testable en isolation) et l’écrit dans le TCB au registre TLS via ThreadControlBlock::refresh_current_tid — l’écriture du TCB est unsound sur l’hôte du gate (registre %fs programmé par glibc, #151) ; appelle aussi air_alloc::reset_after_fork() (ADR-056 D8 — remet le verrou de l’arène globale d’aplomb dans l’enfant ; le mécanisme de reset est host-testé à 100 % côté air-alloc, seul son invocation dans ce chemin enfant est target-only). N’a de sens que dans l’enfant sur *-linux-air. Prouvée on-target (selftest rt/). |
Annexe — résidus internes aux tests (NON suivis comme exceptions production)
Conformément à ADR-035, les
bras de code de test (court-circuit d’assertion, garde de harnais) ne sont
pas des exceptions de couverture production — ils sont listés ici pour
mémoire seulement et exclus du décompte. Le code de production
correspondant est couvert (100 % lignes pour linked.rs/shared.rs).
| Résidu (test) | Pourquoi non couvert |
|---|---|
4 sous-branches de linked.rs::tests (court-circuit d’assertions a == -62 || a >= 0, matches! du résultat LINK_TIMEOUT) | Bras d’assertion de test ; la production de linked.rs est 100 % lignes, toutes ses branches métier exercées. |
Retour false (timeout) du thread pair de ring_pool_routes_msg_ring_between_peers (shared.rs::tests) | Garde-fou de harnais (boucle bornée 2000×1 ms) ; le msg_ring arrive bien avant → seul true est pris. |
Récapitulatif par catégorie (production)
Décompte recompté à partir des lignes réelles du registre (les figures
antérieures avaient dérivé — exactement le travers signalé par l’audit 053).
Décompte désormais outillé et vérifié en CI par cargo xtask audit-exceptions,
qui recompte ce tableau à partir des rangées et échoue sur toute divergence (c’est
ce gate qui a corrigé la dérive CHILD-EXIT 9 → 8 antérieure). Décompte
CHILD-EXIT actuel : 11 rangées CHILD-EXIT + 1 rangée combinée
CHILD-EXIT / PRIVILEGE = 12 (les 3 rangées « Cohabitation toolchain » —
process, signal, security — viennent du retrait du flush enfant,
async-signal-safety ; la rangée terminal — session / job-control ADR-060
tranche 5 — valide son cycle nominal dans un enfant forké setsid).
| Catégorie (ADR-035) | Nombre d’entrées |
|---|---|
STRUCTURAL (inclut ex-EFAULT-SAFE + ex-VALUE-UNREACHABLE permanente) | 47 |
| FEATURE-KERNEL | 13 |
| PRIVILEGE | 10 |
| CHILD-EXIT | 12 |
| DEFERRED-TOOLING | 0 |
| Total (production) | 81 |
(Hors décompte : 2 pseudo-lignes — (COUVERT) — déjà couvertes, conservées pour
mémoire — et l’annexe « résidus internes aux tests » ci-dessus. La catégorie
combinée CHILD-EXIT / PRIVILEGE est comptée sous CHILD-EXIT.)
Rappel méthodologie : la métrique de branches se mesure
cargo +nightly llvm-cov --workspace --lib --branch(artefact-free) ; voir la section « artefact[True:0, False:0]» en tête de fichier. « 100 % hors exceptions / couvrable VIDE » = chaque ligne/branche atteignable rouge ⊆ une entrée ci-dessus, vérifié par réconciliation ligne-à-ligne sur les 2 arches (x86_64 speedy, aarch64 raspi).DEFERRED-TOOLINGest vide : aucune branche atteignable n’est exemptée (les 3 ex-candidates sont couvertes par de vrais tests).
Code GÉNÉRÉ exclu de la mesure (capnpc, ADR-040)
Les fichiers Rust générés par le compilateur de schéma Cap’n Proto (*_capnp.rs,
crate air-config-schema) sont committés (politique « code généré committé »,
ADR-040 addendum) mais exclus
de la mesure de couverture — au même titre que xtask (--exclude xtask). Raisons :
- C’est de la sortie d’outil, pas du code Air écrit à la main (accesseurs/setters
générés en masse, majoritairement non appelés) ; sa correction relève du codegen
capnpc(version pinnée), pas de tests Air. - Le code généré réellement utilisé est exercé indirectement par les tests de
binding d’
air-config-compile(round-trip source→artefact→relecture).
Mécanisme : cargo llvm-cov … --ignore-filename-regex '_capnp\.rs$' dans la barrière
(cargo xtask barrier) et la CI (.github/workflows/ci.yml), lignes et branches.
Exclusion globale (pas une exception ligne-à-ligne).
Famille signal (libc) — pause : attente bloquante non injectable (BLOCKING)
AirSignalManager::pause (air-signal) et pause_impl (air-libc-signal::suspend)
suspendent le thread jusqu’à un signal capté (ppoll(0 fd, attente infinie, masque courant)), puis rendent -1/EINTR. Le chemin ne rend jamais sans qu’un signal soit
délivré à un thread muni d’un handler : l’exercer en test exigerait un harnais de
délivrance de signal, or air-poll documente déjà cet EINTR comme non injectable
(cf. crates/air-poll/src/lib.rs : « EINTR — non injectable »). Appeler pause() en test
bloquerait indéfiniment le harnais.
- Correct par construction : composition mince sur
ppoll(couche 0, testée) ; aucune logique propre hormis le mapping d’erreur. - Aucun additif couche 0 :
pauseréutiliseppollexistant.
Absorbé par le plancher de couverture workspace (cargo llvm-cov … --fail-under-lines 96) : ~8 lignes non couvertes, négligeables. Catégorie BLOCKING (analogue au récap
CHILD-EXIT : chemin réel non exerçable en process).
Licence : MPL 2.0
Dernière mise à jour : 2026-07-02 (famille terminal — session / job-control, ADR-060
tranche 5 : 1 rangée CHILD-EXIT (tcsetpgrp/session_id/set_ctty/clear_ctty, cycle
nominal validé en enfant forké setsid) ; récap CHILD-EXIT 11 → 12, total 80 → 81).
Antérieur — 2026-06-30 (étape 6 phase B : air-runtime (couche 1) bascule en
mesure per-fichier — section TARGET-ONLY air-runtime ajoutée (5 entrées : reloc.rs ×3,
fork.rs ×2), catégorie TARGET-ONLY gravée (ADR-035 § amendement), hors décompte production
couche 0). Antérieur — 2026-06-28 : ajout famille futex (ADR-048, re-sceau
couche-0-v1.6) — 2 entrées STRUCTURAL futex_wake ; récap 73 → 75). Antérieur — 2026-06-25 :
code généré *_capnp.rs exclu de la couverture, ADR-040). Antérieur — 055 : taxonomie ADR-035 appliquée
(EFAULT-SAFE/VALUE-UNREACHABLE→STRUCTURAL, TEST-HARNESS→annexe, 3 candidates
couvertes ; récap recompté).
Air — Journal de bord
Archive chronologique append-only (détail session par session, PR par PR). Pour l’état vivant « où en est-on / prochaine tâche / tâches à venir », le document de suivi est
etat-avancement.md— à lire en premier. Ce journal reste la trace historique fine.
2026-07-10 — 🎉 M5 ATTEINT : std tourne sur Air, zéro glibc, 2 arches
Jalon majeur : un programme Rust std (hello-std) lié intégralement contre
libair (zéro glibc) s’exécute on-target — hello from std on air + exit 0 sur
x86_64-air (carbon natif) ET aarch64-air (raspi natif). Static-PIE, pari
ADR-076 tenu de bout en bout.
Le lien de hello-std révélait 12 symboles indéfinis ; comblés en 6 PR :
- #308
__xpg_strerror_r(alias XSI destrerror_r). - #309
open/fcntl— exports non-variadiques cible (#[cfg(target_vendor="air")]). - #310 couche 1
air-runtime::process_context(captureauxv+ bornes de pile au bootstrap) +AirProcessManager::resource_limit— ADR-086. - #311
getauxval+pthread_self+ détection de pile (pthread_getattr_np/attr_getstack/attr_getguardsize). - #312 TLS keys cible (
pthread_key_create/delete/setspecific) via#[thread_local]natif (discipline.tbss) + allocateurKEYSextrait/partagé. - #313
syscallgénérique — couche 0raw_syscall(asm 2 arches) + shim libc cible. EXCEPTION assumée temporaire (ADR-087) : entorse à ADR-021 conv.3 + « libc binde couche 1 » ; à retirer avec l’équipe Rust (le palunixdestdappellefutex/gettiden direct).
Re-sceaux (PR #314, tags signés GPG) : couche-0-v1.12 (raw_syscall) +
couche-1-v1.9 (introspection thread ; l’earmark ADR-078 crypto glisse en v1.10).
Prochaine phase = OpenSSH / air-sshd (ADR-074).
(La campagne M4 réseau + posix_spawn, 2026-07-05→09, n’a pas d’entrée dédiée ici — voir
etat-avancement.md et le registre des ADR.)
2026-07-04 — 🧵 libc M3 Phase B : familles signaux + pthread (couche 2)
Toit libc M3 Phase B (branche feat/libc-m3-phaseB, sur la Phase A ADR-066). Deux
crates de logique mesurée (aucun #[no_mangle]), empilement strict libc →
couche 1 (jamais air-sys-syscall) : air-libc-signal (sur air-signal) et
air-libc-thread (sur air-thread::raw_futex). Shims extern "C" dans
air-libc-capi::{signal,thread}, ré-export/ancrage air-libc-c, types+prototypes
hand-written dans le trailer cbindgen.toml (union struct sigaction,
sighandler_t, opaques pthread + initialiseurs, SIG*/SA_*/PTHREAD_*), exclus de
la génération cbindgen.
- Sécurité #1 — 4 fautes INERTES (ADR-064) :
sigaction/signalsurSIGSEGV/SIGBUS/SIGFPE/SIGILLacceptent (retour 0) et mémorisent la disposition (illusion d’API : table process-globale pouroldact) mais n’installent RIEN → la faute prend toujours l’action par défaut (coredump). Les signaux gérables sont installés en async réel (air_signal::install_handler, le kernel appelle le handler C).sigprocmask/pthread_sigmask→masque ;kill/raise→envoi ;sigpending→pending;sigsuspend→replace_mask+futex_waitinterruptible. - pthread sur
raw_futex(mémoire opaque de l’appelant, jamaisAirMutex) : mutex 3 étatsNORMAL/RECURSIVE/ERRORCHECK(owner =Tid) +timedlock; cond par compteur de séquence +timedwait(échéanceREALTIME/MONOTONIC) ; rwlock lecteur-préférentiel ;once(3 états) ; TLS (key_create+dtor à la sortie de thread viastd, hôte). Cycle de viecreate/join/detach/self/equalsur lespawnd’air-thread(hôte,pthread_t = Tid). Types opaques ABI Air largeur fixe (mutex 40/cond 48/rwlock 56/once 4,sigset_t128) + initialiseurs zéro. - Additifs REMONTÉS (non inventés) : sortie de thread propre (
pthread_exitendort le thread faute de primitive couche 1 +panic=abort), crochet de destruction TLS cible,rt_sigsuspendatomique,killgroupe de processus. Déviations gravées D-M3.1..D-M3.9 (docs/libc-conformance.md). - Validation superviseur en attente (cargo/git gated dans l’environnement de
production) :
gen-capi-header(régénèreair_c.h— trailer M3), barrière +nm -D(sigaction/kill/pthread_create/pthread_mutex_lock…), programme C multi-thread, signal non-faute délivré async + faute → coredump, sur x86_64 ET aarch64.Cargo.lockà régénérer (2 nouvelles crates).
2026-06-29 — 🧭 CORRECTION D’ARCHITECTURE : air-rt est un objet couche 1 (ADR-052)
Relecture du layering : le crate rt/crates/air-rt (phase 2) appelle la couche 0 en direct
(arch::set_fs, mem::mmap, process::clone_thread, futex, exit_group) — violation de la
doctrine d’Air (seule la couche 1 consomme la couche 0 ; personne d’autre). ADR-052 (ratifié BDFL)
grave la correction : air-rt est un objet couche 1, AirRuntime, composé de nouvelles briques
couche 1 à créer (elles n’existent pas encore) — ThreadControlBlock, ThreadLocalStorage,
outils de relocation (cible PIE/static-PIE, ADR-050) — s’appuyant sur air-thread/air-mem/air-proc.
AirRuntime ne dépend jamais de air-sys-syscall (la surface kernel) — air-sys-types (types
transverses : AirError/Errno, Pid/Tid… ADR-052 D6) reste autorisé. Les wrappers de syscalls (dont set_fs, ADR-051)
restent en couche 0 ; leur consommateur devient un objet couche 1 (jamais le runtime). Le tree
hors-arbre rt/ ne garde que le point d’entrée ELF _start + la plomberie cible (build-std/nightly).
Doc mise à jour / tagué obsolète : ADR-049 D8 (bannière + biffure), ADR-051 (note layering +
reformulation « affaire d’air-rt »), macro-architecture (§Couche 1 : objet air-runtime + tableau
d’artefacts), family-arch.md/family-process.md, INDEX, rt/README.md/rt/DECISIONS.md, registre
(v3.17) + SUMMARY. Gain : la logique de runtime, devenue couche 1, retombe sous le **100 % couverture
- barrière 2-arches** (le carve-out
rt/ne couvre plus que_start+ cible). Code à refactorer (création des objets couche 1, déplacement, retrait dépendanceair-sys-syscall—air-sys-typesconservé) = chantier suivi séparément.
2026-06-28 — 🔒 RE-SCEAU couche-0-v1.7 : 7 syscalls pour le runtime Air et la libc
Descellement unique (ADR-051, ratifié BDFL) ajoutant 7 wrappers typés à la couche 0, pour débloquer la phase 2 du programme *-linux-air (runtime air-rt) et pré-équiper l’objectif libc (ADR-046) — un seul re-sceau plutôt que des cycles répétés. arch_prctl (set_fs/get_fs, x86_64-only, nouvelle famille arch) programme le registre TLS du thread principal x86 (sur aarch64 = tpidr_el0 en asm, affaire d’air-rt ; threads spawnés = CLONE_SETTLS). process : getppid (→ Option<Pid>, 0=parent hors namespace), set_tid_address (primitif de join), sched_yield, umask, getcwd (octets, Principe 3), getrusage (+ types Rusage/RusageWho/Timeval #[repr(C)]). Tous en asm 2-arches avec // SAFETY:. 16 tests dont stratégies sûres pour les cas délicats (arch_prctl : no-op sur la base courante ; set_tid_address : threads détachés + Arc). check-syscalls PASS (numéros validés vs headers uapi, 2 arches), couvrable VIDE (2 exceptions STRUCTURAL/EFAULT-SAFE : bras Err de get_fs/getrusage inatteignables par construction — buffer valide + who typé), audit-exceptions cohérent (77 entrées, STRUCTURAL 46). Reporté en couche-0-v1.8 (phase 5, avec OpenSSH comme consommateur) : termios (ioctl typé), PTY, rt_sigaction/sigaltstack (+ décision ADR-020).
2026-06-28 — 🔒 RE-SCEAU couche-0-v1.6 : couche 0 std-free + futex(2) + types FD natifs + clone3 spawn de thread
Descellement unique de la couche 0 (ADR-048 + Amendement 1, ratifiés BDFL) regroupant : (1) famille futex(2) classique (FUTEX_WAIT/WAKE typés, EINTR remonté ; PR #147) ; (2) internalisation des types FD (OwnedFd/BorrowedFd/RawFd natifs d’Air + close(2) dans air-sys-types — core::os::fd n’existe pas en stable ; PR #148) ; (3) std-free #![cfg_attr(not(test), no_std)] (ffi→alloc/core, Arc→alloc, OsString→Vec<u8>, std::sync::Mutex→FutexMutex ; PR #149) ; (4) clone3 spawn de thread (clone_thread + trampoline asm x86_64/aarch64, join CHILD_CLEARTID/futex ; PR #150). Critère de sortie atteint : cargo build -p air-sys-types -p air-sys-syscall --lib compile en #![no_std] (preuve par le compilateur de l’absence de std en code lib). Couverture couvrable VIDE (branches ~84,3 %), // SAFETY: sur chaque unsafe, vérifié sur les 2 arches (bug asm aarch64 x19 réservé LLVM → x21, attrapé par la vérif 2-arches). Débloque la réimpl air-thread std-free (couche 1) sur futex/clone3, prérequis de la libc Air (ADR-046/047). Points à réconcilier par RFC : la spec air-sys-types.md montre encore CloneResult::Child pour les threads (unsound → clone_thread distinct) ; outillage ABI Rust air-abi-check/air-symver non implémenté (ADR-012).
Rôle de ce document
Tableau de bord dynamique du projet. Donne en un coup d’œil :
- L’état d’avancement courant.
- Le prochain chantier prioritaire et son contexte.
- Le backlog priorisé (vue condensée).
- L’historique des sessions précédentes.
Pour la checklist structurelle exhaustive (tous les documents à produire, arborescence cible, statistiques), voir INDEX.md. Pour le détail des ADRs, voir adrs/registre-adrs-fr.md. Les deux sont à consulter quand on cherche un détail précis ; JOURNAL.md sert à savoir où on en est et ce qu’on fait ensuite.
Convention d’usage en début de session : prompt initial type — « Bonjour, on continue sur Air. Lis docs/JOURNAL.md pour le contexte de reprise, puis on attaque [sujet]. »
État courant
2026-06-25 — 🎯 JALON : air-config COMPLET (5 backends + validation cas-réels). Suite des tranches 1-3 (chaîne verticale), livrés : tranche 4 (backends systemd — symlinks *.wants/mask/drop-ins/default.target, remplacement atomique ; fstab — 6 colonnes déterministes ; PR #122) et tranche 5 (passwd/group/shadow, PR #125 — SÉCURITÉ : shadow créé 0600 dès la naissance via openat2, jamais de chmod après-coup ; hashes en Zeroizing effacés après écriture ; jamais loggés/diagnostiqués ; is_safe_hash anti-injection ; tests comptes synthétiques en tempdir, robustes root). Validation cas-réels (PR #126) : corpus de fixtures /etc réalistes + comparaison sémantique (modèle-vs-modèle, détection de perte) = gate CI reproductible ; harnais advisory /air/etc (examples/etc-fidelity) produisant imported/artifact/reemitted/semantic-diff par backend sur la vraie machine — fidélité sémantique parfaite sur le /etc réel de carbon (138 units, 135 comptes), zéro secret (/etc/shadow jamais lu ; re-scan → 0 hash sous /air/etc, vérifié indépendamment). 5 backends /etc couverts (resolv.conf/hosts, systemd, fstab, passwd/group/shadow). Zéro unsafe/as/dépendance externe/surface C ; 6 cibles fuzz ; 100 % hors exceptions STRUCTURAL ; code généré *_capnp.rs exclu de la couverture (ADR-040). A nécessité le fix couche 0 faccessat2 (re-sceau v1.4, PR #123 — faccessat honorait pas son flag AT_SYMLINK_NOFOLLOW). Reste pour clore la couche 1 : l’ABI C (libair-base.so, doc cadrée ADR-027 addendum).
2026-06-25 — 🎯 JALON : air-config — chaîne verticale complète (tranches 1-3, PR #116/#118/#120). Le modèle de configuration d’Air (ADR-033/040/041) est implémenté de bout en bout, en 3 crates : air-config-compile (parseur du format de source maison — schema-directed, hand-rollé, fuzzé ~1,4 M runs ; binding au schéma + did-you-mean ; conversions typées checked anti-overflow/signe, zéro as ; décodage bytes hex/b64 ; secrets zeroizés/élidés ; encodage Cap’n Proto déterministe → artefact bit-pour-bit reproductible), air-config-schema (4 domaines .capnp : network/services/accounts/mounts + annotations $secret/$domain/$backend ; code Rust généré committé ADR-040 ; build sans tool C++ prouvé ; tool capnp 1.1.0 pinné), air-config (façade : lecteur mmap zéro-copie + vérif magic/checksum/version → InvalidData, jamais de lecture muette ; XDG ; générations + switch atomique + rollback via write_atomic ; trait Backend paramétré par racine + backend resolv.conf/hosts émission swap-atomique + import inverse défensif). Aucune fn unsafe exposée, zéro as, zéro dépendance externe, zéro surface C (capnp runtime pur Rust). 3 cibles fuzz ; 100 % lignes+branches hors exceptions STRUCTURAL ADR-035. Code généré *_capnp.rs exclu de la couverture (--ignore-filename-regex, corollaire ADR-040). Tests backends en tempdir (assert explicite root != /etc). Décisions design : ADR-040 (Cap’n Proto + addendum tool-C++/code-committé), ADR-041 (coexistence /etc), ADR-027 addendum (doc ABI C). Tranche 4 différée (backends systemd → passwd/shadow prudent → fstab). Reste couche 1 : tranche 4 air-config + ABI C (libair-base.so).
2026-06-25 — 🎯 JALON : air-base-lib COMPLET (cœur + services, PR #110). Les services sont livrés : AirLog (journalisation journald via protocole socket natif AF_UNIX, zéro dépendance systemd ; petites entrées CLÉ=valeur / forme binaire ; repli memfd scellé + SCM_RIGHTS sur EMSGSIZE ; non-fatal — log() n’échoue jamais, pertes comptées) + identifiants (AirUuid v7/v4, AirId128 via /etc/machine-id, AirMonotonicId). Aucune fn unsafe exposée, zéro as, zéro dépendance externe, zéro surface C ; 94 unit + property + 11 doctests + 2 cibles fuzz (journal_encode, uuid_parse) ; 100 % hors exceptions STRUCTURAL in-code (bras d’erreur kernel inatteignables : add_seals/write memfd, getrandom partiel ≤256 o). Cœur non régressé (byte-identique). tracing-bridge différé (zéro dépendance). §3 Config NON implémenté ici : superseded par ADR-033/040/041 → crate dédiée air-config (compilateur + artefact Cap’n Proto). Couche 1 — état : 8/8 crates cœur + air-base-lib services ✓. Reste pour clore la couche 1 : air-config (chantier dédié) et l’ABI C (libair-base.so, différée).
2026-06-25 — 🎯🎯 JALON MAJEUR : COUCHE 1 — 8/8 CRATES CŒUR IMPLÉMENTÉ (air-process livré, PR #105). Le cœur de la couche 1 est complet : air-base-lib cœur + air-crypto + air-socket + air-filesystem + air-memory + air-device + air-thread + air-process. air-process : AirCommand/AirProcess (clone3+CLONE_PIDFD, redirections dup3, chdir, execve_prepared, wait synchrone waitid-sur-pidfd, signal/kill), AirPipe (pipe2+CLOEXEC), AirExitStatus typé ; drop_privileges (process courant) + drop_privileges_to (drop dans l’enfant post-fork avant exec) — chemin enfant alloc-free (CStrArray + buffer de groupes construits avant clone3, drop_privileges_with_buffer sans alloc → async-signal-safety), ordre sûr no_new_privs→setgroups→setresgid→setresuid→capset→ambient→seccomp→landlock→verify, codes d’échec distincts spawn/drop. Aucune fn unsafe exposée, zéro as. Tests d’intégration réels + 4 tests root (regain de privilège irréversible, accès root révoqué via drop_privileges_to) + Miri ; 100 % lignes+branches hors exceptions ADR-035 in-code (CHILD-EXIT du chemin enfant, STRUCTURAL wait/clone3). A nécessité une extension couche 0 : execve_prepared (exec sans allocation pour le chemin post-fork) — additive, re-scellée couche-0-v1.3. Décisions config gravées en parallèle : ADR-040 (artefact air-config = Cap’n Proto ; addendum : capnpc exige le tool C++ au build → politique code généré committé) + ADR-041 (coexistence /etc : projection générée, RO sélectif, air-config seul écrivain). Reste pour clore la couche 1 : air-base-lib services (logging journald natif + identifiants ; air-config = chantier dédié Cap’n Proto) et l’ABI C (libair-base.so, différée).
2026-06-25 — 🎯 JALON : air-thread livré (PR #97) — threads + synchronisation de la couche 1. AirThreadBuilder (nom, taille de stack, affinité CPU — implémentée via la couche 0 set_cpu_affinity+gettid, handshake par canaux : l’enfant publie son Tid, l’appelant pose l’affinité avant de débloquer le corps) + AirThreadHandle<T> (join→AirResult). AirMutex/AirRwLock/AirSemaphore au-dessus de std::sync, SANS poisoning (poison absorbé via into_inner, testé par empoisonnement délibéré). AirChannel MPSC ; atomics ré-exportés. Aucune fonction unsafe exposée, zéro as, arithmétique saturante. Property-based + Miri (35 tests ; 5 ignorés = affinité en asm + stack, non supportés par Miri). 100 % lignes+branches hors 1 exception STRUCTURAL (usize::try_from(cpu) Err, impossible sur cibles 64 bits, doc in-code). Backend std::sync v1, migration futex maison différée. NB : l’affinité CPU était marquée « différée » dans la spec ; implémentée car la couche 0 (PR #55) la fournit → spec mise à jour. Piloté par agent headless sur carbon. Couche 1 : 7/8 crates cœur implémenté (+ air-thread).
2026-06-24 — 🎯 JALON : air-device livré (PR #94) — énumération/surveillance/identité natives de la couche 1. Natif sysfs + netlink uevent (groupe KERNEL) + evdev, sans libudev/libc. §1–§4 : AirDevice (attributs/propriétés/parent), AirDeviceEnumerator (/sys/class+/sys/bus, dédup canonique, filtres), AirDeviceMonitor (hotplug uevent), typés AirNetDevice/AirBlockDevice/AirUsbDevice. §5 AirInputDevice (evdev : capacités bitmap, horloge monotone EVIOCSCLOCKID, AirInputEvent::from_raw pur). Monitor durci : next_event couvert par test d’intégration root réel (uevent synthétique echo change > /sys/.../uevent) — plus d’exception PRIVILEGE. Aucune fonction unsafe exposée, zéro as, parsing borné get(). 3 cibles cargo-fuzz (uevent/sysfs/input ; input 5,5 M runs, 0 crash). Production 100 % lignes+branches hors 1 exception STRUCTURAL (write_attribute partiel, écriture sysfs atomique ADR-035) ; résidus = code de test (ADR-035 TEST-HARNESS). Ajout couche 1 : air-base-lib::AirInstant::from_monotonic_micros (horodatage evdev ; air-base-lib reste 100 %). Piloté par agents Claude Code headless sur speedy (gate = barrière x86 speedy+carbon + CI ARM). Couche 1 : 6/8 crates cœur implémenté (air-base-lib cœur + air-crypto + air-socket + air-filesystem + air-memory + air-device).
2026-06-24 — 🎯 JALON : air-memory livré (PR #88, par carbon) — allocateurs spécialisés + comptabilité de la couche 1. 5 sections : AirMemoryTracker/AirMemoryUsage (comptabilité per-composant atomique, opt-in, peak monotone via fetch_max) ; AirArena (bump O(1), alignement align_offset/checked_*, contrat gravé : reset/Drop n’exécutent PAS le Drop des valeurs, OOM par try_reserve sans abort, unique unsafe interne avec // SAFETY: par bloc) ; AirObjectPool/AirPooled (Rc/RefCell, croissance sans plafond, RAII respecte Drop) ; AirSlab/AirSlabKey (slots stables anti-ABA, génération wrapping_add u32, zéro unsafe) ; AirBacking (heap ; mmap différé, API non-cassante). Mono-thread (&mut self), aucune fonction unsafe exposée, arithmétique défensive (zéro as). Zéro dépendance externe, ZÉRO surface C. Tests unitaires + property-based + Miri (incl. test concurrent 8 threads sur le tracker → zéro UB/data-race). Couverture 100 % lignes+branches hors 1 exception STRUCTURAL (bras None du take() dans Drop for AirPooled, inatteignable par construction, gardé car un Drop ne doit pas paniquer ; doc in-code ADR-035). Décisions ratifiées : slab wrapping_add, pool sans plafond, peak monotone. 1ère crate couche 1 entièrement pilotée par agents Claude Code headless sur carbon (orchestration multi-machines ; gate de merge = barrière x86 speedy+carbon + Miri + CI ARM, ADR-037). Couche 1 : 5/8 crates cœur implémenté (air-base-lib cœur + air-crypto + air-socket + air-filesystem + air-memory).
2026-06-16 — 🎯 JALON : air-filesystem livré (PR #87, par carbon) — opérations filesystem de la couche 1. 6 sections sur la couche 0 (famille fs + fs::inotify, types air-sys-types::fs) : chemins canoniques & confinement resolve_within (anti-path-traversal par les flags kernel RESOLVE_BENEATH/RESOLVE_NO_MAGICLINKS d’openat2, sécurité par construction) ; écriture atomique (tmpfile + fsync fichier + parent + rename) ; AirTempDir RAII (Drop récursif non-paniquant, symlink-safe) ; copie copy_file_range + repli read/write transparent, copy_tree borné (anti-boucle de symlink) ; watchers sur inotify (debounce, récursivité, corrélation move par cookie, Q_OVERFLOW → variante dédiée Overflow = zéro-perte ADR-032) ; recherche bornée (read_dir, glob maison sans dépendance, find_regex différé). Zéro dépendance externe ajoutée, ZÉRO surface C (gate check-c-surface vert en défaut et --all-features). Correction de contrat : la note spec « inotify à ajouter en couche 0 » était périmée (inotify y est ; décodage inotify_event déjà fait/fuzzé en couche 0 → couche 1 consomme des événements décodés, ne re-parse pas). Tests unitaires + property-based (glob vs oracle, bornage, idempotence) + intégration (confinement, atomicité, copie, watcher inotify réel) + 2 cibles cargo-fuzz (fuzz_glob_match, fuzz_watch_mapping ; 0 crash). Couvrable VIDE (exclusion couverture retirée → air-filesystem mesuré ; lignes ~97 %, les rares bras non couverts sont des gardes défensives STRUCTUREL/ENV documentées in-code, ADR-035). Décision déférée (à acter) : regex (feature search-regex) refusé par la règle des 80 % → find_regex renvoie Unsupported (glob couvre le courant) ; exception nommée vs report définitif laissé à Thierry. Couche 1 : 4/8 crates cœur implémenté (air-base-lib cœur + air-crypto + air-socket + air-filesystem).
2026-06-16 — 🎯 JALON : air-socket livré (PASSE 2, PR #86) — sockets typés + résolution enfichable + client DNS maison. Sockets synchrones sûrs sur la famille net couche 0 : TCP (AirTcpSocket/Listener, connect_to_host IPv6-first), UDP (AirUdpSocket), Unix stream/listener/datagram + passage de FD SCM_RIGHTS (capability AirCom, ADR-001). OwnedFd RAII, as_fd() seam couche 2, zéro unsafe exposé. Résolution maison enfichable et ordonnée (AirNameResolver, sources [hosts, DNS], IPv6-first/IPv4-fallback) — sans libc/NSS/getaddrinfo. Client DNS maison RFC 1035 (AirDnsSource, A+AAAA, UDP + repli TCP sur bit TC, timeout 5 s/2 essais via poll MSG_DONTWAIT+clock_nanosleep). Parseur DNS = surface de sécurité (Principe 3) : slicing borné get(), pas de suivi de pointeur de compression (anti-boucle par construction), checked_* ; fuzz cargo-fuzz obligatoire (fuzz_dns_parse, smoke 300 000 exécutions, 0 crash). Tests unitaires + property + intégration loopback (TCP/UDP, Unix + passage de FD, serveur DNS en-process + repli TCP). AirNetlinkSocket RETIRÉ (décision 2026-06-15) : la couche 0 scellée n’a pas de socket netlink générique → futur crate air-netlink (couche 2, RFC netlink couche 0 requis). Zéro dépendance externe, zéro surface C (check-c-surface vert). Couche 1 : 3/8 crates cœur implémenté (air-base-lib cœur + air-crypto + air-socket).
2026-06-15 — 🎯 JALON : air-crypto livré (PR #80, merge f7057dd) — cœur crypto moderne de la couche 1. 7 sections (random/keys/hash/hmac/aead/kdf/asymmetric) sur RustCrypto/dalek, purs Rust, ZÉRO surface C (gate check-c-surface vert, ban cc respecté). XChaCha20Poly1305 recommandé par défaut (nonce 192 bits, anti-mésusage) ; BLAKE3 différé (blake3 tire cc → conflit zéro-C ; AirHashAlgorithm #[non_exhaustive]) ; streaming AEAD différé (frontière de volume documentée). KAT de conformité officiels (RFC 8439/8032/9106, NIST CAVP, RFC 4231/5869/7748) traversant le câblage de prod (seams pub(crate)) ; property-based ; zeroize-au-Drop vérifié ; fuzz×4. Couvrable VIDE (exclusion couverture retirée → air-crypto mesuré, planchers tenus). Exception crypto nommée dans EXCEPTIONS.md (ADR-034/ADR-016). Couche 1 : 2/8 crates cœur implémenté (air-base-lib cœur + air-crypto).
2026-06-15 — air-base-lib : module encoding (base64/hex) livré (PR #83, merge 9474c8b). Base64 (RFC 4648) + hexadécimal, std-only, zéro dépendance, zéro surface C ; 100 % lignes/branches (encoding.rs), property-based + cibles cargo-fuzz de décodage. Surface validée par Thierry ; livré complet d’un coup (squelette jamais mergé seul) → air-base-lib reste à 100 % sur main. 1ᵉʳ crate produit par la machine carbon (relais multi-machines).
2026-06-15 — 🎯 JALON : 1ᵉʳ crate couche 1 implémenté (air-base-lib cœur, PR #73, merge cb480ac). Erreurs / chaînes-chemins / temps, adossés à icu4x 2.2 (compiled_data, données baked-in). 100 % couvrable VIDE : lignes/branches 100 % du code atteignable ; les 2 seuls bras non couverts — erreur des horloges kernel (AirInstant::now/AirDateTime::now_utc) — sont STRUCTURELLEMENT inatteignables (ADR-035), documentés in-code, sans seam d’injection (cohérent avec la politique clock_settime couche 0). 4 cibles cargo-fuzz (from_utf8/normalize/path/locale), property-based (proptest), doctests exécutables. Décisions calendrier validées par Thierry : en couche 1 (sans formatage), le japonais partage la numérotation grégorienne CE et l’hébraïque un mois ordinal ; l’ère (Reiwa…) et le MonthCode (Adar I/II) relèvent du formatage (couche 2). deny.toml admet Unicode-3.0 (icu4x 2.x a quitté Unicode-DFS-2016). Dette d’outillage consignée (à NE PAS résoudre ici) : les gates xtask couvrable-vide/audit-exceptions sont câblés couche 0 (filtre air-sys-*) ; leur extension à la couche 1 se heurte à une collision de basename (time.rs existe en couche 0 ET en couche 1) + recompute du récap ⇒ RFC/tâche séparée, les exceptions couche 1 restant documentées in-code en attendant. Couche 1 : 1/8 crates cœur implémenté (air-base-lib cœur ✓ ; services à venir).
2026-06-15 — 🔒 Politique « zéro surface C/C++ » VERROUILLÉE au gate (PR #81, merge 4544709 ; issue de l’audit 082). Deux verrous : deny.toml [bans] (ban nominatif cc/cmake/bindgen/pkg-config/aws-lc-sys/ring) + cargo xtask check-c-surface (garde-fou générique anti-*-sys, parseur testé, scope cargo tree -e normal,build), câblé en CI supply-chain et dans cargo xtask barrier. Produit vérifié zéro-C (48 crates, 0 *-sys). Faux positif neutralisé : deny.toml [graph] exclude = ["loom"] retire le sous-arbre cfg(loom) → generator → cc (harnais de modèle de concurrence test-only, déjà exempté dans machete) — le ban cc reste actif sur tout autre chemin. Réintroduction future (ex. ring pour air-tls) = exception NOMMÉE dans docs/EXCEPTIONS.md (ADR-024/034).
Date de dernière mise à jour : 2026-06-14 — 🔒 COUCHE 0 v1 — SCELLÉE. Audit de barrière complète vert sur tout le workspace, 2 arches (x86_64 speedy, aarch64 raspi-srv-2) : cargo fmt --check (stable et nightly), clippy --workspace --all-targets -D warnings (0 warning), cargo test --workspace (918 tests, 0 échec), cargo llvm-cov en root (lignes 97,73 % / branches 82,73 % — chaque rouge ⊆ une exception documentée de COVERAGE-EXCEPTIONS.md ; DEFERRED-TOOLING VIDE → couvrable VIDE au sens strict), cargo audit + cargo deny check + cargo machete verts, // SAFETY: sur CHAQUE bloc unsafe du workspace (lacune time.rs corrigée — PR #64), mdbook build FR+EN sans lien cassé. CODE COMPLET : 11 familles (process/fs/mem/signal/time/net/ipc/security/system/device/ebpf) + io_uring 12 Temps (1, 2a–2d, 3a–3f, 4) + extensions (MmapRegion, privsep, fs::inotify, affinité CPU). Doc développeur FR + EN. Tag couche-0-v1 (annoté, signé GPG). Caveat de sceau — RÉSORBÉ post-sceau (PR #66 mergée, merge 380d389, prompts 060/061) : les gates qualité sont gravés en cargo xtask (5 gates : barrier, couvrable-vide, check-syscalls, audit-exceptions, repro — dev-tooling zéro-dépendance, exclu de la couverture). La reproductibilité ADR-025 est désormais outillée (cargo xtask repro : double-build en env contrôlé, 4 rlibs bit-pour-bit) ; la dette de re-sync des fichier:ligne est outillée (cargo xtask audit-exceptions : format/taxonomie ADR-035 + recompte du récap, échec sur dérive). Ce gate a corrigé une dérive du décompte : registre à 69 entrées production (STRUCTURAL 38, FEATURE-KERNEL 13, PRIVILEGE 10, CHILD-EXIT 8, DEFERRED-TOOLING 0) — CHILD-EXIT 9/total 70 était un sur-comptage introduit au 7b00e75 (055), pas une rangée perdue (décision Thierry, 2026-06-14). cargo xtask check-syscalls certifie par ailleurs 161 numéros de syscalls conformes aux headers uapi des 2 arches (0 écart). Différés assumés (non bloquants) : types epoll spéculatifs (revue fin couche 3), futex synchrone (v2 own-futex air-thread), fanotify, différés io_uring 3a (submit_files_update/msg_ring_fd). Prochaine phase : implémentation couche 1 (air-base-lib cœur d’abord).
Historique — état précédent (2026-06-14, avant sceau)
Taxonomie d’exceptions actée (ADR-035) + 3 candidates COUVERTES — PR #60 mergée (aa47d74) : fs::try_lock, net::get_so_error, process::pidfd_send_signal(Some) ⇒ DEFERRED-TOOLING VIDE / couvrable VIDE au sens strict ; registre recompté (70 entrées production : STRUCTURAL 38, FEATURE-KERNEL 13, PRIVILEGE 10, CHILD-EXIT 9 ; TEST-HARNESS en annexe — CHILD-EXIT 9/total 70 corrigés en 8/69 au 061, sur-comptage débusqué par xtask audit-exceptions) ; ADR-035 companion d’ADR-031. Puis : intro développeur couche 0 (PR #62, reflet Q4 family-security), traduction anglaise couche 0 (18 docs, PR #63).
- Phase : design pré-ouverture publique. Implémentation couche 0 démarrée dans un workspace Rust privé.
- Règle de pilotage actée le 2026-05-27 : le dépôt reste privé tant que la couche 0 n’est pas réellement opérationnelle ; tout travail d’ouverture publique ou de polish associé reste secondaire tant que cet objectif n’est pas atteint.
- Documents fondateurs (vision, charte, principes d’ingénierie) : finalisés en v1.0.
- ADRs fondateurs + ADR-018 imagerie : 18 ADRs (001 à 018, plus ADR-012-bis) finalisés et figés.
- ADRs phase 0 (couche 0, runtime, infrastructure) : série complète, 7 ADRs finalisés (ADR-019 à ADR-025). La zone « design phase 0 » est close.
- Implémentation couche 0 : deux crates privées déjà présentes dans le workspace :
air-sys-types(types fondamentaux) etair-sys-syscall(wrappers syscall). Les 9 familles de syscalls sont implémentées ET couvertes à 100 % (lignes + branches) hors exceptions documentées, validées sur x86_64 (speedy) ET aarch64 (raspi-srv-2) au 2026-06-01 :process,signal,time,ipc,mem,fs,net,security,system+air-sys-types. Suite : 371 (air-sys-syscall) + 80 (air-sys-types) + 74 doctests + 2 + 1 intégration, 0 échec, sur les 2 arches. Reste couche 0 :io_uring, famillesdevice/ebpfà spécifier. - Toolchain Rust : pin exacte relevée à
1.96.0(bump depuis1.95.0le 2026-05-31 ; le minimum requis suit toujours la dernière stable utilisée), avec composantsrustfmt,clippy,rust-src,llvm-tools-previewet targets Linuxx86_64-unknown-linux-gnu+aarch64-unknown-linux-gnu. Motivation : conserver un dépôt Linux-first sur des versions stables récentes, débloquer l’outillage moderne (cargo-deny,cargo-audit,cargo-machete) et bénéficier du linkerrust-lldpar défaut surx86_64-unknown-linux-gnu. - Specs couche 0 : toutes les familles sont spécifiées (2026-06-11). 11 familles (
process,fs,mem,signal,time,net,ipc,security,system,device,ebpf) + io_uring (maître + 13 Temps, FR+EN) + air-sys-types (partiel). Les deux derniers trous —device(uevent/evdev, parsers zéro-alloc, sysfs→fs) etebpf(bpf() exhaustif 37 cmd + perf_event_open) — sont comblés. Reste à produire : la spec exhaustiveair-sys-types(type par type), en parallèle de l’implémentation. Socle de bindings tranché : syscalls directs viacore::arch::asm!, sans dépendance externe (pas derustix). - Documents de setup (phases A à D) : finalisés.
- Macro-architecture : passe 1 (nettoyage) appliquée le 2026-05-20. Section 2 (couche 0) alignée sur les ADRs phase 0 (019-022) et sur le découpage en deux crates
air-sys-types+air-sys-syscall. Toutes les références prospectives ADR-018 à ADR-026 neutralisées en marqueurs neutres. Passe 2 (complétion sections couches 1-5) à mener plus tard en parallèle de l’implémentation. - Documentation infrastructure :
README.md,README-fr.md,LICENSE,.gitignoreprésents à la racine ; toute la doc (dontdocs/EXCEPTIONS.md,docs/COVERAGE-EXCEPTIONS.md,docs/JOURNAL.md) est consolidée sousdocs/(hygiène documentaire 2026-06-01). Restent à produire :CONTRIBUTING.md,SECURITY.md,CODE_OF_CONDUCT.md,CHANGELOG.mdet les guides/conventions dérivés. - Dépôt GitHub :
air-desktop-project/air, privé, créé le 2026-05-20. Premier commitb3737d6signé GPG (cléA9F56C4D9F59EE03), DCOSigned-off-by, verified par GitHub (reason: valid). Branchemainpoussée surorigin/main. Compte GitHub fondateur :tdelhaise. - Durcissement CI — PR #10 mergée le 2026-06-02 (
3f1034esurmain). Corrige le flaky des 3 testspidfd_*(défaut d’observation de test, pas une course de production : le numéro de fd fermé était réutilisé par un autre thread avant l’observationEBADF). Correctif côté test, API gelée : double défense par construction — dup vers un fd haut (min(soft NOFILE−1, 4096), non réutilisable par les opens concurrents qui prennent le plus petit fd libre) +FD_OBSERVATION_LOCKsérialisant les 3 frères. Plus purge.profraw(cargo llvm-cov clean) avant chaque mesure de couverture et doc du bruit landlock bénin (docs/CI.md). Couverture inchangée (96.61 % lignes / 78.79 % branches — baseline avec exceptions). 3 runs CI verts consécutifs sur les 2 arches. Réserve notée : l’immunité du fd haut tient tant que la suite ne dépasse pas le repère haut — à revisiter si un test ouvre des milliers de fd concurrents. - Incident opérationnel
raspi-srv-2— détecté puis RÉSOLU (2026-06-02). Pendant PR #10, raspi ne pouvait plusgit fetch(remote inaccessible) : machine figée sur un vieux SHA (202e1d9, sansfs.rs),reset --hard origin/mainen échec silencieux, validation initiale sur code périmé (faux « vert », rattrapé via unE0432) ; contournée alors par rsync. Réparé par Thierry : remote SSH + clé de déploiement dédiée —git remote set-url origin git@github.com:air-desktop-project/air.gitpuisgit config core.sshCommand "ssh -i ~/.ssh/air_deploy -o IdentitiesOnly=yes".git fetchfonctionne. Réflexe conservé : après chaque délégation, vérifier le SHA réellement bâti — au moment du fix raspi était encore àc01f3b8(PR #9) et doit re-fetch pour atteindre3f1034e(PR #10). - Renommage ADR-029 — PR #11 mergée le 2026-06-02 (
d1ce8adsurmain). Types façade + paramètres des crates couche 0 dé-abrégés (refactor mécanique, comportement préservé, couverture maintenue, barrière verte 2 arches). Suite : champs publics façade (PR #12), puis io_uring Temps 1. Cette session lande la spec io_uring (maître + Temps 1–4, FR+EN, ADR-028/029) jusque-là non commitée.
Structure physique du dépôt : racine = README.md, README-fr.md, LICENSE, CLAUDE.md, AGENTS.md, .gitignore, Cargo.toml, Cargo.lock, rust-toolchain.toml, docs/, crates/, target/. Toute la documentation vit sous docs/ (y compris EXCEPTIONS.md et COVERAGE-EXCEPTIONS.md, déplacés le 2026-06-01) ; tous les ADRs vivent dans docs/adrs/. Les documents de cadrage et le workspace couche 0 cohabitent dans le même dépôt privé.
Prochain chantier
Implémentation couche 1 — air-base-lib (cœur) d’abord.
🔒 La couche 0 est SCELLÉE (v1, 2026-06-14, tag couche-0-v1). CODE complet
(11 familles + io_uring 12 Temps + extensions), barrière complète verte sur les
2 arches, 100 % de couverture hors exceptions documentées (couvrable VIDE au sens
strict, ADR-035), doc développeur FR + EN. Le socle syscall n’évolue plus que par
RFC (ADR-015). La phase couche 0 est close.
Prochain chantier : démarrer la couche 1, en commençant par le cœur de
air-base-lib (erreurs, chaînes/chemins, temps — cf.
specs/layer-1/air-base-lib-core.md), au-dessus du socle couche 0 gelé.
En parallèle / hors phase : outillage xtask qualité (dont
xtask audit-exceptions pour re-synchroniser les renvois fichier:ligne du
registre de couverture — dette ouverte au sceau), et la spec exhaustive
air-sys-types type par type si besoin pour la couche 1.
Différés assumés de couche 0 (non bloquants, revus en temps voulu) : types
epoll spéculatifs (revue fin couche 3), futex synchrone (v2 own-futex
air-thread), fanotify, différés io_uring 3a (submit_files_update/
msg_ring_fd).
Backlog priorisé
Court terme (avant ouverture publique)
- Famille
ipc. Prochain chantier (cf. ci-dessus). - Famille
memminimale utile. Priorité suivante :mmap_anonymous,mmap_file,munmap,mprotect,memfd_create. - Famille
fsminimale.openat2/openat,read,write,pread,pwrite,statx,lseek,fsync,fdatasync. io_uring— Temps 1. À démarrer une fois les familles synchrones minimales en place, conformément à ADR-022.- Documents d’ouverture publique (
CONTRIBUTING.md,SECURITY.md,CODE_OF_CONDUCT.md,CHANGELOG.md) : explicitement secondaires tant que la couche 0 n’est pas réellement opérationnelle.
Moyen terme (en parallèle de l’implémentation)
- Révision
architecture/macro-architecture-fr.md— passe 2 (complétion). Enrichir sections couches 1 à 5 avec les détails issus des ADRs et specs phase 0+ une fois la couche 1 entamée. La passe 1 (nettoyage) est faite ; la passe 2 reste à mener. - Spec détaillée du module io_uring (9 documents). Voir backlog
INDEX.md. À produire au niveau de détail des autres specs de familles. Réf. ADR-022 +specs/layer-0/io-uring-overview.md. - Spec exhaustive de
air-sys-types. Compléterspecs/layer-0/air-sys-types.mdtype par type (méthodes, invariants, tests). - Documents communauté :
docs/guides/first-contribution.md,docs/guides/getting-started/.
Plus tard
- Documents internes mainteneurs :
docs/maintainers/release-process.md,moderation.md,onboarding-maintainers.md,cve-handling.md. - ADRs services système couche 5 à produire : screencapture, appearance/theming, share, firewalld, power, printd/bluetooth/nfc, prefs, keychain. Numérotation à assigner au moment de l’instruction (≥ ADR-026, sans pré-réservation).
- ADRs ouverts identifiés dans le registre : logging/observabilité au-delà de journald, mise à jour atomique du système, App Store Air, protocole privé AirCom
air-wm↔ apps natives, politique d’accès des agents IA à l’arbre sémantique et aux captures vectorielles.
Dettes connues
- Protections de branche GitHub non actives. Le tier gratuit GitHub Free bloque l’API de protection de branche sur les repos privés d’organisation. Conséquence : actuellement, aucune protection contre force-push ou suppression de branche sur
main; aucune exigence d’enforcement côté GitHub des signatures (la signature reste appliquée localement par discipline et vérifiée à l’affichage). Mitigations actuelles : Thierry seul mainteneur, tous les commits signés GPG + DCO par convention. Résolution prévue : soit passage en GitHub Team payant si jugé utile avant l’ouverture publique, soit attente automatique de l’ouverture publique (le repo public sur Free débloque les protections). À réévaluer quand le projet approchera de l’ouverture publique ou s’il commence à avoir des collaborateurs externes. - Spécifications phase 0 — COMBLÉES (2026-06-11). Les deux dernières familles couche 0 manquantes sont désormais spécifiées au niveau des autres :
- Famille
device:docs/specs/layer-0/family-device.md— socket netlink uevent + itérateurclé=valeurzéro-alloc, evdev (ioctls dédiésEVIOC*+ lecture typéeinput_event), sysfs renvoyé àfs. Consommée parair-wm/air-console(ADR-007). - Famille
ebpf:docs/specs/layer-0/family-ebpf.md— bpf() exhaustif (37 sous-commandes) +perf_event_open/ioctls, RAII, charge des programmes déjà assemblés (logique → couche 1). - Reste : l’implémentation de ces deux familles (specs prêtes à pousser). Traductions EN à produire en passe ultérieure (comme io_uring).
- Famille
- Socle de bindings — TRANCHÉ (2026-05-31, dette résolue). La couche 0 appelle les syscalls directement via
core::arch::asm!, sans dépendance externe (pas derustix).rustixavait été évoqué un temps comme hypothèse de travail dans la macro-architecture early-stage ; la piste est abandonnée. Trace historique conservée dansmacro-architecture-fr.md(section « Socle de bindings »). - Cohérence macro-architecture passe 2. La passe 1 a aligné la section 2 (couche 0). Les sections couches 1 à 5 contiennent encore des détails datant de la rédaction early-stage qui ne sont plus précis par rapport aux ADRs et specs ultérieurs (par ex. nomenclature
AirEventvs runtime async ADR-023, articulationair-aircomcôté couche 2 vsio_uring, etc.). À traiter dans une passe 2 menée en parallèle de l’implémentation couche 1, quand le détail concret existera. madviseio_uring + futex (différés de 2a/2c) — RÉSOLUS (2026-06-12, PR #31).submit_madvise(OP 25) etsubmit_futex_{wait,wake,waitv}(OP 51/52/53) exigeaient un handle de vivacité partagé sur le mapping (slot S1 empêchantmunmaptant qu’une op est en vol). C’est désormaisMmapRegion/MmapRegionLiveness(air-sys-syscall::mem, refcountArc, prouvé Miri+loom) —Mappingreste inchangé. Les deux ops sont branchées ; nommageMapping/MmapRegionet signature futex (&MmapRegion+offset) réconciliés.- Hygiène doc couverture Temps 1 io_uring. Le diagnostic 2a (rapport 017) a relevé ~5 branches/lignes STRUCTURAL préexistantes (non introduites par 2a) du cœur Temps 1 non encore tabulées individuellement dans
COVERAGE-EXCEPTIONS.md: gardesok_or(EINVAL)?debuild(i32::try_from(fd),NonZeroU32::new(cq_entries)) et dering_sizes(checked_add/checked_mulde débordement de tailles d’anneau, fuzzées viaring_sizes_decode), et le brasNonedeleak_forget. Toutes inatteignables par construction (valeurs kernel jamais hors-borne). Tolérées par le plancher CI 96/78 ; à tabuler explicitement dans une passe d’hygiène doc (orthogonale à la livraison 2a). - Additions couche 0 découvertes en spécifiant les couches supérieures (2026-06-11, étendu 2026-06-12) — la méthode « doc-d’abord » paie : spécifier la couche 1 révèle des primitifs couche 0 manquants, désormais spécifiés (specs landées) et à implémenter :
fs::inotify(docs/specs/layer-0/family-fs-inotify.md) —inotify_init1/add_watch/rm_watch+ décodage zéro-alloc desinotify_event. Prérequis deAirFileSystemWatcher(air-filesystem, couche 1).processprivsep (docs/specs/layer-0/family-process-privsep.md) — IMPLÉMENTÉ (2026-06-12, PR #34) :setgroups/setresgid/setresuid+getresgid/getresuid+ typesUid/Gid/ResUid/ResGid. Test de sécurité vert (enfant forké : après réduction versnobody, regain root/gid →EPERM, réajout de groupe →EPERM; saved-set figé). Couverture 100 % hors exceptions, 2 arches. Débloqueair-process::drop_privileges. (NumérosetresgidARM64 corrigé 143→149 ; constat :Uid/Gidetsetuid/setgidn’existaient pas — types créés par cette PR.)MmapRegion(docs/specs/layer-0/family-mem-mmap-region.md) — IMPLÉMENTÉ (2026-06-12, PR #31) : mapping partageable refcounté + garde de vivacité (Miri+loom). A débloqué et branchésubmit_madvise(2a) ETsubmit_futex_*(2c) ; signature futex et nommageMapping/MmapRegionréconciliés. (Reste à implémenter dans cette liste :fs::inotifyetepoll.)- Famille
epoll(découvert au Temps 2c) —EpollOp/EpollEventont été ajoutés àair-sys-types::io_uring(consommés par la façadesubmit_epoll_ctl), mais aucun wrapperepoll_create1n’existe en couche 0 : l’epfd des tests 2c est créé par unepoll_create1brut test-only. Si d’autres consommateurs apparaissent, produire une petite familleepoll(epoll_create1+ ré-hébergerEpollOp/EpollEvent) — à côté defs::inotifyetMmapRegion.
- Réconciliation macro-architecture (passe 2) — points précisés (2026-06-11). En complément de la dette « cohérence macro-architecture passe 2 » ci-dessus :
AirLog/AirId128(couche 1) n’utilisent PASsd-journal/sd-id128(couche 2) : socket natif journald (/run/systemd/journal/socket) et lecture directe de/etc/machine-id. Corriger les mentions « via sd-journal/sd-id128 » demacro-architecture-fr.md(≈ l.311, 313, 624, 625, 1547).- Renommage crates couche 1 (ADR-029, explicite, sans abréviation) :
air-fs→air-filesystem,air-proc→air-process,air-mem→air-memory. Mettre à jour la liste de crates demacro-architecture-fr.md. Les familles couche 0 (fs/mem/net/ipc) restent inchangées (acté).
- Couche 1 — specs COMPLÈTES 8/8 (2026-06-11 → 2026-06-12).
air-base-lib(cœur + services),air-filesystem,air-memory(allocateurs arena/pool/slab + comptabilité per-composant ; mono-thread ; backing heap enfichable),air-process(synchrone, pidfd,drop_privileges= orchestration + politiques fournies),air-thread(threads + synchronisation, backendstd::syncv1),air-socket(sockets + résolution de noms enfichable, client DNS maison),air-crypto(primitives RustCrypto) etair-device(énumération/surveillance/identité natives sysfs+uevent) spécifiés (API Rust ; ABI C différée, cf. ADR-027). Les 8 crates de la couche 1 sont désormais spécifiées. Specs landées sousdocs/specs/layer-1/. Restent hors crates : le compilateur seccomp déclaratif et le helper Landlock→chemins (côtéair-filesystem). Implémentation à venir (après les Temps io_uring restants). - ADR-033 acté (modèle de configuration, 2026-06-12). Source typée canonique → compilateur validant (barre couches 0/1, fuzzé) → artefact binaire reproductible schema-first (version + checksum) consommé par le runtime ; projections texte JSON/YAML/TOML en import/export ; sûreté par générations + switch atomique + rollback ; backends de compilation enfichables = joint d’indépendance (unit files systemd générés et validés pour V1, jamais une dépendance dure). L’intégrité des binaires est explicitement hors périmètre → renvoyée à un futur ADR (dette identifiée).
air-devicenatif — dette ADR-005 à amender (2026-06-12).air-devicedécide énumération/surveillance/identité natives (sysfs + uevent kernel), zérolibudev/udevd; surface en vocabulaire Air (joint), evdev au niveau périphérique (interprétation → compositeur, hors périmètre), 3 sous-systèmes typés (net/block/usb) + générique. Dette : la ligne «libudeven couche 1 » d’ADR-005 est à amender (pré-public : édition directe ou RFC ADR-015) ; la règle du joint (« vocabulaire Air, vérif CI no-systemd-types ») reste à outiller plus tard.air-thread— décisions gravées + 2 nouveaux manques couche 0 (2026-06-12). Le backend de synchronisation (AirMutex/RwLock/Semaphore, sans poisoning) eststd::syncpar expédient v1 ; migration planifiée vers une implémentation futex maison (« on maîtrise ce qui s’y fait ;stda ses propres contraintes, pas les nôtres »). Cela révèle 2 primitifs couche 0 manquants (à côté defs::inotifyetepoll) :sched_setaffinity(prérequis deAirThreadBuilder::cpu_affinity) et un futex synchrone (futex(2)WAIT/WAKEdirect — prérequis de l’implémentation futex maison). (Distinct du futex io_uring déjà livré au 2c : ici un wrapper syscall synchrone classique.)air-socket— client DNS maison à fuzzer (2026-06-12). Décision (override macro-archi) : résolution de noms enfichable et ordonnée (AirNameResolutionSource/AirNameResolver,AirHostsFileSource, client DNS maison sansgetaddrinfo/libc — litresolv.conf, UDP + repli TCP, politique IPv6-first/IPv4-fallback). Le parsing des réponses DNS est de la donnée hostile → fuzzing obligatoire (cargo-fuzz des décodeurs) à l’implémentation.air-crypto— exceptions 80 % +air-tlsséparé (2026-06-12). Zéro crypto maison : RustCrypto (SHA2/3, BLAKE3, Ed25519/X25519, AEAD, HKDF/Argon2),zeroize,subtle(constant-time) sont des exceptions à la règle des 80 % (Principe 6, nommées comme icu4x/ADR-016) → à acter dansDEPENDENCIES.md/docs/EXCEPTIONS.mdà l’implémentation. Cœur moderne uniquement (RSA/legacy différés) ; TLS différé à une specair-tlsséparée. Rigueur : vecteurs de test connus (RFC/NIST) + fuzzing des décodeurs.- io_uring 3a — différés (dette, PR #40, 2026-06-12). Deux opcodes non livrés au Temps 3a, par décision BDFL :
submit_files_update(op 20, mise à jour asynchrone de la table de FD fixes dans le flux) etmsg_ring_fd(MSG_SEND_FD) — plomberie d’op async (slab S1 d’un tableau de FD) tangentielle à la registration. La mise à jour synchrone (FixedFdTable::set/clear, register opcode 14) couvre le besoin immédiat. À reprendre avec le runtime async (ou un addendum 3a-bis). (Écarts kernel 3a — headers bornés à l’opcode 26,register_clock(Realtime)→EINVAL [variante conservée], CLOEXEC refusé sur descripteur direct,register_napi→EINVAL hors NIC/NAPI — documentés dans la spec 3a §13 bis etCOVERAGE-EXCEPTIONS.md.) - io_uring —
io-uring-overview.mddécrit la sémantique soft/hard À L’ENVERS (dette, PR #44, 2026-06-12). L’ancien overview inverse soft (IO_LINK) et hard (IO_HARDLINK). La référence correcte est la spec 3c §2 (vérifiée contreio_uring_enter(2)et confirmée sur kernel réel 6.17) : soft rompt la chaîne sur erreur de complétion (short read inclus), hard ne rompt pas. À corriger dansio-uring-overview.mdlors de sa réconciliation (son rôle d’inventaire est déjà repris par le doc maître ; pas bloquant).
Historique des sessions
Sessions 2026-06-16 → 2026-07-08 — Sceau couche 1, campagnes libc, PAL chantiers A/B (résumé de rattrapage)
Le suivi fin a été tenu dans etat-avancement.md (doc vivant) et la
mémoire agent ; cette entrée consolide l’arc pour l’archive. Détail décisionnel dans les ADRs
062→076.
- Couche 1 SCELLÉE
couche-1-v1.0(2026-07-03, ADR-062) puis re-scellée en additif jusqu’àv1.6: errno_location (ADR-065), signaux/futex (ADR-066),air-account(ADR-067), credentials +drop_to_user(ADR-068), campagne réseau registreair-handle+ moteur socketair-socket(ADR-069). - Toit libc C-ABI (scopée OpenSSH) : M0→M4 (crt/errno, str/mem, malloc+printf fuzzé, FILE*,
fileio, signaux+pthread, temps, comptes passwd/group/shadow, identifiants) + surface socket
complète (30 symboles
libair_c.so: inet, cycle de vie, transfert, sockopt, getaddrinfo, poll/select,sendmsg/recvmsg/SCM_RIGHTS). Descellements couche 0v1.10(ADR-070 sockaddr + ADR-071 sockopt). CI basculée x86 primaire + cross-check/natif aarch64 (ADR-072). - Doctrines : configuration binaire (ADR-073), vision
air-sshd(ADR-074 — la vraie cible ; OpenSSH-compile n’était qu’une fonction de forçage couche 1), fourniture du PAL (ADR-075). - PAL chantier A COMPLET : 6 briques additives couche 1 (Condvar, Once, os_str, env-mutation,
yield/name, destructeurs
thread_local). Bug de fondation aarch64 corrigé (gdb : le TCB 64 o recouvrait la zone TLStpidr_el0+16) — validé 2 arches. - PAL chantier B — option A (ADR-076) :
stdnatif sur la libc d’Air (pas la glibc) ; cibleenv=musl+vendor=air.build-std=stdcompile entièrement pour Air, zéro patch. Cible de production : compléterlibairpour lierstd→hello-stdon-target 2 arches.
Session 2026-06-15 — Extension additive du sceau couche 0 → couche-0-v1.1 (PR #77)
Réouverture délibérée et additive du sceau couche 0 (décision BDFL),
pilotée par les contrats couche 1 — découverte en Passe 2 d’air-base-lib
(073) : sa table From<Errno> mappe ETIMEDOUT/EPIPE/ECONNRESET, que
air_sys_types n’exposait pas (29 constantes ; ETIME 62 ≠ ETIMEDOUT 110). Ni
amendable sans réouverture (couche 0 scellée), ni « inventable » en numéros bruts
en couche 1 (ADR-032) → arrêt-signal (073), puis cette extension. PR #77 mergée
(merge commit 7d03932, code 32a1f13 signé GPG + DCO), barrière complète verte
2 arches (la barrière du nouveau sceau). Re-sceau : tag couche-0-v1.1
(signé GPG) sur le merge commit ; couche-0-v1 conservé comme jalon d’origine.
Constantes ajoutées (valeurs asm-generic, identiques x86_64/aarch64), chacune
justifiée par un contrat landé, rien de spéculatif :
ETIMEDOUT(110),EPIPE(32),ECONNRESET(104) —air-base-lib§1 (From<Errno>→TimedOut/BrokenResource) ;ELOOP(40),EXDEV(18) —air-filesystem(# Errors) ;- alias
EWOULDBLOCK ≡ EAGAIN(valeur 11), pour le mapping par nom.
Display reste basé sur la valeur brute (aucun bras per-const) → zéro branche
ajoutée ; couvrable VIDE maintenu (lignes 97,74 % / branches 82,73 %), aucune
nouvelle exception ADR-035. Le mapping From<Errno> d’air-base-lib est désormais
satisfiable par nom → la Passe 2 (073) est débloquée.
Session 2026-06-14 (suite) — Désactivation du trailer Co-Authored-By mergée (PR #74)
CI-only (prompts 069/070). PR #74 mergée (merge commit 4f48144, code
41c6cb6 signé GPG + DCO), CI verte 2 arches avant merge (couverture sautée :
couche0=false, ADR-036). Le trailer Co-Authored-By: Claude est désactivé au
niveau du repo via .claude/settings.json versionné
({"attribution":{"commit":"","pr":""},"includeCoAuthoredBy":false}) ;
.gitignore tracke uniquement settings.json (.claude/* +
!.claude/settings.json ; settings.local.json/claude_session.id restent
ignorés). Le Signed-off-by (DCO) est conservé — seul Co-Authored-By est
retiré. Décision : pas de réécriture de l’historique ; les commits passés (qui
portaient le trailer jusqu’à #65) sont conservés tels quels. Le comportement
était un défaut client de Claude Code (auto-attribution suite à une mise à
jour), pas une consigne du projet : Claude Code n’est en aucun cas l’auteur ;
c’est un outil d’automatisation, comme l’est un compilateur. Vérification
définitive de la prise d’effet : les commits du prompt 071 (session fraîche
post-merge) ; sinon bug client #18253 → repli ~/.claude/settings.json.
Session 2026-06-14 (suite) — Filtrage par chemin de la re-vérification couche 0 mergé (PR #70, ADR-036)
CI-only (prompts 064/065). PR #70 mergée (merge commit 200b374, code
9321036 signé GPG + DCO), CI verte 2 arches avant merge. ADR-036 landé
(filtrage par chemin de la re-vérification d’une couche scellée ; SUMMARY/INDEX/
registre v3.4). La couche 0 scellée n’est plus re-mesurée (couverture
lignes/branches) sur les PR qui ne touchent pas couche 0 / toolchain / CI /
politique de couverture : job changes (git diff, zéro nouvelle dépendance
d’action) → variable FULL ; étapes de couverture gatées if: env.FULL == 'true'. Re-vérification COMPLÈTE toujours garantie : (a) PR touchant ces
chemins, (b) merge push→main (main jamais non vérifié — dérive de
toolchain bloquée au merge ; confirmé : le run post-merge sur main a re-mesuré
la couverture, vert 2 arches), (c) planifié hebdomadaire (schedule cron
lundi 04:00 UTC). Une PR couche 1 pure garde fmt/clippy/cargo test --workspace (couche 0 bâtie + testée), couverture sautée. Mécanique de
déterminisme de couverture inchangée (purge .profraw, sudo -n, restauration
de propriété de target/) — ne tourne que dans la branche complète. Doc :
docs/CI.md (section « Filtrage par chemin… ADR-036 »).
Session 2026-06-14 (suite) — sccache branché sur les runners mergé (PR #68)
CI-only (prompts 062/063). PR #68 mergée (merge commit 4541cbd, code
82ff55d signé GPG + DCO), CI verte 2 arches avant merge. sccache comme
RUSTC_WRAPPER sur le job test-coverage (store hors target/,
$HOME/.cache/sccache → survit au git clean) : install par binaire pré-bâti
musl à SHA256 épinglé (v0.10.0, fallback cargo install signalé). La
recompilation de la couche 0 scellée par clippy/test est éliminée —
100 % de hit à cache chaud sur le Pi (53/53, 0 miss) ; durée test-coverage
Pi 6 m 23 s (froid) → 5 m 46 s (chaud). Les passes llvm-cov en root sont
volontairement non cachées (choix (b), ADR-031 : sous sudo sccache viserait
le cache de root, et -C instrument-coverage se cache mal) → gros gain restant
= ADR-036 (filtrage par chemin de la re-mesure de couverture). Garde-fou : le
gate cargo xtask repro neutralise RUSTC_WRAPPER (preuve de déterminisme bâtie
sans cache, vérifié). Doc : docs/CI.md (section « Cache de compilation —
sccache »).
Session 2026-06-14 (suite) — Gates qualité gravés en cargo xtask mergés (PR #66) + bascule doc
Post-sceau (prompts 060/061). PR #66 mergée (merge commit 380d389,
code def6c1a signé GPG + DCO), CI verte sur les 2 arches avant merge.
Crate xtask (dev-tooling, membre du workspace, zéro dépendance externe
→ Cargo.lock du produit et repro ADR-025 intacts ; exclue de la couverture via
--exclude xtask, non soumise au 100 %). 5 gates (sortie structurée +
--json, exit ≠ 0 sur violation, pour CC et la CI) :
barrier: un seul point d’entrée (fmt stable+nightly, clippy-D warnings, test, couverture lignes+branches root, audit/deny/machete, pré-filtre// SAFETY:advisory, cohérenceCargo.toml↔DEPENDENCIES.md) — VERTE.couvrable-vide(anti-gaming) :llvm-cov⨯ registre. Gate DUR = plancher (lignes 97,73 % / branches 82,73 %) ; réconciliation par symbole/ section/proximité ADVISORY (la reachability n’est pas mécaniquement décidable, cf.CI.md) ; sépare prod / test-resident.check-syscalls: numéros déclarés (// SAFETY: SYS_x (arch = N)+ consts) vs headers uapi des 2 arches (x86_64unistd_64.h, aarch64asm-generic, indirection__NR3264_*résolue) — 161 conformes, 0 écart (garde la classe d’erreur « privsep ARM64 »).audit-exceptions: format/taxonomie ADR-035 + recompte du récap → a corrigé une dérive :CHILD-EXIT 9→8, total70→69(sur-comptage introduit au7b00e75/055, pas une rangée perdue — décision Thierry). Registre désormais à 69 entrées (STRUCTURAL 38, FEATURE-KERNEL 13, PRIVILEGE 10, CHILD-EXIT 8, DEFERRED-TOOLING 0).repro: double-build bit-pour-bit en env contrôlé (SOURCE_DATE_EPOCH,--remap-path-prefix, toolchain pinné) — 4 rlibs identiques → atteste ADR-025.
Parseurs testés sur entrées synthétiques (34 tests). CI : job
supply-chain exécute check-syscalls + audit-exceptions ; étapes llvm-cov
gagnent --exclude xtask. Doc : docs/outillage-xtask.md (+ SUMMARY, CI.md).
Dettes de sceau RÉSORBÉES : reproductibilité ADR-025 outillée (repro),
re-sync fichier:ligne outillée (audit-exceptions).
Session 2026-06-14 (suite) — Taxonomie d’exceptions ADR-035 + 3 candidates COUVERTES mergées (PR #60)
Suite de la passe de consolidation (le CODE de la couche 0 reste complet).
PR #60 mergée (merge commit aa47d74, code 7b00e75 signé GPG + DCO), CI
verte 2 arches.
- 3 candidates « atteignables-mais-dures » COUVERTES par de vrais tests
déterministes (leurs exceptions supprimées) :
fs::try_lockcontention →Ok(false): harnais multi-processus forké (clone3) — enfant détient le verrou exclusif, parenttry_lock→Ok(false), synchro par pipes (sans sleep), récoltewaitid.net::get_so_error(brasval != 0) :connectnon bloquant vers un port loopback réservé-puis-fermé →ECONNREFUSEDviaSO_ERROR, boucle bornée.process::pidfd_send_signal(Some(&info)): brasSomeexercé via un pidfd de soi-même +SignalInfo::new_queue(déjà public — aucun ajout de surface) ; le kernel rejette parEINVAL(si_signo=0 ≠ sig) avant toute délivrance → sûr même vers soi-même.- ⇒
DEFERRED-TOOLINGVIDE / couvrable VIDE au sens strict.
- Taxonomie appliquée à
COVERAGE-EXCEPTIONS.md(ADR-035) :EFAULT-SAFE+VALUE-UNREACHABLEpermanente → STRUCTURAL ;CHILD-EXITconservée ;TEST-HARNESSretiré → annexe « résidus internes aux tests » (hors décompte). Récap recompté depuis les lignes réelles (figures antérieures dérivées) : 70 entrées production (STRUCTURAL 38, FEATURE-KERNEL 13, PRIVILEGE 10, CHILD-EXIT 9, DEFERRED-TOOLING 0). (⚠️ ce recompte a introduit un sur-comptageCHILD-EXIT 9au lieu de 8 — total réel 69, corrigé au061par le gatextask audit-exceptions, cf. session 2026-06-14 « Gates qualité xtask ».) - ADR-035 (« Taxonomie des exceptions de couverture », companion d’ADR-031,
sans l’amender) créé ; registre ADRs (table + historique v3.3),
SUMMARY.md,INDEX.md.
Dette consignée : la re-vérification durable des
fichier:lignedu registre d’exceptions (numéros sujets à dérive) — passer à des renvois par symbole ou un check automatisé (futurxtask audit-exceptions). Prochain chantier → clôture couche 0 : doc d’introduction développeur (introduction-fr.md, encart « dev C/C++ ») + reflet de Q4 (seccompprimitif) dansfamily-security+ réconciliation INDEX/état → traduction anglaise → sceau barrière complète.
Session 2026-06-14 — Consolidation couche 0 : test flaky uevent durci mergé (PR #58)
Première étape de la passe de consolidation (le CODE de la couche 0 reste
complet). PR #58 mergée (merge commit e74d96e, code 04e6a41 signé GPG + DCO),
CI verte 2 arches.
- Correctif test flaky :
device::tests::uevent_read_nonblock_without_event_is_eagain(socket uevent groupe KERNEL NONBLOCK exigeantEAGAIN) cassait par intermittence quand un vrai uevent kernel (hotplug/udev) était diffusé pendant la fenêtre sur un runner partagé. Remplacé paruevent_read_nonblock_returns_promptly_eagain_or_valid_eventqui teste le vrai contrat NONBLOCK : retour prompt = soitEAGAIN(file vide), soit unUEventMessagebien formé (événement parasite décodable) ; jamais bloquer/ paniquer/trame malformée (EINTRtoléré, ADR-021 c.2). Boucle de robustesse (256 lectures/run) ; prouvé non-flaky (20/20). Changement test-only. - Normalisation du registre
COVERAGE-EXCEPTIONS.md(non controversée, aucun reclassement) : la catégorieTEST-HARNESS(2 entrées) était utilisée sans être ni définie (légende) ni comptée (récap) → ajoutée (Total 60 → 62) ; note d’audit + date. - Audit du registre rendu (
053-consolidation-flaky-exceptions.out) : 3 candidates « atteignables-mais-dures » signalées (net::get_so_error,fs::try_lockcontention,process::pidfd_send_signal(Some)) ; proposition de taxonomie (fusionEFAULT-SAFE/VALUE-UNREACHABLE-permanent → STRUCTURAL, garderCHILD-EXIT, retirerTEST-HARNESS, addendum ADR) — décisions BDFL prises ; application au prompt055.
Prochain chantier →
055: appliquer la taxonomie d’exceptions retenue + couvrir les 3 candidates atteignables + addendum ADR-035. Puis : doc d’intro développeur couche 0, traduction EN, sceau barrière complète.
Session 2026-06-13 (suite) — fs::inotify + affinité CPU mergés (PR #55) — 🎉 CODE couche 0 COMPLET
Passe de complétion couche 0 : deux primitifs indépendants révélés par la
spécification de la couche 1, livrés en un PR (modules disjoints d’io_uring).
PR #55 mergée (merge commit a009f9b, code b609b87 signé GPG + DCO), CI
verte 2 arches.
fs::inotify(air-sys-syscall::fs::inotify+ typesair-sys-types::fs) : RAIIInotify(IN_CLOEXECpar défaut) ;inotify_init/add_watch/remove_watch/read_events; décodeur emprunté zéro-allocation desinotify_eventde taille variable, zéro-perte (ADR-032) — queue tronquée signalée (InotifyEvents::truncated()), jamais avalée. Syscalls vérifiés uapi 6.12 (init1 294/26, add_watch 254/27, rm_watch 255/28). Tests : intégration kernel réelle (CREATE/MODIFY/MOVED_FROM↔MOVED_TO parcookie/DELETE/IGNORED) + décodeur pur + property-based + fuzz 3 M runs (0 crash). Décodeur 100 %/100 %.fanotifyhors périmètre ; récursivité = couche 1.- Affinité CPU (famille
process, specfamily-process-affinity.mdcréée) :set_cpu_affinity/get_cpu_affinity(Option<Tid>, &CpuSet);None= tâche appelante (pid 0typé) ;CpuSetréutilisé depuisair-sys-types::system. Syscalls vérifiés (setaffinity 203/122, getaffinity 204/123). Tests : round-trip set→get,EINVAL/ESRCH, cross-arch. Débloqueair-thread::cpu_affinity(couche 1). - +1 exception STRUCTURAL (épuisement ressources
inotify_init+ casts défensifsi64→i32). Aucune dépendance de production ajoutée. Aucun écart kernel.
🎉 CODE de la couche 0 COMPLET : io_uring (12 Temps) + 11 familles de syscalls
fs::inotify+ affinité CPU — tous implémentés et mergés pour la cible 6.12 (hors interfaces obsolètes →UNSUPPORTED.md). Plus de code à écrire. Prochain chantier → passe de consolidation couche 0 : audit du registre d’exceptions, doc d’introduction développeur, traduction anglaise globale, réconciliation INDEX/état, sceau barrière complète. Différés conservés (epoll types spéculatifs, futex synchrone v2,fanotify, différés io_uring 3a).
Session 2026-06-13 (suite) — io_uring Temps 4 (accès brut) mergé (PR #53) — 🎉 MODULE io_uring COMPLET
Sous-module air-sys-syscall::io_uring::raw : la soupape de sécurité d’ADR-022
(Décision 1) — manipulation directe des SQE/CQE pour les ~5 % de cas que l’API typée
(Temps 1–3f) ne couvre pas. L’ordering d’anneau reste géré par la façade ;
l’unsafe ne porte que sur le contenu du SQE et la validité des buffers.
PR #53 mergée (merge commit c91c693, code 9fe14fb signé GPG + DCO), CI verte
2 arches.
- Types miroirs
#[repr(C)]:RawSubmissionQueueEntry(#[repr(transparent)]surIoUringSqe, 64 o),RawCompletionQueueEntry(16 o),RawOpcode(u8); assertions statiques de taille/offset (uapi 6.12, identiques x86_64/aarch64) → dérive = échec de compilation. Accesseurs typés (pas de calcul d’offset manuel). - Soumission brute :
raw_get_submission_queue_entry(unsafe) ; publication =submit()/submit_and_wait()(sûrs : store release par la façade). Complétion brute :raw_peek_completion_queue_entry(sûr, load acquire, réf. bornée) +raw_advance_completion_queue(sûr, borné). Capacités SQ/CQ. Aucun pointeur mmap nu exposé. - Coexistence brut/niveau 2 (§5) :
RAW_USER_DATA_TAG = 1 << 63. La moisson gérée court-circuite sur le tag (slab non touché, CQE non consommé) ; le slab borne sa génération à 31 bits (GENERATION_MASK) ⇒ le bit 63 duuser_datagéré est toujours nul → brut et niveau 2 coexistent sur le même ring sans collision.debug_assertdu tag manquant (testé via#[should_panic]). - Tests : layout statique ; intégration kernel (NOP brut ↔
raw_peek/raw_advance; op brute vs wrapper niveau 2 ; coexistence slab+brut sans collision ; SQ pleine →None;advanceborné ; moisson gérée qui s’efface devant une brute en tête) ; Miri (chemins purs : layout, slab génération 31 bits — 16 tests verts) ; fuzz du décodageRawCompletionQueueEntry(cibleio_uring_4_raw_cqe, 2 M runs, 0 crash).raw.rs100 % lignes / couvrable vide (aucune exception propre au Temps 4) ;ring.rs100 %/100 %. - Correctif annexe (ADR-032) : 3 littéraux
Completiondefuzz_api(#[cfg(fuzzing)], non bâti en CI) restés sans le champmultishotdepuis le Temps 3d → la fuzz-cible ne compilait plus ; dette silencieuse purgée. Aucun écart kernel.
🎉 Module io_uring COMPLET : les 12 Temps (1, 2a–2d, 3a–3f, 4) sont implémentés et mergés pour la cible 6.12 (hors interfaces obsolètes →
UNSUPPORTED.md). Prochain chantier → clôture couche 0 :fs::inotify+sched_setaffinity, puis passe de consolidation. Dette : traduction anglaise globale des documents du module io_uring (à planifier).
Session 2026-06-13 (suite) — io_uring Temps 3f (confinement) mergé (PR #51)
Sous-module air-sys-syscall::io_uring::sandbox : confine un ring pour qu’il
ne puisse émettre que les opérations explicitement mises en liste blanche,
imposé par le kernel — matérialise la soundness S3. Brique io_uring du
modèle de capabilities d’Air (ADR-001 AirCom, ADR-010 entitlements signés), en
défense en profondeur avec family-security (seccomp/Landlock). PR #51
mergée (merge commit 39aa92f, code eb494c0 signé GPG + DCO), CI verte 2
arches (coverage root).
RestrictionSet(default-deny,#[derive(Default)]) :allow_op/allow_register/allow_sqe_flags/require_sqe_flags+from_entitlements(point d’intégration couche 5, ADR-010 — la politique vit en couche 5).RegisterOp(énum typée des register-ops, ADR-021 conv. 3 — pas deu8magique) ;SqeFlagSet(drapeauxIOSQE_*, partage la repr. deSubmitOptions).- Flux en 3 temps imposé kernel + reflété par l’API :
IoUringBuilder::restrict→ ringR_DISABLED+REGISTER_RESTRICTIONS;IoUring::enable→REGISTER_ENABLE_RINGS; restrictions immuables ensuite. Default-deny : dès unallow_op, le kernel refuse tout le reste (-EACCES). Le stubENOSYSdebuild()(Temps 1) est remplacé par le vraiREGISTER_RESTRICTIONS(test obsolèterestrict_then_build_is_refused_with_enosysretiré). - Preuve de sécurité (§8) :
openat2REFUSÉ (-EACCES) sur un ring « réseau seul » alors que le process a le droit d’ouvrir le fichier → la voie de contournement de seccomp est fermée. Aussi : opcode autorisé qui passe, default-deny,require_sqe_flagsimposé, soumission avantenablerefusée (EBADFD), immuabilité aprèsenable(refus kernel), propagation d’erreurREGISTER_RESTRICTIONS(simulateur), property-based (tout sous-ensemble encode exactement ses opcodes). - Couverture (root) :
sandbox.rs100 % lignes + branches / couvrable vide ; unique résidu = conversion défensiveusize→u32du nombre de restrictions (STRUCTURAL, inatteignable).raw:REGISTER_RESTRICTIONS(11),io_uring_register_restriction_op(0/1/2/3), miroirio_uring_restriction(16 o, assert). TOTAL lignes 96,2 % / branches lib 78,2 %. Aucun écart kernel. Prochain chantier → io_uring Temps 4 (raw), dernier du module.
Session 2026-06-13 (suite) — io_uring Temps 3e (multi-thread) mergé (PR #49)
Sous-module air-sys-syscall::io_uring::shared : les modèles d’usage
multi-thread du ring, par-dessus le cœur Temps 1, ATTACH_WQ (3a) et
msg_ring (2c). Aucun register opcode propre. PR #49 mergée (merge commit
f9be984, code 43c957a signé GPG + DCO), CI verte 2 arches (coverage root).
unsafe impl Send for IoUringréalisé (ADR-022 D6, documenté depuis le Temps 1 mais jamais implémenté : les pointeurs bruts mmappés rendaientIoUringauto-!Send).// SAFETY:: mmaps/FD process-global,Send= transfert d’ownership exclusif,!Syncpréservé (PhantomData<Cell>). Invariant habilitant de tout le Temps. Surface publique inchangée (impl auto).LockedIoUring(Mutex<IoUring>,Send + Sync) : primitivewith_lock(couvre toute op d’IoUringen&self) + raccourcis nommés fréquents — pas de ~50 forwarders (Principe 7) ; verrou unique, sans poisoning.RingPool(thread-per-core) : le 1er ring crée le pool io-wq, les suivantsATTACH_WQ(workers kernel bornés, Pi 4) ; ring fds enregistrés ;into_rings(un ring possédé par worker, déplacé) ;handle(w) -> RingHandlesubmit_message_ring_data_to(routagemsg_ringcross-thread par fd ; le 2c, qui prend&IoUring, reste inchangé).SqpollIoUring:SETUP_SQPOLLSQ_AFF, soumission sansio_uring_enteren régime établi, réveilNEED_WAKEUPgéré (seule magie cachée, documentée).
- Soundness (cœur du Temps) :
IoUring: !Syncprouvé par doctestcompile_fail,LockedIoUring: Send+Syncpar doctest + assertion statique ; loom sur la discipline de verrou (exclusion mutuelle, aucune mise à jour perdue) ; tests d’intégration concurrents réels (threads). - Couverture (root, après complément
046/PR #49) :shared.rsbranches 100 % / couvrable vide — l’exceptionEFAULT-SAFEsur les brasenterSQPOLL est levée (couverts au simulateur : injection-EINTRsur la couturesyscall::enter, errno remonté tel quel ADR-021) ; résidus = fallbackSQPOLL-EPERMdes tests (PRIVILEGE) + timeout du thread pair (TEST-HARNESS). Flakiness réelle corrigée :locked_io_uring_concurrent_nops(course de complétion) rendu atomique sous un seulwith_lock. TOTAL lignes 96,1 % / branches lib 78,1 %. Aucun écart kernel. Prochain chantier → io_uring Temps 3f (confinement).
Session 2026-06-13 — io_uring Temps 3d (opérations multishot) mergé (PR #46)
Sous-module air-sys-syscall::io_uring::multishot : une unique soumission
produit un flux de complétions (CQE_F_MORE), par-dessus le cœur Temps 1, les
buffers fournis du 3b et les descripteurs directs du 3a. Aucun register
opcode ; opcode dédié READ_MULTISHOT (49) + drapeaux d’op. PR #46 mergée
(merge commit ba26ff9, code 00ccee3 signé GPG + DCO), CI verte 2 arches
(coverage root, raspi a bâti 00ccee3).
- Cycle de vie S1 : slot vivant tant que
CQE_F_MORE, libéré uniquement à la terminale (2ᵉ cas après le NOTIF zero-copy du 2b). Slab étendu (reserve_multishot/is_multishot+ champmultishotdu slot) ;Completionporte un marqueur multishot lu avantcomplete;MultishotToken(sémantique fixée) ;Completion::multishot_token(); génération anti-CQE-tardif prouvée Miri (test pur du slab). - Façades :
submit_accept_multishot[_direct](ACCEPT +ACCEPT_MULTISHOT; variante directe = slot auto-allouéFixedFdTable),submit_receive_multishot(RECV +RECV_MULTISHOT) /submit_read_multishot(READ_MULTISHOT49) sur buffers fournis,submit_poll_multishot(POLL_ADD +POLL_ADD_MULTI, edge),submit_timeout_multishot(TIMEOUT +TIMEOUT_MULTISHOT, ticks),cancel_multishot. Pénurie-ENOBUFSTERMINE le multishot, distincte d’une erreur réseau (testé). - Choix signalés (surface inchangée) :
cancel_multishotutilise l’annulation synchrone (IORING_REGISTER_SYNC_CANCELpar jeton) plutôt que l’opASYNC_CANCEL(cohérent avec-> Result<()>, pas de CQE d’annulation à drainer) ;submit_poll_multishotedge-triggered (la surface ne porte pas d’option level-triggered ;IORING_POLL_ADD_LEVEL=1<<3non exposé). - Couverture (root) :
multishot.rs100 % lignes + branches / couvrable vide ; cycle S1 prouvé sous Miri. TOTAL lignes 97,7 % / branches 81,9 %. 9 tests d’intégration + 2 tests slab purs. Aucun écart kernel. Prochain chantier → io_uring Temps 3e (multi-thread).
Session 2026-06-12 (suite) — io_uring Temps 3c (opérations liées) mergé (PR #44)
Sous-module air-sys-syscall::io_uring::linked : chaînes d’opérations liées
(IOSQE_IO_LINK/IO_HARDLINK + LINK_TIMEOUT op 15), par-dessus le cœur Temps 1.
Aucun register opcode. PR #44 mergée (merge commit 383eb99, code 575d3ab
signé GPG + DCO), CI verte 2 arches (coverage root, raspi a bâti 575d3ab).
LinkedChainBuilderviaring.link_chain():first/then(soft,IO_LINKsur le prédécesseur) /then_hard(IO_HARDLINK) /with_link_timeout(LINK_TIMEOUTbornant le maillon précédent ; émission isolée refusée) /submit() -> ChainTokens.- Mode staging : les
submit_*existants réservent slot + écrivent le SQE (drapeau de lien sur le prédécesseur) sans publier la queue ; publication unique ausubmit()(un seul store-release). Aucune duplication desprepare_*. Réservation atomique : un maillon non réservable (EBUSY) déclenche un rollback (release idempotent + rewind de la tail) — jamais de chaîne partielle publiée. - Sémantique soft/hard CORRECTE confirmée sur kernel réel (6.17) : soft rompt
sur erreur de complétion — un short read COMPTE comme erreur (piège testé
explicitement) → suivants
-ECANCELED; hard ne rompt pas.link_timeoutexpirant (maillon annulé) et non-expirant (timeout annulé) testés. skip_cqe_on_successsur les intermédiaires (seule la finale arrive) ;ChainTokenscorrèle chaque complétion à son maillon. Helpersring.rs(local_tail/or_sqe_flags/rewind_to) ;raw.rs(IOSQE_IO_LINK/HARDLINK,OP_LINK_TIMEOUT).- Couverture (root) :
linked.rslignes de production 100 % / couvrable vide (résidu branches = court-circuits d’assertions de test ; garde STRUCTURAL préexistante deprepare). TOTAL lignes 97,7 % / branches 81,7 %. 14 tests +ChainTokenssous Miri. Aucun écart kernel. Prochain chantier → io_uring Temps 3d (multishot).
Session 2026-06-12 (suite) — io_uring Temps 3b (buffers fournis ring-mapped) mergé (PR #42)
Sous-module air-sys-syscall::io_uring::provided : buffers fournis
ring-mapped (IORING_REGISTER_PBUF_RING 22 / UNREGISTER 23 / PBUF_STATUS
26), par-dessus le cœur Temps 1 et la registration Temps 3a. PR #42 mergée
(merge commit 8ca051c, code e56eba0 signé GPG + DCO), CI verte 2 arches
(coverage root, raspi a bâti e56eba0).
ProvidedBufferRing:register(countpuissance de 2,buf_size; modes app etkernel_mmap),unregister,status,group_id,available. Anneau de descripteursio_uring_buf[count](tailu16@ offset 14, avancé par store-release) + mémoire de données possédée (count×buf_size).- Sélection auto :
submit_receive_provided/submit_read_provided(IOSQE_BUFFER_SELECT, aucun buffer au slot S1) +bundle: bool(IORING_RECVSEND_BUNDLE). Pénurie ⇒ complétion-ENOBUFS. -
- Guard RAII
ProvidedBufferviaCompletion::into_provided_buffer(&mut group) id/data/data_mut;Dropréapprovisionne (réécrit le descripteur, store-release du tail). L’emprunt&mutinterdit le désenregistrement tant qu’un buffer vit (doctestcompile_fail).
- Guard RAII
- Incrémental (
IOU_PBUF_RING_INC) :CQE_F_BUF_MORE⇒ même buffer réutilisé, pas de réappro ; réappro à la complétion finale. Accountingavailableidempotent (out_flagsparbid). - Legacy évacués →
docs/UNSUPPORTED.mdcréé :PROVIDE_BUFFERS(31),REMOVE_BUFFERS(32), +OPENAT(18). - Écarts kernel signalés (spec 3b §9 bis,
COVERAGE-EXCEPTIONS.md) :IOU_PBUF_RING_INC/RECVSEND_BUNDLE/CQE_F_BUF_MOREabsents des headers exécuteurs (uapi 6.11/6.12, validés au runtime 6.17) ; bloc-code spec §3 aligné sur la prose (parambundleajouté à la signature, surface effective inchangée). - Couverture (root) :
provided.rslignes 99,6 % / couvrable vide (résidu = bras?desmmap= EFAULT-SAFE ; gardesbid<count= STRUCTURAL). TOTAL lignes 97,7 % / branches 82,0 %. 9 tests (intégration : recv loopback,-ENOBUFS+réappro,kernel_mmap,status,read/bundle; synthétiques déterministesBUF_MORE/réappro/None; property 256 cycles ; simulateur) + 1 doctestcompile_fail. Prochain chantier → io_uring Temps 3c (opérations liées).
Session 2026-06-12 (suite) — io_uring Temps 3a (registration) mergé (PR #40)
Sous-module air-sys-syscall::io_uring::registration : les 21 register
opcodes (io_uring_register(2), n° 427) du Temps 3a, livrés par-dessus le cœur
Temps 1. PR #40 mergée (merge commit 34c164e, code aefec1a signé GPG +
DCO), CI verte 2 arches (x86_64 speedy + aarch64 raspi-srv-2, coverage root).
FixedFdTable(FILES2/FILES_UPDATE2/FILE_ALLOC_RANGE/UNREGISTER_FILES) :register(sparse)/register_with/set/clear/set_alloc_range/unregister(restitue tous lesOwnedFd— zéro discard) /slot() -> FixedSlot<'t>.- Variantes « direct » :
submit_openat2_direct/accept_direct/socket_directFixedSlotTarget::{Index, Alloc}(Alloc= enum typé, jamais~0Uexposé). Découverte kernel :SOCK_CLOEXEC/O_CLOEXECrejeté (EINVAL) sur un descripteur direct ⇒ non posé (leO_CLOEXECest posé àfixed_fd_install). Usage :submit_read_fixed_file/write_fixed_file(viaFixedSlot),submit_fixed_fd_install(FD direct → FD ordinaire).
-
RegisteredBuffers(BUFFERS2/BUFFERS_UPDATE/CLONE_BUFFERS/UNREGISTER_BUFFERS)register(Vec)/register_mmap(MmapRegionpossédées)/update/clone_from/unregister/slice() -> RegisteredBufferSlice<'b>+submit_read_fixed/write_fixed.
- Ring fd enregistré (RING_FDS/UNREGISTER_RING_FDS) : câblé dans le chemin
io_uring_enter(IORING_ENTER_REGISTERED_RING). eventfd (4/5/7), personality (9/10, +IoUring::with_personality), io-wq (IOWQ_AFF 17/18, MAX_WORKERS 19 →WorkQueueWorkerLimits), NAPI (27/28), horloge (29). - Types ajoutés :
FixedFdTable/FixedSlot/FixedSlotTarget/RegisteredBuffers/RegisteredBufferSlice/Personality/WorkQueueWorkerLimits/NapiConfig/ClockSource(sous-moduleregistration) ;CpuSetcréé dansair-sys-types::system(miroircpu_set_t, 1024 bits ; n’existait pas, ADR-029). - Sûreté :
FixedSlot/RegisteredBufferSliceliés par lifetime → ne survivent pas au désenregistrement (prouvé par doctestscompile_fail— pas de dépendancetrybuildajoutée). Téardown S2 inchangé (kernel auto-nettoie au close ; lesOwnedFdpossédés se ferment au drop, aucune fuite). - Écarts kernel signalés (jamais corrigés en silence, ADR-032) : (1) headers
installés bornés à l’opcode 26 (
IORING_REGISTER_LAST == 27) — NAPI/CLOCK/ CLONE_BUFFERS (27-30) repris de l’uapi 6.12 amont, validés à l’exécution ; (2)register_clock(Realtime)rendEINVAL(io_uring n’accepte que MONOTONIC/ BOOTTIME) — type conservé, rejet testé ; (3)register_napirendEINVALsur les exécuteurs (NAPI non exposé) → chemin succès en exception FEATURE-KERNEL. - Couverture (root) :
registration.rsbranches 100 %, lignes 99.2 % (résidu = corps succès NAPI, FEATURE-KERNEL) ;air-sys-types::system(CpuSet) 100 % branches. TOTAL lignes 97.6 % / branches 81.9 % (planchers 96/78). Tests : 23 intégration/simulateur/bornes + 2 doctestscompile_fail. - Différés (signalés) :
submit_files_update(op 20, mise à jour async de la table dans le flux) etmsg_ring_fd(MSG_SEND_FD) — plomberie d’op async tangentielle à la registration, proposés en suite ; la table se met à jour synchroniquement viaset/clear(register opcode 14). Prochain chantier → io_uring Temps 3b (provided, buffers fournis ring-mappedPBUF_RING).
Session 2026-06-12 (suite) — io_uring Temps 2d (URING_CMD, opcode 46) mergé (PR #37)
Le passthrough io_uring (IORING_OP_URING_CMD, 46) est livré : transmettre
une commande opaque au gestionnaire uring_cmd du fichier visé (socket,
NVMe, ublk…). Sous-module air-sys-syscall::io_uring::cmd, surface validée
(io-uring-2d-cmd.md) non modifiée.
- 4 commandes socket typées sûres :
submit_socket_inq/outq(SIOCINQ/SIOCOUTQ→into_result),submit_getsockopt(buffer de sortie ownership S1 →into_buffer_result),submit_setsockopt(valuedéplacée →completed).level: SocketOptionLevel. - Générique :
unsafe trait UringCommand(cmd_op/cmd_len/encode/Output/interpret) +submit_uring_cmd<C>(vérifiecmd_len ≤ 16, ou≤ 80siSQE128,EINVALen amont — pas de troncature) +submit_uring_cmd_raw(unsafe, dernier recours). Structures NVMe/ublk hors couche 0 (mécanisme seul). - Types :
UringCmdFlags(FIXED= bit 0, seul flag 6.12) ;SocketOptionLevel(air-sys-types::net— n’existait pas, créé, ADR-029 ; la spec le disait « partagéfamily-net» maisfamily-netn’utilisait que desi32bruts). - Bug latent corrigé —
SQE128désormais réellement soumissible : la soumission enSETUP_SQE128était buggée depuis le Temps 1 —SubmissionRing::prepareindexait les SQE parsizeof(IoUringSqe)= 64 o, donc les SQE de 128 o étaient mal placés (stride faux) et partiellement non zéro-initialisés ; seul le setup SQE128 marchait, jamais la soumission (aucun test ne soumettait en 128 o). Corrigé :sqe_sizemémorisé, stride en octets, slot entier zéro-init, provenance mmap pour écrire la zonecmd[]étendue ;submit_opfactorisé ensubmit_filled(pointeur brut). Validé sur kernel réel (round-trip 128 o). Dette « SQE128 soumission » RÉSOLUE (PR #37). - Ergonomie signalée :
UringCommand::interpret(&self)impose au consommateur de conserver une instance de la commande (passée par valeur àsubmit) pour interpréter la complétion — fidèle à la surface validée. - PR #37 (merge commit
b7be2ae), commit8b91ae7signé GPG + DCO. Barrière verte 2 arches : fmt (stable et nightly — incident rustfmt résolu), clippy-D, tests (591 lib),llvm-covroot (lignes 97.4 %, branches 81.6 % ;cmd.rs100 % branches, 0 ligne manquante, couvrable vide), Miri (encodeborné), fuzzio_uring_2d_decode(200 k runs), audit/deny/machete. Prochain chantier → io_uring Temps 3a (registration).
Session 2026-06-12 (suite) — extension couche 0 process privsep livrée (PR #34)
Le 4ᵉ manque couche 0 (le plus prioritaire, sécurité) est comblé : la famille
process reçoit les 6 wrappers indispensables à une réduction de privilèges
correcte (drop_privileges d’air-process, Principe 10).
- 6 wrappers :
set_groups(&[Gid])/get_groups(&mut [Gid]) -> &[Gid](setgroups/getgroups,&[]= largage total, zéro alloc),set_resgid/set_resuid(Option<Gid/Uid>×3 ;None= composante inchangée =(id_t)-1typé, ADR-021 conv. 1) qui fixent le saved-set ⇒ réduction irréversible, etget_resgid -> ResGid/get_resuid -> ResUid(vérif défensive).// SAFETY:sur les 6 wrappers + 12 helpers asm. - Types ajoutés (
air-sys-types::process) :Uid/Gid(#[repr(transparent)]suru32; contrairement àPid/Tid,0= root est valide ⇒ pas deNonZeroI32;ROOT/from_raw/as_raw, types distincts) +ResUid/ResGid. Constat : ils n’existaient pas (nisetuid/setgid). - Numéros de syscalls vérifiés sur l’uapi 6.12 par arche — la spec donnait
setresgidARM64 = 143 (FAUX), corrigé en 149 (headerasm-generic). Retenus : setgroups 116/159, getgroups 115/158, setresgid 119/149, setresuid 117/147, getresgid 120/150, getresuid 118/148. Spec corrigée embarquée dans #34. - Test de sécurité (le cœur), vert en root :
privsep_drop_is_irreversible(enfant forké, observé parwaitid) — aprèsset_resgid(nobody³)+set_resuid(nobody³), le regain root →EPERM, regain gid →EPERM, réajout de groupe →EPERM;getres*confirment le saved-set figé. Corps d’enfants sans branche locale (combinés par&) → le flush LLVM impossible après largage n’ajoute aucune branche non couverte. - PR #34 (merge commit
7e07525), 3 commits signés GPG + DCO (9bd2335code,b3b8cbfspec corrigée). Barrière verte 2 arches : fmt, clippy-D, tests (791),llvm-covroot (lignes 97.3 %, branches 81.7 % ; production rouge = 5 bras erreur EPERM/EFAULT ⊆ exceptions PRIVILEGE/STRUCTURAL, couvrable vide), audit/deny/machete. Prochain chantier → io_uring Temps 2d (URING_CMD).
Session 2026-06-12 — MmapRegion livré (PR #31) : madvise (2a) + futex (2c) débloqués/branchés
Le dernier prérequis transverse de la couche 0 io_uring est levé. MmapRegion
(mapping partageable refcounté + garde de vivacité) est implémenté dans
air-sys-syscall::mem, et submit_madvise (Temps 2a) + submit_futex_*
(Temps 2c) sont branchés — les deux reliquats différés sont fermés d’un coup.
MmapRegion=Arc<MmapRegionInner>(ptr/len/prot),munmapau dernier drop (erreur ignorée commeMapping).MmapRegionLiveness(clone de l’Arc) garée dans le slot S1 →munmapimpossible tant qu’une op est en vol : ni use-after-unmap, ni fuite.Mappingreste strictement inchangé (RAII unique, zéro-coût, couverture 100 % intacte). Une allocArcassumée (exception ADR-021 c.4, opt-in).- Soundness prouvée : Miri (ownership : no UAF / no leak / drop-once) +
loom (drops concurrents des gardes →
munmapexactement une fois, jamais en avance). Intégration kernel réel : madvise WILLNEED/DONTNEED, futex wait réveillé par wake,futex_waitvmulti-attente, drop de la région en vol. - Ops branchées :
submit_madvise(&MmapRegion, range, advice)(OP 25) ;submit_futex_wait/wake(&MmapRegion, offset, …)(OP 51/52),submit_futex_waitv(OP 53). Signature futex corrigée (&AtomicU32insoundable →&MmapRegion+offset). Types ajoutés :FutexFlags,FutexWaiter; opcodes +futex_waitvABI. - Trois durcissements d’API appliqués avant merge (PR #31, second commit) :
from_mapping(mapping, prot)(prot explicite, plus de défautREAD|WRITEfaux → fin d’un SIGSEGV latent sur mapping read-only) ;futex_wordexigeWRITE(la réf rendue est mutable) ;FutexWaitersansmask(lestruct futex_waitvkernel n’en porte pas — champ jamais transmis, footgun ADR-032). Test ajouté :from_mappingread-only →bytes()OK,futex_word()EINVALavant accès. - Specs réconciliées (FR + EN) :
family-mem-mmap-region.md(§1from_mapping(prot),futex_word/WRITE ; §3FutexWaitersans mask),io-uring-2a §6.2(madvise) etio-uring-2c §6.1(futex) passent de « différé » à « implémenté ». - PR #31 (merge commit
b61250e), 2 commits signés GPG + DCO (c9e3fbcimpl,b12be19durcissements). Barrière verte 2 arches : fmt, clippy-D, tests (780), Miri, loom,llvm-covroot (TOTAL lignes 97.7 % / branches 81.9 % ; couvrable du nouveau code = vide), audit/deny/machete, mdbook. Prochain chantier → io_uring Temps 2d (URING_CMD).
Session 2026-06-11 (suite) — io_uring Temps 2c (async) mergé (PR #28)
Implémentation, durcissement et merge du cœur async-spécifique du Temps 2c d’io_uring.
- 9 façades + 2 accesseurs typés (PR #28, merge commit
1f2e5cb) :submit_nop_with_result,submit_timeout(+_remove/_update),submit_cancel(async,CancelTargetToken/Fd/Op/Any +CancelFlags::ALL),submit_poll_add(+_remove/_update),submit_epoll_ctl,submit_waitid,submit_message_ring_data(MSG_DATA) ; accesseursinto_poll_result,into_waitid_result.SignalInfo/WaitTarget/WaitOptionsréutilisés defamily-process/signal;KernelTimespec/CancelTargetdu Temps 1. - Couverture 100 % hors exceptions (root) : lignes 97.4 % / branches 81.7 %.
Ensemble « couvrable » VIDE — zéro région ni branche de prod 2c rouge, et
AUCUNE exception 2c (pas de garde
u32ici). Miri (ownership des payloads Timeout/Waitid/Epoll restitués sans UAF), proptest (cancel idempotent), fuzz (io_uring_2c_decode). - Types ajoutés à
air-sys-types:io_uring::{PollEvents, TimeoutFlags, TimeoutSpec, CancelFlags, MessageRingFlags, EpollOp, EpollEvent};signal::SignalInfo::zeroed(). L’epfd des tests vient d’unepoll_create1brut test-only (pas de fausse famille epoll publique). - Spec 2c amendée embarquée dans la PR (réconcilie
ETIME_SUCCESSn’altère pas leres,cancelsansALLrend 0, types epoll, futex→MmapRegion). - Différés (signalés) : futex (51/52/53) → PR coordonnée
family-mem(handle de vivacitéMmapRegion, le même quemadvise— un seul handle débloque les deux) ;link_timeout(15) → Temps 3c ;files_update(20),fixed_fd_install(54),msg_ring_fd(40 MSG_SEND_FD) → Temps 3a. - 2 arches : speedy x86_64 + raspi-srv-2 aarch64 (incl. l’asm
epoll_create1aarch64). Reste couche 0 : io_uring Temps 2d→4, 3a–3f. Prochain : Temps 2d (URING_CMD).
Session 2026-06-11 (suite) — io_uring Temps 2b (réseau) mergé (PR #26) + ADR-032
Implémentation, durcissement et merge du Temps 2b d’io_uring (12 opérations réseau) — stratégique AirCom (ADR-001 : zero-copy + passage de FD).
- 12 façades réseau + accesseurs typés (PR #26, merge commit
6055b11) : accept/_with_peer, connect, socket, bind, listen, send, receive, send_message, receive_message, send_zero_copy, send_message_zero_copy, shutdown. -
- Cycle zero-copy à DEUX complétions (CQE résultat
F_MORE→ CQE NOTIF qui restitue) porté par lehas_moredu slab (Temps 1, sans extension) ; prouvé sûr Miri (le buffer survit à la 1ʳᵉ complétion). Passage de FDSCM_RIGHTS - FD reçus matérialisés en
OwnedFdCLOEXEC,MSG_CTRUNCsignalé, aucune fuite. Invariants Air :MSG_NOSIGNAL/SOCK_CLOEXECpar défaut.
- Cycle zero-copy à DEUX complétions (CQE résultat
- Types possédés
OwnedSendMessage/OwnedReceiveMessageajoutés àair-sys-types::net: lesSendMessageRequest<'a>/ReceiveMessageRequest<'a>defamily-netsont basés sur des emprunts (sync), inadaptés à l’ownership asynchrone S1. Sérialisation sockaddr + cmsg réutilisées denet.rs(pub(crate), DRY).ReceiveMessageMeta,ZeroCopyFlagsajoutés. into_zero_copy_buffers(restitution intégrale des buffers d’unsendmsg_zc, jamais le premier seul) — corrige le point relevé au rapport 019. Spec 2b corrigée en conséquence (types possédés + restitution intégrale).- ADR-032 (« Préservation des données confiées : zéro discard silencieux ») gravé : règle transverse — une API Air ne discarde jamais une donnée confiée (restitution intégrale, ressources matérialisées, troncatures signalées, chemins d’erreur compris). Registre v3.0.
- Couverture 100 % hors exceptions (root) : lignes 97.4 % / branches 81.6 %.
Ensemble « couvrable » VIDE (résidu = 3 gardes
u32STRUCTURAL, §2b). proptest + Miri + cargo-fuzz (io_uring_2b_recvmsg). 2 arches (speedy + raspi-srv-2). Reste couche 0 : io_uring Temps 2c→4, 3a–3f. Prochain : Temps 2c (async).
Session 2026-06-11 (suite) — io_uring Temps 2a (filesystem) implémenté et mergé (PR #24)
Implémentation, durcissement et merge du Temps 2a d’io_uring (opérations filesystem), par-dessus le cœur du Temps 1.
- 23 façades FS + 4 accesseurs typés (PR #24, base
main@26a8bcd, merge commita2eea18).read/write/readv/writev,fsync/sync_file_range/fallocate/ftruncate,openat2/close,statx,fadvise,splice/tee,mkdirat/unlinkat/renameat/linkat/symlinkat, 4× xattr ; accesseursinto_vectored_result/into_statx/into_xattr_result/opened_fd. Sans nouveau type de ring : opcodesIORING_OP_*(uapi 6.12) encodés en SQE, état possédé garé dans le slot S1 (slab généralisé à un payload hétérogèneOwnedOp), restitution typée à la complétion. - Ownership prouvé sound (Miri) :
Vec<u8>/Vec<Vec<u8>>+iovec/Box<Statx>/CStringdéplacés dans le slot ; un buffer en vol ne peut être ni libéré ni réutilisé. Bug corrigé :submit_closeparquait l’OwnedFd→ double close (attrapé par le contrôle IO-safety) ; correctifinto_raw_fd(cession au kernel, aucun payload garé). - Types ajoutés à
air-sys-types::fs(conformesfamily-fs) :FsyncFlags,SyncFileRangeFlags,UnlinkFlags,LinkFlags,XattrFlags,FadviseAdvice(distinct demem::MadviseAdvice— espaces de valeursPOSIX_FADV_*≠MADV_*), et un miroir brutStatx#[repr(C)](256 o, assert de layout). - Couverture 100 % hors exceptions (mesurée en root, ADR-031) : lignes
97.1 % / branches 82.1 % au TOTAL (planchers 96/78). Diagnostic 017 :
0 branche de prod 2a couvrable, seules rouges = 6 gardes
u32(buffers > 4 GiB) documentées STRUCTURAL. Tests : intégration kernel 6.12 (round-trips) + proptest (transfert borné + restitution fidèle, chemins non-UTF-8) + simulateur (EBUSY) + Miri + cargo-fuzz (io_uring_2a_decode). - Barrière verte 2 arches : x86_64 (speedy natif) + aarch64 (CI self-hosted
raspi + run natif
fs_opsau SHA bâti).gh pr checks 24vert sur les 2. - Différé (signalé) :
madvise(PR coordonnéefamily-mem, cf. Dettes),read_fixed/write_fixed+ variantes direct/fixed (Temps 3a). Reste couche 0 : io_uring Temps 2b→4, 3a–3f. Prochain : Temps 2b (réseau).
Session 2026-06-11 (suite) — device + ebpf implémentées et mergées : 11 familles couche 0 livrées
Implémentation, durcissement (proptest + fuzz) et merge des deux dernières familles de la couche 0, puis bascule ✅.
device(PR #20, basemain) : uevent netlink (authenticiténl_pid==0/SCM_CREDENTIALS) + evdev (18 ioctlsEVIOC*, lecture typéeinput_event), sysfs renvoyé àfs.InputEvent.sec/useceni64(fidélitéstruct timevalLP64). 100 % hors exceptions + test d’intégration uinput réel + proptest + fuzzfuzz_uevent_parse.ebpf(PR #21, empilée puis rebasée surmain) :bpf()37 sous-commandes +perf_event_open+ 12 ioctls (dont le pontperf_event_set_bpf_program), RAII. Vrai 100 % hors exceptions STRUCTURAL via un harnais d’intégration privilégié réel (programme compteur assemblé main avecBPF_PSEUDO_MAP_FD+ raw_tracepoint, cgroup v2 attach/link, bpffs pin/get, btf_load + carte BTF-typée, introspection, perf complet). proptest + fuzzfuzz_bpf_obj_info. Bugs trouvés/corrigés par le harnais (bpf_program_queryoffsets,bpf_program_test_runpointeurs NULL).- CI — mesure de couverture en root (PR #22, ADR-031).
ebpfétant quasi-tout-privilégiée, sa couverture plafonnait ~74 % en non-root et faisait tomber le total sous le plancher 96 %. Le jobtest-coverageexécute désormais les étapesllvm-covviasudo -nsur les runners self-hosted (NOPASSWD sur les deux) pour que les tests d’intégration privilégiés tournent sous instrumentation. Modèle de confiance dépôt privé/mono-mainteneur, à revisiter avant l’ouverture publique. - 11 familles couche 0 livrées (100 % hors exceptions, 2 arches : speedy
x86_64 + raspi-srv-2 aarch64) :
process,signal,time,ipc,mem,fs,net,security,system,device,ebpf. #19 (brouillon) fermée, branches mergées supprimées. Reste couche 0 : io_uring Temps 2a→4, 3a–3f. Traductions EN des specs reportées (fin de couche 1).
Session 2026-06-11 — Specs device + ebpf : la couche 0 est entièrement spécifiée
Production des deux dernières specs de famille manquantes, au niveau de détail des autres (docs/specs/layer-0/family-device.md, family-ebpf.md), pour permettre une délégation d’implémentation en une passe.
device— trois sous-systèmes : socket netlinkNETLINK_KOBJECT_UEVENT(UEventSocketRAII +readqui vérifie l’authenticiténl_pid==0/uid==0) avec décodeurUEventMessageemprunté, zéro-alloc (itérateurclé=valeur) ;evdev(lecture typéeInputEvent/slice_from_bytes, ioctls dédiésEVIOC*—evdev_device_id,evdev_name,evdev_supported_codes,evdev_abs_info,evdev_grab/release,evdev_set_clock…, jamais d’ioctl générique, ADR-021 c.3) ;sysfsexplicitement renvoyé àfs(pas de wrapper, anti-duplication). ~22 fonctions. Décision : décoder un format de fil kernel fixe (uevent, input_event) est du miroir légitime en couche 0 (précédentSignalFdInfo) ; le modèle riche de device → couche 1.ebpf— couverture exhaustive debpf(): les 37 sous-commandes deenum bpf_cmd(cible 6.12) chacune en fonction dédiée typée (cartes + éléments + batch, programmes, attache/liens, épinglage, introspection par id, BTF, stats/jetons), RAIIBpfMap/BpfProgram/BpfLink/Btf. Plusperf_event_open(+PerfEventScopesans sentinelle-1) et ses ~12 ioctls dédiés, dontperf_event_set_bpf_program(le pont eBPF↔perf). On charge des programmes déjà assemblés ; assembleur, chargeur libbpf, BTF/CO-RE, décodage du ring-buffer → couche 1 (aligné sur le précédent seccomp Q4). ~51 fonctions.- ABI vérifiée sur en-têtes kernel :
bpf(x86_64 321 / aarch64 280),perf_event_open(298 / 241),NETLINK_KOBJECT_UEVENT=15, ioctlsEVIOC*, layoutsinput_event(24 o LP64),input_id,input_absinfo, énumérationbpf_cmd. - Docs de suivi réconciliées :
INDEX.md(tableau + arbre + stats),etat-avancement.md(lignes device/ebpf 📝→🔜, section 3), JOURNAL (cet historique + État courant + « Prochain chantier » réaligné, qui pointait encore par erreur suripc). - Suite : déléguer l’implémentation de
deviceetebpf(squelette-doc-d’abord), puis les Temps io_uring restants. Traductions EN des deux specs à produire ultérieurement (comme io_uring).
Session 2026-06-01 (suite, 3g) — mem + net finis : 9 familles couche 0 à 100 % hors exceptions, 2 arches
Dernière passe de couverture. mem et net portées à 100 % hors exceptions
(lignes + branches, mesuré artefact-free --workspace --lib --branch), validées
sur speedy (x86_64) et raspi-srv-2 (aarch64). Branche
feat/layer0-familles-restantes, 4d9dbd8 → e46e90d.
mem(3d802dd) : branches 35,7 → 58,9 % (reste = exceptions). Erreurs réellement atteignables testées (mprotect/madvise/msync EINVAL sur adresse non alignée ; mlock/mlock2/munlock ENOMEM sur intervalle non mappé ; mlockall EINVAL ; memfd_create nom trop long ; process_vm_* ESRCH PID inexistant ; mmap_fixed succès + EEXIST).debug_assert→ 2 should_panic. Test flakymmap_fixed_noreplace_anonymousretiré. Divergence d’environnement mlockall (réussit sur speedy, EPERM sur raspi) documentée en PRIVILEGE.net(e46e90d) : ~30 tests. Erreurs sockets via fd invalide → EBADF (listen/getsockname/getpeername/sendmsg/recvmsg/sockopts) ; socket EPROTONOSUPPORT ; socketpair EOPNOTSUPP ; clamps val > i32::MAX (rcvbuf/sndbuf/linger/keepalive/backlog) ; parseursraw_to_socket_addretparse_scm_rights(buffers tronqués/dégénérés) ; passage de FD SCM_RIGHTS bout-en-bout sur socketpair. Reste = exceptions STRUCTURAL (gardes de parseur inatteignables avec données kernel bien formées ;get_so_errorval≠0 = erreur socket asynchrone exigeant poll/epoll non wrappés ;if letsur variante déterministe) + FEATURE-KERNEL (IPv6 absent, skip fcntl).
Couche 0 — 9 familles de syscalls à 100 % couverture (lignes + branches) hors
exceptions, cross-arch. COVERAGE-EXCEPTIONS.md : 52 entrées (PRIVILEGE 9,
FEATURE-KERNEL 12, CHILD-EXIT 8, EFAULT-SAFE 8, STRUCTURAL 11, VALUE-UNREACHABLE 4).
Barrière au HEAD verte sur les 2 arches : fmt / clippy -D warnings /
cargo test --workspace (371 + 80 + 74 + 2 + intégration). Chiffres bruts llvm-cov
(exceptions restant rouges par construction) : lignes ~96,6 %, branches ~78,8 %.
io_uring reste le prochain gros chantier couche 0. PR ouverte (non mergée).
Session 2026-06-01 (suite, 3f) — Couverture 100 % : signal/process/ipc/fs/types + artefact llvm-cov élucidé
Reprise du chantier couverture couche 0 (branche feat/layer0-familles-restantes,
77f4e86 → b6396ce). Objectif : 100 % lignes+branches hors exceptions, mesuré
sur les 2 exécuteurs Linux. API gelée (tests seulement).
Découverte méthodologique majeure — artefact [True:0, False:0] de
llvm-cov --branch. Les régions de branche fantômes collées sur des lignes
pourtant couvertes viennent exclusivement du binaire de test d’intégration
(tests/main_thread_invariant.rs), qui lie la lib sans #[cfg(test)] et n’exécute
que getpid/gettid : son instanciation [0,0] ne fusionne pas avec les compteurs
cfg(test). Preuve : --workspace = 36 shadows sur process ; --workspace --lib =
0 shadow. Décision actée : la barrière branches se mesure
cargo +nightly llvm-cov --workspace --lib --branch (artefact-free) ; les lignes
restent en --workspace. Documenté en tête de COVERAGE-EXCEPTIONS.md.
4 familles syscall + air-sys-types portées à 100 % hors exceptions (2 arches) :
signal(9806f99) : recette flush enfant forké réutilisée (child_exit), EAGAIN/ESRCH ciblés, 2 should_panic, noop_handler, retrait deis_rootmort.process(08103a1) : 2 should_panic ; artefacts [0,0] élucidés ; 40 branches restantes ⊆ exceptions.ipc(a7dfe45) : EventFd::write EINVAL, vmsplice/splice-Some EBADF ; retrait d’un test splice buggé + helper mort.fs(c6945ff) : EBADF sur preadv/pwritev/fallocate/copy_file_range/ sync_file_range, symlinkat EEXIST ; retrait du helper mortsyscall0.air-sys-types(b6396ce) : réconcilié (résidu = défense-en-profondeur).
Plus : correction de 2 violations fmt --check préexistantes (lot à l’aveugle,
d9b126f). COVERAGE-EXCEPTIONS.md : nouvelle catégorie STRUCTURAL, 42 entrées
(PRIVILEGE 8, FEATURE-KERNEL 11, CHILD-EXIT 8, EFAULT-SAFE 7, VALUE-UNREACHABLE 4,
STRUCTURAL 4).
État au HEAD b6396ce, identique speedy/raspi : fmt/clippy -D warnings/
cargo test --workspace (328+80+74+2) verts. Familles 100 % hors exceptions :
time, system, security (préc.) + signal, process, ipc, fs + types complet.
RESTE (2 familles, gaps branches réels mesurés) : mem (branches 35.71 %,
36 rouges) et net (branches 52.59 %, 55 rouges). PR non ouverte (on n’ouvre
qu’au 100 % global). Détail : prompt/couche0-familles-restantes.3f.output.md.
Session 2026-06-01 — Passe 2 des 6 familles + validation cross-arch (x86_64 + aarch64)
Implémentation (passe 2) des 6 familles restantes (ipc, mem, fs, net,
security, system) et première validation réelle sur deux exécuteurs
Linux : speedy (Mac mini Intel, x86_64, Ubuntu 24.04) et raspi-srv-2
(Raspberry Pi 4, aarch64, Ubuntu 26.04). macOS n’est plus un juge (compile
seulement ; rate les tests Linux-gated, les doctests exécutés, et les bugs de
signedness/arch). Toolchain montée à stable 1.96.0 (MSRV = dernière stable).
Bugs aarch64-spécifiques trouvés au premier passage réel sur aarch64 et
corrigés (internes asm!/valeurs, API publique inchangée) :
- clone3 / hang des tests fork : en exécution parallèle, l’enfant d’un
clone3fork héritait des mutex du harnais multi-thread verrouillés par des threads absents ;std::process::exit(atexit/flush) deadlockait (futex_do_wait). Le wrapperclone3lui-même est correct (run sériel OK). Fix : sortie enfant viaexit_groupdirect (helper interne#[cfg(test)]). - openat / EINVAL sur aarch64 :
O_DIRECTORY/O_NOFOLLOW/O_DIRECT/O_LARGEFILE/O_TMPFILEont des valeurs différentes entre x86_64 et aarch64 (ABI asm-generic).OpenFlagsfigeait les valeurs x86_64 →OpenFlags::DIRECTORYdésignaitO_DIRECTsur aarch64. Fix : valeurs conditionnelles àtarget_arch(idemPipeFlags/StatusFlags::DIRECT). - 7 doctests (jamais exécutés car le Mac faisait
cargo doc, pascargo test --doc) : importsAsFdmanquants,GetrandomFlagsnon re-exporté au niveau crate, exempleSockFprog(E0716). Corrigés.
État vérifié sur les deux arches (HEAD 61868ca) : fmt/clippy -D warnings/build verts ; cargo test --workspace = **196 + 78 + 1 (intégration)
- 74 + 2 doctests, 0 échec** ; plus aucun hang
process::sur aarch64.
Outillage couverture : nightly dédié installé sur les deux exécuteurs pour la
mesure de branches (cargo +nightly llvm-cov --branch) ; le build/CI reste sur
stable.
Audit systématique des constantes cross-arch (fait, résultat négatif). Un
programme C de référence compilé sur les deux arches a comparé ~270
constantes kernel (O_, MAP_, MADV_, MFD_, SIG*, SA_, SO_/SOCK_/MSG_,
SECCOMP_, LANDLOCK_, CLONE_, RLIMIT_, CAP_, PR_, F_SEAL_*, errno…) aux
valeurs hardcodées d’air-sys-types. Seule divergence : la famille O_*
(O_DIRECTORY/O_NOFOLLOW/O_DIRECT/O_LARGEFILE/O_TMPFILE), déjà
corrigée. Tout le reste est identique entre x86_64 et aarch64 — aucune autre
correction nécessaire.
Harnais sous-processus seccomp/Landlock (fait, 2/3).
seccomp_set_mode_filter (filtre allow-all) et LandlockRuleset::restrict_self
sont désormais fonctionnellement testés dans un enfant forké (clone3 +
waitid, sortie exit_group), validés sur les deux arches. La mesure de
couverture de leur branche succès reste une exception légitime (l’enfant ne
réécrit pas son .profraw ; cf. COVERAGE-EXCEPTIONS.md). seccomp_set_mode_strict
reste une exception (bloque exit_group + l’I/O du profil).
Reste à faire (chantier couverture, non terminé) : tests fs/mem manquants
(fcntl_*, readlinkat, chemins d’erreur), passage à 100 % lignes + branches
sur les deux arches, promotion éventuelle d’exit_group en API publique
(ADR-021 conv. 5).
Session 2026-05-27 — Famille time complète
Implémentation de la famille time couche 0 dans les deux crates fondatrices :
air-sys-types::time:Clock,Instant,SleepDeadline,SleepError,TimerFdFlags,TimerSetFlags,TimerFdSpec.air-sys-syscall::time:clock_gettime,clock_settime,clock_getres,clock_nanosleep,timerfd_create, wrapper RAIITimerFd::{as_fd, into_fd, arm, disarm, current, read}.Errno::EAGAINajouté pour couvrir les lectures non bloquantes sans événement disponible.- Tests ajoutés : chemins monotonic/resolution/sleep relatifs, validation cross-clock pour
clock_nanosleep, lecture non bloquantetimerfd, armement one-shot, round-tripcurrent/disarm.
Résultat : cargo test -p air-sys-types -p air-sys-syscall vert le 2026-05-27, soit 165 tests passants au total.
Session 2026-05-24 (suite) — Famille signal complète
Branche feat/layer-0-family-signal. Implémente la famille signal couche 0 conformément à docs/specs/layer-0/family-signal.md et ADR-020 (signalfd par défaut + sigaction restreint aux 4 signaux synchrones fatals) :
- Sous-section 1 (signalfd + masques) :
signalfd_create,signalfd_create_blocking,SignalFd::{read, update_mask, as_fd, into_fd, from_owned_fd},block_signals,unblock_signals,set_signal_mask,current_signal_mask,wait_for_signal. - Sous-section 2 (envoi) :
kill,tgkill,rt_sigqueueinfo. - Sous-section 3 (sigaction restreint) : sous-module
synchronous_handleravecinstall_fatal_handleretrestore_handler(unsafe), restreints àFatalSignal::{Segv, Bus, Fpe, Ill}.
Dette d’ambiguïté SignalInfo vs SignalQueueInfo — fermée. La PR pidfd_* avait consigné l’ambiguïté entre les deux types (family-process.md nommait SignalInfo, family-signal.md nommait SignalQueueInfo pour ce qui était fondamentalement le même siginfo_t côté kernel). Résolution dans cette PR : un seul type SignalInfo porte le concept ; SignalQueueInfo disparaît.
SignalInforeste opaque côté représentation (#[repr(C)] struct { raw: [u8; 128] }, layoutsiginfo_tLinux) — sa représentation est inchangée pour ne pas casserpidfd_send_signalqui passe son pointeur au kernel as-is.SignalInfogagne un constructeur publicnew_queue(value: SignalValue)qui fixesi_code = SI_QUEUE(-1), seule valeur que le kernel accepte sans drapeau privilégié pour un envoi cross-process userspace (cf.do_rt_sigqueueinfodanskernel/signal.c: « Not even root can pretend to send signals from the kernel »). Le champsi_signoest laissé à zéro — le kernel l’écrase avecsigà la délivrance.SignalValue { Integer(i32), Pointer(u64) }est promu type public, conforme spec.rt_sigqueueinfo(pid, signal, info: &SignalInfo)consomme la même API.
Déviation mineure de spec assumée. family-signal.md documentait rt_sigqueueinfo(..., info: SignalQueueInfo) par valeur avec champs publics { code: i32, value: SignalValue }. La présente implémentation passe &SignalInfo (référence) construit via new_queue (constructeur discipliné). Raison de sécurité : exposer code brut serait un footgun — le kernel rejette les si_code arbitraires en envoi cross-process avec EPERM. Exposer un constructeur discipliné évite que l’utilisateur ne fabrique un siginfo_t qui se ferait rejeter, ou pire qui « falsifierait » l’origine du signal. La spec sera mise à jour à froid (hors périmètre PR).
pidfd_send_signal non modifié. Périmètre strict respecté : pidfd_send_signal (PR process déjà mergée) n’est PAS touché. Son trou de couverture Some(_) (3 lignes) reste acté. Si un test légitime de cette PR l’avait appelé en Some(...), le trou se serait fermé naturellement — ce n’était pas un objectif et ce n’est pas arrivé (aucun test de cette PR ne fait appel à pidfd_send_signal). Diff vérifié : crates/air-sys-syscall/src/process.rs n’est pas dans la liste des fichiers modifiés.
Constantes Signal ajoutées — 4, vérifiées vs asm-generic/signal.h. SIGILL=4, SIGBUS=7, SIGFPE=8, SIGUSR1=10. Identiques x86_64/aarch64 (ces archs incluent asm-generic/signal.h ; les archs comme MIPS/SPARC/Alpha qui surchargent sont hors ADR-014). Signal total après cette PR : 10/~31 standards POSIX, toujours stub partiel documenté en tête de module.
Borne structurelle Signal::try_from_raw ∈ [1, 64] — correction Principe 4. Pendant la phase de contrôle, défaut identifié : Signal::try_from_raw validait seulement > 0, autorisait donc Signal(99999) qui se serait fait rejeter par EINVAL au runtime côté kernel. Violation du Principe 4 (validation amont) : Signal doit porter sa garantie de validité dans le type, pas attendre le kernel.
Corrections appliquées dans cette PR (périmètre légitime — Signal EST le type central de la famille signal que cette PR complète déjà) :
Signal::try_from_raw(raw)rejette désormais aussiraw > 64(=_NSIGLinux). RenvoieNonepour toutraw ∉ [1, 64].Signal::from_nonzero(qui ne validait pas la borne) retiré : aucune utilisation hors du test roundtrip lui-même, simplification de l’API publique. Les constantes internes (SIGUSR1, etc.) passent parSelf(unwrap_nz(N))qui ne touche pas àfrom_nonzero.unwrap_nzinterne renforcé : panique au compile-time si une constante future est définie avec une valeur hors[1, 64]. Défense en profondeur ; toute régression d’invariant détectée à la compilation.- Constante
NSIG: i32 = 64exposée au module (privée, mais documentée). - 4 nouveaux tests :
try_from_raw_accepts_lower_bound_1,try_from_raw_accepts_upper_bound_nsig,try_from_raw_rejects_above_nsig(65, 99999,i32::MAX),unwrap_nz_runtime_call_panics_above_nsig(65, 100).
Conséquence couverture — 4 lignes acquittées « défense en profondeur ». Avant la borne : « no-op silencieux pour signal > 64 » — comportement faible (silently ignore une demande explicite). Après la borne : défense en profondeur derrière une validation amont — un Signal valide ne peut structurellement pas avoir une valeur > 64 ; les branches None restent comme garde par Principe 5 (sur-sécuriser, ne jamais corrompre) au cas où un futur constructeur oublierait la borne. unreachable!() refusé : transformerait une garde silencieuse en panic potentiel, ce qui est précisément ce que Principe 5 nous demande d’éviter.
Les 4 lignes non couvertes en line-coverage dans air-sys-types/src/signal.rs :
- 3 lignes : branches
None => false/None => self/None => selfdeSignalMask::{contains, with, without}— défensives, inatteignables tant queSignal::try_from_rawborne à[1, 64]. - 1 ligne : bras
None => panic!("unreachable: n > 0 guaranteed by the check above")dansunwrap_nz— inatteignable parce que la garden <= 0 || n > NSIGau-dessus filtre déjà tout argument zéro avant queNonZeroI32::newsoit appelé. Conservé pour satisfaire l’exhaustivité dumatchconst ; même statut « défense en profondeur ».
Toutes catégorie « défense en profondeur, structurellement inatteignable derrière borne validée » — justification cette fois solide (la borne EST garantie par le type).
Vérification non-régression famille process. La borne ≤ 64 ne casse aucun usage existant : tous les signaux utilisés dans family-process sont dans [4, 19] (SIGCHLD=17, SIGKILL=9, SIGSEGV=11, SIGSTOP=19, SIGTRAP=5, SIGABRT=6, SIGBUS=7, SIGFPE=8, SIGILL=4). Les 92 tests air-sys-syscall passent inchangés sur 3 runs stress successifs après l’introduction de la borne.
Stratégie de test pour l’état global signal — mise en œuvre.
MaskGuardRAII (test-only,mod tests) : capture le mask courant en toute première ligne de chaque test, restaure intégralement viaSIG_SETMASK(jamaisSIG_BLOCK/UNBLOCKrelatif) à la destruction. Couvre la contamination per-thread (cargo test recycle les workers).tgkill(self_pid, self_tid, sig)pour les tests d’envoi/réception intra-process : cible exactement le thread courant, évite tout routage à un worker collatéral cargo. Combiné avecsignalfd_create_blocking, donne une livraison déterministe sans flakiness.- Fork-enfant mono-thread pour les opérations process-wide ou unsafe :
kill(cible PID, pas TID — risque de router vers un worker tiers en multi-thread).rt_sigqueueinfo(idem, cible PID).install_fatal_handler/restore_handler(handlers PROCESS-WIDE, contamineraient les autres tests).
- Aucun test ne déclenche réellement un signal fatal (SIGSEGV via
ud2, etc.) — l’install/restore est validé par round-trip, le déclenchement effectif reste hors périmètre (crash reporter end-to-end est un cas family-security / future PR).
Aucune nouvelle dépendance externe. Tous les types kernel (signalfd_siginfo 128 octets, sigset_t u64, struct sigaction 24/32 octets selon arch) sont décrits en #[repr(C)] directement. core::ffi::{c_int, c_void} couvre l’ABI des handlers.
Tests bout-en-bout. 37 tests dans air-sys-syscall::signal (mask round-trip, signalfd round-trip, wait_for_signal bloquant, tgkill+signalfd, kill en fork-enfant, rt_sigqueueinfo en fork-enfant pour les deux variantes SignalValue, ESRCH, install/restore en fork-enfant, parsing signalfd_siginfo unit, errno helper local, wait_for_signal timeout-non-supporté). 10 tests dans air-sys-types::signal (4 nouvelles consts, SignalInfo new_queue avec Integer et Pointer + offsets vérifiés sur as_bytes, SignalMask helpers, SignalFdFlags, FatalSignal mapping).
Stress test : 5 runs successifs de cargo test --workspace, 0 échec. La stratégie tgkill+fork pour les opérations process-level neutralise complètement les races multi-thread observées en début d’itération (signal: 10 sur un worker collatéral).
Métriques finales. 92 tests dans air-sys-syscall (22 nouveaux pour signal) + 58 dans air-sys-types (13 nouveaux pour signal, dont 4 de bornes ajoutés en phase de contrôle finale) + 1 intégration. Total 150 tests passants. Couverture production : air-sys-types::errno/signal/process 100 % fonctions ; air-sys-types::signal 98.3 % lignes (4 lignes catégorie « défense en profondeur » détaillées ci-dessus) ; air-sys-syscall 94-95 % lignes avec gaps catégorisés (mêmes catégories que famille process : Err paths via helper commun déjà testé, branches Ok inatteignables sans privilèges, exits enfants fork-pattern, skips AppArmor/Yama).
Session 2026-05-24 — Fin de la famille process : groupes/sessions, prctl, rlimits, capabilities
Branche feat/layer-0-process-complete. Implémentation des sous-sections 4 à 7 de docs/specs/layer-0/family-process.md, complétant la famille process en couche 0 :
- Sous-section 4 (groupes/sessions) :
setpgid,getpgid,setsid,getsid. Convention 1 ADR-021 appliquée —Option<Pid>partout, jamais la sentinelle kernel0. - Sous-section 5 (prctl par opération) : 12 fonctions individuelles typées (
set/get_parent_death_signal,set/get_no_new_privs,set/get_thread_name,set/get_dumpable,set/get_keep_caps,set_timer_slack,cap_ambient_raise/lower/clear_all/is_set). Convention 3 ADR-021 strictement appliquée : helper privéraw_syscall_prctlnon exposé, chaque opération est sa propre fonction publique typée. - Sous-section 6 (rlimits) :
getrlimit,setrlimit,prlimit— implémentation pivot surprlimit64(syscall moderne, atomique, ciblable).getrlimit/setrlimitdeviennent des alias de compatibilité qui délèguent àprlimit(None, ...). - Sous-section 7 (capabilities) :
capget,capsetvia Linux v3 capabilities (header + 2×__user_cap_data_struct). Conversionu64 ↔ (lo, hi)confinée àair-sys-syscall;CapabilityMaskcôtéair-sys-typesreste un type pur.
Capability — stub partiel à 20 variantes. Création de l’enum Capability avec les 20 variantes listées par la spec (sous-section 7), discriminants explicites tirés de include/uapi/linux/capability.h et vérifiés un-à-un dans un test (capability_discriminants_match_uapi_linux_capability_h). CAP_SYS_PACCT = 20 est délibérément absent (pas listé par la spec) — aucune confusion possible avec CAP_SYS_ADMIN = 21. Les ~21 capabilities restantes (CAP_SETPCAP, CAP_LINUX_IMMUTABLE, CAP_NET_BROADCAST, CAP_IPC_LOCK/OWNER, CAP_SYS_PACCT, CAP_SYS_TTY_CONFIG, CAP_MKNOD, CAP_LEASE, CAP_AUDIT_, CAP_MAC_, CAP_SYSLOG, CAP_WAKE_ALARM, CAP_BLOCK_SUSPEND, CAP_PERFMON, CAP_BPF, CAP_CHECKPOINT_RESTORE, …) seront ajoutées au fil des PRs qui les utiliseront. Le commentaire de tête de l’enum le documente.
CapabilityMask u64 ↔ split (u32, u32) — test du placement absolu. Les Linux v3 capabilities exposent leur bitfield via deux mots u32 côté ABI (datap[0] = bits 0-31, datap[1] = bits 32-63). Une éventuelle inversion lo/hi dans mask_to_words se compenserait dans un simple round-trip via words_to_mask — le bug serait invisible. D’où trois tests d’absolu : mask_to_words_places_bit_0_in_lo, mask_to_words_places_bit_32_in_hi, words_to_mask_recombines_with_correct_placement. Vérifient le placement exact d’un bit dans chaque mot, sur valeur brute (1_u64 << 32) — indépendamment des variants Capability disponibles. Aucune Capability de ce stub n’occupe le mot haut (max = SysTime = 25) ; quand la PR ultérieure ajoutera SetFCap = 31 puis MacOverride = 32, le mot haut sera exercé via une capability réelle.
Aucun ajout Errno ni Signal. Les 6 errnos et 6 signaux du stub partiel actuel suffisent (vérifié via croisement spec↔code). Discipline « ne pas pré-implémenter sans appelant » respectée.
Vérification des numéros de syscall vs spec — aucune divergence. setpgid 109/154, getpgid 121/155, setsid 112/157, getsid 124/156, prctl 157/167, getrlimit 97/163, setrlimit 160/164, prlimit64 302/261, capget 125/90, capset 126/91. Tous concordent ; la spec n’indiquait pas le numéro de prctl (157 x86_64 / 167 aarch64 dans linux/syscalls.h).
Allocation heap justifiée par convention 4 ADR-021. Une seule fonction de cette PR alloue dans son happy-path : get_thread_name() -> CString. Justification : kernel écrit dans un buffer transient de 16 octets ; la spec exige un type owned côté caller (peut sortir du frame). Allocation bornée à 16 octets (un seul Box<[u8]>), exception explicitement admise par la convention 4. capget ne fait pas d’allocation heap : CapabilitySet (3 × u64 = 24 octets) est retourné par valeur sur la pile, le buffer kernel intermédiaire (KernelCapData[2] = 24 octets) est aussi pile.
Mitigation d’une flakiness pré-existante. Le test waitid_dispatches_processgroup_and_anyprocessgroup (PR clone3+waitid) appelait waitid(AnyProcessGroup | EXITED | NOHANG) en parallèle des tests qui forkent. Sans NOWAIT, cet appel pouvait consommer un événement appartenant à un autre test parallèle (canonical SIGKILL test, fork tests de cette PR), provoquant un ECHILD chez le test légitime. Mitigation appliquée dans cette PR : ajout de WaitOptions::NOWAIT (peek-only) au test dispatch. Avant : 1/N failure rate ; après : 5 runs stress successifs sans failure.
Tests skippés en environnement dev — récap consolidé. À l’issue de cette PR, trois conditions runtime Ubuntu 24.04 par défaut masquent silencieusement des chemins applicatifs :
- AppArmor (
apparmor_restrict_unprivileged_userns=1) — bloqueclone3_with_new_user_namespace_runs_or_skips. - Yama (
yama.ptrace_scope ≥ 2) — bloqueraitpidfd_getfd_duplicates_fd_from_child(Ubuntu 24.04 par défaut =1, OK). - Non-root + non-pgrp-leader runner cargo — neutralise le scénario EPERM direct de plusieurs tests (
setsid,capset_raise_non_permitted,setrlimit_raising_hard,cap_ambient_raise_non_permitted,clone3_newpid_without_caps). La majorité ont été mitigés via scénarios fork (l’enfant fork’é se rend pgrp leader, puis exerce le chemin EPERM côté setsid ; les autres restent skip-on-root).
Validation effective de ces chemins relève d’un runner CI permissif (sans AppArmor userns restriction, avec ptrace_scope ≤ 1, et idéalement avec un harness cargo test qui rend le test runner pgrp leader). À provisionner avec le pipeline CI.
Trous de couverture restants — catégorisés. Sur ~1960 lignes production dans air-sys-syscall/process.rs, ~25 lignes restent non couvertes après cette PR. Aucune ne révèle un bug ou un risque :
- 3 lignes : branche
Some(&info)depidfd_send_signal(stubSignalInfoopaque, acted PR pidfd_*). - 10 lignes : corps complet de
set_no_new_privs. Testable en enfant fork’é dès aujourd’hui (clone3+waitid sont mergés ; l’enfant l’appelle, vérifie viaget_no_new_privs, exit code ; parent waitid). Choix de regroupement, pas une impossibilité : le test bout-en-bout sera écrit dans la PRfamily-security(seccomp) qui constitue le contexte naturel d’usage de NNP — les deux fonctions partagent les mêmes fixtures fork-enfant et la même discipline de scénario. - ~12 lignes : branches d’erreur (
if ret < 0 { return Err(errno_from_negative_syscall_ret(ret)) }) de wrappers prctl simples (set/get_parent_death_signal,get_no_new_privs,set/get_thread_name,set/get_dumpable,set/get_keep_caps,set_timer_slack,cap_ambient_lower/clear_all/is_set) et decapget. Ces opérations n’échouent pas en pratique sur un kernel sain ; leur siteErrest inatteignable sans injection de kernel-failure. La logique de décodage Err elle-même est intégralement couverte via le helper communerrno_from_negative_syscall_ret(un point unique appelé identiquement par tous ces sites), testé en isolation parerrno_from_negative_syscall_ret_maps_known_codessur 6 valeurs négatives synthétiques (-1,-3,-4,-9,-10,-22→EPERM,ESRCH,EINTR,EBADF,ECHILD,EINVAL). Seuls les sites d’appel restent non exercés en line-coverage ; la sémantique d’erreur est validée centralement. Aucun risque de logique d’erreur jamais validée. - ~1 ligne :
Ok(())decap_ambient_raise— la fonction ne SUCCÈDE que si l’appelant acapdans permitted∩inheritable. En non-root standard, c’est jamais. Couvrable seulement avec un binaire ayant des file capabilities pré-positionnées. - ~2 lignes : variante
SuidDumpabledu décodageget_dumpable(valeur kernel2, dépréciée — kernels modernes retournentEINVALsur PR_SET_DUMPABLE=2, donc le décodeur n’a jamais à la traiter).
Documentation au code (commentaires explicites) + ce JOURNAL = trace complète. Pas de tricherie sur les chiffres.
Pattern admis transversalement — exits d’échec côté enfant dans les tests fork. À partir de cette PR, le pattern « enfant fork’é encode son résultat de round-trip par code de sortie » (if X.is_err() { std::process::exit(N); } pour N ≠ 0, succès = exit(0)) est acté comme convention de test. Les branches exit(N) d’erreur ne sont prises que si la round-trip échoue côté enfant — c’est-à-dire jamais sur un environnement sain. Ces lignes apparaîtront systématiquement en non-couvertes par llvm-cov, par construction. À ne plus comptabiliser comme dette de couverture à chaque PR : c’est le coût normal d’un pattern de test fork-enfant robuste, supérieur à un test in-process qui contaminerait l’état partagé. Si la round-trip échoue en pratique, la branche s’exécute, le compteur s’incrémente, et la couverture remonte — c’est un canari, pas un trou.
Métriques finales. 70 tests dans air-sys-syscall (38 nouveaux), 45 dans air-sys-types (9 nouveaux), 1 binaire d’intégration inchangé — total 116 tests passants. Coverage production : 100 % air-sys-types, ~93 % air-sys-syscall (130 lignes manquées dont 30 production catégorisées en (A)/(B)/(C)/(D)/(E) ci-dessus + 100 lignes test = skips environnementaux + exits d’échec enfant + branches signal-induced). Régions à 89 % global, conformément à la politique JOURNAL antérieure (régions < 100 % acceptées sur branches d’invariants par construction et skips environnementaux).
Session 2026-05-23 — pidfd_open + pidfd_send_signal + pidfd_getfd
Troisième PR couche 0, sur la branche feat/layer-0-pidfd. Implémentation des trois wrappers pidfd_* de la famille process (spec docs/specs/layer-0/family-process.md sous-section 3) :
pidfd_open(pid, flags) -> Result<PidFd, Errno>— syscall 434 (x86_64 et aarch64), Linux 5.3+.pidfd_send_signal(pidfd, signal, info) -> Result<(), Errno>— syscall 424, Linux 5.1+.pidfd_getfd(pidfd, target_fd, flags) -> Result<OwnedFd, Errno>— syscall 438, Linux 5.6+.
PidFd est réutilisé (existe déjà depuis la PR clone3, qui le retournait dans CloneResult::Parent via CLONE_PIDFD) ; aucune réimplémentation. Drop hérité de OwnedFd — fermeture du fd à la destruction du PidFd, sans impl Drop explicite (qui aurait été redondant et aurait pu interdire les move-out de into_fd). Le test pidfd_drop_closes_underlying_fd prouve cette fermeture en post-mortem via waitid → EBADF sur un RawFd re-borrowé après drop.
Ajouts dans air-sys-types :
PidFdOpenFlags(bitflagsu32) : une seule valeur,NONBLOCK = 0x800, conforme au seul drapeau valide en kernel actuel.SignalInfo(stub opaque) :#[repr(C)] pub struct SignalInfo { _opaque: [u8; 128] }dansair-sys-types::signal. Aucun constructeur public — le type est type-reachable mais value-unreachable depuis l’extérieur de la crate. La signaturepidfd_send_signal(..., info: Option<&SignalInfo>)reste strictement conforme à la spec ; seulNoneest utilisable. Test internesignal_info_size_matches_linux_siginfo_tancre la taille à 128 octets (ABI kernel).Errno::EBADF(9),Errno::ESRCH(3) : deux nouvelles constantes, chacune référencée par un test (drop closure, PID inexistant). Le totalErrnoreste un stub partiel (6 constantes sur ~140), conformément à la discipline établie dans la PR précédente : on ajoute uniquement ce qui a un appelant.
Vérification des valeurs Errno vs asm-generic/errno-base.h. Les six constantes (EPERM=1, ESRCH=3, EINTR=4, EBADF=9, ECHILD=10, EINVAL=22) sont définies dans asm-generic/errno-base.h et utilisées identiquement par x86_64 et aarch64. Aucune surcharge dans asm/errno.h pour ces deux architectures (les surcharges existent sur Alpha/MIPS/SPARC/ParISC, hors ADR-014). Vérifié à la rédaction.
Dette terminologique signalée — SignalInfo vs SignalQueueInfo. La spec family-process.md (sous-section 3, pidfd_send_signal) nomme le paramètre info: Option<&SignalInfo> ; la spec family-signal.md (sous-section 2, rt_sigqueueinfo) introduit SignalQueueInfo avec champs structurés (code: i32 + value: SignalValue). Côté kernel, c’est pourtant le même siginfo_t qui passe dans les deux syscalls. Cette ambiguïté terminologique entre les deux specs doit être harmonisée en PR family-signal (renommer, fusionner, ou clarifier explicitement les deux types — par ex. SignalInfo = vue opaque côté ABI ; SignalQueueInfo = builder Air ergonomique au-dessus). Cette PR-ci ne tranche pas et laisse SignalInfo opaque pour éviter de figer son design.
Trou de couverture acté — branche Some(&info) de pidfd_send_signal. Les lignes 742-744 (extraction du pointeur depuis &SignalInfo) restent non couvertes en ligne, parce que SignalInfo n’a aucun constructeur public dans cette PR — la branche est type-reachable, value-unreachable depuis l’extérieur. Refus délibéré d’introduire un constructeur pub(crate) ou un SignalInfo::zeroed() juste pour la couverture : ce serait commencer à donner du contenu au stub, contre la décision adoptée pour SignalInfo opaque, et un test avec SignalInfo zéroé serait un faux test (validerait « ça ne plante pas », pas que info est transmis correctement — le kernel pourrait même rejeter un si_code incohérent). Le chemin None est testé bout-en-bout par le test canonique SIGKILL — c’est suffisant pour cette PR. // TODO(family-signal): tester Some(&info) avec un vrai constructeur est porté par le commentaire de la fonction. Couverture ré-atteinte 100 % production dès que la PR family-signal apporte le builder public.
Scénario canonique « parent tue enfant » enfin implémenté. La PR clone3 avait posé un waitid_decodes_child_killed_by_signal qui utilisait l’auto-abort() côté enfant (équivalent kernel pour le décodage siginfo, mais non représentatif de l’usage réel). Cette PR ajoute pidfd_send_signal_canonical_parent_kills_child_with_sigkill : parent fork (CLONE_PIDFD), enfant entre en boucle sleep, parent pidfd_send_signal(pidfd, SIGKILL, None), waitid observe Killed { signal: SIGKILL, .. }. C’est l’orchestration réellement employée en production. Le commentaire de tête de waitid_decodes_child_killed_by_signal a été actualisé (sans réécriture du test) pour pointer vers le scénario canonique au lieu d’un TODO(family-signal). La ligne loop { sleep } côté enfant du nouveau test reste non couverte : SIGKILL est uncatchable, donc aucun atexit handler ne s’exécute côté enfant ; même limitation que std::process::abort(), déjà actée précédemment. Trou test-code, pas production.
Skip environnement-dépendant — Yama ptrace_scope. Le test pidfd_getfd_duplicates_fd_from_child requiert que le kernel autorise le ptrace parent → enfant. Sur les systèmes où /proc/sys/kernel/yama/ptrace_scope ≥ 2, le kernel refuse même cette relation (CAP_SYS_PTRACE requis). Le test détecte cette condition au runtime et skip proprement avec un eprintln! explicite, à la même manière que le skip AppArmor pour CLONE_NEWUSER côté clone3. Aucune ligne production n’est dépendante de cette condition — la couverture production reste équivalente.
Tests skippés en environnement dev Ubuntu 24.04 — exécution réelle relève de la CI sur runner permissif. À l’issue de cette PR, deux restrictions runtime Ubuntu 24.04 par défaut peuvent silencieusement masquer du chemin réel :
- AppArmor (
kernel.apparmor_restrict_unprivileged_userns=1) bloque le testclone3_with_new_user_namespace_runs_or_skips(PR clone3+waitid). Sans privilèges et sans levée AppArmor, la création deCLONE_NEWUSERéchoue avant fork : le test ressort en SKIP, son corps n’est pas exécuté. - Yama (
kernel.yama.ptrace_scope ≥ 2) bloquerait le testpidfd_getfd_duplicates_fd_from_child(cette PR). Sur Ubuntu 24.04 la valeur par défaut est1(parent → enfant autorisé), donc le test s’exécute en pratique ; mais sur tout système avec≥ 2il passerait en SKIP.
Conséquence pour le suivi qualité : « cargo test passe 70/70 » sur cette machine ≠ « tous les chemins applicatifs sont validés ». Les chemins NEWUSER et pidfd_getfd-via-ptrace ne sont pas exercés sur un dev Ubuntu 24.04 stock. Leur validation effective relève d’un runner de référence en CI configuré sans ces restrictions (image dédiée, capabilities ajustées, ou simplement userns activés et yama.ptrace_scope=0/1). À provisionner quand le pipeline CI sera mis en place ; en attendant, le BDFL est conscient que ces deux tests sont en lecture seule sur sa machine.
Tests ajoutés et couverture obtenue. Sept tests pour les pidfd_* :
pidfd_open_returns_pidfd_usable_for_waitid_over_child_lifetime— happy path bout-en-bout, prouve aussi que le pidfd survit à la mort de l’enfant.pidfd_open_returns_esrch_for_nonexistent_pid— branche ESRCH, PID0x7FFF_FFFEau-delà dePID_MAX_LIMIT(~2^22).pidfd_drop_closes_underlying_fd— preuve RAII, avec caveat documenté sur la flakiness en multi-thread (mitigée par le fait qu’aucun code n’est exécuté entredropet la vérificationEBADF).pidfd_send_signal_on_closed_pidfd_returns_ebadf— chemin d’erreur depidfd_send_signal.pidfd_send_signal_canonical_parent_kills_child_with_sigkill— scénario canonique SIGKILL parent → enfant.pidfd_getfd_duplicates_fd_from_child— happy path avec skip Yama siptrace_scope ≥ 2.pidfd_getfd_on_closed_pidfd_returns_ebadf— chemin d’erreur depidfd_getfd.
Couverture finale : production 100 % lignes hors les 3 lignes Some(_) de pidfd_send_signal, qui sont le trou délibéré du stub SignalInfo (acté ci-dessus). Régions à 95 % global. Les 35 lignes restantes non couvertes sont toutes en mod tests (skips environnement + branches signal-induced exits déjà actées).
Session 2026-05-22 (suite) — clone3 + waitid, et première exception dépendance
Deuxième PR couche 0 sur la branche feat/layer-0-clone3-waitid. Implémentation des wrappers clone3 (unsafe, fork-style) et waitid (safe) de la famille process, et de la matière de types associée dans air-sys-types : CloneFlags (bitflags), CloneArgs, CloneResult, StackSpec, PidFd, WaitTarget, WaitOptions, WaitStatus, WaitEvent, plus minimum vital pour Errno, Signal, et réexport std::os::fd::{OwnedFd, BorrowedFd, RawFd}.
Périmètre de cette PR sur CloneArgs. Trois champs exposés (flags, exit_signal, stack) — la spec couche 0 (docs/specs/layer-0/family-process.md) en liste neuf. Les six restants (pidfd, child_tid, parent_tid, tls, set_tid, cgroup) seront ajoutés au fil des syscalls qui les motivent (notamment execve pour stack, namespaces de mount pour set_tid). L’évolution est strictement additive ; aucun champ existant ne sera renommé ni retiré. La demande de pidfd se fait via CloneFlags::PIDFD — le PidFd résultant est retourné dans CloneResult::Parent. La création de thread (CLONE_VM/CLONE_THREAD/CLONE_SIGHAND ou stack: Some(_)) est refusée explicitement avec Errno::EINVAL tant que les tests bout-en-bout ne la couvrent pas.
Première exception au Principe 6 — bitflags. L’utilisation de la crate bitflags (~30+ types de drapeaux Linux à venir dans la couche 0) ne respecte pas la règle des 80 % du Principe 6 : la macro bitflags! émet pour chaque type un grand nombre de traits et de méthodes (BitOr/BitAnd/BitXor/Not/Sub/Extend/FromIterator/iter/from_bits/…) dont Air n’utilise qu’une fraction. Le ratio se mesure sur le code émis, pas sur la « surface d’API utile ». Adoption par exception explicite (modèle ADR-016 / icu4x), inscrite dans EXCEPTIONS.md à la racine. Distinction importante consignée pour qu’elle ne se vide pas par mesure subjective : DEPENDENCIES.md reste réservé aux dépendances qui passent le seuil ; les autres vivent dans EXCEPTIONS.md, jamais par redéfinition du dénominateur.
Pin strict appliqué localement sur bitflags. Cette PR pose bitflags = "=2.11.1" (et non "^2.4" / "2.4") dans [workspace.dependencies]. Le pin strict apporte deux garanties complémentaires au Cargo.lock committé : (1) le lock garantit la reproductibilité du build tant qu’il est intact (ADR-025) ; (2) le pin strict ajoute la reproductibilité de la résolution — si le lock est supprimé ou qu’une re-résolution est forcée (ajout d’une autre dép qui contraint le solver, cargo generate-lockfile), une borne lâche ^2.4 peut glisser silencieusement vers la plus récente 2.x compatible, ce qu’=2.11.1 interdit. Le pin reste cohérent entre Cargo.toml, Cargo.lock, et EXCEPTIONS.md. Question délibérément non tranchée ici : politique transversale (=strict pour toute la couche 0/1 ? ^caret au-delà ? autre découpage ?). Cette décision touche aux documents fondateurs (phase-B1, ADRs) et n’a pas sa place dans une PR de wrapper syscall. À instruire dans un ADR ou un futur DEPENDENCY_POLICY.md dédié, où elle sera revue à froid pour ce qu’elle est : une politique transversale, pas un détail technique de clone3.
Errno et Signal adoptés en stubs partiels. Cette PR n’inscrit dans air-sys-types que les constantes effectivement référencées par son code et ses tests : Errno::{EPERM, EINTR, ECHILD, EINVAL} (4 sur les ~140 prévues par ADR-019) et Signal::{SIGCHLD, SIGABRT, SIGKILL, SIGSEGV, SIGSTOP, SIGTRAP} (6 sur les ~31 POSIX standards de family-signal.md). Le périmètre complet — autres constantes, méthodes name()/description() pour Errno, SignalMask/SignalFdInfo pour Signal — viendra dans les PRs dédiées (ADR-019 complet pour Errno, famille signal pour Signal). Discipline assumée : ne pas pré-implémenter des constantes sans appelant ici, sous peine de figer leur design hors de leur PR et de créer du code mort. Le commentaire de tête de chaque module le note explicitement pour qu’un futur contributeur ne tombe pas dans le piège « tant qu’à faire, j’ajoute les autres signaux pendant que j’y suis ».
Stratégie de coverage post-fork. cargo-llvm-cov configure LLVM_PROFILE_FILE avec un placeholder %p ; chaque processus (parent et chaque enfant forké) écrit son propre .profraw, fusionnés à l’agrégation. Conséquence : l’enfant qui termine par std::process::exit(N) (atexit handlers exécutés, profile flushé) contribue à la couverture du wrapper. L’enfant qui termine par std::process::abort() (SIGABRT) ou un signal-induced exit ne flush PAS son profil — limitation acceptée.
Trous de couverture connus et assumés (sur le périmètre de cette PR) :
-
Variants
WaitEvent::Stopped,WaitEvent::Continued,WaitEvent::Trapped: couverts uniquement par le helper privésiginfo_to_wait_eventtesté sur desSigInfosynthétisés en mémoire. Le remplissage effectif desiginfo_tpar le kernel pour ces variants (CLD_STOPPED/CLD_CONTINUED/CLD_TRAPPED) n’est PAS testé bout-en-bout. Trois scénarios manquent : (a)SIGSTOP/SIGTSTPenvoyé à un enfant +waitid(WSTOPPED), (b)SIGCONTenvoyé après reprise +waitid(WCONTINUED), (c)ptrace-trap +waitidcôté tracer. Si un futur bug surgit sur la conversionsi_code → variantepour l’un de ces trois cas, regarder en priorité le helpersiginfo_to_wait_eventet ajouter le scénario bout-en-bout manquant — l’absence de test n’équivaut pas à une absence de risque. -
Test exec dans l’enfant (5ᵉ test clone3 de la spec) : déféré à la PR
execve. Sans wrapper Air d’execve, on ne peut pas tester la séquence fork+exec. Le commentaire au-dessus du module de tests le note. -
Test namespaces
CLONE_NEWUSERskippé : sur Ubuntu 24.04+ (et tout système oùkernel.apparmor_restrict_unprivileged_userns=1), le test passe par uneprintln!("SKIP …")et unreturn. Les ~20 lignes du corps du test ne sont pas exécutées dans cet environnement. Le wrapperclone3lui-même n’a aucun branchement spécifique sur les bitsCLONE_NEW*, donc la couverture production n’est pas réduite — c’est uniquement le test environnement-dépendant qui est en lecture seule sur cet environnement. Re-exécuter ce test sur un système qui autorise les userns non privilégiés (ex. Fedora Workstation, conteneur dédié) fermerait les lignes correspondantes. -
Test capabilities (
NEWPIDsansNEWUSER) : branche SKIP root non couverte. Le test contient un gardeif is_root() { eprintln!(...); return; }. Quand le runner n’est pas root (cas habituel), les lignes du SKIP ne sont pas exécutées. Si jamais le runner tourne en root, le scénario « EPERM attendu » ne s’applique pas et le SKIP entre en jeu ; sinon le corps du test couvre la brancheErr(EPERM). -
Test
waitid_decodes_child_killed_by_signal— orchestration canonique différée. Le test fait s’auto-abort()l’enfant (production de SIGABRT côté enfant), ce qui donnesiginfoidentique à « parent → child viakill/pidfd_send_signal» :si_code = CLD_KILLED/CLD_DUMPED,si_status = SIGABRT. La branche du dispatch waitid exercée est la même — l’objet du test (validation du décodage enWaitEvent::Killed) est rempli. Ce que ce test ne prouve pas : l’orchestration parent→enfant réellement employée en production (où le parent contrôle la mort de l’enfant). Cette orchestration exige un wrapperkilloupidfd_send_signal, qui appartient à la famillesignalet n’est pas dans cette PR. Option refusée délibérément : écrire ~40 lignes d’asm!test-only pour unraw_killétait tentant mais aurait été un syscallkillréimplémenté hors périmètre, sans SAFETY revues, sans conventions ADR-021 — exactement le débordement qu’on a corrigé sur Errno/Signal. Le test garde une branche// TODO(family-signal):qui le pointera vers le scénario canonique quand le wrapper existera. -
Trou de couverture résiduel sur l’auto-
abort()côté enfant.std::process::abort()(etSIGKILLsi on avait choisi le scénario canonique) bypasse les atexit handlers, donc le compteur LLVM côté child n’est pas flushé. La lignestd::process::abort();apparaît non couverte alors qu’elle s’exécute bel et bien (sinon l’enfant ne mourrait pas et le parent ne verrait pasWaitEvent::Killed). Compromis acceptable, hors de portée raisonnable : couvrir cette ligne demanderait un appel explicite à__llvm_profile_write_file()(lien runtime LLVM, non portable hors instrumentation) ou un signal catchable + handler — complications non justifiées par le bénéfice.
Tests : 35 dans air-sys-types (dont les 9 hérités du bootstrap + 26 nouveaux pour Errno/Signal/Clone*/Wait*/PidFd) ; 26 dans air-sys-syscall (dont les 6 hérités pour getpid/gettid + 20 nouveaux pour clone3/waitid/siginfo_to_wait_event/build_kernel_clone_args/errno_from_negative_syscall_ret) ; 1 binaire d’intégration main_thread_invariant inchangé. Total 62 tests passants.
Couverture finale : 100 % lignes production sur les quatre fichiers source ; régions à 96 % global (gap concentré dans les expect() d’invariants par construction et les arms de match jamais pris, conformément à la politique consignée dans la première moitié de la session).
Session 2026-05-22 — Bootstrap couche 0 : workspace + getpid/gettid
Première session d’implémentation. Création de la branche feat/layer-0-bootstrap et mise en place du workspace Cargo (edition 2024, rust-toolchain.toml pinné à 1.85.0) avec les deux crates fondatrices air-sys-types et air-sys-syscall. Deux premières fonctions de la famille process implémentées strictement conformes à la spec (docs/specs/layer-0/family-process.md) : getpid() -> Pid et gettid() -> Tid, syscalls appelés directement via core::arch::asm! sur x86_64 et aarch64, sans passer par la libc. Tests unitaires et test d’intégration harness = false pour l’invariant gettid()==getpid() sur le thread principal (impossible à observer dans un #[test] standard du fait du test runner cargo).
Décision méthodologique consignée — seuil régions llvm-cov. Les régions llvm-cov < 100 % sont acceptées lorsqu’elles correspondent à des branches d’échec d’assertions de test (assert_eq!/assert_ne!) ou de .expect() sur des invariants garantis (par exemple, le retour POSIX strictement positif de getpid et gettid consommé par NonZeroI32::new(...).expect(...)), jamais prises en exécution normale. Le critère du Principe 1 (100 % lignes + branches) reste tenu et reste le seuil normatif ; le seuil « régions » n’est pas un objectif. Motivation : ces branches inatteignables existent par construction (preuve de l’invariant côté kernel/POSIX), les supprimer demanderait soit de dégrader le diagnostic (perdre les messages d’assertion qui aident l’humain en cas de régression sur le wrapper asm!), soit d’enfreindre le Principe 5 en remplaçant un expect() vérifié par un unsafe { new_unchecked } — choix qui transformerait une violation d’invariant bruyante en UB silencieux. À noter pour tout futur contributeur tenté de viser 100 % de régions : c’est un faux objectif.
Décision afférente — préférence stricte pour la construction vérifiée. Sur la couche 0, NonZeroI32::new(...).expect(...) est préféré à unsafe { NonZeroI32::new_unchecked(...) } même quand l’invariant POSIX garantit la non-nullité. Raisons : (1) Principe 5, pas d’optimisation avant mesure sur un chemin non chaud ; (2) si l’asm! des syscalls est jamais mal câblé (numéro de syscall ou registre), new_unchecked(0) est un UB silencieux alors qu’un expect() rend la violation bruyante et diagnostiquable ; (3) la branche d’échec, jamais prise, relève du même statut que les autres branches d’invariants — couverture lignes conservée à 100 %, voir décision ci-dessus.
Session 2026-05-20 (suite 5) — Rédaction du README final (EN + FR)
Refonte du README.md à la racine du dépôt. Le précédent était une version provisoire qui décrivait essentiellement la structure de la documentation. Le nouveau est la vitrine publique du projet prête pour le jour de l’ouverture publique (avec un bandeau de statut à retirer ce jour-là).
Décision sur la langue : README principal en anglais (convention GitHub internationale, anticipation de l’ouverture publique vers une communauté internationale), avec un README-fr.md complémentaire qui fournit la version française. Les deux pointent l’un vers l’autre. Validation BDFL.
Contenu du nouveau README (les deux versions ont une structure identique) :
- Bandeau de statut « phase de design, pas encore ouvert publiquement » à retirer le jour de l’ouverture.
- Présentation d’Air en deux paragraphes : stack en couches, deux profils (
air-baseconsole,air-desktopgraphique), Rust polyglotte via ABI C. - État du projet : phase de design, jalons documentaires atteints, pas de code public, pas de date publique.
- Architecture en bref : les 6 couches en quelques lignes avec liens vers ADR-001 (AirCom), ADR-014 (machines de référence), ADR-025 (builds reproductibles).
- Table de documentation avec chemins et langues, indiquant clairement ce qui est FR / EN / les deux.
- Section « Building Air » : « Not yet applicable » + stratégie documentée (toolchain pinnée, Cargo.lock committé, reproductibilité bit-pour-bit). Pas de procédures fantômes.
- Section « Contributing » : « Not yet open to external contributions », explication du rôle BDFL actuel, énumération de ce qui sera en place à l’ouverture publique (CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md, RFC, DCO).
- Communication : domaine
air-desktop.org(réservé, pas de site), org GitHubair-desktop-project, channels à annoncer plus tard. - Trademark : intention de dépôt à la fondation, modèle Linux Foundation pour la politique d’usage.
- Licence : MPL 2.0, renvoi vers LICENSE.
Mises à jour de cohérence : JOURNAL.md mis à jour, nouveau prochain chantier identifié = CONTRIBUTING.md détaillé (le README y fait référence et la matière existe déjà dans docs/setup/phase-B*.md). Le backlog court terme est réordonné en conséquence.
Fichiers committés via Claude Code en local : README.md (modifié), README-fr.md (nouveau), docs/JOURNAL.md (modifié). Commit 048e2cb signé GPG + DCO, vérifié par GitHub (reason: valid), push b3737d6..048e2cb sur origin/main. Diff cumulé : +277 / −54 lignes.
Session 2026-05-20 (suite 4) — Mise en place du dépôt GitHub privé
Sujet soulevé par Thierry : « git c’est aussi un historique… une mémoire ». Création du repo pour versionner immédiatement la documentation, même si on ne produit pas encore de code, afin de capturer l’historique fin des décisions.
Décision stratégique tranchée par BDFL : repo privé d’abord, pas public. Le moment du flip vers public sera l’ouverture publique du projet, à maîtriser plus tard quand README final, CONTRIBUTING, SECURITY, marque protégée et channels communauté seront en place. Cohérent avec la politique d’édition design-phase actuelle.
Nouvelle pratique actée : pour les tâches d’exécution locale (git, gh, build, etc.), Claude produit des prompts autonomes pour Claude Code que Thierry copie-colle dans son terminal. Thierry n’intervient qu’en dernier recours. Mémoire sauvegardée pour les futures sessions.
Travaux effectués :
- Côté Cowork (préparation) :
LICENSEcréé (texte officiel MPL 2.0),.gitignorecréé (macOS/Linux/Windows/éditeurs/backups, avec note queCargo.locksera committé),README.mdenrichi d’un bandeau « phase de design pré-ouverture publique » à retirer le jour J,git init -b mainexécuté dans le workspace avec configuration localeuser.name/user.email. Rédaction du prompt Claude Code détaillé (pré-vols, création repo, commit signé, push, protections de branche, rapport final). - Côté Claude Code (exécution locale) :
- Repo
air-desktop-project/aircréé en privé sur GitHub. - Première authentification
ghrafraîchie pour ajouter les scopesadmin:gpg_keyetadmin:public_key. - Clé GPG
A9F56C4D9F59EE03ajoutée au compte GitHubtdelhaise. - Commit initial
b3737d6signé GPG + DCOSigned-off-by, vérifié par GitHub (verified: true,reason: valid). - Push
origin mainréussi. - Protections de branche non appliquées : le tier gratuit GitHub Free bloque l’API de protection sur les repos privés d’org. Documenté comme dette connue (cf. section ci-dessus).
- Repo
- Côté Cowork (clôture) : JOURNAL mis à jour avec l’état du dépôt, le SHA du commit initial, la dette « protections de branche », et la nouvelle pratique de délégation à Claude Code.
État final : dépôt opérationnel, signé, sourcé, prêt à recevoir tous les travaux ultérieurs. Premier point de bascule franchi.
Session 2026-05-20 (suite 3) — Enrichissement d’ADR-001 (AirCom vs D-Bus)
Sujet soulevé par Thierry : le choix de ne pas reposer sur D-Bus comme transport principal est l’un des plus visibles et contestables d’Air, mais l’ADR-001 dans sa forme initiale faisait 12 lignes — anormalement court par rapport aux autres ADRs phase 0 (145 à 210 lignes). Pour un projet qui prétend à la transparence et à la qualité argumentative, cette dette de documentation pouvait créer de la défiance chez les contributeurs venant de l’écosystème D-Bus traditionnel.
Approche retenue : enrichir substantiellement ADR-001 selon trois principes posés par BDFL — pragmatisme, sources d’informations, cohérence/incohérence avec les exigences de qualité d’Air. Travaux effectués :
- Rédaction d’un ADR-001 substantiel (197 lignes, dans la fourchette des autres ADRs phase 0) avec dix sections : contexte (reconnaissance de la place de D-Bus dans l’écosystème), décision en 4 points fermes, justification technique en 5 sous-sections (broker centralisé, typage/versioning, FD passing, zero-copy/back-pressure, sécurité par policy XML — chaque propriété de D-Bus mise en regard d’une exigence Air nommée : Charte principe 4, Principe d’ingénierie 8, ADR-010, ADR-012, ADR-014, etc.), alternatives évaluées (kdbus 2013-2017, bus1 2016-2018, dbus-broker, évolution incrémentale), synthèse de AirCom, périmètre exact du pont D-Bus dans Air (sd-bus peer-to-peer pour systemd,
air-dbus-bridgeoptionnel,xdg-desktop-portal-aircomme exception assumée), articulation avec ADR-002/005/010/012/013/022, FAQ anticipée de 6 questions courantes, conséquences (bénéfices/coûts/risques), statut futur. - Sources publiques citées : spécification D-Bus officielle, dépôt
dbus-broker(Tom Gundersen / David Herrmann), trois articles LWN sur kdbus et bus1. La pédagogie repose sur des faits vérifiables, pas sur des opinions. Note : les liens LWN précis sont à vérifier avant ouverture publique. - Inspirations AirCom assumées ouvertement : XPC (macOS), Cap’n Proto, FIDL (Fuchsia), Mojo (Chromium), Binder (Android). Cela coupe court à toute accusation de NIH non avouée.
- Ton : factuel, sans posture évangéliste, reconnaissance explicite de la place de D-Bus dans l’écosystème, exception
xdg-desktop-portal-airassumée, FAQ qui désamorce 6 questions classiques (« pourquoi pas étendre D-Bus ? », « est-ce que c’est du NIH ? », etc.). - Mises à jour de cohérence : registre passé en v2.3 (bloc historique enrichi, table inchangée — la décision n’a pas évolué, seule sa documentation l’a fait).
Relu et validé par BDFL sans modification.
Complément vision (priorité 2 du plan initial) : ajout d’un paragraphe pédagogique de 4-5 phrases dans vision-fr.md et vision-en.md, inséré juste après le paragraphe décrivant la couche 2. Le paragraphe (a) reconnaît la place de D-Bus dans l’écosystème Linux (systemd, NetworkManager, BlueZ, polkit, freedesktop), (b) cadre la décision « Air parle D-Bus pour interop, conçoit AirCom pour son cœur natif », (c) explicite les besoins spécifiques (sécurité capability-based, stabilité 10 ans, perf matériel modeste) que D-Bus ne couvre pas par construction, (d) renvoie vers ADR-001 pour le détail. Ton sobre, factuel, cohérent avec le reste de la vision. Le sujet est désormais traité à deux niveaux : visible et accessible dans la vision (porte d’entrée publique), argumenté et sourcé dans ADR-001 (référence technique).
Session 2026-05-20 (suite 2) — Passe 1 de nettoyage de la macro-architecture
Résolution de la dette de cohérence entre architecture/macro-architecture-fr.md (rédigée en early-stage) et les ADRs phase 0 (019-025) finalisés depuis. Travaux effectués :
- Inventaire complet des divergences :
- 35 références prospectives ADR-018 à ADR-026 désignant des services système couche 5 non instruits (protocole privé AirCom
air-wm/apps,air-screencapture,air.system.appearance/theming,air-share,air-firewalld,air-power,air-printd/air-bluetoothd/air-nfcd,air-prefs,air-keychain), en collision avec les ADRs phase 0 réels. - Distinction subtile : ADR-022 prospectif avait deux significations dans le doc (framework networking couche 2 d’une part,
air-firewalldcouche 5 d’autre part) — neutralisations séparées. - Section 2 (couche 0) entièrement obsolète : crate
air-sysunique avec 11 sous-modules incohérents avec le découpage actuel des specs, modèle d’erreurAirSysErrorenum riche contredisant ADR-019 (Errno minimaliste), conventions ADR-021 absentes, choixrustixnon confirmé.
- 35 références prospectives ADR-018 à ADR-026 désignant des services système couche 5 non instruits (protocole privé AirCom
- Révision de la section 2 (couche 0) — réécriture de la sous-section « Crate principale » et des sous-sous-sections détaillées :
- Découpage en deux crates
air-sys-types(types fondamentaux) +air-sys-syscall(wrappers de syscalls). - Liste des 10 familles actuellement spécifiées (process, fs, mem, signal, time, net, ipc, security, system, io_uring) avec pointeurs vers les specs.
- Identification des 3 trous à combler ultérieurement : famille
device, familleebpf, confirmationrustix. - Module
io_uringintroduit avec référence à ADR-022 et ses 4 Temps. - Sous-section « Gestion d’erreur » alignée sur ADR-019 (
Errno#[repr(transparent)]surNonZeroI32, sans contexte ; enrichissement aux couches 1+). - Nouvelle sous-section « Conventions transverses » consignant les 5 conventions d’ADR-021.
- Sous-section
rustixclarifiée en « hypothèse de travail à confirmer en début d’implémentation, dans le cadre du workflow ADR-024 ».
- Découpage en deux crates
- Neutralisation par sed multi-passes des 35 références prospectives. Toutes remplacées par des marqueurs neutres « ADR à produire — service [X] » qui ne pré-réservent aucun numéro. Aucune occurrence prospective résiduelle (vérification par grep exhaustive).
- Mises à jour de cohérence :
INDEX.mdpassé en v1.3 (statut macro-architecture mis à jour, section « Point d’attention » réécrite, backlog réordonné),JOURNAL.mdmis à jour (état courant, prochain chantier = README final, dettes connues purgées des dettes résolues et complétées des dettes restantes).
Vérifications finales : aucune référence ADR cassée, table des matières intacte (10 sections, 1764 lignes — quasi inchangé en volume), références aux ADRs réels uniquement (001-017 fondateurs, 018 imagerie, 019-022/024 phase 0).
Session 2026-05-20 (suite) — ADR-025 « Stratégie de builds reproductibles »
Clôture de la série de design phase 0. Travaux effectués :
- Trois arbitrages structurants validés par BDFL : (1) périmètre = artefacts Rust natifs uniquement pour la phase 0 (bundles
.airapp, images OS = ADRs ultérieurs) ; (2) calendrier = bloquant en CI dès le premier commit phase 0 ; (3) outillage = approche minimaliste Rust natif + discipline, pas de Nix ni de système de build alternatif. - Rédaction d’
ADR-025-builds-reproductibles-fr.md(~210 lignes) couvrant : contexte (rattachement à Charte principes 3 et 5, ADR-013, ADR-015, phase-C), décision en 4 points fermes, mécanismes techniques (toolchain pinnée viarust-toolchain.tomlexact,Cargo.lockcommitté, container Debian par digest SHA-256, env vars whitelistées,SOURCE_DATE_EPOCHdérivée du commit, RUSTFLAGS avec--remap-path-prefix+codegen-units=1+--build-id=none, linkermold, discipline surbuild.rset code généré), vérification CI multi-pipelines (pr-fulldouble-build +diffoscope,pr-hardwarecross-machine,main-nightlytriple-build), articulations avec ADR-010/013/014/015/024 et Principes 1/6/7, conséquences (bénéfices/coûts/risques), statut futur (ADRs ultérieurs pour bundles et image OS). - Mises à jour de cohérence : registre passé en v2.2 (ajout ADR-025, retrait de la mention « à produire »),
INDEX.mdpassé en v1.2 (statut ADR-025 = OK, statistiques recalculées, ordre de priorité court terme revu),JOURNAL.mdmis à jour (état courant, prochain chantier = révision macro-architecture passe 1).
État final : zone de design phase 0 close. Prochain chantier identifié = révision macro-architecture passe 1.
Session 2026-05-20 — Réorganisation documentation et résolution doublon ADR-018
Cohérence et structuration. Travaux effectués :
- Réorganisation physique : déplacement des 22 fichiers traînant à la racine vers
docs/selon l’arborescence cible décrite dansINDEX.md(vision/, charte/, principes-ingenierie/, adrs/, architecture/, specs/layer-0/). Suppression des obsolètes (ADR-009-bis, ancienINDEX.mdracine,.DS_Store). - Résolution du doublon ADR-018 : la série phase 0 instruite (018-erreurs, 019-signaux, 020-conventions, 021-io_uring, 022-runtime, 023-deps, 024-reproducible prévu) a été décalée de +1 pour libérer ADR-018 au profit du « Modèle d’imagerie » fondateur acté en registre v2.0. Nouvelle numérotation : 019=erreurs, 020=signaux, 021=conventions, 022=io_uring, 023=runtime, 024=workflow-deps, 025=reproductibles (à produire).
- Convention de nommage unifiée : tous les ADRs suivent désormais
ADR-NNN-titre-fr.md. Les 6 ADRs phase 0 ont été renommés depuis le style anglais sans préfixe. - Propagation des renumérotations : titres internes H1 des 6 ADRs phase 0 mis à jour ; références croisées corrigées dans
setup/etspecs/layer-0/. - Registre des ADRs : passé en v2.1 (intégration des ADRs phase 0 + bloc historique). Liens vers les fondateurs apparentés corrigés pour utiliser les chemins relatifs
../vision/,../charte/, etc. - Index :
docs/INDEX.mdpassé en v1.1 (arborescence à jour, point d’attention sur la collision macro-architecture, statistiques recalculées). - README.md racine : refondu pour décrire la nouvelle structure et pointer vers
docs/INDEX.md. - Identification de la dette macro-architecture vs ADRs phase 0 (cf. section « Dettes connues »).
État final vérifié : 50 fichiers .md dans docs/, 25 ADRs sous docs/adrs/, aucun lien markdown cassé, aucune référence aux anciens chemins.
Session 2026-05-18 — Documents fondateurs version 1.0
Finalisation et figement des documents fondateurs en v1.0 :
- Vision (FR + EN), Charte (FR + EN), Principes d’ingénierie (FR + EN).
- Registre des ADRs v2.0 (restructuré en fichiers individuels par ADR).
- 17 ADRs fondateurs (001 à 017) finalisés.
- ADR-018 « Modèle d’imagerie » instruit en phase de design pré-ouverture publique.
- ADR-009 révisé pour intégrer l’amendement « API exclusivement déclarative » (anciennement envisagé sous nom ADR-009-bis ; politique d’édition design-phase appliquée — pas de pattern
-bisavant ouverture publique). - Politique d’édition design-phase actée : avant ouverture publique, les amendements aux documents fondateurs s’intègrent directement, pas par fichier
-bis.
Sessions antérieures à 2026-05-18 — Spécifications phase 0
Sessions consacrées à la spécification de la couche 0 et du runtime async. Productions :
- 6 ADRs phase 0 (numérotés à l’époque 018 à 023, renumérotés 019 à 024 le 2026-05-20).
- 9 specs de familles de syscalls dans
specs/layer-0/(process, fs, mem, signal, time, net, ipc, security, system). - Spec partielle
air-sys-types(~25 types). - Spec
io-uring-overview(synthèse, détail Temps 1-4 à produire). - Documents de setup phases A (décisions techniques), B1 (structure workspace), B2 (conventions opérationnelles), C (infrastructure), D (communauté).
- Document de macro-architecture des 6 couches (rédigé en early-stage, dette de cohérence à traiter — cf. section « Dettes connues »).
Licence du document : MPL 2.0 Statut : journal de bord interne, mis à jour à chaque fin de session.