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

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’ancienne EFAULT-SAFE (bras EFAULT/EBADF exclu car buffers/sorties valides par construction — le mot-clé « EFAULT-SAFE » reste dans les justifications) et l’ancienne VALUE-UNREACHABLE permanente (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é via waitid.
  • 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, spawn CLONE_SETTLS) — ni exécuté ni mesuré par le gate (*-linux-gnu, glibc : unsound, #151). Granularité fichier (--ignore-filename-regex) ; preuve par le selftest rt/ 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-SAFE et VALUE-UNREACHABLE (permanente) reclassées en STRUCTURAL ; TEST-HARNESS sorti du registre production (→ annexe) ; 3 candidates « atteignables-mais- dures » désormais COUVERTES par de vrais tests, leurs exceptions supprimées (fs::try_lock contention, net::get_so_error async, process::pidfd_send_signal Some) ⇒ DEFERRED-TOOLING dé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 que getpid/gettid. Son instanciation (hash de coverage-map différent de celle compilée cfg(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 sur process.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 / LignesCatégorieJustification
open_by_handle_at — corps complet (chemin succès, l.2453-2495)PRIVILEGECAP_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-KERNELopenat2(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.
Landlock réseau — les trois gardes if abi() < 4 { … return } des tests a_network_ruleset_is_created_when_the_abi_allows_it, a_port_rule_needs_a_ruleset_that_handles_the_network, asking_for_an_access_the_ruleset_does_not_handle_is_refusedFEATURE-KERNELLandlock ABI v4 (réseau) date de Linux 6.7 ; les deux exécuteurs tournent bien au-delà, donc la garde n’est jamais prise et son corps est mort. Elle existe pour un noyau antérieur, où le test se sauterait en le disant plutôt que d’échouer. Le chemin nominal, lui, est exercé : aucun de ces trois tests n’a sauté sur les exécuteurs (vérifié — zéro occurrence de « ne prouve RIEN »).
close — branche d’erreur if ret < 0 => return Err (l.725)STRUCTURALclose 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)STRUCTURALLe 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).

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 / LignesCatégorieJustification
inotify_init — bras d’erreur if ret < 0 => return ErrSTRUCTURALN’é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)STRUCTURALConversion 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 / LignesCatégorieJustification
Branches d’erreur (if ret < 0 => return Err) de eventfd2 (l.194) et pipe2 (l.243)STRUCTURALAvec 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::readEAGAIN, EventFd::write(u64::MAX)EINVAL, vmsplice fd invalide→EBADF.
Garde de skip if test_memfd_create(...) < 0 { return; } du test tee_einval_on_non_pipeFEATURE-KERNELLe 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 / LignesCatégorieJustification
Mapping::drop / munmap — branche d’erreur if ret < 0 (l.133-134)STRUCTURALraw_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)STRUCTURALmunlockall(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-KERNELCONFIG_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)PRIVILEGEPris 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)PRIVILEGECes 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 / LignesCatégorieJustification
raw_to_socket_addr — branche if len < 3 (AF_UNIX, l.164)STRUCTURALInatteignable : 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)STRUCTURALDé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)STRUCTURALDéfense en profondeur (Principe 5) : dans cette closure, val != 0 est déjà garanti par le else du if val == 0NonZeroI32::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.)
Test sockopt_ipv6_v6only_set_on_ipv6_socketif let Ok(fd) = socket(Ipv6, ..) (l.2349)FEATURE-KERNELLe 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é.
getpeername — closure None (EAFNOSUPPORT) de raw_to_socket_addr(..).ok_or_else(..) (net.rs, l.1060)STRUCTURALInatteignable 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)STRUCTURALInatteignable 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 / LignesCatégorieJustification
Corps des wrappers exécutés dans l’enfant : raw_syscall_landlock_restrict_self (l.236-253), LandlockRuleset::restrict_self (l.420-424), restrict_self_with (le mécanisme qu’il partage avec la pose TSYNC, ajouté le 2026-08-14), seccomp_set_mode_strict (l.620-627), helper de test raw_exitCHILD-EXITExé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_childCHILD-EXITExé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-EXITNe 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/landlockFEATURE-KERNELPris 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 clone3FEATURE-KERNELkernel.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.rs 40,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_coverage vide) 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 / LignesCatégorieJustification
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, capbset_read (noms complets cités pour le rapprochement mécanique du gate)FEATURE-KERNELCes 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-EXITLe 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, fchdirCHILD-EXITExé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-EXITPrimitive 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-EXITabort() 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 capabilitiesPRIVILEGELe 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èsPRIVILEGECAP_SETPCAP + la capability visée requises. Le chemin erreur EPERM est couvert.
capset chemin succèsPRIVILEGECAP_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-KERNELCô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-EXITBras 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)STRUCTURALBras 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), et capbset_drop (bras EPERM)PRIVILEGEL’erreur de ces setters est EPERM, atteignable uniquement pour un appelant non privilégié (après réduction ; pour capbset_drop, un appelant sans CAP_SETPCAP — exercé in-process par la branche non-root de capbset_drop_removes_from_bounding_set, jamais prise sous le gate root). 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)STRUCTURALgetresgid/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_childCHILD-EXIT / PRIVILEGELes 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)STRUCTURALgetrusage(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 / LignesCatégorieJustification
Défense en profondeur SignalMask::{contains,with,without} — branches None => false/self (air-sys-types, l.330/346/360)STRUCTURALInatteignables 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 > NSIGSTRUCTURALConst 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)STRUCTURALbit_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)STRUCTURALAvec 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::readEAGAIN, tgkill/kill/rt_sigqueueinfoESRCH.
async_handler (ADR-066) — bras d’erreur if ret < 0 => return Err de async_handler::restore ; bras d’erreur if ret < 0 de rt_sigpendingSTRUCTURALrestore 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-EXITLe 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_readCHILD-EXITExé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.
Artefacts const-eval de signal.rs : corps des const fn zeroed (KernelSigaction/PreviousHandler/PreviousDisposition), const-foldés → jamais exécutés au runtime, et constantes de taille (SIGNALFD_SIGINFO_SIZE_I32) — signal.rs (l.444, 622, 649-656)STRUCTURALUn const fn sans argument appelé en contexte non-const (let mut sa = KernelSigaction::zeroed();) est const-foldé par le compilateur : son corps n’est jamais exécuté au runtime, donc jamais instrumenté (rouge llvm-cov permanent). Idem une constante const … : i32 = 128; — évaluée à la compilation, pas du code atteignable. Les fonctions qui les utilisent (rt_sigaction, install_fatal_handler) sont couvertes par ailleurs. Inatteignable par construction.

