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

Outillage cargo xtask — gates qualité gravés

Ce document décrit la crate d’outillage xtask : elle grave en outillage déterministe et réutilisable les gates qualité qu’on vérifiait à la main — en particulier ceux qui nous ont mordus (numéros de syscalls par arche, invariant « couvrable vide », dérive du décompte d’exceptions).

Statut. xtask est de l’outillage de développement, pas du code Air livré. Il est membre du workspace (pour que cargo xtask <gate> se résolve via l’alias .cargo/config.toml), mais exclu de la mesure de couverture (--exclude xtask) et non soumis au 100 % des couches 0/1 (carve-out esprit ADR-030). Il reste linté (clippy --workspace -D warnings), testé (cargo test --workspace exécute ses tests de parseurs) et audité (deny/audit/machete). Il n’introduit aucune dépendance externe (std seulement) : le Cargo.lock du produit et la reproductibilité (ADR-025) restent intacts.

Invocation

cargo xtask barrier [--no-coverage]        # toute la barrière, un seul point d'entrée
cargo xtask couvrable-vide [--strict] [--no-root] [--layer N]
cargo xtask crates-of-layer N [--and-above] [--names]
cargo xtask check-syscalls
cargo xtask check-layers
cargo xtask audit-exceptions [--strict]
cargo xtask repro [--keep]

Chaque sous-commande accepte --json (sortie machine) et rend un code de sortie : 0 = vert (ou signalements non bloquants), 1 = violation, 2 = erreur d’usage / interne. Conçu pour la CI comme pour Claude Code.

Les gates

1. barrier — un seul point d’entrée pour la barrière

Enchaîne et agrège : fmt (stable et nightly), clippy --workspace --all-targets -D warnings, cargo test --workspace, couverture lignes + branches en root (planchers ADR-031 : lignes ≥ 96, branches ≥ 78), cargo audit, cargo deny check, cargo machete, présence d’un // SAFETY: sur chaque bloc unsafe { … } du socle, cohérence Cargo.toml ↔ DEPENDENCIES.md. Récapitulatif structuré pass/fail + chiffres.

--no-coverage saute les étapes root (boucle de dev rapide sans sudo).

2. couvrable-vide — anti-gaming (le plus important)

Lance llvm-cov (root), parse les lignes non couvertes du code production et les croise avec COVERAGE-EXCEPTIONS.md.

  • Gate DUR (mécaniquement décidable) : le plancher agrégat des lignes des crates non réconciliés (≥ 90) et, pour les branches, la non-régression contre la référence versionnée xtask/couverture-reference.toml (échec sous référence − 0.10 ; proposition de relèvement au-dessus de référence + 0.25). C’est le filet anti-gaming central — la couverture brute ne peut pas chuter en douce, et l’ancien plancher absolu (78) ne peut plus dériver au-dessus de la mesure réelle. Cf. CI.md.
  • Assist (advisory / --strict) : l’invariant « couvrable VIDE » (toute ligne rouge atteignable ⊆ registre) n’est pas mécaniquement vérifiable — la reachability est une décision humaine (cf. CI.md) et les fichier:ligne du registre dérivent. La réconciliation se fait donc par symbole (fonction englobante mentionnée au registre) ou par proximité de ligne (±40, pour absorber la dérive). Les lignes non rapprochées sont signalées ; --strict les durcit en violations (et fait échouer aussi sur un fichier production non couvert sans aucune section au registre). Les références du registre au-delà du fichier sont signalées comme orphelines.

--no-root mesure sans sudo (couverture partielle, ebpf plafonné — diagnostic seulement).

--layer N — mesure PAR COUCHE (ADR-143). Ne change aucune règle : il change le périmètre. llvm-cov reçoit -p <crate> pour les seules crates dont [package.metadata.air] layer = N (moins xtask et air-libc-c, hors mesure comme en mode complet) ; les deux volets ci-dessus s’appliquent ensuite à l’identique sur ce qui a été mesuré. Le mode sans --layer reste le mode complet, inchangé. Utilisé par la CI pour ne re-mesurer que les couches touchées par une PR.

Limite. Une couche mesurée seule ne bénéficie plus des tests des couches supérieures : la mesure peut être plus sévère que dans le mode complet (faux échec possible, jamais faux succès). D’où l’absence de --layer 0 en CI : une PR couche 0 escalade en mode complet.

2 bis. crates-of-layer — le scope de test par couche

$ cargo xtask crates-of-layer 2
-p air-account-cli -p air-admin-gate -p air-async …

