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.