system — famille système

Branche / LignesCatégorieJustification
sethostname chemin succèsPRIVILEGECAP_SYS_ADMIN requis. Le chemin erreur EPERM est couvert par sethostname_without_cap_sys_admin_returns_eperm.
uname — branche ret < 0 (l.219/220)STRUCTURALuname(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)STRUCTURALidem uname : seul EFAULT possible, exclu par construction.
getrandom — branche ret < 0 (l.174/175)STRUCTURALAvec 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 / LignesCatégorieJustification
clock_settime chemin succès avec modification effectivePRIVILEGECAP_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 / LignesCatégorieJustification
map_rings/ring_sizes — bras else du layout deux mmaps (mod.rs l.313 ; Some(mmap_file(…)) l.338-344)FEATURE-KERNELIORING_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)STRUCTURALCes 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)STRUCTURALLe 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)STRUCTURALIoUringOpcode 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)STRUCTURALsubmit_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)STRUCTURALInvariant : 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 / LignesCatégorieJustification
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)STRUCTURALLe 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 / LignesCatégorieJustification
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)STRUCTURALIdentique 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 / LignesCatégorieJustification
register_napi — corps succès Ok(NapiConfig { … }) et unregister_napiOk(()) (registration.rs), + le bras Ok du test register_napi_round_trip_when_availableFEATURE-KERNELIORING_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) rend EINVAL : io_uring 6.12/6.17 n’accepte que MONOTONIC/BOOTTIME pour l’horloge des timeouts. Le type ClockSource::Realtime reste 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 / LignesCatégorieJustification
Bras Err des ? sur MmapRegion::new_anonymous (anneau app) et mmap_file (anneau kernel) dans register (provided.rs)STRUCTURALUn 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 ≥ countout_flags.get_mut rend None) (provided.rs)STRUCTURALLe 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 / LignesCatégorieJustification
SubmissionRing::prepare — garde if space_left()==0 { return None } (ring.rs)STRUCTURALPré-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 / LignesCatégorieJustification
Bras Err(e) => { assert!(matches!(e, EPERM)); return } des 5 tests SqpollIoUring (fallback si SETUP_SQPOLL refusé)PRIVILEGESQPOLL 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 / LignesCatégorieJustification
Bras Err du u32::try_from(encoded.len()).map_err(EINVAL)? dans apply_restrictions (mod.rs)STRUCTURALConversion 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 / LignesCatégorieJustification
debug_assert_raw_tags} fermant if cfg!(debug_assertions) (mod.rs, l.1013)STRUCTURALArtefact 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 de raw.rs (≈ 80 % sur le fichier) sont des bras d’assert!/should_panic de tests, jamais du code de production (qui n’a aucune branche dans raw.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 / LignesCatégorieJustification
uevent_socket_open — branche d’erreur if ret < 0 de socket (l.187)STRUCTURALsocket(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)STRUCTURALsetsockopt(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)STRUCTURALbind 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éesSTRUCTURALLa 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 / LignesCatégorieJustification
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)STRUCTURALDé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 / LignesCatégorieJustification
ppoll — bras d’erreur if ret < 0 => return Err(errno_from_negative_syscall_ret(ret)) (poll.rs, l.107)STRUCTURALppoll(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 / LignesCatégorieJustification
futex_wake — bras d’erreur if ret < 0 => return Err(errno_from_negative_syscall_ret(ret)) (futex.rs, l.168)STRUCTURALFUTEX_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)STRUCTURALConversion 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 / LignesCatégorieJustification
get_fs — bras d’erreur if ret < 0 { return Err(…) } (arch.rs, l.110)STRUCTURALarch_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 0Ok(None)) — donc aucune exception pour tcgetpgrp. Résidu :

