Spec couche 1 — air-process (gestion de processus + séparation de privilèges)
Spécification technique — Version 1.0. Couche 1 « Primitives système ».
Renvoi ADR-055 (no_std + environnement). Depuis ADR-055 D2,
air-processest#. L’héritage d’environnement deAirCommand::build_envpne lit plusstd::env::vars_os()mais la crateair-env(ADR-055 D1 :air_env::vars(), octets) — qui vit sousair-processETair-runtimepour casser le cycleair-runtime → air-process. La monnaie d’erreur vient d’air-base-core(ADR-054), elle aussino_std.
Position et méthode
air-process fournit la gestion applicative de processus : lancer / attendre
/ signaler des enfants (AirProcess), des canaux typés (AirPipe), et le helper
de séparation de privilèges drop_privileges (Principe 10), consommé partout
par les services Air. Elle s’appuie sur air-base-lib (AirError) et consomme
massivement la couche 0 : process (clone3/execve/waitid/pidfd/ids/caps/prctl),
ipc (pipe2), security (seccomp/landlock), signal.
API Rust d’abord (ABI C différée). Méthode doc-d’abord.
Décision de couche : synchrone.
AirProcess::waitbloque (viawaitidsur un pidfd). La supervision asynchrone (attente via l’event loop io_uring,waitid/pidfd dans le reactor) relève de la couche 2. Pas de supervision longue durée ici (→ systemd viaair-launchd, couche 5).
Carte de consommation couche 0 (journal de dette doc).
AirProcess→process(clone3, execve, waitid, pidfd_open/send_signal/getfd) ;AirPipe→ipc(pipe2) ;drop_privileges→process(prctl no_new_privs, capset, setgroups/setresgid/setresuid — cf. manque ci-dessous) +security(seccomp, landlock) ; signaux → typeSignal.
⚠️ Manque couche 0 sécurité-critique (4ᵉ découvert) — à combler AVANT drop_privileges
La famille process expose setuid/setgid mais pas setgroups,
setresgid, setresuid. Une réduction de privilèges correcte les exige :
setgroups(&[])— larguer tous les groupes supplémentaires ; les oublier laisse le processus dans des groupes privilégiés (faille de privsep classique).setresgid/setresuid— fixer real + effective + saved ;setuid/setgidseuls ne garantissent pas le saved-set → possibilité de regagner les privilèges. La séquence sûre estsetresgidpuissetresuid.
Action : ajouter setgroups, setresgid, setresuid à la famille process
(couche 0) — une petite extension, prioritaire (sécurité) — avant
d’implémenter drop_privileges. (À côté de fs::inotify, MmapRegion, epoll ;
celui-ci est le plus urgent.)
Section 1 — Lancement : AirCommand / AirProcess
#![allow(unused)]
fn main() {
/// Constructeur d'un processus enfant.
pub struct AirCommand { /* program, args, env, redirections, cwd, child_setup */ }
pub enum AirStdio { Inherit, Null, Piped, Fd(OwnedFd) }
impl AirCommand {
pub fn new(program: &AirPath) -> Self;
pub fn arg(self, arg: &AirPath) -> Self; // arguments = octets (AirPath)
pub fn args<I>(self, args: I) -> Self; // I: IntoIterator<Item = &AirPath>
pub fn env(self, key: &AirOsStr, value: &AirOsStr) -> Self; // env = chaîne-OS (pas un chemin)
pub fn env_clear(self) -> Self; // environnement vide (services confinés)
pub fn current_dir(self, dir: &AirPath) -> Self;
pub fn stdin(self, io: AirStdio) -> Self;
pub fn stdout(self, io: AirStdio) -> Self;
pub fn stderr(self, io: AirStdio) -> Self;
/// Réduction de privilèges à appliquer **dans l'enfant**, après fork, avant
/// execve (le pattern launcher Air). Voir §3.
pub fn drop_privileges_to(self, config: AirPrivilegeDrop) -> Self;
/// Lance via `clone3` + `execve` ; retourne un `AirProcess` (adossé à un pidfd).
/// # Errors `AirError` (programme introuvable, permissions, ressources…).
pub fn spawn(&self) -> AirResult<AirProcess>;
}
/// Handle d'un enfant, **adossé à un pidfd** (pas de course sur PID recyclé).
pub struct AirProcess { /* pidfd: OwnedFd, pid: Pid, stdin/out/err: Option<AirPipe*> */ }
impl AirProcess {
pub fn id(&self) -> Pid;
pub fn pidfd(&self) -> BorrowedFd<'_>; // intégrable dans un event loop (couche 2)
/// Attend la fin (bloquant, `waitid` sur le pidfd).
pub fn wait(&mut self) -> AirResult<AirExitStatus>;
/// Non bloquant : `Some` si terminé, `None` sinon.
pub fn try_wait(&mut self) -> AirResult<Option<AirExitStatus>>;
/// Envoie un signal via `pidfd_send_signal` (jamais sur un PID recyclé).
pub fn signal(&self, signal: Signal) -> AirResult<()>;
pub fn kill(&self) -> AirResult<()>; // SIGKILL
/// Prend les extrémités de pipe créées par `AirStdio::Piped`.
pub fn take_stdin(&mut self) -> Option<AirPipeWriter>;
pub fn take_stdout(&mut self) -> Option<AirPipeReader>;
pub fn take_stderr(&mut self) -> Option<AirPipeReader>;
}
pub struct AirExitStatus { /* opaque */ }
impl AirExitStatus {
pub fn success(&self) -> bool;
pub fn code(&self) -> Option<i32>; // None si tué par signal
pub fn terminating_signal(&self) -> Option<Signal>;
}
}
Sûreté du chemin post-fork / pré-exec (à graver). Entre
clone3etexecve, dans l’enfant, seules des opérations async-signal-safe sont permises (pas d’allocation heap, pas de verrou, pas de code Rust arbitraire). La mise en place des redirections (dup2 des FD), ledrop_privilegeset l’execvesont écrits en conséquence (FD/buffers préparés avant le fork ; aucune alloc dans l’enfant). C’est la zone la plus délicate de la crate ; tests dédiés + revue.
pidfd partout (anti-course). L’enfant est manipulé via son pidfd
(clone3 avec CLONE_PIDFD) : wait/signal ne peuvent jamais cibler un PID
recyclé par un autre processus. C’est l’invariant de robustesse d’AirProcess.
Section 2 — Canaux : AirPipe
#![allow(unused)]
fn main() {
/// Crée une paire de pipe (CLOEXEC par défaut), via `pipe2` (couche 0 `ipc`).
/// # Errors `AirError`.
pub fn air_pipe() -> AirResult<(AirPipeReader, AirPipeWriter)>;
pub struct AirPipeReader { /* OwnedFd */ }
pub struct AirPipeWriter { /* OwnedFd */ }
impl AirPipeReader {
pub fn read(&mut self, buffer: &mut [u8]) -> AirResult<usize>; // EINTR remonté tel quel (ou helper)
pub fn as_fd(&self) -> BorrowedFd<'_>;
}
impl AirPipeWriter {
pub fn write(&mut self, data: &[u8]) -> AirResult<usize>;
pub fn as_fd(&self) -> BorrowedFd<'_>;
}
}
Décision. CLOEXEC par défaut (cohérent couche 0) ; les extrémités sont des
types distincts (Reader/Writer) — un pipe a un sens de circulation, le
typage l’impose. read/write synchrones ; le zéro-copie (splice/tee) reste
en couche 0 / couche 2 selon le besoin.
Section 3 — Séparation de privilèges : drop_privileges
Périmètre acté : orchestration + politiques fournies.
air-processorchestre la séquence atomique dans le bon ordre ; il applique un filtre seccomp et un ruleset Landlock déjà construits par l’appelant (types couche 0security). Le compilateur seccomp déclaratif (ruleset → BPF, par-arch) et le helper Landlock → chemins sont des specs séparées / différées (pas dansair-process).
#![allow(unused)]
fn main() {
/// Configuration d'une réduction de privilèges. Politiques seccomp/landlock
/// **pré-construites** (couche 0) — optionnelles.
pub struct AirPrivilegeDrop {
pub uid: Uid,
pub gid: Gid,
pub supplementary_groups: Vec<Gid>, // souvent vide (largage total)
pub seccomp_filter: Option<SockFprog<'static>>, // BPF déjà formé (couche 0)
pub landlock: Option<LandlockRuleset>, // ruleset déjà construit (couche 0)
pub clear_ambient_caps: bool, // défaut true
}
/// Applique la réduction **dans l'ordre sûr**, **IRRÉVERSIBLE**. À appeler dans
/// l'enfant (après fork, avant execve) ou par un service sur lui-même au démarrage.
///
/// # Errors
/// `AirError` si une étape échoue. **Toute erreur après le début de la séquence
/// est FATALE** : l'état de privilèges est partiellement réduit, l'appelant ne
/// doit pas continuer (typiquement `exit` immédiat). Documenté.
///
/// # Safety / async-signal-safety
/// Pensée pour être appelable dans le chemin post-fork (pas d'alloc dans le cœur
/// de la séquence ; `supplementary_groups` matérialisé **avant**).
pub fn drop_privileges(config: &AirPrivilegeDrop) -> AirResult<()>;
}
L’ordre sûr (le cœur de la valeur ajoutée)
prctl(PR_SET_NO_NEW_PRIVS)— permet d’appliquer seccomp sansCAP_SYS_ADMINet empêche de regagner des privilèges via un binaire setuid.setgroups(supplementary_groups)(souvent&[]) — larguer les groupes supplémentaires, tant qu’on est encore privilégié.setresgid(gid, gid, gid)— real + effective + saved (avant l’uid).setresuid(uid, uid, uid)— real + effective + saved → plus de retour à root.- Réduire les capabilities :
capsetà l’ensemble cible (souvent vide) ; vider l’ambient set (clear_ambient_caps) ; (le bounding set est réduit en amont si nécessaire). - Appliquer le filtre seccomp fourni (
seccomp_set_mode_filter) — aprèsno_new_privs. - Appliquer Landlock fourni (
restrict_self) — irréversible. - Vérification défensive :
getres*/contrôles confirmant qu’on ne peut pas regagner les privilèges (defense in depth, Principe 5 « sur-sécuriser »).
Pourquoi cet ordre. Groupes/gid/uid se règlent tant qu’on est privilégié (étapes 2-4) ; les capabilities après ; seccomp/Landlock en dernier (une fois posés, on ne peut plus défaire).
setres*(et nonset*) garantit le saved-set → impossible de revenir en arrière. C’est précisément ce quedrop_privilegesrend impossible à se tromper pour les services Air.
Dépendance : nécessite l’ajout couche 0 de setgroups/setresgid/setresuid
(cf. encadré en tête). Sans eux, drop_privileges ne peut être correct —
donc à implémenter après cette petite extension couche 0.
Section 4 — Attente : bornée, interruptible, ou infinie
Cadrage détaillé : docs/draft/wait-timeout-design-fr.md. Couche 0 : famille poll
(ppoll), ADR-044.
Motivation — supprimer le hang infini subi
L’incident fork du 2026-06-25 (PR #128) a exposé un footgun : wait() faisait un
waitid bloquant non borné sur le pidfd ; un enfant qui deadlock ne se termine
jamais → le parent hang indéfiniment, sans que le développeur l’ait anticipé. Un
deadlock est indétectable depuis le parent (indiscernable d’un enfant lent) : on ne
peut donc pas le détecter, seulement garantir que le parent ne perd jamais le
contrôle en silence.
Décision : le wait() bloquant non borné est retiré. Il n’existe plus de façon
innocente de bloquer à l’infini. L’attente passe par des portes nommées ; toute
attente potentiellement infinie est consciente, greppable, observable (Principe 4 ;
pas de défaut dangereux silencieux).
Surface
| Méthode | Réveil sur | Bloque indéfiniment ? |
|---|---|---|
try_wait() | (sonde, waitid NOHANG) | non |
wait_timeout(AirDuration) | exit ou échéance | non — Ok(None) à l’échéance |
wait_until(&mut [PollFd]) | exit ou un fd du caller | non — rompue par un wakeups[i] |
wait_forever() | exit seul | oui, assumé (nom greppable, doc qui crie) |
#![allow(unused)]
fn main() {
impl AirProcess {
pub fn wait_timeout(&mut self, timeout: AirDuration) -> AirResult<Option<AirExitStatus>>;
pub fn wait_until(&mut self, wakeups: &mut [PollFd<'_>]) -> AirResult<AirWaitWakeup>;
pub fn wait_forever(&mut self) -> AirResult<AirExitStatus>;
}
pub enum AirWaitWakeup {
Exited(AirExitStatus), // enfant terminé (récolté + mémorisé)
Interrupted, // un wakeups[i] est prêt ; lire ses revents pour savoir lequel
}
}
Sémantique
Ok(None)= expiration (pas une erreur ;AirErrorKind::TimedOutnon utilisé ici — choix BDFL).AirDuration::ZERO⇒ équivaut àtry_wait.wait_until= exposition fidèle deppollmulti-fd : le caller passe ses propres fd de réveil (signalfdADR-020,timerfdwatchdog,eventfdannulation). Lesreventsne sont pas masqués (ADR-032 / doctrine frontière 1).wakeupsvide ⇒ équivaut àwait_forever.wait_foreverréservé au cas « vivacité garantie par un superviseur externe » (ex. watchdog systemd) ; ne revient jamais si l’enfant deadlock — doc qui crie.- Anti-discard (ADR-032) :
try_waitavant de bloquer et après réveilppoll⇒ une terminaison survenue dans la fenêtre est toujours récoltée ; l’expiration n’écrase jamais un statut disponible. EINTRrepris en couche 1 à délai recalculé depuis une échéanceAirInstantmonotone (la valeur ajoutée vs couche 0, qui remonteEINTR). Statut mémorisé.
Idiome canonique — l’attente infinie observable
#![allow(unused)]
fn main() {
let status = loop {
match proc.wait_timeout(AirDuration::from_secs(30))? {
Some(s) => break s,
None => { /* tick : le parent reprend la main — log, vérifie, décide */ }
}
};
}
Infini (un enfant lent n’est jamais tué) mais jamais un hang silencieux : un
deadlock se manifeste (ticks répétés). Le « borne dure puis kill » se compose
(wait_timeout → None → kill → wait_forever) ; pas de helper dédié
(air-process reste minimal). Quand le parent a des événements propres, wait_until
remplace la boucle.
Tests
Expiration (enfant vivant), terminaison dans la fenêtre (statut exact + mémoïsation),
ZERO ≡ try_wait, course « termine à l’échéance » (anti-discard), wait_until
(interruption signalfd, watchdog timerfd, quel fd via revents, wakeups vide ≡
wait_forever), mémoïsation croisée, EINTR (STRUCTURAL si non injectable).
Récapitulatif air-process
| Domaine | API principale | Couche 0 consommée |
|---|---|---|
| Lancement | AirCommand, AirProcess, AirStdio, AirExitStatus | process (clone3/execve/waitid/pidfd_*) |
| Canaux | air_pipe, AirPipeReader/Writer | ipc (pipe2) |
| Privsep | drop_privileges, AirPrivilegeDrop | process (prctl/capset/setgroups/setres*), security (seccomp/landlock) |
| Attente | wait_timeout, wait_until, wait_forever, try_wait, AirWaitWakeup | poll (ppoll, ADR-044), process (waitid/pidfd) |
Différé / séparé : compilateur seccomp déclaratif (ruleset → BPF) ; helper
Landlock → chemins (domaine confinement fs — naturellement côté air-filesystem,
qui fait déjà resolve_within) ; supervision async (couche 2) ; ABI C.
Stratégie de tests
- Couverture 100 % lignes + branches (Principe 1, couche fondatrice).
- Intégration (kernel réel) : spawn d’un binaire (
/bin/true,/bin/echo),wait/try_wait(code de sortie, mort par signal),signal/killvia pidfd, redirections (Piped: écrire stdin / lire stdout, round-trip),env_clear,current_dir.drop_privileges: dans un enfant (fork), vérifier qu’après l’appel l’enfant ne peut pas regagner root (setresuid échoue), que les groupes supplémentaires sont vides, qu’un syscall interdit par le filtre seccomp est bien bloqué, et que Landlock confine. Tests en sous-processus isolés (comme les tests seccomp/landlock de la couche 0), skip propre si l’environnement ne le permet pas (→COVERAGE-EXCEPTIONS.mdprivilège). - Sûreté post-fork : un test cible que le chemin enfant n’alloue pas (allocateur
de test qui panique sur
allocdans la fenêtre fork→exec). - Property-based : parsing/encodage des arguments/env (octets non-UTF-8 via
AirPath),AirExitStatus(code vs signal). - Miri : sur la logique pure (construction d’
AirCommand,AirExitStatus) ; les chemins syscall/fork sont en run natif (#[cfg_attr(miri, ignore)]). - Fuzzing : décodage du statut de sortie (
siginfo/waitid, données kernel).
Décisions de fond
- Synchrone ; supervision async = couche 2.
pidfdpartout (clone3CLONE_PIDFD, pidfd_send_signal, waitid-sur-pidfd) — robustesse anti-course, jamais d’action sur un PID recyclé.- Chemin post-fork async-signal-safe — aucune alloc dans la fenêtre fork→exec ; zone la plus délicate, testée et revue.
drop_privileges= orchestration de l’ordre sûr + application de politiques fournies (seccomp/landlock pré-construits). Le compilateur déclaratif est ailleurs/différé.setres*+setgroupsobligatoires —setuid/setgidseuls sont un footgun ; d’où l’extension couche 0 prioritaire.- Erreur en cours de séquence = fatale (état partiellement réduit) — documenté ; l’appelant sort.
Travail à reprendre
- Extension couche 0
process:setgroups,setresgid,setresuid(sécurité-critique, prioritaire — prérequis dedrop_privileges). - Compilateur seccomp déclaratif (
SeccompFilter/SeccompRule→ BPF) — spec séparée (substantielle, par-arch). - Helper Landlock → chemins — naturellement côté
air-filesystem. - ABI C d’
air-process; autres crates couche 1 (air-socket,air-crypto,air-device,air-thread).
Licence du document : MPL 2.0
Statut : Spécification technique d’air-process (couche 1). API Rust ; ABI C
différée. Synchrone ; privsep = orchestration + politiques fournies.