ADR-165 — Les formats de fichiers système sont des codecs sans I/O
Statut : Proposé (2026-08-23). Décide que tout format de fichier système qu’Air lit ou
écrit — /etc, /proc, /sys — vit dans un codec pur, sans entrée/sortie, couvert à
100 % et fuzzé. Gèle la liste des formats de la première tranche.
Catégorie : Architecture transverse, anneau 1. Généralise ADR-091 (motif réseau sans-IO) aux formats de fichiers. Décline le Principe 11 (entrée stricte) et le Principe 3 (zéro présomption).
Contexte
Ce n’est pas une idée neuve : c’est ADR-091 appliqué ailleurs
ADR-091 a rendu normatif le motif sans-IO pour le réseau : un cœur pur — framer, codec, machine à états — qu’on peut fuzzer et couvrir intégralement, et un pilote d’I/O mince par-dessus. L’argument tenait en une phrase : on ne teste exhaustivement que ce qui ne touche à rien.
Le même argument vaut mot pour mot pour un format de fichier. Un décodeur de /etc/passwd
qui ouvre lui-même le fichier ne se teste qu’avec un système de fichiers ; séparé, il se
teste avec un tableau d’octets — et se fuzze.
Cet ADR ne décide donc pas un motif : il constate que celui d’ADR-091 s’applique, et l’étend.
Ce que le dépôt fait aujourd’hui
La duplication est mesurée, au 2026-08-23 :
| Format | Crates qui le touchent |
|---|---|
passwd | 15 |
shadow | 9 |
group | 6 |
hosts | 6 |
/proc/…/stat | 5 |
Quinze crates autour de /etc/passwd. Toutes ne le parsent pas — certaines ne font que
le nommer —, mais au moins sept en portent une lecture : air-account,
air-account-cli, air-account-schema, air-admin-gate, air-config, air-libc-pwd,
air-sshd.
Chaque duplication est un endroit où deux lecteurs peuvent diverger — et le Principe 11 dit précisément que c’est là qu’est la faille.
Décisions
D1 — Un format, un codec, sans I/O
Tout format de fichier système qu’Air lit ou écrit est un codec pur : il prend des octets et rend une valeur typée, ou l’inverse. Il n’ouvre rien, ne lit rien, n’écrit rien.
Corollaire non négociable, et c’est la raison d’être de la règle : 100 % de couverture (lignes et branches) et une cible de fuzzing par codec. Un codec qui ne peut pas être fuzzé n’a pas été correctement séparé de son I/O.
D2 — Un seul crate, un module par format — jamais par répertoire
Le regroupement se fait par grammaire, pas par emplacement.
La raison est concrète : /proc/mounts et /etc/fstab partagent la même grammaire.
Découper par répertoire les mettrait dans deux crates et dupliquerait le codec — c’est-
à-dire referait exactement le défaut que cet ADR corrige.
Deux autres constats achèvent d’écarter le découpage par répertoire :
-
/devn’a aucun format texte. Ce sont des nœuds de périphérique et des structures d’ioctl, qui relèvent des types de couche 0. Le crate serait vide. -
/sysest pour moitié un scalaire par fichier, et/procest hétérogène — du scalaire (/proc/sys/*) à la ligne de cinquante-deux champs (/proc/self/stat).(Rectifié le 2026-08-24. Cette ligne disait « presque partout un scalaire par fichier ». La mesure — 27 848 attributs lus sur les 46 288 parcourus d’un
/sysréel — dit 12 865 scalaires sur 26 188 attributs texte, contre 8 462 chaînes libres, 2 298 multi-lignes et 1 294 listes. La décision n’en est pas affaiblie : elle en est renforcée, puisque c’est justement l’hétérogénéité qui interdit un crate par répertoire. Mais la phrase était plus optimiste que le réel, et elle n’avait jamais été confrontée.)
Le crate porte donc, en tête de sa documentation, une table chemin → module : on garde
l’évidence de « où chercher », sans payer la duplication.
D3 — La tolérance de chaque format est une décision, pas un détail d’implémentation
C’est le point qui mordra, et il ne se délègue pas.
Le Principe 11 proscrit le parseur tolérant. Mais un codec qui refuserait un /etc/fstab
réel — avec ses commentaires, ses lignes vides, ses espaces de fin — serait inutilisable,
et son refus ferait chercher une porte plus simple.
Pour chaque format, cet ADR (ou son amendement) dit ce que le codec accepte et ce qu’il refuse. L’implémenteur ne tranche pas : il applique.
La règle générale, dont chaque format peut déroger explicitement :
| Posture par défaut | |
|---|---|
| Commentaires et lignes vides | acceptés là où le format les définit, ignorés |
| Espaces de tête ou de fin | acceptés là où le format les définit comme séparateurs |
| Champ manquant | refusé — jamais de valeur par défaut inventée |
| Champ surnuméraire | refusé — c’est le signe d’une autre grammaire |
| Encodage | octets, jamais d’UTF-8 présumé (Principe 3) |
| Nombre hors bornes | refusé, jamais tronqué |
D4 — Le codec rend des octets, pas des chaînes
Un nom d’utilisateur, un point de montage, un chemin de /proc peuvent contenir des octets
non-UTF-8 : Unix l’autorise. Un codec qui présumerait de l’UTF-8 perdrait de la donnée
valide, ou paniquerait sur une entrée légitime.
Les codecs travaillent donc sur &[u8] et rendent des vues d’octets ; la conversion en
texte est une décision d’appelant, jamais du codec.
D5 — Ce qui existe déjà migre, il ne coexiste pas
Les lectures dispersées dans air-account, air-config, air-socket, air-device et
air-system rejoignent le crate. Elles ne restent pas en route parallèle : deux codecs
du même format sont deux lecteurs qui divergeront.
Le décodeur de scalaire /proc/sys écrit le 2026-08-23 dans air-system::parameters en
fait partie — il est arrivé avant cet ADR, il en relève.
La première tranche — liste gelée
Onze formats. Pour chacun : la référence qui fait autorité — un codec qui invente sa grammaire est pire que pas de codec — et la posture de tolérance quand elle déroge au défaut de D3.
| # | Format | Chemins | Référence | Dérogation à D3 |
|---|---|---|---|---|
| 1 | passwd | /etc/passwd | passwd(5) | champ gecos : contenu opaque, jamais découpé |
| 2 | shadow | /etc/shadow | shadow(5) | champs d’âge vides admis (sens : « non fixé ») |
| 3 | group | /etc/group | group(5) | liste de membres vide admise ; membre vide dans une liste non vide accepté et ignoré (cf. amendement) |
| 4 | fstab | /etc/fstab | fstab(5) | commentaires #, lignes vides ; 4 champs admis (les deux derniers valent 0) |
| 5 | mtab / mounts | /etc/mtab, /proc/mounts, /proc/self/mounts | fstab(5), proc(5) | même codec que 4 — c’est la raison d’être de D2 |
| 6 | hosts | /etc/hosts | hosts(5) | commentaires, alias multiples |
| 7 | resolv.conf | /etc/resolv.conf | resolv.conf(5) | directives inconnues ignorées — le format est extensible par conception |
| 8 | scalaire sysctl | /proc/sys/** | proc(5), sysctl(8) | aucune — entier décimal signé, un \n final au plus |
| 9 | meminfo | /proc/meminfo | proc(5) | clés inconnues ignorées ; unité kB obligatoire quand présente |
| 10 | proc stat | /proc/[pid]/stat | proc(5) | le champ comm est entre parenthèses et peut en contenir — le piège classique |
| 11 | attribut sysfs | /sys/** | sysfs(5) | scalaire ou chaîne d’octets ; pas de grammaire propre |
Le format 10 mérite son avertissement.
/proc/[pid]/statest le format qui casse naïvement tous les analyseurs : le second champ est le nom de l’exécutable entre parenthèses, et ce nom peut contenir des parenthèses et des espaces. Découper sur l’espace donne un décalage silencieux de tous les champs suivants. Le codec doit trouver la dernière parenthèse fermante, pas la première.
Le format 11 n’a pas de grammaire, et la mesure va plus loin que la page de manuel.
sysfs(5)énonce des garanties que/sysne tient pas. Mesuré le 2026-08-24 : 27 848 attributs lus sur les 46 288 parcourus d’une machine réelle.
Ce que la référence laisse croire Ce que la mesure dit « should be ASCII text files » 487 binaires, dont 482 portant des NULtient dans une page ( PAGE_SIZE)74 dépassent 4 096 o ; btf/vmlinuxfait 7 114 264 ose termine par \n66 attributs texte n’en ont pas une valeur par fichier 2 298 sont multi-lignes
PAGE_SIZEborne ce queshow()écrit, pas ce qu’une lecture rend — la confusion est facile et coûteuse. Et la page se termine elle-même sur « This manual page is incomplete, possibly inaccurate ».Trois exemples achèvent d’écarter toute devinette, tous relevés sur des fichiers réels :
…/cpu0/topology/core_cpusvaut5\net son voisin immédiatcore_cpus_listvaut0,2\n— le même ensemble {0, 2}, l’un en masque, l’autre en liste. Et5\nest cinq dans des milliers d’autres attributs.\nseul est la liste vide dans…/cpu/offlineet la chaîne vide dansuevent_helper.- Le booléen a treize écritures mesurées, dont deux n’en sont pas :
sched_ext/state = disabledest une énumération, et le contraire depower/control = onestauto, pasoff.Conséquence normative : le codec du format 11 n’est pas un décodeur. C’est un objet inerte plus des décodeurs typés que l’appelant choisit, chacun calqué sur la fonction du noyau qui a produit la valeur — deux producteurs de booléens donnent deux décodeurs. Aucun repli d’un décodeur sur un autre : un codec qui devine est exactement la faille que le Principe 11 proscrit.
Amendement — le membre vide d’une liste group(5) (2026-08-24)
Origine. La session déléquée aux trois premiers codecs s’est arrêtée devant un cas que la première rédaction ne tranchait pas, et l’a signalé plutôt que de décider seule — ce que D3 lui demandait explicitement :
a,,beta,— un membre vide dans une liste de membres non vide. Refuser par lecture stricte de D3 (« champ manquant → refusé ») ? La glibc, elle, accepte.
D3-bis — un élément vide de liste est accepté et ignoré
Dans la liste de membres de
group(5), un élément vide est ignoré. La ligne est acceptée ; le groupe rend les membres non vides.
Trois raisons, et la troisième est décisive.
- Une liste n’est pas un champ. D3 refuse le champ manquant parce qu’un champ absent oblige à inventer une valeur par défaut. Un élément vide dans une liste n’oblige à rien : c’est un artefact de séparateur, pas une donnée absente.
- Un nom d’utilisateur vide est irreprésentable. L’ignorer ne perd donc aucune
information — au contraire, le conserver produirait un membre
""qui ne désigne personne et que chaque appelant devrait filtrer. - Refuser rendrait tout le fichier illisible pour une virgule. Un
/etc/groupportant une virgule finale existe en vrai — unusermodbâclé, une édition à la main. Air refuserait alors une connexion à cause d’une faute de frappe qui ne cache aucune ambiguïté. C’est exactement le cas que D3 prévoit dans son propre exposé : « un codec qui refuserait un/etc/fstabréel serait inutilisable, et son refus ferait chercher une porte plus simple. »
Ce que cette dérogation n’ouvre pas. Elle vaut pour un élément de liste, et pour lui
seul. Un champ vide de passwd(5) ou de group(5) — un gid absent, par exemple —
reste refusé : là, il faudrait inventer.
Ce que cet ADR ne décide pas
- Le nom du crate ni son découpage en modules — affaire d’implémentation, tant que D2 tient.
- Les formats au-delà de la première tranche.
cpuinfo,/proc/[pid]/maps,/proc/net/*, les unités systemd viendront par amendement, chacun avec sa référence et sa posture. - L’écriture de tous les formats. Certains ne sont lus que par Air (
/proc,/sysen lecture) ; l’exigence d’un encodeur se décide format par format. - Le sort des lectures existantes au-delà de D5 : quelle crate migre quand relève de la feuille de route, pas d’une décision d’architecture.
Conséquences
- Un travail délégable. Un codec sans I/O n’a aucune dépendance sortante : sa production n’entre en conflit avec aucun autre chantier, et se spécifie intégralement par cette liste.
- Les managers en profitent immédiatement :
AirAccountManager,AirConfigManageretair-devicecessent chacun de porter leur lecture. - Une dette de migration, à faire une fois : sept crates au moins portent aujourd’hui
une lecture de
passwd. - Le crate est couche 1 et sans dépendance hors du cœur : c’est ce qui rend le 100 % et le fuzzing atteignables.
Alternatives rejetées
- Un crate par répertoire (
/proc,/sys,/dev,/etc). Séparefstabdemountsqui partagent leur grammaire — donc duplique le codec, le défaut même qu’on corrige — et crée un crate vide pour/dev. - Laisser chaque crate parser ce dont elle a besoin. C’est l’état actuel : quinze crates
autour de
passwd, et autant d’endroits où deux lecteurs peuvent diverger. - Des codecs qui ouvrent leurs fichiers. Rend le 100 % et le fuzzing inatteignables, et contredit ADR-091 qu’on se contente ici d’étendre.
- Rendre des
String. Perd les noms non-UTF-8, que Unix autorise (D4).
Licence du document : MPL 2.0