Branche / LignesCatégorieJustification
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 branchesCHILD-EXITChemin 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) : setsidset_cttysession_id == SIDtcsetpgrptcgetpgrp == 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-ONLYthread_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 / LignesCatégorieJustification
apply_relative_relocationsreloc.rs corps (l.415-427)TARGET-ONLYL’é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_selfreloc.rs corps (l.438-473)TARGET-ONLYOrchestration 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_relroreloc.rs corps (l.581-617)TARGET-ONLYDurcissement 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_childfork.rs corps (l.194-201)TARGET-ONLYHook 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_childfork.rs corps (l.221-263)TARGET-ONLYReset 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/).

air-sandbox — couche 1, application au noyau (ADR-112 §3)

Le manager de confinement air-sandbox (couche 1, ADR-112) compile ses politiques de façon pure (numéros → BPF, chemins → ruleset) : seccomp.rs, syscall.rs, profile.rs, landlock.rs sont mesurés à 100 %. Seul le module apply.rs pose la cage — trois méthodes qui enchaînent des syscalls irréversibles (no_new_privs → Landlock → seccomp(2)) qui ne peuvent pas s’exécuter dans le process du gate (ils le confineraient / le tueraient). Elles sont donc exercées dans un enfant forké (clone3) qui sort par exit_group sans flush du profil LLVM (async-signal-safety — cf. § Note cohabitation toolchain). L’application réelle est prouvée on-target par les tests privilégiés (pre_auth_kills_on_forbidden_syscallSIGSYS ; sftp_worker_landlock_confines_pathsEACCES), via le code de sortie / signal observé par waitid. Toute la logique de compilation en amont (filtre BPF, handled_access, découpage des chemins) est couverte à 100 % hors enfant.

