Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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) et process::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 » pour fcntl).

Pourquoi dup2/fcntl sont le point de rupture

Dès qu’on introduit un état userland (le registre), dup2(old, new) le stresse :

  1. Aliasnew et old pointent 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_CLOEXEC par-fd).
  2. Fermeture silencieuse de la cible — si new était enregistré, dup2 le 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 descriptionpartagé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’appeler close. Fuite = entrée résiduelle → détectable (LIB_AIR_DEBUG).
  • Vrai fd kernel rendu au C (invariant d’interop : poll/select/fcntl/ SCM_RIGHTS l’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 = Nonedup_fd (plus petit numéro libre) ; target = Some(n)dup3.
  • Si target dé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> que old).
  • 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 vers air-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 feature std d’air-sys-types dans air-socket (aujourd’hui activée inconditionnellement → tire std dans le graphe) pour garder le chemin de production no_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 crate air-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, drapeaux AI_* 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/fcntl cohé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/fcntl opè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)

  1. air-handle : registre + duplicateur + close + adoption + hygiène std ; re-domiciliation de tous les objets fd couche 1 ; re-pointage dup/dup2/dup3/ fcntl/close libc. → re-sceau couche 1.
  2. AirSocketEngine : politique + ops fines socket, sur le registre.
  3. air-libc-socket : sockaddr/inet_pton/byte-order (mesuré + fuzzé).
  4. Shims socket bruts → moteur.
  5. getaddrinfo/getnameinfo → résolveur.
  6. poll/select.