$ cargo xtask crates-of-layer 1 --and-above --names
air-account air-account-cli air-account-schema …

Helper (pas un gate) : sortie brute sur stdout, directement injectable dans cargo test $(cargo xtask crates-of-layer 2). --and-above ajoute les couches supérieures (ADR-143 §2 : couche 1 touchée → tester 1 et 2, la 2 en dépendant). --names omet les -p. Même source de vérité que check-layers : un scope vide est une erreur (code 2), jamais un silence.

3. check-syscalls — numéros déclarés vs headers uapi (ce qui nous a piégés)

Extrait les numéros de syscalls déclarés dans air-sys-syscall (commentaires // SAFETY: SYS_<nom> (<arche> = <N>) et constantes d’arche const SYS_… / const NR_IO_URING_…) et les compare, pour chaque arche, aux headers uapi :

ArcheHeader (overridable)
x86_64/usr/include/x86_64-linux-gnu/asm/unistd_64.h (XTASK_UNISTD_X86_64)
aarch64/usr/include/asm-generic/unistd.h (XTASK_UNISTD_AARCH64) — aarch64 utilise la table générique

Le header générique est présent sur les deux exécuteurs, donc les deux arches sont vérifiables depuis n’importe lequel. Tout écartVIOLATION (exit ≠ 0), avec le détail syscall : code_déclaré vs header. Si un header manque pour une arche, c’est signalé clairement (et ignoré), jamais un échec silencieux. La résolution des indirections (__NR_mmap → __NR3264_mmap → 222) est gérée.

4. audit-exceptions — format/taxonomie ADR-035 du registre

Valide chaque entrée de COVERAGE-EXCEPTIONS.md :

  • catégorie ∈ {STRUCTURAL, PRIVILEGE, FEATURE-KERNEL, CHILD-EXIT, DEFERRED-TOOLING} ; aucun TEST-HARNESS au registre production (ADR-035 §2) ;
  • justification non vide ;
  • fichier:ligne existant : tout .rs cité doit exister, toute ligne citée ≤ la taille du fichier, et tout symbole entre backticks doit être présent dans le fichier cité (anti-dérive — résout la dette de re-sync notée au sceau) ;
  • récapitulatif recomputé à partir des rangées et confronté au récapitulatif affiché — toute divergence est une VIOLATION (anti-dérive du décompte, le travers dénoncé par l’audit 053).

Les entrées DEFERRED-TOOLING sont signalées (dette à couvrir). --strict durcit les orphelines fichier:ligne / symbole en violations.

5. repro — double-build bit-pour-bit (ADR-025)

Construit le même arbre deux fois en environnement contrôlé (toolchain pinné, SOURCE_DATE_EPOCH fixe, --remap-path-prefix sur la source / le target de chaque build / CARGO_HOME), dans deux target distincts remappés vers le même préfixe logique, puis diffe bit-pour-bit l’ensemble des *.rlib du socle. Toute divergence ⇒ exit ≠ 0, avec le détail des fichiers non déterministes (premier octet divergent). C’est l’attestation de reproductibilité visée par l’ADR-025 (le sceau couche 0 n’avait qu’un constat same-host non outillé). --keep conserve target/repro pour inspection.

6. deb — paquets Debian des livrables Air (ADR-100/ADR-103 déploiement)

Produit les paquets Debian des exécutables livrés — air-account (crate air-account-cli), air-keystore (crate air-keystore-cli) et air-sshd (binaires air-sshd + air-ssh) — pour l’architecture de l’hôte : build linux-air (-Z build-std contre la std linux-air, ADR-103), assemblage d’un arbre DEBIAN/control + usr/sbin/<bin> (0755, outils root), scellé via dpkg-deb (outil natif Ubuntu — pas de dépendance cargo, Principe 6). Garde-fou : readelf -d doit rendre zéro NEEDED (static-pie, zéro glibc) — sinon échec. La table des livrables vit dans xtask/src/packaging.rs, partagée avec le gate check-target (voir plus bas) qui prouve qu’aucun exécutable livrable ne l’a manquée. Sortie : target/debian/<package>_<version>_<arch>.deb. Déterministe : SOURCE_DATE_EPOCH figé clampe les mtime.deb reproductible pour un binaire donné (ADR-025). Les deux architectures (amd64 sur speedy/carbon, arm64 sur raspi-srv-2) se produisent nativement sur leur runner — aucun cross-toolchain. Prérequis : dpkg-deb (Debian/Ubuntu).

Dépendances inter-paquets. Les livrables se lisent entre eux, et le control le déclare (champ depends de la table, rendu en Depends:) :

paquetDepends:pourquoi
air-account(aucun)racine du graphe — la base comptes ne présuppose rien.
air-keystoreair-accountle gate admin ADR-101 qui autorise toute mutation du keystore lit shadow.aircfg.
air-sshdair-keystore, air-accountauthentifie des utilisateurs de la base comptes ; administre ses certificats via la CLI keystore.

D’où l’ordre d’installation air-accountair-keystoreair-sshd, désormais tenu par dpkg/apt plutôt que par une consigne. Les dépendances sont non versionnées : ce qui est requis est le fichier que l’autre paquet installe, pas une révision de son format — et les trois livrables suivent des cycles de version distincts. Une liste vide n’écrit aucune ligne Depends: (un champ vide serait mal formé).

check-layers — doctrine de couche mécanisée (ADR-052 / ADR-077)

La doctrine de couche — « seule la couche 1 consomme la couche 0 » ; « les toits C-ABI (air-libc-*, *-capi) bindent la couche 1, jamais la 0 en direct » (ADR-077) ; air-sys-types transverse (ADR-052 D6) — était tenue à la main / en revue. Ce gate la mécanise : chaque crate air-* déclare sa couche dans [package.metadata.air] (layer = 0|1|2), zone ignorée par Cargo (même précédent que [package.metadata.cargo-machete]) donc inerte ; le gate lit ces déclarations et prouve que le graphe de dépendances les respecte :

  • (a) toute crate air-* déclare layer (sinon « crate sans couche ») ;
  • (b) pas de dépendance montante (M ≤ N) ;
  • (c) pas de saut (M ≥ N-1) : un toit layer=2 ne touche pas la couche 0 en direct — il passe par la couche 1.

air-sys-types est exclu de (b)/(c) (transverse). Les arêtes air-* cible-only ([target.'cfg(…)'.dependencies]) qui violeraient les règles — p.ex. l’escape hatch syscall de la couche 0 cadré par ADR-087, absent du graphe hôte livré — sont signalées en advisory (non bloquant), pas rouge. Branché dans barrier.

check-abi — la rupture d’ABI C, refusée (BLOQUANT)

cargo xtask check-abi [--regenerer]

xtask/abi-reference.toml fige la surface exportée des 4 headers C committés (392 déclarations au 2026-08-14). Une déclaration qui disparaît ou dont la signature change est une violation : dans le premier cas le consommateur C cesse de lier, dans le second — le pire des deux — il compile encore et se comporte autrement. Un ajout n’est que signalé : il ne rompt personne.

Pourquoi il fallait un gate de plus. gen-capi-header --check prouve que le header coïncide avec les sources, et les tests abi_conformance compilent un vrai consommateur C. Ni l’un ni l’autre ne voit une rupture : retirer un symbole et régénérer le header donne un header parfaitement cohérent avec un code parfaitement cohérent. C’est précisément ce qu’une promesse de dix ans (ADR-012) interdit.

Le zonage, et pourquoi il n’est pas une échappatoire. Chaque crate portant un header committé déclare sa zone de stabilité (ADR-012) dans [package.metadata.air], au même endroit que layer et role :

[package.metadata.air]
layer = 2
abi_zone = "stable"   # stable | internal | experimental

Une rupture est refusée en zone stable et seulement signalée en internal / experimental — sans quoi ces deux zones, qu’ADR-012 crée précisément pour permettre l’évolution libre, ne serviraient à rien. Une zone absente est traitée au plus strict : on ne relâche pas la garde au motif qu’on ne sait pas ce qu’on garde. Et une valeur hors vocabulaire est distinguée de l’absence, pour qu’une faute de frappe ne devienne pas silencieuse.

Une zone ne se déclasse pas. La référence consigne la zone de chaque surface ; l’abaisser (stableexperimental) est refusé comme une rupture. Sans cette règle, il suffirait de reclasser une surface la veille de la rompre : le zonage serait exactement l’échappatoire qu’il est censé encadrer. La monter est libre — c’est s’engager davantage.

Une rupture délibérée se fait avec --regenerer, dans le commit qui la porte. Ce n’est pas une échappatoire : la rupture devient un diff committé, visible en revue, au lieu d’un silence.

Le niveau des symboles. Depuis le 2026-08-14, le gate confronte aussi la surface exportée par chaque cdylib à la surface déclarée. C’est un niveau que ni gen-capi-header --check ni la comparaison des déclarations ne peuvent atteindre : tous deux lisent des textes, alors qu’un consommateur C lie un objet, et les deux peuvent diverger sans qu’aucun ne mente.

ÉcartVerdict
déclaré, non exportéviolation — le consommateur compile, puis échoue au lien
exporté, non déclaréviolation — surface livrée que rien ne garde
non déclaré mais consignétoléré : cliquet, dette nommée
bibliothèque non construitesignalé — « non vérifié », jamais « conforme »

Deux listes nommées, jamais devinées : les _Unwind_* du dérouleur, qu’une cdylib porte par construction ; et les symboles que le consommateur fournit (air_main, dont le header documente un contrat en sens inverse — c’est le programme lié qui l’apporte au runtime).

Premier passage : 27 écarts, tous réels — 20 air_value_* ré-exportés par libair_object_c.so (à retirer, décision BDFL du 2026-08-14) et 7 découverts à cette occasion dans libair_c.so (air_fmt_*, air_libc_*). Ils sont consignés dans la référence comme cliquet : ceux-là passent, un nouveau échoue.

Ce qu’il ne prouve toujours pas. La disposition des structures (un champ réordonné garde sa signature) et le versionnement de symboles ELF — ce dernier relevant de check-symver et d’ADR-158.

check-symver — aucune .so livrée sans versions de symboles (BLOQUANT)

cargo xtask check-symver

ADR-158 retient le versionnement de symboles et en diffère la mise en œuvre : Air ne livre aujourd’hui aucune bibliothèque partagée, et le versionnement achète une seule chose — la coexistence de deux versions d’un même symbole —, qui ne protège personne tant que rien n’est livré.

Ce gate est ce qui rend ce report sûr. Attacher des versions à des symboles qui n’en portaient pas est soi-même une rupture de liaison : une première bibliothèque publiée sans versions rendrait la voie A inatteignable sans casser ce qu’elle protège. Une .so doit donc porter ses versions dès sa première publication, et c’est ce que ce gate exige.

Aujourd’hui il ne trouve aucune bibliothèque livrée — et il l’écrit, plutôt que de rendre un vert muet. Le compteur est publié pour qu’on voie le jour où il cesse d’être zéro.

Comment il conclut. Une bibliothèque se reconnaît à son nom (lib*.so*, le nommage d’ADR-029) — supposition assumée, un exécutable static-PIE étant lui aussi un ELF ET_DYN. La présence des versions se lit dans l’objet : la section .gnu.version_d ne naît que d’un script de version. La table des sections est décodée à la main, sans dépendance ni readelf à installer, et un objet illisible est refusé, jamais déclaré conforme.

check-dco — l’attestation d’origine, mécanisée (BLOQUANT)

cargo xtask check-dco [--base <ref>]

Sur la plage base..HEAD (défaut : origin/main..HEAD), chaque commit apporté par la branche doit porter son Signed-off-by — le Developer Certificate of Origin, la déclaration nominative « j’ai le droit de soumettre ce code sous la licence du projet ». Le gate refuse aussi tout trailer Co-Authored-By: Claude : un outil n’est pas co-auteur.

Les commits de fusion sont hors champ (--no-merges), et c’est de fond : une fusion n’introduit aucun contenu écrit par quiconque, elle joint deux histoires déjà attestées par les commits qu’elle réunit. Sans cette exemption, le gate échouait sur un commit que personne n’a écrit — pour une pull request, actions/checkout place HEAD sur refs/pull/N/merge, une fusion que GitHub fabrique à la volée.

Trois précisions sur ce qu’il prouve. La concordance entre le sign-off et l’auteur n’est qu’un signalement — relayer le patch d’un tiers est légitime ; et elle est omise quand l’auteur est un …@users.noreply.github.com, identité que la forge substitue au squash-merge. La signature cryptographique n’est constatée que présente : git distingue « non signée » de « signée mais invérifiable ici », et un runner de CI n’a pas les clés publiques des contributeurs. Enfin, quand la plage est vide, le gate écrit « 0 commit vérifié » plutôt que de rendre un vert muet.

check-dependencies — la règle des 80 %, ce qu’on peut en mécaniser (consultatif)

cargo xtask check-dependencies [--bloquant]

Toute dépendance livrée ([dependencies] et [target.*.dependencies] ; les dev-dependencies n’embarquent rien chez personne) doit porter un audit dans le DEPENDENCIES.md de sa crate, nom entre accents graves — une mention en prose ne vaut pas audit. Deux exclusions nommées : les façades rustc-std-workspace-* (tirées par rustc-dep-of-std pour construire std) et loom, hors du graphe de build normal.

Il ne juge pas le ratio de 80 % : estimer la part d’API consommée demande de lire le code appelant et la surface publique de la crate. C’est un jugement, pas une mesure — et un gate qui prétendrait le mesurer serait exactement le travers que ce catalogue combat.

check-safety-comments — chaque bloc unsafe porte-t-il sa justification ? (consultatif)

cargo xtask check-safety-comments [--bloquant]

Balaye crates/ et dénombre séparément production et code de test — y compris les modules hors-ligne (src/fd/tests.rs), dont le #[cfg(test)] vit chez le parent et qui, sans cela, passeraient pour du code livré. Traverse les formes légitimes : commentaire multi-ligne, attribut intercalé, continuation d’expression, // SAFETY: partagé par deux blocs voisins, et bloc en tête d’une unsafe fn dont la doc porte déjà # Safety.

Consultatif par nature, et c’est écrit dans ses tests : un contrôle ligne-à-ligne ne peut pas établir qu’un commentaire distant couvre bien ce bloc.

check-target — discipline de build linux-air mécanisée (ADR-103)

Doctrine ADR-103 : tout exécutable Air est livré pour *-unknown-linux-air (std linux-air, PAL couche 1, sans libc C), jamais pour *-unknown-linux-gnu — la std gnu reste un banc de test hôte, jamais un livrable. Comme check-layers, le gate lit la zone inerte [package.metadata.air] des Cargo.toml : aucune dépendance, aucun build.

Marquage et cible :

  • (a) toute crate déclare [package.metadata.air] (crate identifié) ;
  • (b) tout exécutable déclare target = "linux-air" ou "host-tool" — un choix explicite, jamais un défaut ;
  • (c) la valeur de target est l’une des deux ;
  • (d) un exécutable host-tool (outillage dev/build hôte gnu, jamais livré) doit être allowlistéxtask seul aujourd’hui.

Couverture de livraison (couplage avec deb) : déclarer la bonne cible ne suffit pas — un exécutable linux-air que cargo xtask deb ne packagerait pas serait compilé mais jamais livré, silencieusement. Le gate vérifie donc dans les deux sens contre la table xtask/src/packaging.rs :

  • (e) tout crate produisant un exécutable linux-air est packagé (ou justifié dans UNPACKAGED_LINUX_AIR, vide aujourd’hui), et chacun de ses binaires est installé par le paquet ;
  • (f) réciproquement, tout paquet désigne un crate qui existe, produit un exécutable, est linux-air, et produit bien chacun des binaires déclarés.

La table de packaging vit dans un module partagé (xtask/src/packaging.rs) consommé par deb (production) et par ce gate : le couplage est tenu par le compilateur, pas par un parseur de source — le gate ne peut pas mentir sur ce que deb fabrique. (f) attrape la dérive inverse (crate renommé, binaire supprimé) : deb la signalerait aussi, mais seulement au build, et seulement sur un runner Linux-air ; le gate, lui, tourne partout et sans build. Branché dans barrier et en CI.

Intégration CI

Le job supply-chain du workflow ci appelle cargo xtask check-syscalls, check-c-surface, check-target et audit-exceptions (rapides, sans root) en source unique des gates correspondants.

Le job test-coverage porte la couverture, mesurée en root (ADR-031) par couvrable-vide lui-même :

  • mode completcargo xtask couvrable-vide --strict (toutes couches réconciliées) sur les occasions rares d’ADR-143 §4 (tag, workflow_dispatch, nightly, couche 0 touchée, forçage CI/build) ;
  • mode par couchecargo xtask couvrable-vide --strict --layer N pour chaque couche touchée par la PR, et cargo test $(cargo xtask crates-of-layer N …) pour le périmètre de test (cf. CI.md).

repro et barrier restent disponibles localement / à la demande.

Pourquoi un gate buggé est pire que pas de gate

Un gate qui rougit au hasard (faux positif) ou qui passe à tort (faux négatif) donne une fausse confiance. C’est pourquoi les parseurs de xtask (couvrable-vide, check-syscalls, audit-exceptions, et les helpers de barrier/repro) sont testés sur entrées synthétiques (cargo test --workspace), et pourquoi la frontière entre gate dur (mécaniquement décidable : planchers, numéros de syscalls, taxonomie, cohérence du décompte) et assist advisory (reachability, dérive fichier:ligne) est explicite.


Licence : MPL 2.0