Branche / LignesCatégorieJustification
Corps de la pose de cage exécuté dans l’enfant forké : profile_name, apply, apply_landlock, apply_landlock_with, apply_seccomp, apply_seccomp_with, apply_to_all_threadsapply.rs ; et le bras Enforce de enterjournal.rs (l.95-102)CHILD-EXITSyscalls irréversibles (no_new_privs/landlock_create_ruleset/restrict_self/seccomp_set_mode_filter) : non exécutables dans le process du gate (il serait confiné/tué). Exercés dans un enfant forké (clone3) qui sort par exit_group sans flush (flush_child_coverage vide — async-signal-safety, § Note cohabitation toolchain). Prouvés par le code de sortie / SIGSYS / EACCES observés via waitid (tests pre_auth_allows_profile_syscalls, pre_auth_kills_on_forbidden_syscall, sftp_worker_landlock_confines_paths, enter_enforce_confines_and_journals_applied). L’opt-out, l’adaptateur air-log et le trait sont, eux, couverts hors enfant. Ajout du 2026-08-14apply_to_all_threads (pose sur TOUS les fils, TSYNC) et les deux mécanismes *_with qu’elle partage avec apply : même nature, même enfant forké. La PROPRIÉTÉ de TSYNC — qu’un fil frère soit dans le domaine, et qu’un frère plus strict soit écrasé — est prouvée par des témoins (tests/ relançant le binaire de test), qui tournent dans un processus neuf sortant lui aussi par exit_group : ils ne rapportent donc aucune couverture, par construction. Le CHEMIN est exercé en plus par apply_to_all_threads_poses_a_real_cage_even_on_a_lone_thread, dont le code de sortie est la preuve d’exécution. Couverture réactivable avec une toolchain Air-compatible.
Les deux replis de masquer_les_droits_de_repertoireapply.rs : statx en échec, et StatxMask::TYPE absent de la réponseSTRUCTURALEFAULT-SAFE, par construction. Deux gardes défensives sur un descripteur QUI VIENT D’ÊTRE OUVERT avec succès, et un tampon fourni par l’appelant. statx avec AT_EMPTY_PATH sur un O_PATH valide n’a pas de mode d’échec atteignable depuis un test : le fd est vivant, le chemin est ancré, et le noyau rend toujours STATX_TYPE — c’est un champ de base, pas une extension optionnelle. Elles ne sont pas décoratives pour autant : la seconde évite le piège d’ADR-176 D2 sur MNT_ID — lire un 0 pour une réponse ferait passer TOUT inode pour « pas un répertoire », et masquerait les droits de répertoire partout. La première choisit délibérément de GARDER les droits demandés : masquer sur un doute sous-accorderait en silence à un répertoire légitime, ce qui se diagnostique bien plus mal qu’un EINVAL suivi d’un refus de cage. Ce qui EST couvert : les deux issues utiles — un répertoire garde tout, un fichier régulier perd les droits de répertoire — sur un vrai /etc et un vrai /etc/hostname (les_droits_de_repertoire_sont_retires_d_un_fichier_et_gardes_sur_un_repertoire).

air-process — couche 1, privsep (réconciliée, ADR-122 phase B)

