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

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-process est #![no_std] (core/alloc::ffi ; plus de std::ffi/ std::os::unix). L’héritage d’environnement de AirCommand::build_envp ne lit plus std::env::vars_os() mais la crate air-env (ADR-055 D1 : air_env::vars(), octets) — qui vit sous air-process ET air-runtime pour casser le cycle air-runtime → air-process. La monnaie d’erreur vient d’air-base-core (ADR-054), elle aussi no_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::wait bloque (via waitid sur 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 via air-launchd, couche 5).

Carte de consommation couche 0 (journal de dette doc). AirProcessprocess (clone3, execve, waitid, pidfd_open/send_signal/getfd) ; AirPipeipc (pipe2) ; drop_privilegesprocess (prctl no_new_privs, capset, setgroups/setresgid/setresuid — cf. manque ci-dessous) + security (seccomp, landlock) ; signaux → type Signal.

⚠️ 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/ setgid seuls ne garantissent pas le saved-set → possibilité de regagner les privilèges. La séquence sûre est setresgid puis setresuid.

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 clone3 et execve, 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), le drop_privileges et l’execve sont é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-process orchestre 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 0 security). Le compilateur seccomp déclaratif (ruleset → BPF, par-arch) et le helper Landlock → chemins sont des specs séparées / différées (pas dans air-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)

  1. prctl(PR_SET_NO_NEW_PRIVS) — permet d’appliquer seccomp sans CAP_SYS_ADMIN et empêche de regagner des privilèges via un binaire setuid.
  2. setgroups(supplementary_groups) (souvent &[]) — larguer les groupes supplémentaires, tant qu’on est encore privilégié.
  3. setresgid(gid, gid, gid) — real + effective + saved (avant l’uid).
  4. setresuid(uid, uid, uid) — real + effective + saved → plus de retour à root.
  5. 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).
  6. Appliquer le filtre seccomp fourni (seccomp_set_mode_filter) — après no_new_privs.
  7. Appliquer Landlock fourni (restrict_self) — irréversible.
  8. 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 non set*) garantit le saved-set → impossible de revenir en arrière. C’est précisément ce que drop_privileges rend 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éthodeRéveil surBloque indéfiniment ?
try_wait()(sonde, waitid NOHANG)non
wait_timeout(AirDuration)exit ou échéancenon — Ok(None) à l’échéance
wait_until(&mut [PollFd])exit ou un fd du callernon — rompue par un wakeups[i]
wait_forever()exit seuloui, 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::TimedOut non utilisé ici — choix BDFL). AirDuration::ZERO ⇒ équivaut à try_wait.
  • wait_until = exposition fidèle de ppoll multi-fd : le caller passe ses propres fd de réveil (signalfd ADR-020, timerfd watchdog, eventfd annulation). Les revents ne sont pas masqués (ADR-032 / doctrine frontière 1). wakeups vide ⇒ équivaut à wait_forever.
  • wait_forever ré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_wait avant de bloquer et après réveil ppoll ⇒ une terminaison survenue dans la fenêtre est toujours récoltée ; l’expiration n’écrase jamais un statut disponible.
  • EINTR repris en couche 1 à délai recalculé depuis une échéance AirInstant monotone (la valeur ajoutée vs couche 0, qui remonte EINTR). 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_timeoutNonekillwait_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), ZEROtry_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

DomaineAPI principaleCouche 0 consommée
LancementAirCommand, AirProcess, AirStdio, AirExitStatusprocess (clone3/execve/waitid/pidfd_*)
Canauxair_pipe, AirPipeReader/Writeripc (pipe2)
Privsepdrop_privileges, AirPrivilegeDropprocess (prctl/capset/setgroups/setres*), security (seccomp/landlock)
Attentewait_timeout, wait_until, wait_forever, try_wait, AirWaitWakeuppoll (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/kill via 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.md privilège).
  • Sûreté post-fork : un test cible que le chemin enfant n’alloue pas (allocateur de test qui panique sur alloc dans 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

  1. Synchrone ; supervision async = couche 2.
  2. pidfd partout (clone3 CLONE_PIDFD, pidfd_send_signal, waitid-sur-pidfd) — robustesse anti-course, jamais d’action sur un PID recyclé.
  3. Chemin post-fork async-signal-safe — aucune alloc dans la fenêtre fork→exec ; zone la plus délicate, testée et revue.
  4. drop_privileges = orchestration de l’ordre sûr + application de politiques fournies (seccomp/landlock pré-construits). Le compilateur déclaratif est ailleurs/différé.
  5. setres* + setgroups obligatoiressetuid/setgid seuls sont un footgun ; d’où l’extension couche 0 prioritaire.
  6. 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 de drop_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.