air-process (couche 1) est réconcilié ligne à ligne par couvrable-vide depuis le déploiement phasé du gate (ADR-122, décision BDFL 2026-07-28). Ses lignes non couvertes sont de deux natures, déjà annotées dans le code (commentaires COVERAGE) mais jusqu’ici non enregistrées (la couche 1 n’était tenue que par le plancher agrégat) : (1) les corps privsep exécutés dans un enfant forké (fork → drop → exec), sortant par exit_group/execve sans flush LLVM (CHILD-EXIT, async-signal-safety — cf. § Note cohabitation toolchain) ; (2) les bras EINTR des attentes, non injectables de façon déterministe (STRUCTURAL, même précédent que waitid/ppoll) ; (3) depuis ADR-125, la réduction de capabilities d’un processus qui reste root (reduce_capabilities_to), irréversible et donc impossible à exercer dans le process du gate ; (4) depuis ADR-127, la mort du processus si la restauration d’une identité effective temporaire échouait (STRUCTURAL — cf. la rangée dédiée). Depuis ADR-130, drop_privileges_with_buffer rétablit la cascade de mort du parent que le changement d’identifiants efface : l’appel se fait dans l’enfant forké (mêmes lignes CHILD-EXIT que les étapes 3-8), mais la fonction appelée, restore_parent_death_signal, est couverte en process — trois tests unitaires l’exercent hors fork (rien à rétablir, parent disparu, parent inchangé), donc aucune exception nouvelle. Le point d’ancrage pre-exec AirCommand::default_sigpipe_on_exec (additif couche 1, 2026-08-03) tombe dans la même rangée que le PDEATHSIG : son armement vit dans run_child, donc dans l’enfant, et sa réussite est prouvée par la disposition que l’enfant execé annonce lui-même (tests/integration.rs, lue dans /proc/self/status) ; le champ et le constructeur sont, eux, couverts en process. Le reste d’air-process (types, credentials, command host-testable) est couvert — dans le run workspace complet, les tests d’air-sshd exercent en plus command.rs/manager.rs.

Branche / LignesCatégorieJustification
Corps privsep exécutés dans l’enfant forké : run_spawn_child, apply_file_action + bras enfant de spawn_processspawn.rs (l.148-288) ; fork_process bras enfant — fork.rs (l.140-158) ; drop_privileges, drop_privileges_with_buffer (étapes 3-8), verify_reduction_no_allocprivilege.rs (l.165-377) ; armements async_handler::install(SIGPIPE, SIG_DFL) et set_parent_death_signal dans run_childcommand.rs (l.712-736)CHILD-EXITFork → largage de privilèges → execve/exit_group, dans un enfant forké (clone3/fork_process) qui ne flushe pas son profil LLVM (flush_child_coverage vide — async-signal-safety, § Note cohabitation toolchain). Prouvés par le code de sortie de l’enfant observé via waitid (tests spawn_* et les tests privilégiés tests/privilege_root). Les setuid/seccomp/Landlock ne sont atteints que sous root. Réactivable avec une toolchain Air-compatible.
Corps de reduce_capabilities_to et de ses auxiliaires contains_capability, capability_mask, verify_capability_reductionprivilege.rs (l.495-567)CHILD-EXITConfinement d’un processus qui reste root (ADR-122 §6 / ADR-125) : capbset_drop exige CAP_SETPCAP, et la réduction est irréversible — l’exécuter dans le process du gate le priverait définitivement de ses capabilities pour tous les tests suivants. Exercée dans un enfant forké qui ne flushe pas son profil LLVM (async-signal-safety, § Note cohabitation toolchain), sous root — air-process/tests/privsep_primitives (reduce_capabilities_to_bounds_a_process_that_stays_root : réduction conforme, bounding set purgé, regain refusé, identité root préservée) et, de bout en bout, le monitor privsep d’air-sshd (air-sshd/tests/privsep_root). Hors root, seule la première opération est atteinte (EPERM avant toute mutation) — chemin exercé en process, mais le gate mesure en root (ADR-031). Réactivable avec une toolchain Air-compatible.
AirTemporaryIdentity::drop — bras exit_process(EXIT_EFFECTIVE_IDENTITY_NOT_RESTORED) sur échec de restauration — credentials.rs (l.309)STRUCTURALInatteignable par construction (ADR-127) : assume exige real == effective == saved == 0, or le noyau autorise toujours un processus à poser en effectif l’une de ses trois valeurs courantes — sans capability. Reposer l’uid/gid effectif ne peut donc pas échouer, et CAP_SETGID est revenu avec l’uid effectif avant la restauration des groupes. Le bras existe parce qu’un processus privilégié ne continue pas sous une identité qu’il n’a pas choisie : il meurt. Les chemins nominaux (descente, garde imbriquée refusée, restauration, restauration après panique) sont couverts en process sous le gate root (credentials::tests), et la propriété est prouvée de bout en bout dans le monitor privsep réel (air-sshd/tests/authorize_root).
Bras d’erreur EINTR des attentes wait_timeout (l.243), wait_until (l.307), wait_forever (l.346), harvest_nohang (l.389) — process.rsSTRUCTURALwaitid/pidfd ne retournent EINTR que sur signal pendant l’attente — non injectable de façon déterministe en CI (même précédent que waitid/ppoll couche 0). Les chemins succès (attente bornée, pidfd prêt, nohang) sont couverts ; le décodage Errno l’est via le helper commun.

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 : 14 rangées CHILD-EXIT + 1 rangée combinée CHILD-EXIT / PRIVILEGE = 15 (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 ; la rangée air-sandbox — application au noyau ADR-112 §3 — pose la cage seccomp/Landlock dans un enfant forké ; la rangée air-process — privsep phase B, corps fork → drop → exec en enfant forké).

⚠️ Une classe entière d’exemptions est devenue sans objet — et le gate ne la voit qu’au hasard

Constaté le 2026-08-24, sur la première mesure de main en root après la migration d’ADR-163.

La migration a sorti 234 fichiers de code de test vers des tests.rs voisins, et le filtre par défaut de cargo-llvm-cov écarte ces fichiers de la mesure. Toute exemption qui portait sur du code de test n’exempte donc plus rien.

Le gate en a signalé dix, et seulement dix — parce qu’elles citaient des numéros de ligne au-delà de la fin du fichier (fs.rs l.3467/3505/3507/3511 pour 2792 lignes, net.rs l.2403/2426/2443/2478/2505/2585 pour 2378). Elles ont été retirées le même jour, après vérification que les tests concernés vivent bien désormais en tests.rs — ce sont des exemptions sans objet, pas des tests disparus.

Ce n’est pas la fin du sujet. Le gate ne détecte que le débordement de fichier : une exemption dont la ligne citée existe encore, mais dont le code a déménagé, passe inaperçue. Quatre autres sujets d’exemption ont été localisés en tests.rs par simple grep (a_network_ruleset, tee_einval_on_non_pipe, mlock_munlock_pair, sockopt_ipv6_v6only_set_on_ipv6_socket) sans être retirés : établir qu’une exemption est superflue demande une mesure, et les exécuteurs de couverture étaient saturés.

À faire, dans l’ordre : passer chaque entrée portant sur un test au crible d’une mesure, puis apprendre au gate à confronter le sujet de l’exemption à l’endroit où il vit — pas seulement son numéro de ligne à la longueur du fichier.

Catégorie (ADR-035)Nombre d’entrées
STRUCTURAL (inclut ex-EFAULT-SAFE + ex-VALUE-UNREACHABLE permanente)49
FEATURE-KERNEL11
PRIVILEGE10
CHILD-EXIT15
DEFERRED-TOOLING0
Total (production)85

(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-TOOLING est 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 : pause réutilise ppoll existant.

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


air-sshd — binaire démon (couche 2, ADR-099 E.3) — exclusion fichier-entier

Le binaire démon air-sshd/src/bin/air-sshd.rs est exclu du gate lignes (--ignore-filename-regex, comme air-libc-capi et air-libc-strmem). Ce n’est pas une exception de taxonomie (STRUCTURAL/CHILD-EXIT/…) mais une exclusion fichier-entier d’un artefact dont le profil de couverture est structurellement non mesurable :

  • Le démon est testé via son vrai binaire (tests d’intégration d’air-sshd — exec réel, deux connexions simultanées, séparation de privilèges, interopérabilité OpenSSH), mais chaque test le termine par SIGKILL en fin : son profil LLVM n’est jamais flushé (fichier mesuré à 0 %) — situation identique à air-libc-capi.
  • Sa logique de service (serve_forever : accept-loop concurrent, ensemble non ordonné, sentinelle) vit dans le module de bibliothèque daemon, désormais mesuré in-process par un test dédié (client se connectant à un listener éphémère).
  • Le binaire client air-ssh n’est pas exclu : les tests l’attendent via sa sortie standard (sortie propre) ⇒ son profil se flushe (couverture réelle).
  • Le gate branches utilise --lib ⇒ les binaires en sont déjà hors.

air-sshd — couture I/O SFTP connection/sftp_serve.rs (couche 2, ADR-104 S.2) — exclusion fichier-entier

Le pilote I/O du sous-système SFTP air-sshd/src/connection/sftp_serve.rs (serve_sftp/send_sftp_data/finish_sftp) est exclu du gate lignes et du gate branches (--ignore-filename-regex, comme le binaire démon ci-dessus). Ce n’est pas une exception de taxonomie (STRUCTURAL/CHILD-EXIT/…) mais une exclusion fichier-entier d’un artefact dont le profil de couverture est structurellement non mesurable aujourd’hui :

  • Ce code est prouvé par tests/sftp_interop.rs : le vrai client sftp d’OpenSSH liste le répertoire servi et rapatrie un fichier octet-pour-octet. Il est donc réellement exécuté — pas du code non testé.
  • Mais ce test lance le démon en processus séparé puis l’arrête par Child::kill() (SIGKILL) en fin (comme tous les tests du binaire démon) : un SIGKILL ne déclenche pas les handlers atexit, donc le runtime de profilage LLVM du démon n’écrit jamais son .profraw → la couverture exécutée ici est jetée par la mesure. Situation identique au binaire démon bin/air-sshd.rs.
  • Découpage volontaire (ADR-091, motif sans-IO) : toute la logique testable en isolation vit dans le cœur sans-IO air-sftp-proto (≥ 96 % lignes, fuzzé) et le moteur air-sshd::sftp (SftpEngine, ≈ 96 %), tous deux mesurés in-process. Ne reste dans sftp_serve.rs que l’orchestration d’entrées/sorties — le pilote I/O mince qu’ADR-091 désigne comme n’étant pas la cible du test unitaire.
  • Le fichier a été isolé exprès (extrait de connection.rs vers un sous-module dédié) pour que l’exclusion soit chirurgicale : le reste de connection.rs (dispatch de session, exec/shell, direct-tcpip) reste mesuré.

Levée de l’exclusion (déclencheur écrit). Retirer cette entrée dès que le drain gracieux sur SIGTERM du démon atterrit (durcissement noté dans daemon.rs) : le démon sortira alors proprement et flushera son .profraw. La preuve S.5 du transfert ≥ 100 Mio (tests/sftp_interop.rs, scellée en CI par AIR_SFTP_INTEROP_REQUIRED=1) exerce bien ce code — contrôle de fenêtre de canal compris — mais reste un test d’intégration qui SIGKILL le démon : elle valide serve_sftp sans pouvoir en mesurer la couverture (le .profraw du démon tué n’est jamais écrit). Le gate branches est --lib mais l’exclusion y est aussi posée pour cohérence (le fichier n’a pas de test unitaire de lib).

air-account-cli — point d’entrée main.rs (couche 2, ADR-100 A.4) — exclusion fichier-entier

Le point d’entrée air-account-cli/src/main.rs est exclu du gate lignes (--ignore-filename-regex). Ce n’est pas une exception de taxonomie mais une exclusion fichier-entier d’un artefact structurellement non mesurable en process :

  • fn main collecte argv et lit le mot de passe sur stdin (read_stdin_password) : ni std::env::args réel ni la lecture de stdin ne sont exerçables par un test unitaire in-process ; seul un sous-processus le ferait, non instrumenté par llvm-cov.
  • read_stdin_password est réduit au câblage des descripteurs réels du processus (fd 0 pour les octets et les ioctl termios, stderr pour le prompt) : toute la logique de saisie — prompt contextuel, coupure/restauration de l’écho en terminal, lecture brute inchangée en non-tty — vit dans src/password.rs, paramétré par les trois extrémités et donc mesuré in-process, régime terminal compris (PTY réel via air_terminal::openpty).
  • Toute la logique testable a été extraite dans cli.rs (parsing d’arguments + dispatch, run rendant un Result<String, String> avec mot de passe injecté) et commands.rs (import/user/passwd sur répertoire de base injectable), tous deux mesurés in-process (≥ 94 % lignes) — main.rs réduit à ~30 lignes de plomberie I/O.
  • Le vrai binaire est par ailleurs validé de bout en bout (import /etc/etc/air, user add/mod/del, passwd Argon2id) hors mesure de couverture.
  • Le gate branches utilise --lib ⇒ le binaire en est déjà hors.

air-keystore-cli — point d’entrée main.rs (couche 2, ADR-115 inc.1) — exclusion fichier-entier

Même régime, même raison que ci-dessus : le point d’entrée air-keystore-cli/src/main.rs est exclu du gate lignes (--ignore-filename-regex). Ce n’est pas une exception de taxonomie mais une exclusion fichier-entier d’un artefact structurellement non mesurable en process :

  • fn main collecte argv réel (std::env::args) et lit l’horloge (unix_now_seconds) : ni l’un ni l’autre n’est exerçable par un test in-process.
  • Toute la logique testable est extraite et mesurée in-process : cli.rs (dispatch des verbes + mise en forme, run rendant un Result<String, String> avec horloge et nonce injectés), certificate.rs (cert inspect/verify), flags.rs (analyseur d’arguments). main.rs est réduit à ~35 lignes de plomberie.
  • L’orchestration d’émission elle-même ne vit pas ici : elle est dans la crate air-cert-issue, mesurée séparément — donc rien de substantiel ne se cache derrière cette exclusion.
  • Le gate branches utilise --lib ⇒ le binaire en est déjà hors.

air-agent — point d’entrée bin/air-agent.rs (couche 2, ADR-116 inc.1) — exclusion fichier-entier

Même régime, même raison que les deux précédents : le point d’entrée air-agent/src/bin/air-agent.rs est exclu du gate lignes (--ignore-filename-regex). Ce n’est pas une exception de taxonomie mais une exclusion fichier-entier d’un artefact structurellement non mesurable en process :

  • fn main collecte argv réel (std::env::args) et imprime sur stdout/stderr : ni l’un ni l’autre n’est exerçable par un test in-process.
  • Toute la logique testable est extraite et mesurée in-process : cli.rs (analyse des sous-commandes et des drapeaux, run rendant un CliResult au lieu d’imprimer — les tests l’exercent contre un vrai agent lancé dans un fil), et au-delà service.rs, keyring.rs, channel.rs, client.rs, proto.rs. main.rs est réduit à ~15 lignes de plomberie.
  • Rien de substantiel ne se cache derrière l’exclusion : le contrôle d’appelant SO_PEERCRED, le cycle de vie du socket et la politique de l’agent sont tous exercés par des tests de bibliothèque, sur socket réel.
  • Le gate branches utilise --lib ⇒ le binaire en est déjà hors.

Licence : MPL 2.0 Dernière mise à jour : 2026-07-31 (couche 2 — air-agent/src/bin/air-agent.rs exclu fichier-entier (ADR-116 inc.1) : argv + impression réels, toute la logique extraite dans cli.rs/service.rs/keyring.rs/channel.rs/client.rs/proto.rs). Antérieur — 2026-07-30 (couche 2 — air-keystore-cli/src/main.rs exclu fichier-entier (ADR-115 inc.1) : argv + horloge réels, toute la logique extraite dans cli.rs/certificate.rs/flags.rs et dans la crate partagée air-cert-issue). Antérieur — 2026-07-25 (couche 2 — air-sshd/src/connection/sftp_serve.rs exclu fichier-entier (ADR-104 S.2) : couture I/O SFTP prouvée par le vrai client sftp OpenSSH mais profil LLVM jeté par le SIGKILL de teardown ; cœur/moteur sans-IO mesurés in-process ; levée = drain SIGTERM ou harnais in-process S.5). Antérieur — 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é).