Skip to main content

air_sys_syscall/
process.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Wrappers de la famille `process`.
6//!
7//! Cf. `docs/specs/layer-0/family-process.md`. Couvre l'identité (`getpid`,
8//! `gettid`), la création/attente (`clone3`, `waitid`, `exit_group`), l'`exec`
9//! et la redirection (`execve`, `execveat`, `dup3`, `fchdir`, `chdir`), les
10//! groupes/sessions, `prctl` (opérations individuelles), les rlimits, les
11//! capabilities et l'affinité CPU.
12//!
13//! Les wrappers exécutent les syscalls Linux directement via
14//! `core::arch::asm!` ; la libc n'est pas dans le chemin d'appel
15//! (cf. CLAUDE.md : « variantes modernes préférées, syscalls non wrappés
16//! listés dans UNSUPPORTED.md »).
17
18use air_sys_types::fd::{AsRawFd, BorrowedFd, FromRawFd, OwnedFd, RawFd};
19use alloc::vec::Vec;
20use core::convert::Infallible;
21use core::ffi::c_char;
22use core::marker::PhantomData;
23use core::num::NonZeroI32;
24
25use alloc::ffi::CString;
26use core::ffi::CStr;
27
28use air_sys_types::fs::{DirFd, Mode};
29use air_sys_types::system::CpuSet;
30use air_sys_types::{
31    Capability, CapabilityMask, CapabilitySet, CapabilityTarget, CloneArgs, CloneFlags,
32    CloneResult, DumpableMode, Dup3Flags, Errno, ExecveatFlags, Gid, Pid, PidFd, PidFdOpenFlags,
33    ResGid, ResUid, Resource, Rlimit, RlimitValue, Rusage, RusageWho, Signal, SignalInfo,
34    StackSpecification, Tid, Uid, WaitEvent, WaitOptions, WaitStatus, WaitTarget,
35};
36
37#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
38compile_error!(
39    "air-sys-syscall ne supporte que x86_64 et aarch64 (cf. ADR-014 catalogue matériel)."
40);
41
42/// Retourne le PID du processus appelant.
43///
44/// Fonction **totale** : le syscall `getpid(2)` ne peut pas échouer et POSIX
45/// garantit que le PID retourné est strictement positif. C'est pourquoi la
46/// signature retourne `Pid` directement, sans `Result`.
47///
48/// # Performance
49///
50/// Très peu coûteux (~30-50 ns via vDSO sur les kernels récents) ; ici on
51/// appelle le syscall en direct sans passer par la vDSO, donc on est du
52/// côté haut de cette fourchette.
53#[must_use]
54pub fn getpid() -> Pid {
55    let raw = raw_syscall_getpid();
56    // POSIX/Linux : `getpid` retourne un `pid_t` (i32) strictement positif.
57    // La troncature de i64 à i32 est intentionnelle ; les 32 bits hauts sont
58    // garantis nuls pour un PID valide.
59    #[allow(clippy::cast_possible_truncation)]
60    let pid_raw = raw as i32;
61    // Construction *vérifiée* du `NonZeroI32` : un `new_unchecked` ici
62    // (Principe 5 : pas d'optimisation avant mesure, et plus grave : si l'asm!
63    // au-dessus est jamais mal câblé, `new_unchecked(0)` est un UB silencieux,
64    // alors qu'un `.expect()` rend la violation d'invariant bruyante et
65    // diagnostiquable). La branche d'échec n'est pas exécutable sur un kernel
66    // POSIX conforme.
67    let nz = NonZeroI32::new(pid_raw)
68        .expect("kernel a violé l'invariant POSIX : getpid doit retourner > 0");
69    Pid::from_nonzero(nz)
70}
71
72/// Retourne le TID du thread appelant.
73///
74/// Fonction **totale** : le syscall `gettid(2)` ne peut pas échouer et le
75/// kernel garantit un TID strictement positif. Sur Linux, chaque thread a un
76/// TID distinct ; le thread principal d'un processus a `gettid() == getpid()`.
77///
78/// Le type de retour [`Tid`] est **distinct** de [`Pid`] pour empêcher par
79/// typage la confusion entre identifiant de thread et identifiant de
80/// processus (cf. ADR-021 convention 1, et Principe d'ingénierie 7).
81#[must_use]
82pub fn gettid() -> Tid {
83    let raw = raw_syscall_gettid();
84    // Même invariant que getpid : TID strictement positif, fits in i32.
85    #[allow(clippy::cast_possible_truncation)]
86    let tid_raw = raw as i32;
87    // Construction *vérifiée* (cf. justification dans `getpid` ci-dessus).
88    let nz = NonZeroI32::new(tid_raw)
89        .expect("kernel a violé l'invariant POSIX : gettid doit retourner > 0");
90    Tid::from_nonzero(nz)
91}
92
93// ── Helpers spécifiques à l'architecture ─────────────────────────────────
94//
95// Une fonction par couple (syscall, architecture). Pas de wrapper générique
96// (cf. convention 3 ADR-021 : pas de wrapper générique pour les syscalls
97// multiplexés ; ici les helpers sont privés et chaque syscall a sa propre
98// fonction dédiée, donc l'esprit de la convention est respecté).
99
100#[cfg(target_arch = "x86_64")]
101#[inline]
102fn raw_syscall_getpid() -> i64 {
103    let ret: i64;
104    // SAFETY:
105    // - SYS_getpid (x86_64 = 39) ne touche à aucune mémoire utilisateur.
106    // - L'ABI syscall x86_64 prend le numéro de syscall dans RAX, retourne
107    //   en RAX, et clobbe RCX (RIP de retour) et R11 (RFLAGS sauvegardé).
108    //   Les autres registres caller-saved sont préservés.
109    // - `nostack` est correct car le syscall n'utilise pas la pile.
110    // - `readonly` est correct car ce syscall ne modifie aucune mémoire
111    //   accessible au programme.
112    // - `preserves_flags` est correct car le kernel restaure RFLAGS via R11
113    //   au retour (le clobber explicite de R11 ci-dessus le couvre).
114    unsafe {
115        core::arch::asm!(
116            "syscall",
117            in("rax") 39_i64,
118            lateout("rax") ret,
119            lateout("rcx") _,
120            lateout("r11") _,
121            options(nostack, preserves_flags, readonly),
122        );
123    }
124    ret
125}
126
127#[cfg(target_arch = "x86_64")]
128#[inline]
129fn raw_syscall_gettid() -> i64 {
130    let ret: i64;
131    // SAFETY: identique à `raw_syscall_getpid` ci-dessus, avec SYS_gettid
132    // (x86_64 = 186) à la place. Aucune mémoire utilisateur n'est touchée.
133    unsafe {
134        core::arch::asm!(
135            "syscall",
136            in("rax") 186_i64,
137            lateout("rax") ret,
138            lateout("rcx") _,
139            lateout("r11") _,
140            options(nostack, preserves_flags, readonly),
141        );
142    }
143    ret
144}
145
146#[cfg(target_arch = "aarch64")]
147#[inline]
148fn raw_syscall_getpid() -> i64 {
149    let ret: i64;
150    // SAFETY:
151    // - SYS_getpid (aarch64 = 172) ne touche à aucune mémoire utilisateur.
152    // - L'ABI syscall aarch64 prend le numéro dans X8 et retourne en X0.
153    //   Les registres caller-saved en dehors de X0 sont préservés.
154    // - `nostack`, `readonly`, `preserves_flags` : mêmes justifications que
155    //   pour la branche x86_64.
156    unsafe {
157        core::arch::asm!(
158            "svc 0",
159            in("x8") 172_i64,
160            lateout("x0") ret,
161            options(nostack, preserves_flags, readonly),
162        );
163    }
164    ret
165}
166
167#[cfg(target_arch = "aarch64")]
168#[inline]
169fn raw_syscall_gettid() -> i64 {
170    let ret: i64;
171    // SAFETY: identique à `raw_syscall_getpid` ci-dessus, avec SYS_gettid
172    // (aarch64 = 178) à la place. Aucune mémoire utilisateur n'est touchée.
173    unsafe {
174        core::arch::asm!(
175            "svc 0",
176            in("x8") 178_i64,
177            lateout("x0") ret,
178            options(nostack, preserves_flags, readonly),
179        );
180    }
181    ret
182}
183
184// ─────────────────────────────────────────────────────────────────────────
185// clone3
186// ─────────────────────────────────────────────────────────────────────────
187
188/// Structure d'arguments transmise au kernel (`struct clone_args` de
189/// `linux/sched.h`). Représente le ABI Linux 5.7+ (88 octets).
190///
191/// Les champs sont des `u64` même quand ils encodent des pointeurs (les
192/// adresses kernel n'utilisent que les premiers 48 bits sur x86_64/aarch64,
193/// mais l'ABI réserve 64 bits pour forward-compat).
194#[repr(C)]
195#[derive(Default)]
196struct KernelCloneArgs {
197    flags: u64,
198    pidfd: u64,
199    child_tid: u64,
200    parent_tid: u64,
201    exit_signal: u64,
202    stack: u64,
203    stack_size: u64,
204    tls: u64,
205    set_tid: u64,
206    set_tid_size: u64,
207    cgroup: u64,
208}
209
210/// Construit la structure ABI kernel à partir de [`CloneArgs`] Air.
211///
212/// Extrait en fonction privée pour pouvoir tester unitairement le mapping
213/// `exit_signal` (notamment la branche `None` qui ne peut pas être
214/// observée bout-en-bout via fork — le kernel détache l'enfant quand
215/// `exit_signal = 0` et `waitid` retourne alors `ECHILD`).
216fn build_kernel_clone_args(args: &CloneArgs, pidfd_addr: u64) -> KernelCloneArgs {
217    let exit_signal_u64: u64 = args.exit_signal.map_or(0, |sig| {
218        // sig.as_raw() > 0 par construction (NonZeroI32) → cast sign-safe.
219        #[allow(clippy::cast_sign_loss)]
220        {
221            sig.as_raw() as u64
222        }
223    });
224
225    // Pile : `None` → 0/0 (fork classique) ; `Some` → base + taille (thread).
226    // `clone3` valide que `stack == None` (fork) ; `clone_thread` exige `Some`.
227    let (stack_addr, stack_size) = match args.stack {
228        Some(s) => (s.addr as u64, s.size as u64),
229        None => (0, 0),
230    };
231
232    KernelCloneArgs {
233        flags: args.flags.bits(),
234        pidfd: pidfd_addr,
235        child_tid: args.child_tid.map_or(0, |r| r.as_ptr() as u64),
236        parent_tid: args.parent_tid.map_or(0, |r| r.as_ptr() as u64),
237        exit_signal: exit_signal_u64,
238        stack: stack_addr,
239        stack_size,
240        tls: args.tls.unwrap_or(0),
241        set_tid: 0,
242        set_tid_size: 0,
243        cgroup: 0,
244    }
245}
246
247/// Crée un nouveau processus via le syscall `clone3(2)`.
248///
249/// Cf. `docs/specs/layer-0/family-process.md` section `clone3`. La fonction
250/// retourne **deux fois** en cas de succès : une fois côté parent (avec le
251/// PID enfant, et un `PidFd` si [`CloneFlags::PIDFD`] était positionné),
252/// une fois côté enfant (avec [`CloneResult::Child`]). Le `match` sur le
253/// résultat est le seul moyen sûr de discriminer.
254///
255/// # Périmètre
256///
257/// `clone3` est dédié à la **création de processus** style fork ; la création
258/// de **thread** passe par [`clone_thread`] (modèle de retour distinct, cf. sa
259/// doc et la note d'implémentation au-dessus de `clone_thread`) :
260///
261/// - `args.flags` peut contenir tous les bits hors [`CloneFlags::VM`] /
262///   [`CloneFlags::THREAD`] / [`CloneFlags::SIGHAND`] : ces drapeaux de
263///   création de thread sont refusés ici avec [`Errno::EINVAL`] (leur retour
264///   `CloneResult::Child` serait *unsound* sur une pile neuve — voir
265///   `clone_thread`).
266/// - `args.stack` doit être `None` (refusé sinon avec [`Errno::EINVAL`] ; une
267///   pile dédiée n'a de sens que pour un thread → `clone_thread`).
268/// - `args.exit_signal` est typiquement `Some(Signal::SIGCHLD)`.
269///
270/// # Safety
271///
272/// `clone3` peut créer un thread partageant la mémoire avec le parent
273/// (`CLONE_VM`), situation que Rust ne peut pas vérifier. L'appelant doit
274/// donc garantir, selon le scénario :
275///
276/// - **Fork classique** (sans `CLONE_VM`) — aucune précondition
277///   particulière sur la mémoire. L'enfant possède une copie CoW de
278///   l'espace d'adressage du parent ; tous les invariants Rust restent
279///   valables des deux côtés.
280///
281/// - **Création de thread** (`CLONE_VM | CLONE_THREAD | CLONE_SIGHAND`)
282///   — non géré par `clone3` ; l'appel échoue avec [`Errno::EINVAL`]. Utiliser
283///   [`clone_thread`], qui exécute le thread via un trampoline assembleur (pas
284///   de retour Rust sur pile neuve) et expose le *join* par
285///   `CLONE_CHILD_CLEARTID`.
286///
287/// - **Namespaces** (`CLONE_NEWUSER`, `CLONE_NEWNS`, `CLONE_NEWPID`, …)
288///   — l'appelant doit posséder les capabilities nécessaires
289///   (typiquement `CAP_SYS_ADMIN`) ; à défaut, le syscall retourne
290///   [`Errno::EPERM`]. Sur Ubuntu 24.04, les user namespaces non
291///   privilégiés sont restreints par AppArmor
292///   (`kernel.apparmor_restrict_unprivileged_userns=1`) et l'appel
293///   échoue avec [`Errno::EPERM`] même côté unprivileged.
294///
295/// - **« Retourne deux fois »** — après succès, le syscall est exécuté
296///   côté parent **et** côté enfant ; les deux retours apparaissent ici
297///   comme `Ok(_)` distincts. L'enfant doit terminer son travail
298///   (typiquement par `std::process::exit` ou un futur `exit_group`
299///   wrapper) ; revenir « plus loin » dans le code appelant produit
300///   généralement un comportement surprenant pour l'utilisateur.
301///
302/// # Errors
303///
304/// - [`Errno::EINVAL`] : combinaison de flags refusée par cette PR
305///   (création de thread) ou par le kernel (configuration invalide).
306/// - [`Errno::EPERM`] : capabilities insuffisantes (NEWUSER refusé,
307///   AppArmor, …).
308/// - `EAGAIN` (constante non encore définie dans le stub partiel `Errno`)
309///   : limites système atteintes (nombre de processus).
310/// - `ENOMEM` (idem) : mémoire kernel insuffisante.
311/// - `EUSERS` (idem) : trop de namespaces utilisateurs créés.
312/// - `ENOSYS` (idem) : kernel antérieur à 5.3 (`clone3` indisponible).
313///
314/// Les constantes nommées en prose ci-dessus seront promues en
315/// `pub const` au fil des PRs qui les rencontrent réellement
316/// (cf. note « stub partiel » en tête du module `errno`).
317pub unsafe fn clone3(args: &CloneArgs) -> Result<CloneResult, Errno> {
318    // Refus explicite des cas non testés (Principe 4 : valider en amont).
319    if args.stack.is_some() {
320        return Err(Errno::EINVAL);
321    }
322    if args
323        .flags
324        .intersects(CloneFlags::VM | CloneFlags::THREAD | CloneFlags::SIGHAND)
325    {
326        return Err(Errno::EINVAL);
327    }
328
329    let want_pidfd = args.flags.contains(CloneFlags::PIDFD);
330    let mut pidfd_storage: i32 = -1;
331
332    let pidfd_addr: u64 = if want_pidfd {
333        // Adresse du i32 que le kernel renseignera côté parent en cas de succès.
334        let ptr: *mut i32 = &mut pidfd_storage;
335        ptr as u64
336    } else {
337        0
338    };
339
340    let kernel_args = build_kernel_clone_args(args, pidfd_addr);
341
342    let args_ptr: *const KernelCloneArgs = &kernel_args;
343    // size_of::<KernelCloneArgs>() = 88. Air ne cible que des cibles
344    // 64-bit (cf. ADR-014) ; `usize == u64`, donc le cast est exact.
345    #[allow(clippy::cast_lossless)]
346    let args_size: u64 = core::mem::size_of::<KernelCloneArgs>() as u64;
347
348    // SAFETY:
349    // - args_ptr pointe sur `kernel_args` (local valide pour toute la fonction).
350    // - args_size est exactement size_of::<KernelCloneArgs>().
351    // - Si CLONE_PIDFD est positionné, pidfd_addr pointe sur `pidfd_storage`
352    //   qui est local valide ; le kernel y écrit côté parent uniquement.
353    // - Aucune autre adresse mémoire utilisateur n'est passée au kernel
354    //   (parent_tid/child_tid/stack/set_tid/cgroup sont à 0).
355    // - Sémantique « retourne deux fois » : voir doc de la fonction.
356    let ret = unsafe { raw_syscall_clone3(args_ptr as u64, args_size) };
357
358    if ret < 0 {
359        return Err(errno_from_negative_syscall_ret(ret));
360    }
361
362    if ret == 0 {
363        // Côté enfant : ne pas lire `pidfd_storage` (le kernel n'a écrit que
364        // côté parent ; la copie CoW du child contient la valeur initiale).
365        return Ok(CloneResult::Child);
366    }
367
368    // Côté parent : `ret` est le PID enfant, borné par `PID_MAX_LIMIT`
369    // (= 2^22 sur 64-bit Linux), bien en deçà de `i32::MAX`. Troncature
370    // intentionnelle, sûre par construction.
371    #[allow(clippy::cast_possible_truncation)]
372    let child_pid_raw = ret as i32;
373    let child_pid = Pid::try_from_raw(child_pid_raw)
374        .expect("clone3 retour parent : PID strictement positif (POSIX)");
375
376    let child_pidfd = if want_pidfd {
377        let raw_fd = pidfd_storage;
378        // SAFETY: le kernel a écrit un fd valide dans `pidfd_storage` côté
379        // parent après succès de clone3 avec CLONE_PIDFD. Air en prend
380        // ownership ; OwnedFd::Drop fermera le fd.
381        let owned = unsafe { OwnedFd::from_raw_fd(raw_fd) };
382        Some(PidFd::from_owned_fd(owned))
383    } else {
384        None
385    };
386
387    Ok(CloneResult::Parent {
388        child_pid,
389        child_pidfd,
390    })
391}
392
393#[cfg(target_arch = "x86_64")]
394#[inline]
395unsafe fn raw_syscall_clone3(args_ptr: u64, args_size: u64) -> i64 {
396    let ret: i64;
397    // SAFETY:
398    // - SYS_clone3 (x86_64 = 435). Le kernel lit `args_size` octets à
399    //   l'adresse `args_ptr` (validité garantie par l'appelant) ; il peut
400    //   écrire à des sous-pointeurs (pidfd/parent_tid/child_tid) si
401    //   demandés. L'appelant a respecté ces préconditions.
402    // - L'ABI syscall x86_64 prend le numéro dans RAX, arg1/arg2 dans
403    //   RDI/RSI ; retourne en RAX ; clobbe RCX (RIP) et R11 (RFLAGS).
404    // - **Pas de `readonly`** : clone3 écrit dans la mémoire utilisateur
405    //   (output pointers) ; `readonly` serait incorrect ici.
406    // - Sémantique « retourne deux fois » : le syscall revient côté
407    //   parent (rax = pid > 0) et côté enfant (rax = 0). Les deux
408    //   retours sortent normalement de cette fonction.
409    unsafe {
410        core::arch::asm!(
411            "syscall",
412            in("rax") 435_i64,
413            in("rdi") args_ptr,
414            in("rsi") args_size,
415            lateout("rax") ret,
416            lateout("rcx") _,
417            lateout("r11") _,
418            options(nostack, preserves_flags),
419        );
420    }
421    ret
422}
423
424#[cfg(target_arch = "aarch64")]
425#[inline]
426unsafe fn raw_syscall_clone3(args_ptr: u64, args_size: u64) -> i64 {
427    let ret: i64;
428    // SAFETY: voir version x86_64 ; aarch64 syscall ABI : numéro en X8,
429    // arg1/arg2 en X0/X1, retour en X0. svc 0 effectue le syscall.
430    // Comme x86_64, **pas de `readonly`** (clone3 écrit en mémoire user).
431    unsafe {
432        core::arch::asm!(
433            "svc 0",
434            in("x8") 435_i64,
435            inout("x0") args_ptr => ret,
436            in("x1") args_size,
437            options(nostack, preserves_flags),
438        );
439    }
440    ret
441}
442
443// ─────────────────────────────────────────────────────────────────────────
444// clone_thread — création d'un thread (CLONE_VM) via clone3 + trampoline asm.
445//
446// Pourquoi une fonction DISTINCTE de `clone3` (et non un simple
447// `CloneResult::Child` étendu) ? Parce que le modèle « clone3 retourne deux
448// fois dans du Rust » est **unsound** pour un thread doté d'une pile NEUVE :
449// après le syscall, l'enfant reprend l'exécution avec `sp` = sommet de la pile
450// fournie. Laisser le code Rust « revenir » (épilogue de fonction : restauration
451// de registres callee-saved + `ret`/`blr x30`) lirait des **ordures** sur cette
452// pile vierge → crash. La discipline universelle (glibc/musl) est donc :
453// l'enfant ne **revient jamais** dans le code Rust appelant ; un trampoline en
454// assembleur appelle directement un point d'entrée `extern "C"` fourni, puis
455// termine le thread via `SYS_exit`. C'est ce que fait `clone_thread`.
456//
457// `clone3` reste, lui, dédié au **fork** (pas de pile neuve, l'enfant partage la
458// copie CoW de la pile parent : `CloneResult::Child` y est sound) et **rejette**
459// `stack`/`CLONE_VM` en amont.
460// ─────────────────────────────────────────────────────────────────────────
461
462/// Taille minimale (octets) acceptée pour la pile d'un thread `clone_thread`.
463/// Une page suffit largement au point d'entrée minimal ; les piles réelles sont
464/// dimensionnées par l'appelant (la libc Air, couche 1).
465const MIN_THREAD_STACK_SIZE: usize = 4096;
466
467/// Crée un **thread** (mémoire partagée, `CLONE_VM`) via `clone3(2)`.
468///
469/// Contrairement à [`clone3`] (réservé au fork), `clone_thread` ne « retourne
470/// pas deux fois » : le nouveau thread exécute le point d'entrée `entry(arg)`
471/// **sur sa pile dédiée** via un trampoline assembleur, puis — si `entry`
472/// revient — termine le **seul** thread courant par `SYS_exit` (jamais
473/// `exit_group`). Seul le **parent** revient de cette fonction, avec le [`Tid`]
474/// du nouveau thread.
475///
476/// # Join (terminaison observable)
477///
478/// Les threads `CLONE_THREAD` ne sont **pas** *waitable* via `waitid`. Le *join*
479/// se fait par `CLONE_CHILD_CLEARTID` : positionner [`CloneFlags::CHILD_CLEARTID`]
480/// et renseigner [`CloneArgs::child_tid`] sur un mot [`core::sync::atomic::AtomicU32`]
481/// partagé. À la mort du thread, le kernel remet ce mot à `0` et émet un
482/// `FUTEX_WAKE` (de portée **partagée** — sans `FUTEX_PRIVATE_FLAG`) dessus. Pour
483/// initialiser le mot avec le TID **sans course**, ajouter
484/// [`CloneFlags::PARENT_SETTID`] avec [`CloneArgs::parent_tid`] sur le **même**
485/// mot (écrit par le kernel, côté parent, avant le retour). Le parent joint
486/// alors en bouclant `futex_wait` jusqu'à lire `0` (cf. la famille
487/// [`futex`](crate::futex)).
488///
489/// # Errors
490///
491/// - [`Errno::EINVAL`] si la validation amont échoue : `stack` absent,
492///   [`CloneFlags::VM`] absent, adresse de pile nulle, taille `< 4096`, `addr`
493///   ou `size` non alignés sur 16, ou sommet de pile en débordement. Le kernel
494///   peut aussi rendre `EINVAL` pour une combinaison de drapeaux incohérente
495///   (p. ex. `CLONE_THREAD` avec un `exit_signal`).
496/// - `EAGAIN`/`ENOMEM` (stub partiel `Errno`) : limites système / mémoire kernel.
497///
498/// # Safety
499///
500/// Hautement `unsafe`. L'appelant doit garantir :
501///
502/// - **Pile.** `stack` désigne une région mémoire valide, possédée, exclusivement
503///   réservée au nouveau thread, d'au moins `size` octets, et **non libérée**
504///   tant que le thread n'est pas joint (sinon use-after-free de la pile).
505/// - **Point d'entrée.** `entry` ne doit exécuter que des opérations sûres dans
506///   ce contexte naissant : **aucune** variable `thread_local` (sauf
507///   [`CloneArgs::tls`] fourni avec un bloc TLS valide via [`CloneFlags::SETTLS`]),
508///   pas d'`unwind`/panic traversant le trampoline, pas de dépendance à un état
509///   d'initialisation propre au thread. Les écritures en mémoire **partagée**
510///   (VM commune) doivent être thread-safe (`Atomic*`).
511/// - **`arg`.** Sa signification est définie par `entry` ; s'il encode un
512///   pointeur, la cible doit rester vivante et correctement synchronisée.
513/// - **Join.** `child_tid`/`parent_tid` (si fournis) pointent un `AtomicU32`
514///   vivant jusqu'à la fin du *join*.
515pub unsafe fn clone_thread(
516    args: &CloneArgs,
517    entry: extern "C" fn(usize),
518    arg: usize,
519) -> Result<Tid, Errno> {
520    // ── Validation amont (Principe 4) ───────────────────────────────────
521    let stack: StackSpecification = args.stack.ok_or(Errno::EINVAL)?;
522    // Un thread partage l'espace d'adressage : CLONE_VM est obligatoire (sinon
523    // la pile neuve ne sert à rien et le join par mot partagé est impossible).
524    if !args.flags.contains(CloneFlags::VM) {
525        return Err(Errno::EINVAL);
526    }
527    if stack.addr.is_null() {
528        return Err(Errno::EINVAL);
529    }
530    if stack.size < MIN_THREAD_STACK_SIZE {
531        return Err(Errno::EINVAL);
532    }
533    let base = stack.addr.addr();
534    // Alignement 16 de la base ET de la taille ⇒ sommet (`base + size`) aligné 16
535    // (exigence ABI x86_64/aarch64 au point d'entrée). `& 0xF` : pas d'arithmétique.
536    if base & 0xF != 0 || stack.size & 0xF != 0 {
537        return Err(Errno::EINVAL);
538    }
539    // Sommet de pile sans débordement d'adresse (Principe 2 : pas d'« + » nu).
540    base.checked_add(stack.size).ok_or(Errno::EINVAL)?;
541
542    // Un thread n'a pas de pidfd : pidfd_addr = 0 (CLONE_PIDFD non pris en charge
543    // par ce chemin ; il appartient au fork).
544    let kernel_args = build_kernel_clone_args(args, 0);
545    let args_ptr: *const KernelCloneArgs = &kernel_args;
546    // size_of::<KernelCloneArgs>() = 88 ; cibles 64-bit uniquement (ADR-014).
547    #[allow(clippy::cast_lossless)]
548    let args_size: u64 = core::mem::size_of::<KernelCloneArgs>() as u64;
549
550    // SAFETY:
551    // - `args_ptr` pointe `kernel_args` (local vivant pour toute la fonction) ;
552    //   `args_size` est exactement sa taille. Le kernel lit cette structure et,
553    //   via `child_tid`/`parent_tid`, peut écrire les mots `AtomicU32` fournis
554    //   (validité garantie par le contrat `unsafe` de cette fonction).
555    // - Le côté ENFANT ne revient pas ici (le trampoline appelle `entry(arg)`
556    //   sur la pile neuve puis `SYS_exit`) : aucun retour Rust sur pile vierge.
557    // - Le côté PARENT reçoit `ret` (TID > 0, ou -errno) et le rend normalement.
558    let ret = unsafe { raw_syscall_clone3_thread(args_ptr as u64, args_size, entry, arg) };
559
560    if ret < 0 {
561        return Err(errno_from_negative_syscall_ret(ret));
562    }
563    // `ret == 0` est inobservable ici (l'enfant ne revient jamais dans ce code).
564    // Le parent reçoit le TID enfant, borné par `PID_MAX_LIMIT` ≪ `i32::MAX`.
565    #[allow(clippy::cast_possible_truncation)]
566    let tid_raw = ret as i32;
567    let tid = Tid::try_from_raw(tid_raw)
568        .expect("clone_thread retour parent : TID strictement positif (POSIX)");
569    Ok(tid)
570}
571
572#[cfg(target_arch = "x86_64")]
573#[inline]
574unsafe fn raw_syscall_clone3_thread(
575    args_ptr: u64,
576    args_size: u64,
577    entry: extern "C" fn(usize),
578    arg: usize,
579) -> i64 {
580    let ret: i64;
581    // SAFETY:
582    // - SYS_clone3 (x86_64 = 435) crée le thread ; le kernel lit `args_size`
583    //   octets à `args_ptr` (struct clone_args valide) et règle `sp` de l'enfant
584    //   au sommet de la pile (`stack`/`stack_size` du struct).
585    // - « Retourne deux fois » : côté PARENT `rax` = TID (> 0) ou -errno → on
586    //   branche en `2:` et rend `rax` à Rust. Côté ENFANT `rax` = 0 : on NE
587    //   revient PAS dans le code Rust (sa pile est vierge). On place `arg` dans
588    //   rdi puis on `call` `entry` (ABI C) sur la pile neuve. Si `entry` revient,
589    //   on termine le SEUL thread courant via SYS_exit (x86_64 = 60), jamais
590    //   exit_group. `ud2` est un garde-fou inatteignable.
591    // - Clobbers du PARENT uniquement (l'enfant ne rend jamais la main au
592    //   compilateur) : rax (ret), rcx et r11 (clobbés par `syscall`), drapeaux
593    //   (pas de `preserves_flags`). `entry`/`arg` sont **épinglés** sur des
594    //   registres callee-saved (r12/r13) que `syscall` ne touche pas : un
595    //   registre callee-saved choisi par le compilateur (`in(reg)`) pourrait
596    //   être rcx/r11 et serait écrasé par le `syscall` AVANT l'usage enfant
597    //   (l'allocateur suppose les `lateout` produits *après* les `in` lus).
598    //   `inout("rdi")` documente que rdi (args_ptr puis arg) est écrasé. Pas de
599    //   `nostack` : le `call` enfant pousse sur la pile (neuve).
600    unsafe {
601        core::arch::asm!(
602            "syscall",
603            "test rax, rax",
604            "jnz 2f",
605            "mov rdi, r13",
606            "call r12",
607            "mov rax, 60", // SYS_exit (x86_64 = 60) : termine CE thread.
608            "xor edi, edi",
609            "syscall",
610            "ud2",
611            "2:",
612            inout("rax") 435_i64 => ret, // SYS_clone3 (x86_64 = 435).
613            inout("rdi") args_ptr => _,
614            in("rsi") args_size,
615            in("r12") entry,
616            in("r13") arg,
617            lateout("rcx") _,
618            lateout("r11") _,
619        );
620    }
621    ret
622}
623
624#[cfg(target_arch = "aarch64")]
625#[inline]
626unsafe fn raw_syscall_clone3_thread(
627    args_ptr: u64,
628    args_size: u64,
629    entry: extern "C" fn(usize),
630    arg: usize,
631) -> i64 {
632    let ret: i64;
633    // SAFETY: voir la version x86_64. ABI aarch64 : SYS_clone3 (aarch64 = 435)
634    // en X8, args en X0/X1, retour en X0. Côté ENFANT (`x0 == 0`) : `arg` → x0,
635    // `blr` `entry` sur la pile neuve, puis SYS_exit (aarch64 = 93) — jamais
636    // exit_group. `udf #0` garde-fou inatteignable. Clobbers du PARENT
637    // uniquement : x0 (ret) ; `x8` est `in` (réécrit côté enfant, consommé).
638    // `entry`/`arg` sont **épinglés** sur des registres callee-saved (x21/x20)
639    // intacts jusqu'à leur usage enfant (symétrie avec x86_64). Pas de `nostack`
640    // (`blr` enfant utilise la pile).
641    unsafe {
642        core::arch::asm!(
643            "svc 0",
644            "cbnz x0, 2f",
645            "mov x0, x20",
646            "blr x21",
647            "mov x8, #93", // SYS_exit (aarch64 = 93) : termine CE thread.
648            "mov x0, #0",
649            "svc 0",
650            "udf #0",
651            "2:",
652            in("x8") 435_i64, // SYS_clone3 (aarch64 = 435).
653            inout("x0") args_ptr => ret,
654            in("x1") args_size,
655            in("x21") entry,
656            in("x20") arg,
657        );
658    }
659    ret
660}
661
662// ─────────────────────────────────────────────────────────────────────────
663// exit_group — terminaison immédiate du processus, sans cleanup userspace.
664//
665// Helper **interne** (`pub(crate)`, hors API publique gelée) : c'est la
666// discipline de sortie correcte pour l'enfant d'un `clone3` fork dans un
667// programme multi-thread. `std::process::exit` exécute les atexit handlers
668// (flush stdio, machinerie de capture du harnais de test…) qui peuvent
669// **deadlocker** sur des mutex hérités verrouillés par les threads du parent
670// — inexistants dans l'enfant. Observé sur aarch64 (`futex_do_wait`) là où
671// x86_64 masquait le bug. `exit_group` est un syscall direct : aucun verrou,
672// aucun atexit, terminaison atomique de tous les threads du processus.
673// ─────────────────────────────────────────────────────────────────────────
674
675/// Termine immédiatement le processus appelant (tous ses threads) avec le
676/// code `status`, via le syscall `child_exit(2)`.
677///
678/// Ne retourne jamais (`!`). N'exécute **aucun** atexit handler, ne flush
679/// **aucun** buffer userspace : c'est précisément ce qui la rend sûre comme
680/// sortie d'un enfant forké d'un programme multi-thread (cf. note ci-dessus).
681///
682/// Primitive publique (spec `family-process`, sous-section 3) : c'est le
683/// chemin de sortie correct pour l'**enfant d'un `clone3` fork** dont l'`exec`
684/// a échoué — terminer immédiatement, sans réveiller la machinerie userspace
685/// héritée du parent. `air-process` (couche 1) l'utilise après un `execve`
686/// raté dans l'enfant. Le code de sortie observé par `waitid`/`wait` est
687/// `status & 0xFF`.
688pub fn exit_group(status: i32) -> ! {
689    // SAFETY: `exit_group(2)` ne lit ni n'écrit aucune mémoire utilisateur ;
690    // il termine le processus et ne retourne pas. `options(noreturn)` est
691    // donc correct.
692    unsafe { raw_syscall_exit_group(status) }
693}
694
695#[cfg(target_arch = "x86_64")]
696#[inline]
697unsafe fn raw_syscall_exit_group(status: i32) -> ! {
698    // SAFETY: SYS_exit_group (x86_64 = 231) ne touche aucune mémoire
699    // utilisateur et ne retourne jamais. `noreturn` autorisé.
700    unsafe {
701        core::arch::asm!(
702            "syscall",
703            in("rax") 231_i64,
704            in("rdi") i64::from(status),
705            options(nostack, noreturn),
706        );
707    }
708}
709
710#[cfg(target_arch = "aarch64")]
711#[inline]
712unsafe fn raw_syscall_exit_group(status: i32) -> ! {
713    // SAFETY: SYS_exit_group (aarch64 = 94) ne touche aucune mémoire
714    // utilisateur et ne retourne jamais. `noreturn` autorisé.
715    unsafe {
716        core::arch::asm!(
717            "svc 0",
718            in("x8") 94_i64,
719            in("x0") i64::from(status),
720            options(nostack, noreturn),
721        );
722    }
723}
724
725// ─────────────────────────────────────────────────────────────────────────
726// waitid
727// ─────────────────────────────────────────────────────────────────────────
728
729// `idtype` du syscall waitid (cf. `linux/wait.h`).
730const P_ALL: i64 = 0;
731const P_PID: i64 = 1;
732const P_PGID: i64 = 2;
733const P_PIDFD: i64 = 3;
734
735// `si_code` valeurs pour SIGCHLD (cf. `linux/signal.h`).
736const CLD_EXITED: i32 = 1;
737const CLD_KILLED: i32 = 2;
738const CLD_DUMPED: i32 = 3;
739const CLD_TRAPPED: i32 = 4;
740const CLD_STOPPED: i32 = 5;
741const CLD_CONTINUED: i32 = 6;
742
743/// Représentation locale du `siginfo_t` Linux pour SIGCHLD.
744///
745/// Layout vérifié sur x86_64 et aarch64 (deux seules architectures Air,
746/// cf. ADR-014) : 128 octets, union 8-octets alignée à l'offset 16. Les
747/// champs `si_pid`/`si_uid`/`si_status` sont au début de la variante
748/// `_sigchld` de l'union ; les offsets reflètent cette disposition.
749#[repr(C)]
750#[derive(Clone, Copy)]
751struct KernelSiginfo {
752    si_signo: i32,
753    si_errno: i32,
754    si_code: i32,
755    _pad0: i32,
756    si_pid: i32,
757    si_uid: u32,
758    si_status: i32,
759    _trailing: [u8; 100],
760}
761
762impl KernelSiginfo {
763    fn zeroed() -> Self {
764        Self {
765            si_signo: 0,
766            si_errno: 0,
767            si_code: 0,
768            _pad0: 0,
769            si_pid: 0,
770            si_uid: 0,
771            si_status: 0,
772            _trailing: [0u8; 100],
773        }
774    }
775}
776
777const _: () = {
778    // Vérifie à la compilation que KernelSiginfo fait bien 128 octets : si le
779    // layout C devait dériver (futur portage), cette assertion explose.
780    assert!(core::mem::size_of::<KernelSiginfo>() == 128);
781};
782
783/// Attend un événement sur un processus enfant via le syscall `waitid(2)`.
784///
785/// Cf. `docs/specs/layer-0/family-process.md` section `waitid`. Retourne :
786///
787/// - `Ok(Some(status))` si un événement a été reçu.
788/// - `Ok(None)` si [`WaitOptions::NOHANG`] était positionné et qu'aucun
789///   événement n'était disponible (kernel renvoie une `siginfo` zéroée).
790/// - `Err(Errno::EINTR)` si l'attente a été interrompue par un signal :
791///   **non retried automatiquement** (convention 2 ADR-021). L'appelant
792///   qui veut retry écrit la boucle explicitement.
793///
794/// # Errors
795///
796/// - [`Errno::ECHILD`] : aucun enfant à attendre dans la cible.
797/// - [`Errno::EINTR`] : interrompu par un signal, voir ci-dessus.
798/// - [`Errno::EINVAL`] : options invalides (aucun parmi
799///   `EXITED`/`STOPPED`/`CONTINUED` positionné) ou cible invalide.
800pub fn waitid(target: WaitTarget<'_>, options: WaitOptions) -> Result<Option<WaitStatus>, Errno> {
801    use air_sys_types::fd::AsRawFd;
802
803    let (idtype, id_i64) = match target {
804        WaitTarget::AnyChild => (P_ALL, 0_i64),
805        WaitTarget::Pid(p) => (P_PID, i64::from(p.as_raw())),
806        WaitTarget::ProcessGroup(p) => (P_PGID, i64::from(p.as_raw())),
807        WaitTarget::AnyProcessGroup => (P_PGID, 0_i64),
808        WaitTarget::PidFd(fd) => (P_PIDFD, i64::from(fd.as_raw_fd())),
809    };
810
811    let mut info = KernelSiginfo::zeroed();
812    let info_ptr: *mut KernelSiginfo = &mut info;
813
814    let options_i64 = i64::from(options.bits());
815
816    // SAFETY:
817    // - info_ptr pointe sur un KernelSiginfo local (128 octets, alignement 4) que
818    //   le kernel renseigne au plus jusqu'à 128 octets — taille fixe et
819    //   exactement celle qu'attend l'ABI Linux pour `siginfo_t`.
820    // - rusage = 0 : on n'expose pas le 5e argument optionnel.
821    let ret = unsafe { raw_syscall_waitid(idtype, id_i64, info_ptr as u64, options_i64) };
822
823    if ret < 0 {
824        return Err(errno_from_negative_syscall_ret(ret));
825    }
826
827    // Cas NOHANG sans événement : le kernel laisse `siginfo` zéroé
828    // (en particulier si_signo == 0, parce qu'un événement waitid réel
829    // a toujours si_signo = SIGCHLD = 17 sur x86_64/aarch64).
830    if info.si_signo == 0 {
831        return Ok(None);
832    }
833
834    let pid = Pid::try_from_raw(info.si_pid)
835        .expect("waitid : si_pid positif pour un événement enfant réel");
836    let event = siginfo_to_wait_event(&info);
837
838    Ok(Some(WaitStatus {
839        pid,
840        uid: info.si_uid,
841        event,
842    }))
843}
844
845/// Helper privé : convertit un `KernelSiginfo` rempli par le kernel en
846/// [`WaitEvent`] typé. Extrait du corps de [`waitid`] pour pouvoir être
847/// testé unitairement avec des `KernelSiginfo` synthétisés en mémoire — ce qui
848/// permet de couvrir les 5 variantes (Exited, Killed, Stopped, Continued,
849/// Trapped) sans dépendre de scénarios kernel réels.
850///
851/// **Limite connue** : ce test sur KernelSiginfo synthétisés couvre la *logique
852/// de dispatch* mais ne valide pas le *remplissage* effectif de
853/// `siginfo_t` par le kernel pour chaque code `CLD_*`. Voir `docs/JOURNAL.md`,
854/// session 2026-05-22 (suite : clone3+waitid), trou de couverture acté.
855fn siginfo_to_wait_event(info: &KernelSiginfo) -> WaitEvent {
856    match info.si_code {
857        CLD_EXITED => WaitEvent::Exited {
858            code: info.si_status,
859        },
860        CLD_KILLED => WaitEvent::Killed {
861            signal: Signal::try_from_raw(info.si_status)
862                .expect("CLD_KILLED : si_status est un numéro de signal valide"),
863            core_dumped: false,
864        },
865        CLD_DUMPED => WaitEvent::Killed {
866            signal: Signal::try_from_raw(info.si_status)
867                .expect("CLD_DUMPED : si_status est un numéro de signal valide"),
868            core_dumped: true,
869        },
870        CLD_TRAPPED => WaitEvent::Trapped {
871            signal: Signal::try_from_raw(info.si_status)
872                .expect("CLD_TRAPPED : si_status est un numéro de signal valide"),
873        },
874        CLD_STOPPED => WaitEvent::Stopped {
875            signal: Signal::try_from_raw(info.si_status)
876                .expect("CLD_STOPPED : si_status est un numéro de signal valide"),
877        },
878        CLD_CONTINUED => WaitEvent::Continued,
879        unknown => unreachable!("kernel a renseigné un si_code inconnu pour SIGCHLD : {unknown}"),
880    }
881}
882
883#[cfg(target_arch = "x86_64")]
884#[inline]
885unsafe fn raw_syscall_waitid(idtype: i64, id: i64, infop: u64, options: i64) -> i64 {
886    let ret: i64;
887    // SAFETY:
888    // - SYS_waitid (x86_64 = 247). Le kernel écrit dans `infop` au plus
889    //   128 octets — validité garantie par l'appelant (KernelSiginfo local).
890    // - rusage (5e arg) à 0 : non utilisé.
891    // - x86_64 syscall ABI : numéro en RAX, args en RDI/RSI/RDX/R10/R8/R9 ;
892    //   retour en RAX ; clobbe RCX et R11.
893    // - **Pas de `readonly`** : le kernel écrit dans `*infop`.
894    unsafe {
895        core::arch::asm!(
896            "syscall",
897            in("rax") 247_i64,
898            in("rdi") idtype,
899            in("rsi") id,
900            in("rdx") infop,
901            in("r10") options,
902            in("r8") 0_i64,
903            lateout("rax") ret,
904            lateout("rcx") _,
905            lateout("r11") _,
906            options(nostack, preserves_flags),
907        );
908    }
909    ret
910}
911
912#[cfg(target_arch = "aarch64")]
913#[inline]
914unsafe fn raw_syscall_waitid(idtype: i64, id: i64, infop: u64, options: i64) -> i64 {
915    let ret: i64;
916    // SAFETY: voir version x86_64 ; aarch64 syscall ABI :
917    // numéro en X8 (= 95), args en X0..X4 (5 args, le 5e = rusage = 0).
918    unsafe {
919        core::arch::asm!(
920            "svc 0",
921            in("x8") 95_i64,
922            inout("x0") idtype => ret,
923            in("x1") id,
924            in("x2") infop,
925            in("x3") options,
926            in("x4") 0_i64,
927            options(nostack, preserves_flags),
928        );
929    }
930    ret
931}
932
933// ─────────────────────────────────────────────────────────────────────────
934// pidfd_open
935// ─────────────────────────────────────────────────────────────────────────
936
937/// Ouvre un pidfd qui référence le processus identifié par `pid`.
938///
939/// Cf. `docs/specs/layer-0/family-process.md`, sous-section 3,
940/// `pidfd_open`. Le pidfd retourné reste valide même après la mort du
941/// processus original (pas de race sur le PID recyclé). Il peut être
942/// utilisé par [`waitid`] (variant `WaitTarget::PidFd`),
943/// [`pidfd_send_signal`], [`pidfd_getfd`], et par les syscalls
944/// `poll`/`epoll` pour détecter la mort du processus.
945///
946/// # Errors
947///
948/// - [`Errno::EINVAL`] : un bit invalide dans `flags`.
949/// - [`Errno::ESRCH`] : aucun processus n'a ce PID.
950/// - `ENOMEM` (stub partiel `Errno`) : mémoire kernel insuffisante.
951pub fn pidfd_open(pid: Pid, flags: PidFdOpenFlags) -> Result<PidFd, Errno> {
952    // SAFETY:
953    // - `pidfd_open(2)` ne lit ni n'écrit aucune mémoire utilisateur :
954    //   il prend deux scalaires et retourne un fd.
955    // - L'ABI x86_64 / aarch64 est documentée dans
956    //   `raw_syscall_pidfd_open` (clobbers, options).
957    let ret = unsafe { raw_syscall_pidfd_open(pid.as_raw(), flags.bits()) };
958    if ret < 0 {
959        return Err(errno_from_negative_syscall_ret(ret));
960    }
961    // `ret` est un fd kernel valide. Linux borne les fd à `i32::MAX`
962    // (`/proc/sys/fs/file-max` n'excède pas cette limite par construction
963    // dans le kernel), donc la troncature est exacte.
964    #[allow(clippy::cast_possible_truncation)]
965    let new_fd: RawFd = ret as RawFd;
966    // SAFETY: le kernel vient d'allouer un fd valide et nous en a
967    // transféré la propriété ; Air le wrappe immédiatement dans
968    // `OwnedFd` puis dans `PidFd`. La fermeture est garantie par Drop.
969    let owned = unsafe { OwnedFd::from_raw_fd(new_fd) };
970    Ok(PidFd::from_owned_fd(owned))
971}
972
973#[cfg(target_arch = "x86_64")]
974#[inline]
975unsafe fn raw_syscall_pidfd_open(pid: i32, flags: u32) -> i64 {
976    let ret: i64;
977    // SAFETY:
978    // - SYS_pidfd_open (x86_64 = 434) ne touche à aucune mémoire
979    //   utilisateur ; il ne fait que retourner un fd.
980    // - ABI syscall x86_64 : numéro en RAX, arg1/arg2 en RDI/RSI ;
981    //   retour en RAX ; clobbe RCX (RIP) et R11 (RFLAGS).
982    // - `readonly` correct : pas d'écriture en mémoire utilisateur.
983    unsafe {
984        core::arch::asm!(
985            "syscall",
986            in("rax") 434_i64,
987            in("rdi") i64::from(pid),
988            in("rsi") i64::from(flags),
989            lateout("rax") ret,
990            lateout("rcx") _,
991            lateout("r11") _,
992            options(nostack, preserves_flags, readonly),
993        );
994    }
995    ret
996}
997
998#[cfg(target_arch = "aarch64")]
999#[inline]
1000unsafe fn raw_syscall_pidfd_open(pid: i32, flags: u32) -> i64 {
1001    let ret: i64;
1002    // SAFETY: ABI aarch64 ; numéro en X8, args en X0/X1, retour en X0.
1003    unsafe {
1004        core::arch::asm!(
1005            "svc 0",
1006            in("x8") 434_i64,
1007            inout("x0") i64::from(pid) => ret,
1008            in("x1") i64::from(flags),
1009            options(nostack, preserves_flags, readonly),
1010        );
1011    }
1012    ret
1013}
1014
1015// ─────────────────────────────────────────────────────────────────────────
1016// pidfd_send_signal
1017// ─────────────────────────────────────────────────────────────────────────
1018
1019/// Envoie un signal au processus référencé par `pidfd`.
1020///
1021/// Cf. `docs/specs/layer-0/family-process.md`, sous-section 3,
1022/// `pidfd_send_signal`. À préférer à un futur `kill(pid, sig)` quand un
1023/// pidfd est disponible : pas de race sur le PID recyclé.
1024///
1025/// # `info`
1026///
1027/// Si `Some(info)`, le kernel utilise le `siginfo_t` fourni (sémantique
1028/// `rt_sigqueueinfo`). Si `None`, le kernel synthétise un `siginfo_t`
1029/// avec `si_code = SI_USER`, équivalent à un `kill(2)` standard.
1030///
1031/// **Note périmètre.** Cette PR n'expose **aucun constructeur public**
1032/// pour [`SignalInfo`] (stub partiel) ; depuis l'extérieur de la crate,
1033/// seul `None` est passable. Le bras `Some(_)` du wrapper reste
1034/// type-reachable mais value-unreachable jusqu'à la PR `family-signal`.
1035///
1036/// # Errors
1037///
1038/// - [`Errno::EBADF`] : pidfd invalide.
1039/// - [`Errno::EINVAL`] : signal invalide.
1040/// - [`Errno::EPERM`] : permissions insuffisantes pour signaler cette cible.
1041/// - [`Errno::ESRCH`] : le processus référencé n'existe plus.
1042pub fn pidfd_send_signal(
1043    pidfd: BorrowedFd<'_>,
1044    signal: Signal,
1045    info: Option<&SignalInfo>,
1046) -> Result<(), Errno> {
1047    let info_ptr: u64 = match info {
1048        Some(i) => {
1049            let p: *const SignalInfo = i;
1050            p as u64
1051        }
1052        None => 0,
1053    };
1054
1055    // SAFETY:
1056    // - `pidfd_send_signal(2)` lit `*info` si le pointeur est non nul ;
1057    //   il ne **modifie** aucune mémoire utilisateur (`readonly` OK).
1058    // - Si `info_ptr == 0` (cas `None`), le kernel synthétise un siginfo.
1059    // - L'appelant garantit que `pidfd` est ouvert (BorrowedFd typé).
1060    // - `flags` (5e arg) est réservé : doit valoir 0.
1061    let ret = unsafe {
1062        raw_syscall_pidfd_send_signal(pidfd.as_raw_fd(), signal.as_raw(), info_ptr, 0_u32)
1063    };
1064    if ret < 0 {
1065        return Err(errno_from_negative_syscall_ret(ret));
1066    }
1067    Ok(())
1068}
1069
1070#[cfg(target_arch = "x86_64")]
1071#[inline]
1072unsafe fn raw_syscall_pidfd_send_signal(pidfd: i32, signal: i32, info: u64, flags: u32) -> i64 {
1073    let ret: i64;
1074    // SAFETY:
1075    // - SYS_pidfd_send_signal (x86_64 = 424). Lecture seulement de
1076    //   `*info` (et seulement si non-null) ; pas d'écriture en mémoire
1077    //   utilisateur, d'où `readonly`.
1078    // - ABI x86_64 : args en RDI/RSI/RDX/R10 ; retour en RAX ; clobbe
1079    //   RCX et R11.
1080    unsafe {
1081        core::arch::asm!(
1082            "syscall",
1083            in("rax") 424_i64,
1084            in("rdi") i64::from(pidfd),
1085            in("rsi") i64::from(signal),
1086            in("rdx") info,
1087            in("r10") i64::from(flags),
1088            lateout("rax") ret,
1089            lateout("rcx") _,
1090            lateout("r11") _,
1091            options(nostack, preserves_flags, readonly),
1092        );
1093    }
1094    ret
1095}
1096
1097#[cfg(target_arch = "aarch64")]
1098#[inline]
1099unsafe fn raw_syscall_pidfd_send_signal(pidfd: i32, signal: i32, info: u64, flags: u32) -> i64 {
1100    let ret: i64;
1101    // SAFETY: ABI aarch64 (numéro en X8, args en X0..X3).
1102    unsafe {
1103        core::arch::asm!(
1104            "svc 0",
1105            in("x8") 424_i64,
1106            inout("x0") i64::from(pidfd) => ret,
1107            in("x1") i64::from(signal),
1108            in("x2") info,
1109            in("x3") i64::from(flags),
1110            options(nostack, preserves_flags, readonly),
1111        );
1112    }
1113    ret
1114}
1115
1116// ─────────────────────────────────────────────────────────────────────────
1117// pidfd_getfd
1118// ─────────────────────────────────────────────────────────────────────────
1119
1120/// Duplique un FD ouvert dans le processus référencé par `pidfd`.
1121///
1122/// Cf. `docs/specs/layer-0/family-process.md`, sous-section 3,
1123/// `pidfd_getfd`. Cas d'usage spécialisé : debuggers, outils de
1124/// surveillance, transfert de ressources entre processus coopérant.
1125///
1126/// Le FD retourné est **propriété de l'appelant** ([`OwnedFd`]) ; il
1127/// pointe sur la même entrée du file table que `target_fd` côté
1128/// processus cible. La fermeture côté cible ne ferme pas la copie locale,
1129/// et réciproquement.
1130///
1131/// # Errors
1132///
1133/// - [`Errno::EBADF`] : `pidfd` invalide, ou `target_fd` non ouvert
1134///   dans le processus cible.
1135/// - [`Errno::EPERM`] : pas de `CAP_SYS_PTRACE` et pas le même
1136///   utilisateur que la cible. Sur Ubuntu, Yama `ptrace_scope ≥ 2`
1137///   peut également bloquer.
1138/// - [`Errno::EINVAL`] : `flags` non nul (réservé).
1139/// - [`Errno::ESRCH`] : le processus référencé n'existe plus.
1140pub fn pidfd_getfd(pidfd: BorrowedFd<'_>, target_fd: RawFd, flags: u32) -> Result<OwnedFd, Errno> {
1141    // SAFETY:
1142    // - `pidfd_getfd(2)` ne touche aucune mémoire utilisateur : il
1143    //   alloue côté kernel une nouvelle entrée dans la file table de
1144    //   l'appelant pointant sur la même file que `target_fd` côté cible.
1145    // - `flags` est réservé (doit être 0 sur kernel actuel).
1146    let ret = unsafe { raw_syscall_pidfd_getfd(pidfd.as_raw_fd(), target_fd, flags) };
1147    if ret < 0 {
1148        return Err(errno_from_negative_syscall_ret(ret));
1149    }
1150    #[allow(clippy::cast_possible_truncation)]
1151    let new_fd: RawFd = ret as RawFd;
1152    // SAFETY: kernel vient de transférer la propriété d'un fd valide.
1153    Ok(unsafe { OwnedFd::from_raw_fd(new_fd) })
1154}
1155
1156#[cfg(target_arch = "x86_64")]
1157#[inline]
1158unsafe fn raw_syscall_pidfd_getfd(pidfd: i32, target_fd: i32, flags: u32) -> i64 {
1159    let ret: i64;
1160    // SAFETY: SYS_pidfd_getfd (x86_64 = 438) ne lit ni n'écrit en
1161    // mémoire utilisateur (`readonly`).
1162    unsafe {
1163        core::arch::asm!(
1164            "syscall",
1165            in("rax") 438_i64,
1166            in("rdi") i64::from(pidfd),
1167            in("rsi") i64::from(target_fd),
1168            in("rdx") i64::from(flags),
1169            lateout("rax") ret,
1170            lateout("rcx") _,
1171            lateout("r11") _,
1172            options(nostack, preserves_flags, readonly),
1173        );
1174    }
1175    ret
1176}
1177
1178#[cfg(target_arch = "aarch64")]
1179#[inline]
1180unsafe fn raw_syscall_pidfd_getfd(pidfd: i32, target_fd: i32, flags: u32) -> i64 {
1181    let ret: i64;
1182    // SAFETY: ABI aarch64 ; numéro en X8 (= 438), args en X0..X2.
1183    unsafe {
1184        core::arch::asm!(
1185            "svc 0",
1186            in("x8") 438_i64,
1187            inout("x0") i64::from(pidfd) => ret,
1188            in("x1") i64::from(target_fd),
1189            in("x2") i64::from(flags),
1190            options(nostack, preserves_flags, readonly),
1191        );
1192    }
1193    ret
1194}
1195
1196// ─────────────────────────────────────────────────────────────────────────
1197// Groupes et sessions (sous-section 4 de family-process.md).
1198// ─────────────────────────────────────────────────────────────────────────
1199
1200/// Place `pid` dans le process group `pgid`.
1201///
1202/// Cf. `docs/specs/layer-0/family-process.md` sous-section 4. Sémantique
1203/// POSIX standard ; cas d'usage : shells, démons, applications terminal.
1204///
1205/// `pid = None` désigne le processus courant (cf. ADR-021 convention 1,
1206/// remplaçant la sentinelle kernel `0`). `pgid = None` désigne « créer un
1207/// nouveau process group dont le leader est `pid` » (sentinelle `0` côté
1208/// kernel).
1209///
1210/// # Errors
1211///
1212/// - [`Errno::EINVAL`] : `pgid` ne désigne pas un process group valide
1213///   dans la session courante.
1214/// - [`Errno::EPERM`] : `pid` n'est pas l'appelant ou un de ses enfants,
1215///   ou tentative de déplacer un processus hors session.
1216/// - [`Errno::ESRCH`] : `pid` n'existe pas.
1217pub fn setpgid(pid: Option<Pid>, pgid: Option<Pid>) -> Result<(), Errno> {
1218    let pid_arg = pid.map_or(0_i32, |p| p.as_raw());
1219    let pgid_arg = pgid.map_or(0_i32, |p| p.as_raw());
1220    // SAFETY: setpgid(2) ne touche aucune mémoire utilisateur ; lecture
1221    // des deux pid_t scalaires.
1222    let ret = unsafe { raw_syscall_setpgid(pid_arg, pgid_arg) };
1223    if ret < 0 {
1224        return Err(errno_from_negative_syscall_ret(ret));
1225    }
1226    Ok(())
1227}
1228
1229/// Retourne le process group ID de `pid` (ou du processus courant si `None`).
1230///
1231/// # Errors
1232///
1233/// - [`Errno::ESRCH`] : `pid` n'existe pas.
1234pub fn getpgid(pid: Option<Pid>) -> Result<Pid, Errno> {
1235    let pid_arg = pid.map_or(0_i32, |p| p.as_raw());
1236    // SAFETY: getpgid(2) ne touche aucune mémoire utilisateur.
1237    let ret = unsafe { raw_syscall_getpgid(pid_arg) };
1238    if ret < 0 {
1239        return Err(errno_from_negative_syscall_ret(ret));
1240    }
1241    // PGID retourné est un pid_t > 0 (process group ID = pid du leader,
1242    // qui est lui-même positif). Troncature exacte.
1243    #[allow(clippy::cast_possible_truncation)]
1244    let raw = ret as i32;
1245    Ok(Pid::try_from_raw(raw).expect("kernel : pgid strictement positif"))
1246}
1247
1248/// Crée une nouvelle session avec l'appelant comme session leader, et le
1249/// place dans un nouveau process group dont il est le leader. Retourne le
1250/// nouveau session ID.
1251///
1252/// # Errors
1253///
1254/// - [`Errno::EPERM`] : l'appelant est déjà process group leader (le test
1255///   runner cargo est typiquement un pgrp leader → `setsid` échoue, ce
1256///   qui est exploité par les tests).
1257pub fn setsid() -> Result<Pid, Errno> {
1258    // SAFETY: setsid(2) ne touche aucune mémoire utilisateur.
1259    let ret = unsafe { raw_syscall_setsid() };
1260    if ret < 0 {
1261        return Err(errno_from_negative_syscall_ret(ret));
1262    }
1263    #[allow(clippy::cast_possible_truncation)]
1264    let raw = ret as i32;
1265    Ok(Pid::try_from_raw(raw).expect("kernel : nouveau SID strictement positif"))
1266}
1267
1268/// Retourne le session ID de `pid` (ou du processus courant si `None`).
1269///
1270/// # Errors
1271///
1272/// - [`Errno::EPERM`] : `pid` est dans une session différente de l'appelant
1273///   et le kernel refuse de divulguer (rare).
1274/// - [`Errno::ESRCH`] : `pid` n'existe pas.
1275pub fn getsid(pid: Option<Pid>) -> Result<Pid, Errno> {
1276    let pid_arg = pid.map_or(0_i32, |p| p.as_raw());
1277    // SAFETY: getsid(2) ne touche aucune mémoire utilisateur.
1278    let ret = unsafe { raw_syscall_getsid(pid_arg) };
1279    if ret < 0 {
1280        return Err(errno_from_negative_syscall_ret(ret));
1281    }
1282    #[allow(clippy::cast_possible_truncation)]
1283    let raw = ret as i32;
1284    Ok(Pid::try_from_raw(raw).expect("kernel : SID strictement positif"))
1285}
1286
1287// ─────────────────────────────────────────────────────────────────────────
1288// Séparation de privilèges (privsep) — setgroups/getgroups + setres*id/getres*id
1289//
1290// Spec : `docs/specs/layer-0/family-process-privsep.md`.
1291//
1292// Opérations **privilégiées** (`CAP_SETUID`/`CAP_SETGID` ou root). Prérequis
1293// d'un `drop_privileges` correct (`air-process`, couche 1) : larguer les
1294// **groupes supplémentaires** ET fixer le ***saved-set*-id** — sans quoi un
1295// `setuid`/`setgid` simple laisse regagner les privilèges.
1296// ─────────────────────────────────────────────────────────────────────────
1297
1298/// `NGROUPS_MAX` (Linux) : nombre maximal de groupes supplémentaires. Au-delà,
1299/// `setgroups(2)` rend `EINVAL` ; on le valide **en amont** (Principe 4).
1300const NGROUPS_MAX: usize = 65536;
1301
1302/// Encode un `Option<Gid>` pour `setresgid` : `None` ⇒ `(gid_t)-1` (composante
1303/// **inchangée**), `Some(g)` ⇒ sa valeur brute. La sentinelle `-1` n'est jamais
1304/// exposée à l'appelant (ADR-021 conv. 1).
1305fn opt_gid_to_raw(gid: Option<Gid>) -> u32 {
1306    gid.map_or(u32::MAX, Gid::as_raw)
1307}
1308
1309/// Encode un `Option<Uid>` pour `setresuid` (`None` ⇒ `(uid_t)-1`). Voir
1310/// [`opt_gid_to_raw`].
1311fn opt_uid_to_raw(uid: Option<Uid>) -> u32 {
1312    uid.map_or(u32::MAX, Uid::as_raw)
1313}
1314
1315/// Remplace la liste des **groupes supplémentaires** du processus (`setgroups`).
1316///
1317/// Pour une réduction de privilèges, passer **`&[]`** (largage total) tant qu'on
1318/// est privilégié : la liste vide est la valeur **normale** du privsep, pas une
1319/// sentinelle. Le layout de `&[Gid]` coïncide avec `&[gid_t]` (`Gid` est
1320/// `#[repr(transparent)]`), donc aucune copie n'est nécessaire.
1321///
1322/// # Errors
1323///
1324/// - [`Errno::EINVAL`] : plus de `NGROUPS_MAX` (65536) groupes (validé en amont).
1325/// - [`Errno::EPERM`] : `CAP_SETGID` absent.
1326/// - [`Errno::EFAULT`] : `groups` hors de l'espace d'adressage (n'arrive pas avec
1327///   une tranche Rust valide).
1328///
1329/// # Examples
1330///
1331/// ```no_run
1332/// use air_sys_syscall::process::set_groups;
1333/// // Largage total des groupes supplémentaires (étape d'un privsep).
1334/// set_groups(&[]).expect("CAP_SETGID requis");
1335/// ```
1336pub fn set_groups(groups: &[Gid]) -> Result<(), Errno> {
1337    // Validation amont (Principe 4) : borne kernel.
1338    if groups.len() > NGROUPS_MAX {
1339        return Err(Errno::EINVAL);
1340    }
1341    let size = groups.len();
1342    let list_ptr = groups.as_ptr() as u64;
1343    // SAFETY: setgroups(2) **lit** `size` entrées `gid_t` à `list_ptr` et n'écrit
1344    // aucune mémoire utilisateur. `groups` est une tranche Rust valide pour la
1345    // durée de l'appel ; `Gid` est `#[repr(transparent)]` sur `u32` (= `gid_t`),
1346    // donc le pointeur et le compte décrivent un tableau de `gid_t` correct.
1347    // `size ≤ NGROUPS_MAX` est validé ci-dessus.
1348    let ret = unsafe { raw_syscall_setgroups(size, list_ptr) };
1349    if ret < 0 {
1350        return Err(errno_from_negative_syscall_ret(ret));
1351    }
1352    Ok(())
1353}
1354
1355/// Lit la liste courante des **groupes supplémentaires** (`getgroups`) dans le
1356/// `buffer` fourni — **zéro allocation** — et retourne la sous-tranche remplie.
1357///
1358/// Vérification défensive de `drop_privileges` (Principe 5 : confirmer la
1359/// réduction). Le `buffer` doit pouvoir contenir tous les groupes courants.
1360///
1361/// # Errors
1362///
1363/// - [`Errno::EINVAL`] : `buffer` trop petit pour la liste complète.
1364/// - [`Errno::EFAULT`] : `buffer` invalide (n'arrive pas avec une tranche Rust).
1365///
1366/// # Examples
1367///
1368/// ```no_run
1369/// use air_sys_syscall::process::get_groups;
1370/// use air_sys_types::Gid;
1371/// let mut buf = [Gid::from_raw(0); 64];
1372/// let groups = get_groups(&mut buf).expect("getgroups");
1373/// assert!(groups.len() <= 64);
1374/// ```
1375pub fn get_groups(buffer: &mut [Gid]) -> Result<&[Gid], Errno> {
1376    let size = buffer.len();
1377    let list_ptr = buffer.as_mut_ptr() as u64;
1378    // SAFETY: getgroups(2) **écrit** jusqu'à `size` entrées `gid_t` à `list_ptr`
1379    // et retourne le nombre écrit. `buffer` est une tranche mutable valide pour
1380    // la durée de l'appel ; `Gid` est `#[repr(transparent)]` sur `gid_t`.
1381    let ret = unsafe { raw_syscall_getgroups(size, list_ptr) };
1382    if ret < 0 {
1383        return Err(errno_from_negative_syscall_ret(ret));
1384    }
1385    // `ret` = nombre de groupes écrits (`0 ≤ ret ≤ size`). Conversion exacte.
1386    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1387    let count = ret as usize;
1388    // `count ≤ size` garanti par le kernel ; `get` évite toute indexation
1389    // panic-able (le bras `None` est structurellement inatteignable).
1390    buffer.get(..count).ok_or(Errno::EINVAL)
1391}
1392
1393/// Fixe **real + effective + saved** GID en un seul appel (`setresgid`).
1394///
1395/// À effectuer **avant** [`set_resuid`] (on ne peut plus changer de GID une fois
1396/// l'UID réduit). `None` pour une composante = « **inchangée** » (sentinelle
1397/// kernel `(gid_t)-1` typée, ADR-021 conv. 1). Fixer le *saved-set-gid* est ce
1398/// qui interdit le **retour** au GID privilégié.
1399///
1400/// # Errors
1401///
1402/// - [`Errno::EPERM`] : changement non autorisé (sans `CAP_SETGID`).
1403/// - [`Errno::EINVAL`] : un des GID n'est pas valide.
1404pub fn set_resgid(
1405    real: Option<Gid>,
1406    effective: Option<Gid>,
1407    saved: Option<Gid>,
1408) -> Result<(), Errno> {
1409    let real = opt_gid_to_raw(real);
1410    let effective = opt_gid_to_raw(effective);
1411    let saved = opt_gid_to_raw(saved);
1412    // SAFETY: setresgid(2) ne touche aucune mémoire utilisateur (3 scalaires
1413    // `gid_t`). `(gid_t)-1` laisse la composante inchangée.
1414    let ret = unsafe { raw_syscall_setresgid(real, effective, saved) };
1415    if ret < 0 {
1416        return Err(errno_from_negative_syscall_ret(ret));
1417    }
1418    Ok(())
1419}
1420
1421/// Fixe **real + effective + saved** UID en un seul appel (`setresuid`).
1422///
1423/// À effectuer **après** [`set_resgid`] (et après [`set_groups`]). `None` =
1424/// composante inchangée (`(uid_t)-1` typé). Fixer le *saved-set-uid* rend la
1425/// réduction de privilèges **irréversible** — garantie qu'un `setuid` simple ne
1426/// donne pas de façon fiable.
1427///
1428/// # Errors
1429///
1430/// - [`Errno::EPERM`] : changement non autorisé (sans `CAP_SETUID`).
1431/// - [`Errno::EINVAL`] : un des UID n'est pas valide.
1432pub fn set_resuid(
1433    real: Option<Uid>,
1434    effective: Option<Uid>,
1435    saved: Option<Uid>,
1436) -> Result<(), Errno> {
1437    let real = opt_uid_to_raw(real);
1438    let effective = opt_uid_to_raw(effective);
1439    let saved = opt_uid_to_raw(saved);
1440    // SAFETY: setresuid(2) ne touche aucune mémoire utilisateur (3 scalaires
1441    // `uid_t`). `(uid_t)-1` laisse la composante inchangée.
1442    let ret = unsafe { raw_syscall_setresuid(real, effective, saved) };
1443    if ret < 0 {
1444        return Err(errno_from_negative_syscall_ret(ret));
1445    }
1446    Ok(())
1447}
1448
1449/// Lit les trois composantes GID **réelle / effective / sauvegardée**
1450/// (`getresgid`) — vérification défensive (confirmer qu'on ne peut plus revenir
1451/// en arrière).
1452///
1453/// # Errors
1454///
1455/// - [`Errno::EFAULT`] : pointeurs invalides (n'arrive pas : pile locale).
1456pub fn get_resgid() -> Result<ResGid, Errno> {
1457    let mut real: u32 = 0;
1458    let mut effective: u32 = 0;
1459    let mut saved: u32 = 0;
1460    // SAFETY: getresgid(2) **écrit** trois `gid_t` aux pointeurs fournis. Les
1461    // trois variables locales sont vivantes, alignées et exclusivement
1462    // empruntées (`from_mut`) pour la durée de l'appel.
1463    let ret = unsafe {
1464        raw_syscall_getresgid(
1465            core::ptr::from_mut(&mut real) as u64,
1466            core::ptr::from_mut(&mut effective) as u64,
1467            core::ptr::from_mut(&mut saved) as u64,
1468        )
1469    };
1470    if ret < 0 {
1471        return Err(errno_from_negative_syscall_ret(ret));
1472    }
1473    Ok(ResGid {
1474        real: Gid::from_raw(real),
1475        effective: Gid::from_raw(effective),
1476        saved: Gid::from_raw(saved),
1477    })
1478}
1479
1480/// Lit les trois composantes UID **réelle / effective / sauvegardée**
1481/// (`getresuid`) — vérification défensive.
1482///
1483/// # Errors
1484///
1485/// - [`Errno::EFAULT`] : pointeurs invalides (n'arrive pas : pile locale).
1486pub fn get_resuid() -> Result<ResUid, Errno> {
1487    let mut real: u32 = 0;
1488    let mut effective: u32 = 0;
1489    let mut saved: u32 = 0;
1490    // SAFETY: getresuid(2) **écrit** trois `uid_t` aux pointeurs fournis. Voir
1491    // [`get_resgid`] pour les préconditions (variables pile vivantes/alignées).
1492    let ret = unsafe {
1493        raw_syscall_getresuid(
1494            core::ptr::from_mut(&mut real) as u64,
1495            core::ptr::from_mut(&mut effective) as u64,
1496            core::ptr::from_mut(&mut saved) as u64,
1497        )
1498    };
1499    if ret < 0 {
1500        return Err(errno_from_negative_syscall_ret(ret));
1501    }
1502    Ok(ResUid {
1503        real: Uid::from_raw(real),
1504        effective: Uid::from_raw(effective),
1505        saved: Uid::from_raw(saved),
1506    })
1507}
1508
1509#[cfg(target_arch = "x86_64")]
1510#[inline]
1511unsafe fn raw_syscall_setpgid(pid: i32, pgid: i32) -> i64 {
1512    let ret: i64;
1513    // SAFETY: SYS_setpgid (x86_64 = 109) — pas d'accès mémoire utilisateur ;
1514    // ABI x86_64 standard (RAX=nr, RDI/RSI=args ; clobbers RCX/R11).
1515    unsafe {
1516        core::arch::asm!(
1517            "syscall",
1518            in("rax") 109_i64,
1519            in("rdi") i64::from(pid),
1520            in("rsi") i64::from(pgid),
1521            lateout("rax") ret,
1522            lateout("rcx") _,
1523            lateout("r11") _,
1524            options(nostack, preserves_flags, readonly),
1525        );
1526    }
1527    ret
1528}
1529
1530#[cfg(target_arch = "x86_64")]
1531#[inline]
1532unsafe fn raw_syscall_getpgid(pid: i32) -> i64 {
1533    let ret: i64;
1534    // SAFETY: SYS_getpgid (x86_64 = 121) — pas d'accès mémoire utilisateur.
1535    unsafe {
1536        core::arch::asm!(
1537            "syscall",
1538            in("rax") 121_i64,
1539            in("rdi") i64::from(pid),
1540            lateout("rax") ret,
1541            lateout("rcx") _,
1542            lateout("r11") _,
1543            options(nostack, preserves_flags, readonly),
1544        );
1545    }
1546    ret
1547}
1548
1549#[cfg(target_arch = "x86_64")]
1550#[inline]
1551unsafe fn raw_syscall_setsid() -> i64 {
1552    let ret: i64;
1553    // SAFETY: SYS_setsid (x86_64 = 112) — pas d'accès mémoire utilisateur,
1554    // pas d'argument.
1555    unsafe {
1556        core::arch::asm!(
1557            "syscall",
1558            in("rax") 112_i64,
1559            lateout("rax") ret,
1560            lateout("rcx") _,
1561            lateout("r11") _,
1562            options(nostack, preserves_flags, readonly),
1563        );
1564    }
1565    ret
1566}
1567
1568#[cfg(target_arch = "x86_64")]
1569#[inline]
1570unsafe fn raw_syscall_getsid(pid: i32) -> i64 {
1571    let ret: i64;
1572    // SAFETY: SYS_getsid (x86_64 = 124) — pas d'accès mémoire utilisateur.
1573    unsafe {
1574        core::arch::asm!(
1575            "syscall",
1576            in("rax") 124_i64,
1577            in("rdi") i64::from(pid),
1578            lateout("rax") ret,
1579            lateout("rcx") _,
1580            lateout("r11") _,
1581            options(nostack, preserves_flags, readonly),
1582        );
1583    }
1584    ret
1585}
1586
1587#[cfg(target_arch = "aarch64")]
1588#[inline]
1589unsafe fn raw_syscall_setpgid(pid: i32, pgid: i32) -> i64 {
1590    let ret: i64;
1591    // SAFETY: SYS_setpgid (aarch64 = 154) — ABI standard, no mem access.
1592    unsafe {
1593        core::arch::asm!(
1594            "svc 0",
1595            in("x8") 154_i64,
1596            inout("x0") i64::from(pid) => ret,
1597            in("x1") i64::from(pgid),
1598            options(nostack, preserves_flags, readonly),
1599        );
1600    }
1601    ret
1602}
1603
1604#[cfg(target_arch = "aarch64")]
1605#[inline]
1606unsafe fn raw_syscall_getpgid(pid: i32) -> i64 {
1607    let ret: i64;
1608    // SAFETY: SYS_getpgid (aarch64 = 155).
1609    unsafe {
1610        core::arch::asm!(
1611            "svc 0",
1612            in("x8") 155_i64,
1613            inout("x0") i64::from(pid) => ret,
1614            options(nostack, preserves_flags, readonly),
1615        );
1616    }
1617    ret
1618}
1619
1620#[cfg(target_arch = "aarch64")]
1621#[inline]
1622unsafe fn raw_syscall_setsid() -> i64 {
1623    let ret: i64;
1624    // SAFETY: SYS_setsid (aarch64 = 157).
1625    unsafe {
1626        core::arch::asm!(
1627            "svc 0",
1628            in("x8") 157_i64,
1629            lateout("x0") ret,
1630            options(nostack, preserves_flags, readonly),
1631        );
1632    }
1633    ret
1634}
1635
1636#[cfg(target_arch = "aarch64")]
1637#[inline]
1638unsafe fn raw_syscall_getsid(pid: i32) -> i64 {
1639    let ret: i64;
1640    // SAFETY: SYS_getsid (aarch64 = 156).
1641    unsafe {
1642        core::arch::asm!(
1643            "svc 0",
1644            in("x8") 156_i64,
1645            inout("x0") i64::from(pid) => ret,
1646            options(nostack, preserves_flags, readonly),
1647        );
1648    }
1649    ret
1650}
1651
1652// ─────────────────────────────────────────────────────────────────────────
1653// Helpers syscall bruts — privsep (numéros vérifiés sur l'uapi 6.12 par arche :
1654// x86_64 = unistd_64.h, aarch64 = asm-generic/unistd.h).
1655// ─────────────────────────────────────────────────────────────────────────
1656
1657#[cfg(target_arch = "x86_64")]
1658#[inline]
1659unsafe fn raw_syscall_setgroups(size: usize, list: u64) -> i64 {
1660    let ret: i64;
1661    // SAFETY: SYS_setgroups (x86_64 = 116). **Lit** `size` `gid_t` à `list` ;
1662    // n'écrit aucune mémoire utilisateur (`readonly`).
1663    unsafe {
1664        core::arch::asm!(
1665            "syscall",
1666            in("rax") 116_i64,
1667            in("rdi") size,
1668            in("rsi") list,
1669            lateout("rax") ret,
1670            lateout("rcx") _,
1671            lateout("r11") _,
1672            options(nostack, preserves_flags, readonly),
1673        );
1674    }
1675    ret
1676}
1677
1678#[cfg(target_arch = "aarch64")]
1679#[inline]
1680unsafe fn raw_syscall_setgroups(size: usize, list: u64) -> i64 {
1681    let ret: i64;
1682    // SAFETY: SYS_setgroups (aarch64 = 159). Lit la liste ; n'écrit rien.
1683    unsafe {
1684        core::arch::asm!(
1685            "svc 0",
1686            in("x8") 159_i64,
1687            inout("x0") size => ret,
1688            in("x1") list,
1689            options(nostack, preserves_flags, readonly),
1690        );
1691    }
1692    ret
1693}
1694
1695#[cfg(target_arch = "x86_64")]
1696#[inline]
1697unsafe fn raw_syscall_getgroups(size: usize, list: u64) -> i64 {
1698    let ret: i64;
1699    // SAFETY: SYS_getgroups (x86_64 = 115). **Écrit** jusqu'à `size` `gid_t` à
1700    // `list` (pas de `readonly`). Retourne le nombre écrit ou `-errno`.
1701    unsafe {
1702        core::arch::asm!(
1703            "syscall",
1704            in("rax") 115_i64,
1705            in("rdi") size,
1706            in("rsi") list,
1707            lateout("rax") ret,
1708            lateout("rcx") _,
1709            lateout("r11") _,
1710            options(nostack, preserves_flags),
1711        );
1712    }
1713    ret
1714}
1715
1716#[cfg(target_arch = "aarch64")]
1717#[inline]
1718unsafe fn raw_syscall_getgroups(size: usize, list: u64) -> i64 {
1719    let ret: i64;
1720    // SAFETY: SYS_getgroups (aarch64 = 158). Écrit la liste (pas de `readonly`).
1721    unsafe {
1722        core::arch::asm!(
1723            "svc 0",
1724            in("x8") 158_i64,
1725            inout("x0") size => ret,
1726            in("x1") list,
1727            options(nostack, preserves_flags),
1728        );
1729    }
1730    ret
1731}
1732
1733#[cfg(target_arch = "x86_64")]
1734#[inline]
1735unsafe fn raw_syscall_setresgid(real: u32, effective: u32, saved: u32) -> i64 {
1736    let ret: i64;
1737    // SAFETY: SYS_setresgid (x86_64 = 119). Trois scalaires `gid_t` ; aucun
1738    // accès mémoire utilisateur (`readonly`).
1739    unsafe {
1740        core::arch::asm!(
1741            "syscall",
1742            in("rax") 119_i64,
1743            in("rdi") i64::from(real),
1744            in("rsi") i64::from(effective),
1745            in("rdx") i64::from(saved),
1746            lateout("rax") ret,
1747            lateout("rcx") _,
1748            lateout("r11") _,
1749            options(nostack, preserves_flags, readonly),
1750        );
1751    }
1752    ret
1753}
1754
1755#[cfg(target_arch = "aarch64")]
1756#[inline]
1757unsafe fn raw_syscall_setresgid(real: u32, effective: u32, saved: u32) -> i64 {
1758    let ret: i64;
1759    // SAFETY: SYS_setresgid (aarch64 = 149). Trois scalaires ; pas d'accès mém.
1760    unsafe {
1761        core::arch::asm!(
1762            "svc 0",
1763            in("x8") 149_i64,
1764            inout("x0") i64::from(real) => ret,
1765            in("x1") i64::from(effective),
1766            in("x2") i64::from(saved),
1767            options(nostack, preserves_flags, readonly),
1768        );
1769    }
1770    ret
1771}
1772
1773#[cfg(target_arch = "x86_64")]
1774#[inline]
1775unsafe fn raw_syscall_setresuid(real: u32, effective: u32, saved: u32) -> i64 {
1776    let ret: i64;
1777    // SAFETY: SYS_setresuid (x86_64 = 117). Trois scalaires `uid_t` ; `readonly`.
1778    unsafe {
1779        core::arch::asm!(
1780            "syscall",
1781            in("rax") 117_i64,
1782            in("rdi") i64::from(real),
1783            in("rsi") i64::from(effective),
1784            in("rdx") i64::from(saved),
1785            lateout("rax") ret,
1786            lateout("rcx") _,
1787            lateout("r11") _,
1788            options(nostack, preserves_flags, readonly),
1789        );
1790    }
1791    ret
1792}
1793
1794#[cfg(target_arch = "aarch64")]
1795#[inline]
1796unsafe fn raw_syscall_setresuid(real: u32, effective: u32, saved: u32) -> i64 {
1797    let ret: i64;
1798    // SAFETY: SYS_setresuid (aarch64 = 147). Trois scalaires ; pas d'accès mém.
1799    unsafe {
1800        core::arch::asm!(
1801            "svc 0",
1802            in("x8") 147_i64,
1803            inout("x0") i64::from(real) => ret,
1804            in("x1") i64::from(effective),
1805            in("x2") i64::from(saved),
1806            options(nostack, preserves_flags, readonly),
1807        );
1808    }
1809    ret
1810}
1811
1812#[cfg(target_arch = "x86_64")]
1813#[inline]
1814unsafe fn raw_syscall_getresgid(real: u64, effective: u64, saved: u64) -> i64 {
1815    let ret: i64;
1816    // SAFETY: SYS_getresgid (x86_64 = 120). **Écrit** trois `gid_t` aux trois
1817    // pointeurs (pas de `readonly`).
1818    unsafe {
1819        core::arch::asm!(
1820            "syscall",
1821            in("rax") 120_i64,
1822            in("rdi") real,
1823            in("rsi") effective,
1824            in("rdx") saved,
1825            lateout("rax") ret,
1826            lateout("rcx") _,
1827            lateout("r11") _,
1828            options(nostack, preserves_flags),
1829        );
1830    }
1831    ret
1832}
1833
1834#[cfg(target_arch = "aarch64")]
1835#[inline]
1836unsafe fn raw_syscall_getresgid(real: u64, effective: u64, saved: u64) -> i64 {
1837    let ret: i64;
1838    // SAFETY: SYS_getresgid (aarch64 = 150). Écrit trois `gid_t` (pas readonly).
1839    unsafe {
1840        core::arch::asm!(
1841            "svc 0",
1842            in("x8") 150_i64,
1843            inout("x0") real => ret,
1844            in("x1") effective,
1845            in("x2") saved,
1846            options(nostack, preserves_flags),
1847        );
1848    }
1849    ret
1850}
1851
1852#[cfg(target_arch = "x86_64")]
1853#[inline]
1854unsafe fn raw_syscall_getresuid(real: u64, effective: u64, saved: u64) -> i64 {
1855    let ret: i64;
1856    // SAFETY: SYS_getresuid (x86_64 = 118). **Écrit** trois `uid_t` aux trois
1857    // pointeurs (pas de `readonly`).
1858    unsafe {
1859        core::arch::asm!(
1860            "syscall",
1861            in("rax") 118_i64,
1862            in("rdi") real,
1863            in("rsi") effective,
1864            in("rdx") saved,
1865            lateout("rax") ret,
1866            lateout("rcx") _,
1867            lateout("r11") _,
1868            options(nostack, preserves_flags),
1869        );
1870    }
1871    ret
1872}
1873
1874#[cfg(target_arch = "aarch64")]
1875#[inline]
1876unsafe fn raw_syscall_getresuid(real: u64, effective: u64, saved: u64) -> i64 {
1877    let ret: i64;
1878    // SAFETY: SYS_getresuid (aarch64 = 148). Écrit trois `uid_t` (pas readonly).
1879    unsafe {
1880        core::arch::asm!(
1881            "svc 0",
1882            in("x8") 148_i64,
1883            inout("x0") real => ret,
1884            in("x1") effective,
1885            in("x2") saved,
1886            options(nostack, preserves_flags),
1887        );
1888    }
1889    ret
1890}
1891
1892// ─────────────────────────────────────────────────────────────────────────
1893// prctl (sous-section 5 de family-process.md).
1894//
1895// Convention 3 ADR-021 : `prctl(op, args...)` n'est PAS exposé. Chaque
1896// opération est exposée comme fonction dédiée typée. Le helper
1897// `raw_syscall_prctl` est strictement interne au module.
1898// ─────────────────────────────────────────────────────────────────────────
1899
1900// Opérations `prctl(2)` exposées par cette PR (cf. `include/uapi/linux/prctl.h`).
1901const PR_SET_PDEATHSIG: i32 = 1;
1902const PR_GET_PDEATHSIG: i32 = 2;
1903const PR_GET_DUMPABLE: i32 = 3;
1904const PR_SET_DUMPABLE: i32 = 4;
1905const PR_GET_KEEPCAPS: i32 = 7;
1906const PR_SET_KEEPCAPS: i32 = 8;
1907const PR_SET_NAME: i32 = 15;
1908const PR_GET_NAME: i32 = 16;
1909const PR_SET_TIMERSLACK: i32 = 29;
1910const PR_SET_NO_NEW_PRIVS: i32 = 38;
1911const PR_GET_NO_NEW_PRIVS: i32 = 39;
1912const PR_CAP_AMBIENT: i32 = 47;
1913
1914// Sous-opérations de PR_CAP_AMBIENT (arg2).
1915const PR_CAP_AMBIENT_IS_SET: u64 = 1;
1916const PR_CAP_AMBIENT_RAISE: u64 = 2;
1917const PR_CAP_AMBIENT_LOWER: u64 = 3;
1918const PR_CAP_AMBIENT_CLEAR_ALL: u64 = 4;
1919
1920/// Définit le signal envoyé au processus courant à la mort de son parent.
1921///
1922/// `None` désactive la notification (le kernel envoie 0, traité comme
1923/// « aucun signal »).
1924///
1925/// # Errors
1926///
1927/// - [`Errno::EINVAL`] : signal hors plage.
1928pub fn set_parent_death_signal(signal: Option<Signal>) -> Result<(), Errno> {
1929    let sig_arg: u64 = signal.map_or(0_u64, |s| {
1930        // s.as_raw() > 0 par construction (NonZeroI32). Cast sign-safe.
1931        #[allow(clippy::cast_sign_loss)]
1932        {
1933            s.as_raw() as u64
1934        }
1935    });
1936    // SAFETY: PR_SET_PDEATHSIG — pas d'accès mémoire utilisateur.
1937    let ret = unsafe { raw_syscall_prctl(PR_SET_PDEATHSIG, sig_arg, 0, 0, 0) };
1938    if ret < 0 {
1939        return Err(errno_from_negative_syscall_ret(ret));
1940    }
1941    Ok(())
1942}
1943
1944/// Retourne le signal envoyé au processus à la mort du parent.
1945/// `Ok(None)` si la notification est désactivée.
1946///
1947/// # Errors
1948///
1949/// - [`Errno::EINVAL`] : ne se produit pas pour PR_GET_PDEATHSIG en
1950///   pratique.
1951pub fn get_parent_death_signal() -> Result<Option<Signal>, Errno> {
1952    // Le kernel écrit le signal dans `*(int *)arg2`.
1953    let mut out: i32 = 0;
1954    let out_ptr: *mut i32 = &mut out;
1955    // SAFETY: PR_GET_PDEATHSIG écrit un int à arg2 ; `out` est local
1956    // valide pour toute la durée du syscall.
1957    let ret = unsafe { raw_syscall_prctl(PR_GET_PDEATHSIG, out_ptr as u64, 0, 0, 0) };
1958    if ret < 0 {
1959        return Err(errno_from_negative_syscall_ret(ret));
1960    }
1961    Ok(Signal::try_from_raw(out))
1962}
1963
1964/// Active le bit `no_new_privs` du processus.
1965///
1966/// **Opération irréversible.** Une fois positionné, ce bit ne peut pas
1967/// être effacé pour la durée de vie du processus — toute tentative
1968/// (`set_no_new_privs` après coup, ou via execve d'un binaire SUID) est
1969/// neutralisée par le kernel. Conséquence pour les tests : exécuter
1970/// `set_no_new_privs()` dans un test contamine **tous les tests
1971/// ultérieurs** dans la même process. Cette PR ne teste donc que
1972/// `get_no_new_privs()` ; le set est laissé à la PR `family-security`
1973/// (seccomp) qui s'exécute dans un processus enfant fork'é.
1974///
1975/// # Errors
1976///
1977/// - [`Errno::EINVAL`] : ne se produit pas en pratique.
1978pub fn set_no_new_privs() -> Result<(), Errno> {
1979    // arg2 doit être 1 selon le contrat kernel ; toute autre valeur
1980    // retourne EINVAL.
1981    // SAFETY: PR_SET_NO_NEW_PRIVS — pas d'accès mémoire utilisateur.
1982    let ret = unsafe { raw_syscall_prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) };
1983    if ret < 0 {
1984        return Err(errno_from_negative_syscall_ret(ret));
1985    }
1986    Ok(())
1987}
1988
1989/// Indique si le bit `no_new_privs` est positionné pour le processus.
1990///
1991/// # Errors
1992///
1993/// - Aucune en pratique pour cette opération.
1994pub fn get_no_new_privs() -> Result<bool, Errno> {
1995    // SAFETY: PR_GET_NO_NEW_PRIVS — pas d'accès mémoire utilisateur ; la
1996    // valeur est retournée comme code de retour du syscall (0 ou 1).
1997    let ret = unsafe { raw_syscall_prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0) };
1998    if ret < 0 {
1999        return Err(errno_from_negative_syscall_ret(ret));
2000    }
2001    Ok(ret != 0)
2002}
2003
2004/// Définit le nom du thread (max 15 caractères + NUL).
2005///
2006/// Le kernel tronque silencieusement à 15 caractères si `name` est plus
2007/// long. Le nom est utilisé par `/proc/PID/task/TID/comm` et exposé par
2008/// `top`/`ps -L`.
2009///
2010/// # Errors
2011///
2012/// - `EFAULT` : pointeur invalide ; ne se produit pas via l'API safe.
2013pub fn set_thread_name(name: &CStr) -> Result<(), Errno> {
2014    let ptr = name.as_ptr();
2015    // SAFETY: PR_SET_NAME lit la C-string à arg2 (kernel tronque à 16
2016    // octets). `name` est un &CStr valide pour la durée du call.
2017    let ret = unsafe { raw_syscall_prctl(PR_SET_NAME, ptr as u64, 0, 0, 0) };
2018    if ret < 0 {
2019        return Err(errno_from_negative_syscall_ret(ret));
2020    }
2021    Ok(())
2022}
2023
2024/// Retourne le nom du thread courant.
2025///
2026/// # Allocation
2027///
2028/// Convention 4 ADR-021 exception explicite : « la sémantique exige un
2029/// type owned ». Le kernel écrit ≤ 16 octets dans un buffer transient ;
2030/// le caller récupère une `CString` qui peut sortir du frame. Allocation
2031/// bornée à 16 octets (un seul `Box<[u8]>`), happy-path.
2032///
2033/// # Errors
2034///
2035/// - Aucune en pratique pour cette opération.
2036pub fn get_thread_name() -> Result<CString, Errno> {
2037    let mut buffer: [u8; 16] = [0; 16];
2038    let ptr: *mut u8 = buffer.as_mut_ptr();
2039    // SAFETY: PR_GET_NAME écrit exactement 16 octets dans `*(char *)arg2` ;
2040    // `buffer` est local valide pour la durée du call. Le kernel garantit la
2041    // terminaison NUL (en tronquant si nécessaire).
2042    let ret = unsafe { raw_syscall_prctl(PR_GET_NAME, ptr as u64, 0, 0, 0) };
2043    if ret < 0 {
2044        return Err(errno_from_negative_syscall_ret(ret));
2045    }
2046    // Le buffer est garanti contenir au moins un NUL (le kernel tronque
2047    // à 15 caractères + NUL). On extrait la C-string jusqu'au premier NUL.
2048    let length = buffer.iter().position(|&b| b == 0).unwrap_or(16);
2049    // CString::new exige absence de NUL interne ; on passe les bytes
2050    // *avant* le NUL trouvé.
2051    Ok(CString::new(&buffer[..length])
2052        .expect("buffer kernel sans NUL interne avant le terminateur"))
2053}
2054
2055/// Définit le mode `dumpable` du processus.
2056///
2057/// # Errors
2058///
2059/// - [`Errno::EINVAL`] : valeur hors plage (ne se produit pas via l'API
2060///   typée).
2061pub fn set_dumpable(mode: DumpableMode) -> Result<(), Errno> {
2062    let mode_arg = u64::from(mode as u32);
2063    // SAFETY: PR_SET_DUMPABLE — pas d'accès mémoire utilisateur.
2064    let ret = unsafe { raw_syscall_prctl(PR_SET_DUMPABLE, mode_arg, 0, 0, 0) };
2065    if ret < 0 {
2066        return Err(errno_from_negative_syscall_ret(ret));
2067    }
2068    Ok(())
2069}
2070
2071/// Retourne le mode `dumpable` actuel.
2072///
2073/// # Errors
2074///
2075/// - Aucune en pratique pour cette opération.
2076pub fn get_dumpable() -> Result<DumpableMode, Errno> {
2077    // SAFETY: PR_GET_DUMPABLE — pas d'accès mémoire utilisateur ;
2078    // valeur retournée comme code de retour.
2079    let ret = unsafe { raw_syscall_prctl(PR_GET_DUMPABLE, 0, 0, 0, 0) };
2080    if ret < 0 {
2081        return Err(errno_from_negative_syscall_ret(ret));
2082    }
2083    match ret {
2084        0 => Ok(DumpableMode::NotDumpable),
2085        1 => Ok(DumpableMode::Dumpable),
2086        2 => Ok(DumpableMode::SuidDumpable),
2087        // Le kernel garantit 0, 1, ou 2. Sortie hors plage = bug kernel.
2088        other => unreachable!("PR_GET_DUMPABLE a retourné {other}, hors plage [0,2]"),
2089    }
2090}
2091
2092/// Définit le bit `keep_caps` : si vrai, les capabilities `permitted`
2093/// sont conservées lors d'un changement d'UID effectif vers non-zéro.
2094///
2095/// # Errors
2096///
2097/// - [`Errno::EINVAL`] : valeur invalide (ne se produit pas via l'API typée).
2098pub fn set_keep_caps(keep: bool) -> Result<(), Errno> {
2099    let arg: u64 = u64::from(keep);
2100    // SAFETY: PR_SET_KEEPCAPS — pas d'accès mémoire utilisateur.
2101    let ret = unsafe { raw_syscall_prctl(PR_SET_KEEPCAPS, arg, 0, 0, 0) };
2102    if ret < 0 {
2103        return Err(errno_from_negative_syscall_ret(ret));
2104    }
2105    Ok(())
2106}
2107
2108/// Indique si le bit `keep_caps` est positionné.
2109///
2110/// # Errors
2111///
2112/// - Aucune en pratique pour cette opération.
2113pub fn get_keep_caps() -> Result<bool, Errno> {
2114    // SAFETY: PR_GET_KEEPCAPS — pas d'accès mémoire utilisateur.
2115    let ret = unsafe { raw_syscall_prctl(PR_GET_KEEPCAPS, 0, 0, 0, 0) };
2116    if ret < 0 {
2117        return Err(errno_from_negative_syscall_ret(ret));
2118    }
2119    Ok(ret != 0)
2120}
2121
2122/// Définit la « timer slack » du processus (nanosecondes).
2123///
2124/// Le timer slack permet au kernel d'agréger les timers proches pour
2125/// réduire les wake-ups. `0` réinitialise à la valeur par défaut héritée.
2126///
2127/// # Errors
2128///
2129/// - Aucune en pratique.
2130pub fn set_timer_slack(slack_ns: u64) -> Result<(), Errno> {
2131    // SAFETY: PR_SET_TIMERSLACK — pas d'accès mémoire utilisateur.
2132    let ret = unsafe { raw_syscall_prctl(PR_SET_TIMERSLACK, slack_ns, 0, 0, 0) };
2133    if ret < 0 {
2134        return Err(errno_from_negative_syscall_ret(ret));
2135    }
2136    Ok(())
2137}
2138
2139/// Ajoute `cap` à l'ensemble ambient du thread.
2140///
2141/// L'ensemble ambient est transmis à `execve`, contrairement aux
2142/// inheritable seuls. Exige `cap` dans `permitted ∩ inheritable`.
2143///
2144/// # Errors
2145///
2146/// - [`Errno::EPERM`] : `cap` n'est pas dans permitted ∩ inheritable, ou
2147///   `SECBIT_NO_CAP_AMBIENT_RAISE` est positionné.
2148/// - [`Errno::EINVAL`] : valeur de capability inconnue côté kernel.
2149pub fn cap_ambient_raise(cap: Capability) -> Result<(), Errno> {
2150    // SAFETY: PR_CAP_AMBIENT/RAISE — pas d'accès mémoire utilisateur.
2151    let ret = unsafe {
2152        raw_syscall_prctl(
2153            PR_CAP_AMBIENT,
2154            PR_CAP_AMBIENT_RAISE,
2155            u64::from(cap.as_raw()),
2156            0,
2157            0,
2158        )
2159    };
2160    if ret < 0 {
2161        return Err(errno_from_negative_syscall_ret(ret));
2162    }
2163    Ok(())
2164}
2165
2166/// Retire `cap` de l'ensemble ambient.
2167///
2168/// # Errors
2169///
2170/// - [`Errno::EINVAL`] : valeur de capability inconnue côté kernel.
2171pub fn cap_ambient_lower(cap: Capability) -> Result<(), Errno> {
2172    // SAFETY: PR_CAP_AMBIENT/LOWER — pas d'accès mémoire utilisateur.
2173    let ret = unsafe {
2174        raw_syscall_prctl(
2175            PR_CAP_AMBIENT,
2176            PR_CAP_AMBIENT_LOWER,
2177            u64::from(cap.as_raw()),
2178            0,
2179            0,
2180        )
2181    };
2182    if ret < 0 {
2183        return Err(errno_from_negative_syscall_ret(ret));
2184    }
2185    Ok(())
2186}
2187
2188/// Vide totalement l'ensemble ambient du thread.
2189///
2190/// # Errors
2191///
2192/// - Aucune en pratique.
2193pub fn cap_ambient_clear_all() -> Result<(), Errno> {
2194    // SAFETY: PR_CAP_AMBIENT/CLEAR_ALL — pas d'accès mémoire utilisateur.
2195    let ret = unsafe { raw_syscall_prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_CLEAR_ALL, 0, 0, 0) };
2196    if ret < 0 {
2197        return Err(errno_from_negative_syscall_ret(ret));
2198    }
2199    Ok(())
2200}
2201
2202/// Indique si `cap` est dans l'ensemble ambient.
2203///
2204/// # Errors
2205///
2206/// - [`Errno::EINVAL`] : valeur de capability inconnue côté kernel.
2207pub fn cap_ambient_is_set(cap: Capability) -> Result<bool, Errno> {
2208    // SAFETY: PR_CAP_AMBIENT/IS_SET — pas d'accès mémoire utilisateur ;
2209    // valeur retournée comme code de retour (0 ou 1).
2210    let ret = unsafe {
2211        raw_syscall_prctl(
2212            PR_CAP_AMBIENT,
2213            PR_CAP_AMBIENT_IS_SET,
2214            u64::from(cap.as_raw()),
2215            0,
2216            0,
2217        )
2218    };
2219    if ret < 0 {
2220        return Err(errno_from_negative_syscall_ret(ret));
2221    }
2222    Ok(ret != 0)
2223}
2224
2225#[cfg(target_arch = "x86_64")]
2226#[inline]
2227unsafe fn raw_syscall_prctl(op: i32, arg2: u64, arg3: u64, arg4: u64, arg5: u64) -> i64 {
2228    let ret: i64;
2229    // SAFETY: SYS_prctl (x86_64 = 157). Selon `op`, le kernel peut lire
2230    // ou écrire à des pointeurs passés via arg2..arg5 ; les fonctions
2231    // d'opération individuelles (au-dessus) garantissent la validité de
2232    // ces pointeurs lorsqu'elles en passent. Sans pointeur, pas
2233    // d'accès mémoire utilisateur.
2234    //
2235    // **Pas de `readonly`** : certaines opérations (PR_GET_NAME,
2236    // PR_GET_PDEATHSIG) font écrire le kernel à un pointeur user.
2237    unsafe {
2238        core::arch::asm!(
2239            "syscall",
2240            in("rax") 157_i64,
2241            in("rdi") i64::from(op),
2242            in("rsi") arg2,
2243            in("rdx") arg3,
2244            in("r10") arg4,
2245            in("r8") arg5,
2246            lateout("rax") ret,
2247            lateout("rcx") _,
2248            lateout("r11") _,
2249            options(nostack, preserves_flags),
2250        );
2251    }
2252    ret
2253}
2254
2255#[cfg(target_arch = "aarch64")]
2256#[inline]
2257unsafe fn raw_syscall_prctl(op: i32, arg2: u64, arg3: u64, arg4: u64, arg5: u64) -> i64 {
2258    let ret: i64;
2259    // SAFETY: SYS_prctl (aarch64 = 167). Mêmes considérations que x86_64.
2260    unsafe {
2261        core::arch::asm!(
2262            "svc 0",
2263            in("x8") 167_i64,
2264            inout("x0") i64::from(op) => ret,
2265            in("x1") arg2,
2266            in("x2") arg3,
2267            in("x3") arg4,
2268            in("x4") arg5,
2269            options(nostack, preserves_flags),
2270        );
2271    }
2272    ret
2273}
2274
2275// ─────────────────────────────────────────────────────────────────────────
2276// rlimits (sous-section 6 de family-process.md).
2277//
2278// `prlimit64` est le syscall préféré ; `getrlimit`/`setrlimit` legacy
2279// sont exposés mais routés au plus simple. La représentation kernel de
2280// `struct rlimit64` est { u64 rlim_cur ; u64 rlim_max }, identique à
2281// `struct rlimit` sur LP64. On la traduit en `Rlimit` via mapping
2282// `RLIM_INFINITY <-> RlimitValue::Infinity`.
2283// ─────────────────────────────────────────────────────────────────────────
2284
2285#[repr(C)]
2286#[derive(Default, Clone, Copy)]
2287struct KernelRlimit64 {
2288    rlim_cur: u64,
2289    rlim_max: u64,
2290}
2291
2292const RLIM_INFINITY: u64 = u64::MAX;
2293
2294fn rlim_value_to_kernel(v: RlimitValue) -> u64 {
2295    match v {
2296        RlimitValue::Finite(n) => n,
2297        RlimitValue::Infinity => RLIM_INFINITY,
2298    }
2299}
2300
2301fn rlim_value_from_kernel(n: u64) -> RlimitValue {
2302    if n == RLIM_INFINITY {
2303        RlimitValue::Infinity
2304    } else {
2305        RlimitValue::Finite(n)
2306    }
2307}
2308
2309fn rlimit_to_kernel(r: Rlimit) -> KernelRlimit64 {
2310    KernelRlimit64 {
2311        rlim_cur: rlim_value_to_kernel(r.soft),
2312        rlim_max: rlim_value_to_kernel(r.hard),
2313    }
2314}
2315
2316fn rlimit_from_kernel(k: KernelRlimit64) -> Rlimit {
2317    Rlimit {
2318        soft: rlim_value_from_kernel(k.rlim_cur),
2319        hard: rlim_value_from_kernel(k.rlim_max),
2320    }
2321}
2322
2323/// Retourne la `Rlimit` courante du processus pour `resource`.
2324///
2325/// Air recommande [`prlimit`] qui est plus expressif (cible un autre
2326/// processus, opération atomique) ; `getrlimit` est exposé pour
2327/// compatibilité.
2328///
2329/// # Errors
2330///
2331/// - [`Errno::EINVAL`] : ressource inconnue (impossible via l'API typée).
2332pub fn getrlimit(resource: Resource) -> Result<Rlimit, Errno> {
2333    // Délègue à prlimit(self, resource, None) pour ne pas dupliquer
2334    // l'asm! et bénéficier de la même conversion.
2335    prlimit(None, resource, None)
2336}
2337
2338/// Définit la `Rlimit` courante du processus pour `resource`.
2339///
2340/// # Errors
2341///
2342/// - [`Errno::EINVAL`] : valeurs incohérentes (soft > hard, …).
2343/// - [`Errno::EPERM`] : tentative de relever la hard limit sans
2344///   `CAP_SYS_RESOURCE`.
2345pub fn setrlimit(resource: Resource, limit: Rlimit) -> Result<(), Errno> {
2346    // Délègue à prlimit(self, resource, Some(limit)) ; on ignore l'ancienne
2347    // valeur retournée.
2348    let _ = prlimit(None, resource, Some(limit))?;
2349    Ok(())
2350}
2351
2352/// `prlimit64` : lit et/ou modifie atomiquement la `Rlimit` d'un
2353/// processus.
2354///
2355/// `pid = None` désigne le processus courant. Retourne **toujours** la
2356/// limite *avant* modification (équivalent kernel "old_limit").
2357///
2358/// # Errors
2359///
2360/// - [`Errno::EINVAL`] : valeurs incohérentes.
2361/// - [`Errno::EPERM`] : tentative de relever la hard limit sans
2362///   `CAP_SYS_RESOURCE`, ou ciblage d'un autre processus sans droits.
2363/// - [`Errno::ESRCH`] : `pid` n'existe pas.
2364pub fn prlimit(
2365    pid: Option<Pid>,
2366    resource: Resource,
2367    new_limit: Option<Rlimit>,
2368) -> Result<Rlimit, Errno> {
2369    let pid_arg = pid.map_or(0_i32, |p| p.as_raw());
2370
2371    let new_buf: Option<KernelRlimit64> = new_limit.map(rlimit_to_kernel);
2372    let new_ptr: u64 = match &new_buf {
2373        Some(b) => {
2374            let p: *const KernelRlimit64 = b;
2375            p as u64
2376        }
2377        None => 0,
2378    };
2379
2380    let mut old_buf = KernelRlimit64::default();
2381    let old_ptr: *mut KernelRlimit64 = &mut old_buf;
2382
2383    // SAFETY: prlimit64(2) lit `new_limit` si non-null, écrit `old_limit`
2384    // si non-null. Nous fournissons toujours `old_ptr` valide ; `new_ptr`
2385    // est null ou pointe sur `new_buf` valide pour la durée du call.
2386    let ret = unsafe { raw_syscall_prlimit64(pid_arg, resource as u32, new_ptr, old_ptr as u64) };
2387    if ret < 0 {
2388        return Err(errno_from_negative_syscall_ret(ret));
2389    }
2390    Ok(rlimit_from_kernel(old_buf))
2391}
2392
2393#[cfg(target_arch = "x86_64")]
2394#[inline]
2395unsafe fn raw_syscall_prlimit64(pid: i32, resource: u32, new_limit: u64, old_limit: u64) -> i64 {
2396    let ret: i64;
2397    // SAFETY: SYS_prlimit64 (x86_64 = 302). Le kernel lit `new_limit`
2398    // (16 octets) si non-null ; écrit `old_limit` (16 octets) si non-null.
2399    // Pas de `readonly` (écriture sur `*old_limit`).
2400    unsafe {
2401        core::arch::asm!(
2402            "syscall",
2403            in("rax") 302_i64,
2404            in("rdi") i64::from(pid),
2405            in("rsi") i64::from(resource),
2406            in("rdx") new_limit,
2407            in("r10") old_limit,
2408            lateout("rax") ret,
2409            lateout("rcx") _,
2410            lateout("r11") _,
2411            options(nostack, preserves_flags),
2412        );
2413    }
2414    ret
2415}
2416
2417#[cfg(target_arch = "aarch64")]
2418#[inline]
2419unsafe fn raw_syscall_prlimit64(pid: i32, resource: u32, new_limit: u64, old_limit: u64) -> i64 {
2420    let ret: i64;
2421    // SAFETY: SYS_prlimit64 (aarch64 = 261). Mêmes considérations.
2422    unsafe {
2423        core::arch::asm!(
2424            "svc 0",
2425            in("x8") 261_i64,
2426            inout("x0") i64::from(pid) => ret,
2427            in("x1") i64::from(resource),
2428            in("x2") new_limit,
2429            in("x3") old_limit,
2430            options(nostack, preserves_flags),
2431        );
2432    }
2433    ret
2434}
2435
2436// ─────────────────────────────────────────────────────────────────────────
2437// capget / capset (sous-section 7 de family-process.md).
2438//
2439// Version 3 capabilities (kernel ≥ 2.6.25) : header + array de 2 data
2440// structs ; chaque data struct contient `(effective, permitted,
2441// inheritable)` en `u32`. Index 0 = bits 0-31, index 1 = bits 32-63.
2442//
2443// La conversion `CapabilityMask (u64) ↔ (lo, hi)` est faite par les
2444// helpers privés `mask_to_words` / `words_to_mask` ci-dessous, testés
2445// explicitement pour qu'une éventuelle inversion lo/hi soit détectée
2446// (cf. JOURNAL session 2026-05-24).
2447// ─────────────────────────────────────────────────────────────────────────
2448
2449const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522;
2450
2451#[repr(C)]
2452struct KernelCapHeader {
2453    version: u32,
2454    pid: i32,
2455}
2456
2457#[repr(C)]
2458#[derive(Default, Clone, Copy)]
2459struct KernelCapData {
2460    effective: u32,
2461    permitted: u32,
2462    inheritable: u32,
2463}
2464
2465fn mask_to_words(mask: CapabilityMask) -> (u32, u32) {
2466    let bits = mask.bits();
2467    // Troncature exacte : `bits & 0xFFFF_FFFF` fit dans u32 par
2468    // construction.
2469    #[allow(clippy::cast_possible_truncation)]
2470    let lo = (bits & 0xFFFF_FFFF) as u32;
2471    // Troncature exacte : après `>> 32`, la valeur résiduelle est dans
2472    // `[0, u32::MAX]`.
2473    #[allow(clippy::cast_possible_truncation)]
2474    let hi = (bits >> 32) as u32;
2475    (lo, hi)
2476}
2477
2478fn words_to_mask(lo: u32, hi: u32) -> CapabilityMask {
2479    let bits = (u64::from(hi) << 32) | u64::from(lo);
2480    CapabilityMask::from_bits(bits)
2481}
2482
2483fn cap_target_to_kernel_pid(target: CapabilityTarget) -> i32 {
2484    match target {
2485        CapabilityTarget::CurrentThread => 0,
2486        CapabilityTarget::Thread(tid) => tid.as_raw(),
2487        CapabilityTarget::Process(pid) => pid.as_raw(),
2488    }
2489}
2490
2491/// Lit les capabilities du thread cible.
2492///
2493/// # Errors
2494///
2495/// - [`Errno::EINVAL`] : version capabilities non supportée par le kernel
2496///   (Air utilise version 3, supportée depuis 2.6.25).
2497/// - [`Errno::EPERM`] : ciblage d'un thread sans droits (rare en lecture).
2498pub fn capget(target: CapabilityTarget) -> Result<CapabilitySet, Errno> {
2499    let mut header = KernelCapHeader {
2500        version: LINUX_CAPABILITY_VERSION_3,
2501        pid: cap_target_to_kernel_pid(target),
2502    };
2503    let mut data: [KernelCapData; 2] = [KernelCapData::default(); 2];
2504    let header_ptr: *mut KernelCapHeader = &mut header;
2505    let data_ptr: *mut KernelCapData = data.as_mut_ptr();
2506    // SAFETY: capget(2) lit `*header` et écrit deux `KernelCapData`
2507    // consécutives à `data_ptr` ; les deux pointeurs sont locaux et
2508    // valides pour la durée du call.
2509    let ret = unsafe { raw_syscall_capget(header_ptr as u64, data_ptr as u64) };
2510    if ret < 0 {
2511        return Err(errno_from_negative_syscall_ret(ret));
2512    }
2513    Ok(CapabilitySet {
2514        effective: words_to_mask(data[0].effective, data[1].effective),
2515        permitted: words_to_mask(data[0].permitted, data[1].permitted),
2516        inheritable: words_to_mask(data[0].inheritable, data[1].inheritable),
2517    })
2518}
2519
2520/// Écrit les capabilities du thread cible.
2521///
2522/// # Errors
2523///
2524/// - [`Errno::EINVAL`] : version capabilities ou layout invalides.
2525/// - [`Errno::EPERM`] : tentative d'élever une capability hors permitted,
2526///   ou modification d'un autre thread sans droits.
2527pub fn capset(target: CapabilityTarget, set: &CapabilitySet) -> Result<(), Errno> {
2528    let mut header = KernelCapHeader {
2529        version: LINUX_CAPABILITY_VERSION_3,
2530        pid: cap_target_to_kernel_pid(target),
2531    };
2532    let (e_lo, e_hi) = mask_to_words(set.effective);
2533    let (p_lo, p_hi) = mask_to_words(set.permitted);
2534    let (i_lo, i_hi) = mask_to_words(set.inheritable);
2535    let data: [KernelCapData; 2] = [
2536        KernelCapData {
2537            effective: e_lo,
2538            permitted: p_lo,
2539            inheritable: i_lo,
2540        },
2541        KernelCapData {
2542            effective: e_hi,
2543            permitted: p_hi,
2544            inheritable: i_hi,
2545        },
2546    ];
2547    let header_ptr: *mut KernelCapHeader = &mut header;
2548    let data_ptr: *const KernelCapData = data.as_ptr();
2549    // SAFETY: capset(2) lit `*header` et lit deux `KernelCapData`
2550    // consécutives à `data_ptr`. Tout est local et valide pour la durée
2551    // du call.
2552    let ret = unsafe { raw_syscall_capset(header_ptr as u64, data_ptr as u64) };
2553    if ret < 0 {
2554        return Err(errno_from_negative_syscall_ret(ret));
2555    }
2556    Ok(())
2557}
2558
2559#[cfg(target_arch = "x86_64")]
2560#[inline]
2561unsafe fn raw_syscall_capget(header: u64, data: u64) -> i64 {
2562    let ret: i64;
2563    // SAFETY: SYS_capget (x86_64 = 125). Le kernel lit `*header` (8 octets)
2564    // et écrit 2 × 12 octets à `data`. Pas de `readonly` (écriture).
2565    unsafe {
2566        core::arch::asm!(
2567            "syscall",
2568            in("rax") 125_i64,
2569            in("rdi") header,
2570            in("rsi") data,
2571            lateout("rax") ret,
2572            lateout("rcx") _,
2573            lateout("r11") _,
2574            options(nostack, preserves_flags),
2575        );
2576    }
2577    ret
2578}
2579
2580#[cfg(target_arch = "x86_64")]
2581#[inline]
2582unsafe fn raw_syscall_capset(header: u64, data: u64) -> i64 {
2583    let ret: i64;
2584    // SAFETY: SYS_capset (x86_64 = 126). Lecture seule de `*header` et
2585    // `*data` côté user ; `readonly` correct.
2586    unsafe {
2587        core::arch::asm!(
2588            "syscall",
2589            in("rax") 126_i64,
2590            in("rdi") header,
2591            in("rsi") data,
2592            lateout("rax") ret,
2593            lateout("rcx") _,
2594            lateout("r11") _,
2595            options(nostack, preserves_flags, readonly),
2596        );
2597    }
2598    ret
2599}
2600
2601#[cfg(target_arch = "aarch64")]
2602#[inline]
2603unsafe fn raw_syscall_capget(header: u64, data: u64) -> i64 {
2604    let ret: i64;
2605    // SAFETY: SYS_capget (aarch64 = 90).
2606    unsafe {
2607        core::arch::asm!(
2608            "svc 0",
2609            in("x8") 90_i64,
2610            inout("x0") header => ret,
2611            in("x1") data,
2612            options(nostack, preserves_flags),
2613        );
2614    }
2615    ret
2616}
2617
2618#[cfg(target_arch = "aarch64")]
2619#[inline]
2620unsafe fn raw_syscall_capset(header: u64, data: u64) -> i64 {
2621    let ret: i64;
2622    // SAFETY: SYS_capset (aarch64 = 91). `readonly` car lecture seule.
2623    unsafe {
2624        core::arch::asm!(
2625            "svc 0",
2626            in("x8") 91_i64,
2627            inout("x0") header => ret,
2628            in("x1") data,
2629            options(nostack, preserves_flags, readonly),
2630        );
2631    }
2632    ret
2633}
2634
2635// ─────────────────────────────────────────────────────────────────────────
2636// Helper interne de conversion `errno`.
2637// ─────────────────────────────────────────────────────────────────────────
2638
2639/// Convertit une valeur de retour de syscall strictement négative en
2640/// [`Errno`]. Le kernel borne les errno à `[-4095, -1]` (`MAX_ERRNO`).
2641fn errno_from_negative_syscall_ret(ret: i64) -> Errno {
2642    debug_assert!(ret < 0 && ret > -4096);
2643    // `ret.wrapping_neg()` est donc dans `[1, 4095]` — toujours fit in i32.
2644    #[allow(clippy::cast_possible_truncation)]
2645    let raw = ret.wrapping_neg() as i32;
2646    let nz = NonZeroI32::new(raw).expect("errno strictement positif par construction");
2647    Errno::from_nonzero(nz)
2648}
2649
2650// ─────────────────────────────────────────────────────────────────────────
2651// Affinité CPU — sched_setaffinity / sched_getaffinity
2652// Cf. `docs/specs/layer-0/family-process-affinity.md`.
2653// ─────────────────────────────────────────────────────────────────────────
2654
2655#[cfg(target_arch = "x86_64")]
2656#[inline]
2657unsafe fn raw_syscall_sched_setaffinity(pid: i32, len: u64, mask: u64) -> i64 {
2658    let ret: i64;
2659    // SAFETY:
2660    // - SYS_sched_setaffinity (x86_64 = 203). Le kernel **lit** `len` octets à
2661    //   `mask` (jamais d'écriture) → `readonly` correct ; validité du buffer
2662    //   garantie par l'appelant (`CpuSet` local, 128 octets).
2663    // - x86_64 ABI : numéro en RAX, args en RDI/RSI/RDX ; retour RAX ; clobbe
2664    //   RCX/R11.
2665    unsafe {
2666        core::arch::asm!(
2667            "syscall",
2668            in("rax") 203_i64,
2669            in("rdi") i64::from(pid),
2670            in("rsi") len,
2671            in("rdx") mask,
2672            lateout("rax") ret,
2673            lateout("rcx") _,
2674            lateout("r11") _,
2675            options(nostack, preserves_flags, readonly),
2676        );
2677    }
2678    ret
2679}
2680
2681#[cfg(target_arch = "aarch64")]
2682#[inline]
2683unsafe fn raw_syscall_sched_setaffinity(pid: i32, len: u64, mask: u64) -> i64 {
2684    let ret: i64;
2685    // SAFETY: voir version x86_64 ; aarch64 ABI : numéro en X8 (= 122), args en
2686    // X0/X1/X2, retour en X0. Le kernel **lit** `mask` → `readonly`.
2687    unsafe {
2688        core::arch::asm!(
2689            "svc 0",
2690            in("x8") 122_i64,
2691            inout("x0") i64::from(pid) => ret,
2692            in("x1") len,
2693            in("x2") mask,
2694            options(nostack, preserves_flags, readonly),
2695        );
2696    }
2697    ret
2698}
2699
2700#[cfg(target_arch = "x86_64")]
2701#[inline]
2702unsafe fn raw_syscall_sched_getaffinity(pid: i32, len: u64, mask: u64) -> i64 {
2703    let ret: i64;
2704    // SAFETY:
2705    // - SYS_sched_getaffinity (x86_64 = 204). Le kernel **écrit** jusqu'à `len`
2706    //   octets à `mask` → **pas de `readonly`** ; validité/taille du buffer
2707    //   garanties par l'appelant (`CpuSet` local, 128 octets).
2708    // - Retourne le nombre d'octets écrits (≥ 0) ou `-errno`.
2709    unsafe {
2710        core::arch::asm!(
2711            "syscall",
2712            in("rax") 204_i64,
2713            in("rdi") i64::from(pid),
2714            in("rsi") len,
2715            in("rdx") mask,
2716            lateout("rax") ret,
2717            lateout("rcx") _,
2718            lateout("r11") _,
2719            options(nostack, preserves_flags),
2720        );
2721    }
2722    ret
2723}
2724
2725#[cfg(target_arch = "aarch64")]
2726#[inline]
2727unsafe fn raw_syscall_sched_getaffinity(pid: i32, len: u64, mask: u64) -> i64 {
2728    let ret: i64;
2729    // SAFETY: voir version x86_64 ; aarch64 ABI : numéro en X8 (= 123), args en
2730    // X0/X1/X2, retour en X0. Le kernel **écrit** dans `mask` → pas de `readonly`.
2731    unsafe {
2732        core::arch::asm!(
2733            "svc 0",
2734            in("x8") 123_i64,
2735            inout("x0") i64::from(pid) => ret,
2736            in("x1") len,
2737            in("x2") mask,
2738            options(nostack, preserves_flags),
2739        );
2740    }
2741    ret
2742}
2743
2744/// Identifiant de tâche pour les opérations d'affinité : `None` = **tâche
2745/// appelante** (sentinelle kernel `0` typée en `Option`, ADR-021 conv. 1).
2746fn affinity_pid(tid: Option<Tid>) -> i32 {
2747    // `0` = la tâche appelante (jamais exposé comme entier magique).
2748    tid.map_or(0, Tid::as_raw)
2749}
2750
2751/// Fixe l'**affinité CPU** d'une tâche (`sched_setaffinity(2)`) : la tâche ne sera
2752/// ordonnancée que sur les CPU présents dans `cpus`. `tid = None` cible la tâche
2753/// appelante. Débloque `air-thread::cpu_affinity` (couche 1).
2754///
2755/// # Errors
2756///
2757/// - [`Errno::EINVAL`] : `cpus` ne contient **aucun** CPU autorisé/en ligne.
2758/// - [`Errno::ESRCH`] : aucune tâche ne porte ce `tid`.
2759/// - [`Errno::EPERM`] : privilèges insuffisants pour changer l'affinité de `tid`.
2760pub fn set_cpu_affinity(tid: Option<Tid>, cpus: &CpuSet) -> Result<(), Errno> {
2761    let bytes = cpus.as_bytes();
2762    let len = bytes.len() as u64;
2763    let mask = bytes.as_ptr() as u64;
2764    // SAFETY: `mask` pointe les `len` octets (= 128) du `CpuSet` de l'appelant,
2765    // valides en lecture pour la durée de l'appel ; le kernel ne les écrit pas.
2766    let ret = unsafe { raw_syscall_sched_setaffinity(affinity_pid(tid), len, mask) };
2767    if ret < 0 {
2768        return Err(errno_from_negative_syscall_ret(ret));
2769    }
2770    Ok(())
2771}
2772
2773/// Lit l'**affinité CPU** courante d'une tâche (`sched_getaffinity(2)`) **dans**
2774/// `cpus` (buffer fourni, zéro allocation). `tid = None` cible la tâche appelante.
2775///
2776/// # Errors
2777///
2778/// - [`Errno::ESRCH`] : aucune tâche ne porte ce `tid`.
2779/// - [`Errno::EINVAL`] : taille de masque incohérente (ne se produit pas avec un
2780///   `CpuSet` de [`air_sys_types::system::CPU_SETSIZE`] bits).
2781pub fn get_cpu_affinity(tid: Option<Tid>, cpus: &mut CpuSet) -> Result<(), Errno> {
2782    let len = core::mem::size_of::<CpuSet>() as u64;
2783    let mask = core::ptr::from_mut(cpus).cast::<u8>() as u64;
2784    // SAFETY: `mask` pointe les `len` octets (= 128) du `CpuSet` **mutable** de
2785    // l'appelant ; le kernel y écrit au plus `len` octets (le bitmap d'affinité).
2786    // `CpuSet` est `#[repr(C)]` sans padding ⇒ tout motif d'octets est valide.
2787    let ret = unsafe { raw_syscall_sched_getaffinity(affinity_pid(tid), len, mask) };
2788    if ret < 0 {
2789        return Err(errno_from_negative_syscall_ret(ret));
2790    }
2791    Ok(())
2792}
2793
2794// ─────────────────────────────────────────────────────────────────────────
2795// exec / redirection — execve, execveat, dup3, fchdir, chdir.
2796//
2797// Surface pilotée par le contrat de la couche 1 (`air-process`) : lancer un
2798// programme (exec), rediriger ses stdin/stdout/stderr (dup3) et fixer son
2799// répertoire de travail (fchdir / chdir). Cf. `docs/specs/layer-0/
2800// family-process.md`, section « exec / redirection ».
2801// ─────────────────────────────────────────────────────────────────────────
2802
2803/// `AT_FDCWD` : base « répertoire courant » des opérations `*at` (sentinelle
2804/// kernel `-100`, typée [`DirFd::Cwd`] côté Air, cf. ADR-021 convention 1).
2805const AT_FDCWD: i32 = -100;
2806
2807/// Convertit une [`DirFd`] en l'entier attendu par l'ABI syscall (`AT_FDCWD`
2808/// pour [`DirFd::Cwd`], sinon le fd emprunté).
2809fn dirfd_to_raw(dirfd: DirFd<'_>) -> i32 {
2810    match dirfd {
2811        DirFd::Cwd => AT_FDCWD,
2812        DirFd::Fd(fd) => fd.as_raw_fd(),
2813    }
2814}
2815
2816/// Tableau de pointeurs C terminé par `NULL`, pour le marshalling d'`argv` /
2817/// `envp` vers `execve(2)` / `execveat(2)`.
2818///
2819/// Ces syscalls attendent un argument de type `char *const argv[]` : une suite
2820/// de pointeurs vers des chaînes C **terminée par un pointeur `NULL`**. Ce type
2821/// matérialise cette **frontière de marshalling**, qui est *fuzzée*
2822/// (`fuzz/fuzz_targets/fuzz_exec_argv.rs`) : construction depuis une liste
2823/// arbitraire (vide, longue), terminaison `NULL` toujours présente, ordre
2824/// préservé.
2825///
2826/// **Rejet des NUL embarqués — par construction.** Un argument C ne peut pas
2827/// contenir d'octet NUL (il y serait interprété comme terminateur). L'entrée
2828/// étant une tranche de [`CStr`] — type qui **garantit** l'absence de NUL
2829/// interne — l'invariant est assuré *en amont*, là où l'appelant construit ses
2830/// `&CStr` (typiquement via `CStr::from_bytes_with_nul` ou `CString::new`, qui
2831/// rejettent proprement un NUL embarqué). Aucune revalidation n'est donc
2832/// nécessaire ici (Principe 4 : « parse, don't validate »).
2833///
2834/// **Allocation.** La construction alloue un [`Vec`] de `items.len() + 1`
2835/// pointeurs : c'est l'exception d'allocation documentée d'ADR-021 §4 (buffer
2836/// dynamique *intrinsèque* — sa taille dépend du nombre d'arguments, inconnu à
2837/// la compilation). Les chaînes elles-mêmes ne sont **pas** copiées : les
2838/// pointeurs empruntent les [`CStr`] fournis (d'où la durée de vie `'a`).
2839pub struct CStrArray<'a> {
2840    /// Pointeurs vers chaque `CStr`, suivis d'un pointeur `NULL` final.
2841    /// Invariant : `ptrs.len() >= 1` et `ptrs[ptrs.len() - 1].is_null()`.
2842    ptrs: Vec<*const c_char>,
2843    /// Lie la durée de vie des pointeurs à celle de la tranche source.
2844    _borrow: PhantomData<&'a [&'a CStr]>,
2845}
2846
2847impl<'a> CStrArray<'a> {
2848    /// Construit le tableau `NULL`-terminé à partir de `items`.
2849    ///
2850    /// Une tranche **vide** produit un tableau ne contenant que le `NULL` final
2851    /// (`argv = { NULL }`, soit `argc == 0`) — accepté par le kernel.
2852    #[must_use]
2853    pub fn new(items: &'a [&'a CStr]) -> Self {
2854        // `+ 1` pour le terminateur NULL. `saturating_add` par discipline
2855        // (Principe 2) ; la capacité d'une tranche ne peut pas atteindre
2856        // `usize::MAX`, donc la saturation ne se déclenche jamais.
2857        let mut ptrs = Vec::with_capacity(items.len().saturating_add(1));
2858        for item in items {
2859            ptrs.push(item.as_ptr());
2860        }
2861        ptrs.push(core::ptr::null());
2862        Self {
2863            ptrs,
2864            _borrow: PhantomData,
2865        }
2866    }
2867
2868    /// Nombre d'arguments (hors terminateur `NULL`).
2869    #[must_use]
2870    pub fn len(&self) -> usize {
2871        // Invariant : `ptrs` contient toujours le `NULL` final en sus.
2872        self.ptrs.len().saturating_sub(1)
2873    }
2874
2875    /// Vrai si la liste est vide (le tableau ne contient que le `NULL` final).
2876    #[must_use]
2877    pub fn is_empty(&self) -> bool {
2878        self.len() == 0
2879    }
2880
2881    /// Pointeur vers le tableau de pointeurs, à transmettre au syscall.
2882    #[inline]
2883    fn as_ptr(&self) -> *const *const c_char {
2884        self.ptrs.as_ptr()
2885    }
2886}
2887
2888/// Remplace l'image mémoire du processus courant par l'exécutable situé à
2889/// `path`, avec les arguments `argv` et l'environnement `envp`.
2890///
2891/// Cf. `docs/specs/layer-0/family-process.md`. En cas de **succès, ne retourne
2892/// jamais** (le processus exécute désormais le nouveau binaire) — d'où le type
2893/// `Result<Infallible, Errno>` (ADR-021 convention 5). En cas d'échec, retourne
2894/// `Err(errno)`.
2895///
2896/// `argv` / `envp` sont des tranches de [`CStr`] ; le wrapper en construit les
2897/// tableaux de pointeurs C `NULL`-terminés via [`CStrArray`]. Par convention
2898/// POSIX, `argv[0]` est le nom du programme ; une tranche vide est acceptée par
2899/// le kernel (`argc == 0`).
2900///
2901/// Pour exécuter un binaire désigné par un **fd** (sans race sur le chemin),
2902/// préférer [`execveat`] avec [`ExecveatFlags::EMPTY_PATH`].
2903///
2904/// # Errors
2905///
2906/// - [`Errno::EACCES`] : `path` n'est pas exécutable, un répertoire du chemin
2907///   interdit la traversée, ou le système de fichiers est monté `noexec`.
2908/// - [`Errno::ENOENT`] : `path` (ou l'interpréteur `#!` d'un script) n'existe
2909///   pas.
2910/// - [`Errno::ENOEXEC`] : `path` existe mais n'est pas dans un format
2911///   exécutable reconnu (ni ELF, ni script avec `#!`).
2912/// - [`Errno::E2BIG`] : `argv` + `envp` dépassent la limite kernel (`ARG_MAX`).
2913/// - `ENOMEM`, `ETXTBSY`, `ELOOP`, … : cf. `execve(2)`.
2914pub fn execve(path: &CStr, argv: &[&CStr], envp: &[&CStr]) -> Result<Infallible, Errno> {
2915    // L'allocation des tableaux de pointeurs ([`CStrArray::new`]) a lieu **ici**.
2916    // Le cœur (syscall, sans allocation) est délégué à [`execve_prepared`], que
2917    // les appelants en chemin **post-fork** invoquent directement avec des
2918    // [`CStrArray`] matérialisés **avant** le fork (async-signal-safety).
2919    let c_argv = CStrArray::new(argv);
2920    let c_envp = CStrArray::new(envp);
2921    execve_prepared(path, &c_argv, &c_envp)
2922}
2923
2924/// Variante d'[`execve`] **sans allocation** : reçoit des tableaux de pointeurs
2925/// `argv`/`envp` **déjà construits** ([`CStrArray`]).
2926///
2927/// Destinée au chemin **post-fork / pré-`execve`** : l'appelant matérialise les
2928/// [`CStrArray`] dans le parent **avant** `clone3`/`fork`, puis les passe ici
2929/// depuis l'enfant. Cette fonction n'alloue rien — seul un syscall est émis —
2930/// donc elle est sûre dans la fenêtre fork→exec où aucune allocation (verrou
2931/// allocateur potentiellement tenu par un autre thread au moment du fork) n'est
2932/// permise. [`execve`] délègue à cette fonction après avoir bâti ses tableaux.
2933///
2934/// En cas de **succès, ne retourne jamais** (image mémoire remplacée). En cas
2935/// d'échec, retourne `Err(errno)`.
2936///
2937/// # Errors
2938///
2939/// Identiques à [`execve`] (`EACCES`, `ENOENT`, `ENOEXEC`, `E2BIG`, …).
2940pub fn execve_prepared(
2941    path: &CStr,
2942    argv: &CStrArray<'_>,
2943    envp: &CStrArray<'_>,
2944) -> Result<Infallible, Errno> {
2945    // SAFETY:
2946    // - `path` est un `CStr` valide NUL-terminé (le kernel le lit jusqu'au NUL).
2947    // - `argv`/`envp` exposent chacun un tableau de pointeurs C valide, terminé
2948    //   par NULL, vivant pour toute la durée de l'appel ; les chaînes pointées
2949    //   sont les `CStr` empruntés, vivants eux aussi (durée de vie liée par
2950    //   `CStrArray<'a>`). Le kernel ne fait que **lire** ces données.
2951    // - Succès : le syscall ne revient pas (image remplacée). Échec : ret < 0.
2952    let ret = unsafe {
2953        raw_syscall_execve(
2954            path.as_ptr() as u64,
2955            argv.as_ptr() as u64,
2956            envp.as_ptr() as u64,
2957        )
2958    };
2959    // Seul un échec ramène ici : `ret` est toujours `< 0`. La conversion errno
2960    // impose elle-même `ret ∈ [-4095, -1]` (debug_assert interne).
2961    Err(errno_from_negative_syscall_ret(ret))
2962}
2963
2964#[cfg(target_arch = "x86_64")]
2965#[inline]
2966unsafe fn raw_syscall_execve(path: u64, argv: u64, envp: u64) -> i64 {
2967    let ret: i64;
2968    // SAFETY:
2969    // - SYS_execve (x86_64 = 59). Le kernel **lit** la chaîne `path` (jusqu'au
2970    //   NUL) et les tableaux `argv`/`envp` (jusqu'au pointeur NULL, puis chaque
2971    //   chaîne jusqu'à son NUL) ; il n'écrit aucune mémoire utilisateur →
2972    //   `readonly` correct. Validité garantie par l'appelant.
2973    // - Succès : ne revient pas (image remplacée). Échec : ret = -errno.
2974    // - x86_64 ABI : numéro en RAX, args en RDI/RSI/RDX ; retour RAX ; clobbe
2975    //   RCX/R11.
2976    unsafe {
2977        core::arch::asm!(
2978            "syscall",
2979            in("rax") 59_i64,
2980            in("rdi") path,
2981            in("rsi") argv,
2982            in("rdx") envp,
2983            lateout("rax") ret,
2984            lateout("rcx") _,
2985            lateout("r11") _,
2986            options(nostack, preserves_flags, readonly),
2987        );
2988    }
2989    ret
2990}
2991
2992#[cfg(target_arch = "aarch64")]
2993#[inline]
2994unsafe fn raw_syscall_execve(path: u64, argv: u64, envp: u64) -> i64 {
2995    let ret: i64;
2996    // SAFETY: voir version x86_64 ; aarch64 ABI : SYS_execve (aarch64 = 221),
2997    // numéro en X8, args en X0/X1/X2, retour en X0. Lecture seule → `readonly`.
2998    unsafe {
2999        core::arch::asm!(
3000            "svc 0",
3001            in("x8") 221_i64,
3002            inout("x0") path => ret,
3003            in("x1") argv,
3004            in("x2") envp,
3005            options(nostack, preserves_flags, readonly),
3006        );
3007    }
3008    ret
3009}
3010
3011/// Variante *at d'[`execve`] (préférée par ADR-021) : résout l'exécutable
3012/// relativement à `dirfd`, ou — avec [`ExecveatFlags::EMPTY_PATH`] et
3013/// `path == c""` — exécute **directement** le binaire désigné par `dirfd`
3014/// (un fd ouvert sur l'exécutable, typiquement avec `O_PATH`), éliminant toute
3015/// race entre l'ouverture/vérification et l'exec.
3016///
3017/// Cf. `docs/specs/layer-0/family-process.md`. Comme [`execve`], **ne retourne
3018/// jamais en cas de succès**.
3019///
3020/// # Errors
3021///
3022/// Mêmes erreurs qu'[`execve`], plus :
3023///
3024/// - [`Errno::EBADF`] : `dirfd` n'est ni `AT_FDCWD` ni un fd ouvert valide.
3025/// - [`Errno::ENOENT`] : `path` est vide sans [`ExecveatFlags::EMPTY_PATH`].
3026/// - [`Errno::ELOOP`] : composant final symbolique avec
3027///   [`ExecveatFlags::SYMLINK_NOFOLLOW`].
3028pub fn execveat(
3029    dirfd: DirFd<'_>,
3030    path: &CStr,
3031    argv: &[&CStr],
3032    envp: &[&CStr],
3033    flags: ExecveatFlags,
3034) -> Result<Infallible, Errno> {
3035    let c_argv = CStrArray::new(argv);
3036    let c_envp = CStrArray::new(envp);
3037    // SAFETY: identique à `execve` pour `path`/`argv`/`envp` (lecture seule),
3038    // plus :
3039    // - `dirfd` est `AT_FDCWD` ou un fd valide emprunté (résolution relative,
3040    //   ou exécutable direct si `EMPTY_PATH`).
3041    // - `flags` est un masque `AT_*` valide (typé `ExecveatFlags`).
3042    let ret = unsafe {
3043        raw_syscall_execveat(
3044            dirfd_to_raw(dirfd),
3045            path.as_ptr() as u64,
3046            c_argv.as_ptr() as u64,
3047            c_envp.as_ptr() as u64,
3048            flags.bits(),
3049        )
3050    };
3051    // Seul un échec ramène ici : `ret` est toujours `< 0`.
3052    Err(errno_from_negative_syscall_ret(ret))
3053}
3054
3055#[cfg(target_arch = "x86_64")]
3056#[inline]
3057unsafe fn raw_syscall_execveat(dirfd: i32, path: u64, argv: u64, envp: u64, flags: i32) -> i64 {
3058    let ret: i64;
3059    // SAFETY:
3060    // - SYS_execveat (x86_64 = 322). Lecture seule de `path`/`argv`/`envp` (cf.
3061    //   `raw_syscall_execve`) → `readonly`. `dirfd` (étendu en signe : AT_FDCWD
3062    //   = -100 ou fd valide) et `flags` sont des scalaires.
3063    // - Succès : ne revient pas. Échec : ret = -errno.
3064    // - x86_64 ABI : args en RDI/RSI/RDX/R10/R8.
3065    unsafe {
3066        core::arch::asm!(
3067            "syscall",
3068            in("rax") 322_i64,
3069            in("rdi") i64::from(dirfd),
3070            in("rsi") path,
3071            in("rdx") argv,
3072            in("r10") envp,
3073            in("r8") i64::from(flags),
3074            lateout("rax") ret,
3075            lateout("rcx") _,
3076            lateout("r11") _,
3077            options(nostack, preserves_flags, readonly),
3078        );
3079    }
3080    ret
3081}
3082
3083#[cfg(target_arch = "aarch64")]
3084#[inline]
3085unsafe fn raw_syscall_execveat(dirfd: i32, path: u64, argv: u64, envp: u64, flags: i32) -> i64 {
3086    let ret: i64;
3087    // SAFETY: voir version x86_64 ; aarch64 ABI : SYS_execveat (aarch64 = 281),
3088    // numéro en X8, args en X0..X4, retour en X0. Lecture seule → `readonly`.
3089    unsafe {
3090        core::arch::asm!(
3091            "svc 0",
3092            in("x8") 281_i64,
3093            inout("x0") i64::from(dirfd) => ret,
3094            in("x1") path,
3095            in("x2") argv,
3096            in("x3") envp,
3097            in("x4") i64::from(flags),
3098            options(nostack, preserves_flags, readonly),
3099        );
3100    }
3101    ret
3102}
3103
3104/// Duplique le descripteur `old` vers le numéro `new` (`dup3(2)`), en fermant
3105/// atomiquement `new` s'il était ouvert. Retourne le nouveau descripteur,
3106/// possédé ([`OwnedFd`], fermé au `Drop`).
3107///
3108/// Cf. `docs/specs/layer-0/family-process.md`. Variante **moderne** de `dup2`
3109/// (ADR-021) : les drapeaux sont explicites ([`Dup3Flags`], seul `O_CLOEXEC`
3110/// est valide). Cas d'usage couche 1 : rediriger les flux standard d'un enfant
3111/// (`dup3(pipe, STDOUT_FILENO, …)`) juste avant l'`exec`.
3112///
3113/// À la différence de `dup2`, `dup3` **échoue** (`EINVAL`) si `old == new` :
3114/// pas de no-op silencieux. Le numéro `new` doit donc différer de `old`.
3115///
3116/// # Errors
3117///
3118/// - [`Errno::EINVAL`] : `flags` invalides, **ou** `old == new`.
3119/// - [`Errno::EBADF`] : `new` hors de la plage autorisée (`> RLIMIT_NOFILE`).
3120/// - [`Errno::EBUSY`] : course (rare) avec `open`/`dup` concurrents sur `new`.
3121/// - [`Errno::EINTR`] : interrompu par un signal (remonté tel quel, ADR-021
3122///   convention 2).
3123pub fn dup3(old: BorrowedFd<'_>, new: RawFd, flags: Dup3Flags) -> Result<OwnedFd, Errno> {
3124    // SAFETY: `dup3(2)` ne touche aucune mémoire utilisateur ; il installe une
3125    // copie de l'entrée de table de fd `old` au numéro `new` (fermant `new` au
3126    // préalable s'il était ouvert), de manière atomique. `flags` (typé) ne peut
3127    // valoir que `O_CLOEXEC`.
3128    let ret = unsafe { raw_syscall_dup3(old.as_raw_fd(), new, flags.bits()) };
3129    if ret < 0 {
3130        return Err(errno_from_negative_syscall_ret(ret));
3131    }
3132    // Succès : `ret == new` (le numéro demandé), donc tient dans un `i32`.
3133    #[allow(clippy::cast_possible_truncation)]
3134    let new_fd = ret as RawFd;
3135    // SAFETY: le kernel vient d'installer un fd valide au numéro `new`, dont
3136    // Air prend désormais la propriété exclusive (fermé par `OwnedFd::Drop`).
3137    Ok(unsafe { OwnedFd::from_raw_fd(new_fd) })
3138}
3139
3140#[cfg(target_arch = "x86_64")]
3141#[inline]
3142unsafe fn raw_syscall_dup3(oldfd: i32, newfd: i32, flags: i32) -> i64 {
3143    let ret: i64;
3144    // SAFETY: SYS_dup3 (x86_64 = 292) ne touche aucune mémoire utilisateur
3145    // (`readonly`). Args étendus en signe (fd ≥ 0 ici ; l'extension reste
3146    // correcte). x86_64 ABI : args en RDI/RSI/RDX.
3147    unsafe {
3148        core::arch::asm!(
3149            "syscall",
3150            in("rax") 292_i64,
3151            in("rdi") i64::from(oldfd),
3152            in("rsi") i64::from(newfd),
3153            in("rdx") i64::from(flags),
3154            lateout("rax") ret,
3155            lateout("rcx") _,
3156            lateout("r11") _,
3157            options(nostack, preserves_flags, readonly),
3158        );
3159    }
3160    ret
3161}
3162
3163#[cfg(target_arch = "aarch64")]
3164#[inline]
3165unsafe fn raw_syscall_dup3(oldfd: i32, newfd: i32, flags: i32) -> i64 {
3166    let ret: i64;
3167    // SAFETY: voir version x86_64 ; aarch64 ABI : SYS_dup3 (aarch64 = 24),
3168    // numéro en X8, args en X0/X1/X2, retour en X0. Lecture seule → `readonly`.
3169    unsafe {
3170        core::arch::asm!(
3171            "svc 0",
3172            in("x8") 24_i64,
3173            inout("x0") i64::from(oldfd) => ret,
3174            in("x1") i64::from(newfd),
3175            in("x2") i64::from(flags),
3176            options(nostack, preserves_flags, readonly),
3177        );
3178    }
3179    ret
3180}
3181
3182/// Change le répertoire de travail courant du processus pour celui référencé
3183/// par le descripteur `dir` (`fchdir(2)`).
3184///
3185/// Cf. `docs/specs/layer-0/family-process.md`. Variante par fd de [`chdir`] :
3186/// pas de race sur le chemin (le répertoire est déjà ouvert et épinglé). Cas
3187/// d'usage couche 1 : fixer le `current_dir` d'un enfant à partir d'un fd
3188/// vérifié, juste avant l'`exec`.
3189///
3190/// # Errors
3191///
3192/// - [`Errno::EBADF`] : `dir` n'est pas un descripteur ouvert valide.
3193/// - [`Errno::ENOTDIR`] : `dir` ne référence pas un répertoire.
3194/// - [`Errno::EACCES`] : permission de recherche refusée sur le répertoire.
3195pub fn fchdir(dir: BorrowedFd<'_>) -> Result<(), Errno> {
3196    // SAFETY: `fchdir(2)` ne touche aucune mémoire utilisateur ; il bascule le
3197    // répertoire courant du processus vers le répertoire référencé par `dir`.
3198    let ret = unsafe { raw_syscall_fchdir(dir.as_raw_fd()) };
3199    if ret < 0 {
3200        return Err(errno_from_negative_syscall_ret(ret));
3201    }
3202    Ok(())
3203}
3204
3205#[cfg(target_arch = "x86_64")]
3206#[inline]
3207unsafe fn raw_syscall_fchdir(fd: i32) -> i64 {
3208    let ret: i64;
3209    // SAFETY: SYS_fchdir (x86_64 = 81) ne touche aucune mémoire utilisateur
3210    // (`readonly`). x86_64 ABI : argument unique en RDI.
3211    unsafe {
3212        core::arch::asm!(
3213            "syscall",
3214            in("rax") 81_i64,
3215            in("rdi") i64::from(fd),
3216            lateout("rax") ret,
3217            lateout("rcx") _,
3218            lateout("r11") _,
3219            options(nostack, preserves_flags, readonly),
3220        );
3221    }
3222    ret
3223}
3224
3225#[cfg(target_arch = "aarch64")]
3226#[inline]
3227unsafe fn raw_syscall_fchdir(fd: i32) -> i64 {
3228    let ret: i64;
3229    // SAFETY: voir version x86_64 ; aarch64 ABI : SYS_fchdir (aarch64 = 50),
3230    // numéro en X8, argument en X0, retour en X0. Lecture seule → `readonly`.
3231    unsafe {
3232        core::arch::asm!(
3233            "svc 0",
3234            in("x8") 50_i64,
3235            inout("x0") i64::from(fd) => ret,
3236            options(nostack, preserves_flags, readonly),
3237        );
3238    }
3239    ret
3240}
3241
3242/// Change le répertoire de travail courant du processus pour `path`
3243/// (`chdir(2)`).
3244///
3245/// Cf. `docs/specs/layer-0/family-process.md`. Cas d'usage couche 1 : fixer le
3246/// `current_dir` d'un enfant juste avant l'`exec`. Préférer [`fchdir`] quand un
3247/// fd de répertoire est disponible (pas de race sur le chemin).
3248///
3249/// # Errors
3250///
3251/// - [`Errno::ENOENT`] : `path` n'existe pas.
3252/// - [`Errno::ENOTDIR`] : un composant de `path` n'est pas un répertoire.
3253/// - [`Errno::EACCES`] : permission de recherche refusée.
3254/// - [`Errno::ENAMETOOLONG`] : `path` trop long.
3255pub fn chdir(path: &CStr) -> Result<(), Errno> {
3256    // SAFETY: `chdir(2)` **lit** la chaîne `path` (jusqu'au NUL) et n'écrit
3257    // aucune mémoire utilisateur → `readonly`. `path` est un `CStr` valide.
3258    let ret = unsafe { raw_syscall_chdir(path.as_ptr() as u64) };
3259    if ret < 0 {
3260        return Err(errno_from_negative_syscall_ret(ret));
3261    }
3262    Ok(())
3263}
3264
3265/// Change le **répertoire racine** du processus vers `path` (`chroot(2)`) —
3266/// **descellement additif [ADR-085](../../../docs/adrs/ADR-085-descellement-couche0-cumule-libc-std-fr.md)**
3267/// pour la face libc. Confinement de processus (`CAP_SYS_CHROOT` requis).
3268///
3269/// # Errors
3270///
3271/// - [`Errno::EPERM`] : `CAP_SYS_CHROOT` manquant.
3272/// - [`Errno::ENOENT`] : `path` n'existe pas.
3273/// - [`Errno::ENOTDIR`] : un composant de `path` n'est pas un répertoire.
3274pub fn chroot(path: &CStr) -> Result<(), Errno> {
3275    // SAFETY: `chroot(2)` **lit** la chaîne `path` (jusqu'au NUL) et n'écrit aucune
3276    // mémoire utilisateur → `readonly`. `path` est un `CStr` valide.
3277    let ret = unsafe { raw_syscall_chroot(path.as_ptr() as u64) };
3278    if ret < 0 {
3279        return Err(errno_from_negative_syscall_ret(ret));
3280    }
3281    Ok(())
3282}
3283
3284#[cfg(target_arch = "x86_64")]
3285#[inline]
3286unsafe fn raw_syscall_chdir(path: u64) -> i64 {
3287    let ret: i64;
3288    // SAFETY: SYS_chdir (x86_64 = 80). Le kernel **lit** la chaîne `path`
3289    // (jusqu'au NUL), validité garantie par l'appelant ; aucune écriture
3290    // utilisateur → `readonly`. x86_64 ABI : argument unique en RDI.
3291    unsafe {
3292        core::arch::asm!(
3293            "syscall",
3294            in("rax") 80_i64,
3295            in("rdi") path,
3296            lateout("rax") ret,
3297            lateout("rcx") _,
3298            lateout("r11") _,
3299            options(nostack, preserves_flags, readonly),
3300        );
3301    }
3302    ret
3303}
3304
3305#[cfg(target_arch = "aarch64")]
3306#[inline]
3307unsafe fn raw_syscall_chdir(path: u64) -> i64 {
3308    let ret: i64;
3309    // SAFETY: voir version x86_64 ; aarch64 ABI : SYS_chdir (aarch64 = 49),
3310    // numéro en X8, argument (pointeur `path`) en X0, retour en X0. Lecture
3311    // seule → `readonly`.
3312    unsafe {
3313        core::arch::asm!(
3314            "svc 0",
3315            in("x8") 49_i64,
3316            inout("x0") path => ret,
3317            options(nostack, preserves_flags, readonly),
3318        );
3319    }
3320    ret
3321}
3322
3323#[cfg(target_arch = "x86_64")]
3324#[inline]
3325unsafe fn raw_syscall_chroot(path: u64) -> i64 {
3326    let ret: i64;
3327    // SAFETY: SYS_chroot (x86_64 = 161). Le kernel **lit** la chaîne `path` (jusqu'au
3328    // NUL), validité garantie par l'appelant ; aucune écriture utilisateur → `readonly`.
3329    // x86_64 ABI : argument unique en RDI.
3330    unsafe {
3331        core::arch::asm!(
3332            "syscall",
3333            in("rax") 161_i64,
3334            in("rdi") path,
3335            lateout("rax") ret,
3336            lateout("rcx") _,
3337            lateout("r11") _,
3338            options(nostack, preserves_flags, readonly),
3339        );
3340    }
3341    ret
3342}
3343
3344#[cfg(target_arch = "aarch64")]
3345#[inline]
3346unsafe fn raw_syscall_chroot(path: u64) -> i64 {
3347    let ret: i64;
3348    // SAFETY: voir version x86_64 ; aarch64 ABI : SYS_chroot (aarch64 = 51), numéro en
3349    // X8, argument (pointeur `path`) en X0, retour en X0. Lecture seule → `readonly`.
3350    unsafe {
3351        core::arch::asm!(
3352            "svc 0",
3353            in("x8") 51_i64,
3354            inout("x0") path => ret,
3355            options(nostack, preserves_flags, readonly),
3356        );
3357    }
3358    ret
3359}
3360
3361/// API de fuzzing **pure** (sans syscall) de la frontière de marshalling
3362/// `argv`/`envp` ([`CStrArray`]). Le harnais cargo-fuzz
3363/// (`fuzz/fuzz_targets/fuzz_exec_argv.rs`) y délègue ; l'oracle vit ici pour
3364/// accéder aux internes de [`CStrArray`] (le tableau de pointeurs) tout en
3365/// restant testé et couvert. Suit le pattern de [`crate::io_uring::fuzz_api`].
3366pub mod fuzz_api {
3367    use super::CStrArray;
3368    use alloc::ffi::CString;
3369    use alloc::vec::Vec;
3370    use core::ffi::{CStr, c_char};
3371
3372    /// Exerce la chaîne « octets bruts → `CString` → `CStrArray` » et la
3373    /// **parcourt** jusqu'au terminateur `NULL`, garantissant l'absence de
3374    /// panic / UB pour **toute** entrée (Principe 3 — frontière) :
3375    ///
3376    /// - **rejet propre des NUL embarqués** : `CString::new` rend `Err` (jamais
3377    ///   panic) dès qu'un morceau contient un octet NUL ; ces morceaux sont
3378    ///   simplement écartés ;
3379    /// - **totalité** : la construction du tableau ne panique jamais (liste
3380    ///   vide, ou très longue) ;
3381    /// - **terminaison NULL & validité** : le parcours s'arrête sur le `NULL`
3382    ///   final et relit chaque chaîne C (tout pointeur invalide ou toute
3383    ///   terminaison manquante ferait crasher sous sanitizer/libFuzzer).
3384    ///
3385    /// L'oracle *structurel* exact (ordre, exactitude des pointeurs) est
3386    /// vérifié par les tests unitaires `cstr_array_*`.
3387    pub fn marshal_exec_args(chunks: Vec<Vec<u8>>) {
3388        // Frontière 1 — NUL embarqué rejeté proprement (Err, jamais panic).
3389        let owned: Vec<CString> = chunks
3390            .iter()
3391            .filter_map(|chunk| CString::new(chunk.as_slice()).ok())
3392            .collect();
3393        let refs: Vec<&CStr> = owned.iter().map(CString::as_c_str).collect();
3394
3395        // Frontière 2 — construction du tableau de pointeurs C NULL-terminé.
3396        let arr = CStrArray::new(&refs);
3397        let _ = arr.len();
3398        let _ = arr.is_empty();
3399
3400        // Parcours jusqu'au terminateur NULL en relisant chaque chaîne : tout
3401        // écart (pointeur invalide, terminaison absente) ferait crasher la
3402        // cible libFuzzer.
3403        let base = arr.as_ptr();
3404        let mut i = 0_usize;
3405        loop {
3406            // SAFETY: `base` pointe `refs.len() + 1` pointeurs ; on s'arrête au
3407            // premier NULL (présent par construction en position `refs.len()`),
3408            // donc `i` reste borné par `refs.len()`.
3409            let ptr: *const c_char = unsafe { *base.add(i) };
3410            if ptr.is_null() {
3411                break;
3412            }
3413            // SAFETY: `ptr` non NULL ⇒ i-ème chaîne C vivante (empruntée à
3414            // `owned`), NUL-terminée par construction de `CStr`.
3415            let s = unsafe { CStr::from_ptr(ptr) };
3416            let _ = s.to_bytes();
3417            i = i.saturating_add(1);
3418        }
3419    }
3420}
3421
3422// ─────────────────────────────────────────────────────────────────────────
3423// Runtime & libc — re-sceau `couche-0-v1.7` (ADR-051)
3424//
3425// 6 syscalls ajoutés pour le runtime Air (air-rt) et l'objectif libc :
3426// `getppid`, `set_tid_address`, `sched_yield`, `umask`, `getcwd`, `getrusage`.
3427// (Le 7ᵉ, `arch_prctl`, est x86_64-only et vit dans `crate::arch`.)
3428// Conventions ADR-021 : fonction dédiée typée, `Option`/newtypes, pas de
3429// sentinelle, zéro alloc sur le happy path.
3430// ─────────────────────────────────────────────────────────────────────────
3431
3432/// Retourne le PID du **parent** du processus appelant (`getppid(2)`).
3433///
3434/// Retourne `None` lorsque le kernel rend `0`, c.-à-d. quand le parent n'est
3435/// **pas visible** dans le *PID namespace* du processus (ré-parentage hors
3436/// namespace). Le `0` du kernel — qui n'est pas un PID valide — est ainsi
3437/// re-présenté en `Option` plutôt qu'exposé comme sentinelle (ADR-021 §1).
3438#[must_use]
3439pub fn getppid() -> Option<Pid> {
3440    let raw = raw_syscall_getppid();
3441    // PID : `pid_t` (i32) ; les 32 bits hauts sont nuls pour une valeur valide.
3442    #[allow(clippy::cast_possible_truncation)]
3443    let raw32 = raw as i32;
3444    // `try_from_raw` rend `None` pour `0` (parent hors namespace).
3445    Pid::try_from_raw(raw32)
3446}
3447
3448#[cfg(target_arch = "x86_64")]
3449#[inline]
3450fn raw_syscall_getppid() -> i64 {
3451    let ret: i64;
3452    // SAFETY: SYS_getppid (x86_64 = 110) ne touche aucune mémoire utilisateur ;
3453    // clobbe RCX/R11 comme tout `syscall` x86_64.
3454    unsafe {
3455        core::arch::asm!(
3456            "syscall",
3457            in("rax") 110_i64,
3458            lateout("rax") ret,
3459            lateout("rcx") _,
3460            lateout("r11") _,
3461            options(nostack, preserves_flags, readonly),
3462        );
3463    }
3464    ret
3465}
3466
3467#[cfg(target_arch = "aarch64")]
3468#[inline]
3469fn raw_syscall_getppid() -> i64 {
3470    let ret: i64;
3471    // SAFETY: SYS_getppid (aarch64 = 173) ne touche aucune mémoire utilisateur.
3472    unsafe {
3473        core::arch::asm!(
3474            "svc 0",
3475            in("x8") 173_i64,
3476            lateout("x0") ret,
3477            options(nostack, preserves_flags, readonly),
3478        );
3479    }
3480    ret
3481}
3482
3483/// Enregistre l'adresse `clear_child_tid` du thread appelant et retourne son TID
3484/// (`set_tid_address(2)`). À la **fin du thread**, le kernel écrit `0` à cette
3485/// adresse et émet un `FUTEX_WAKE` dessus — primitif de *join* (cf.
3486/// [`clone_thread`] et la famille [`futex`](crate::futex)).
3487///
3488/// `None` désenregistre (passe `NULL`). L'appel **ne peut pas échouer** et rend
3489/// toujours le TID appelant (`> 0`).
3490///
3491/// # Safety
3492///
3493/// Si `clear_child_tid` est `Some(addr)`, le kernel **conservera `addr`** et y
3494/// écrira à la terminaison du thread. L'appelant doit garantir que la mémoire
3495/// pointée reste **valide jusqu'à la fin du thread** (sinon écriture kernel dans
3496/// de la mémoire libérée). Même discipline que [`CloneArgs::child_tid`].
3497pub unsafe fn set_tid_address(clear_child_tid: Option<&core::sync::atomic::AtomicU32>) -> Tid {
3498    let ptr = clear_child_tid.map_or(0_usize, |a| core::ptr::from_ref(a) as usize);
3499    // SAFETY: l'appel délègue ; la liveness de `ptr` jusqu'à la mort du thread
3500    // relève du contrat `unsafe` de cette fonction.
3501    let raw = unsafe { raw_syscall_set_tid_address(ptr) };
3502    // Le kernel rend le TID appelant, strictement positif.
3503    #[allow(clippy::cast_possible_truncation)]
3504    let tid_raw = raw as i32;
3505    let nz = NonZeroI32::new(tid_raw).expect("set_tid_address : TID appelant > 0");
3506    Tid::from_nonzero(nz)
3507}
3508
3509#[cfg(target_arch = "x86_64")]
3510#[inline]
3511unsafe fn raw_syscall_set_tid_address(tidptr: usize) -> i64 {
3512    let ret: i64;
3513    // SAFETY: SYS_set_tid_address (x86_64 = 218). Le kernel **enregistre**
3514    // `tidptr` pour écriture à la mort du thread ; il n'écrit pas pendant
3515    // l'appel. La validité/liveness de `tidptr` relève du contrat de l'appelant.
3516    unsafe {
3517        core::arch::asm!(
3518            "syscall",
3519            in("rax") 218_i64,
3520            in("rdi") tidptr,
3521            lateout("rax") ret,
3522            lateout("rcx") _,
3523            lateout("r11") _,
3524            options(nostack, preserves_flags),
3525        );
3526    }
3527    ret
3528}
3529
3530#[cfg(target_arch = "aarch64")]
3531#[inline]
3532unsafe fn raw_syscall_set_tid_address(tidptr: usize) -> i64 {
3533    let ret: i64;
3534    // SAFETY: SYS_set_tid_address (aarch64 = 96). Voir la branche x86_64.
3535    unsafe {
3536        core::arch::asm!(
3537            "svc 0",
3538            in("x8") 96_i64,
3539            in("x0") tidptr,
3540            lateout("x0") ret,
3541            options(nostack, preserves_flags),
3542        );
3543    }
3544    ret
3545}
3546
3547/// Cède volontairement le CPU à un autre thread prêt (`sched_yield(2)`).
3548///
3549/// Sous Linux, `sched_yield` **réussit toujours** (retour `0`) ; la fonction est
3550/// donc totale.
3551pub fn sched_yield() {
3552    let _ = raw_syscall_sched_yield();
3553}
3554
3555#[cfg(target_arch = "x86_64")]
3556#[inline]
3557fn raw_syscall_sched_yield() -> i64 {
3558    let ret: i64;
3559    // SAFETY: SYS_sched_yield (x86_64 = 24) ne touche aucune mémoire utilisateur.
3560    unsafe {
3561        core::arch::asm!(
3562            "syscall",
3563            in("rax") 24_i64,
3564            lateout("rax") ret,
3565            lateout("rcx") _,
3566            lateout("r11") _,
3567            options(nostack, preserves_flags, readonly),
3568        );
3569    }
3570    ret
3571}
3572
3573#[cfg(target_arch = "aarch64")]
3574#[inline]
3575fn raw_syscall_sched_yield() -> i64 {
3576    let ret: i64;
3577    // SAFETY: SYS_sched_yield (aarch64 = 124) ne touche aucune mémoire utilisateur.
3578    unsafe {
3579        core::arch::asm!(
3580            "svc 0",
3581            in("x8") 124_i64,
3582            lateout("x0") ret,
3583            options(nostack, preserves_flags, readonly),
3584        );
3585    }
3586    ret
3587}
3588
3589/// Fixe le **masque de création de fichiers** du processus et retourne l'ancien
3590/// (`umask(2)`). Opération **totale** (ne peut pas échouer).
3591#[must_use]
3592pub fn umask(new_mask: Mode) -> Mode {
3593    let previous = raw_syscall_umask(u64::from(new_mask));
3594    // L'ancien masque tient dans `mode_t` (12 bits significatifs) ⇒ conversion
3595    // sûre, jamais lossy (Principe 2 : pas d'`as` lossy).
3596    u32::try_from(previous).expect("umask : ancien masque dans la plage mode_t")
3597}
3598
3599#[cfg(target_arch = "x86_64")]
3600#[inline]
3601fn raw_syscall_umask(mask: u64) -> i64 {
3602    let ret: i64;
3603    // SAFETY: SYS_umask (x86_64 = 95) ne touche aucune mémoire utilisateur ;
3604    // rend l'ancien masque.
3605    unsafe {
3606        core::arch::asm!(
3607            "syscall",
3608            in("rax") 95_i64,
3609            in("rdi") mask,
3610            lateout("rax") ret,
3611            lateout("rcx") _,
3612            lateout("r11") _,
3613            options(nostack, preserves_flags, readonly),
3614        );
3615    }
3616    ret
3617}
3618
3619#[cfg(target_arch = "aarch64")]
3620#[inline]
3621fn raw_syscall_umask(mask: u64) -> i64 {
3622    let ret: i64;
3623    // SAFETY: SYS_umask (aarch64 = 166). Voir la branche x86_64.
3624    unsafe {
3625        core::arch::asm!(
3626            "svc 0",
3627            in("x8") 166_i64,
3628            in("x0") mask,
3629            lateout("x0") ret,
3630            options(nostack, preserves_flags, readonly),
3631        );
3632    }
3633    ret
3634}
3635
3636/// Écrit le **répertoire courant** (chemin absolu) dans `buf` et retourne la
3637/// tranche d'octets correspondante, **sans** l'octet NUL terminal (`getcwd(2)`).
3638///
3639/// Le chemin est traité comme des **octets** (jamais supposé UTF-8 — Principe 3).
3640/// L'appelant fournit le buffer (zéro allocation, ADR-021 §4).
3641///
3642/// # Errors
3643///
3644/// - [`Errno::ERANGE`] si `buf` est trop petit pour contenir le chemin + NUL.
3645/// - Autres `Errno` propagés (p. ex. permissions de lecture d'un composant).
3646pub fn getcwd(buf: &mut [u8]) -> Result<&[u8], Errno> {
3647    let size = u64::try_from(buf.len()).expect("getcwd : taille de buffer dans u64");
3648    let ptr = buf.as_mut_ptr() as usize;
3649    // SAFETY: `ptr`/`size` décrivent `buf` (emprunt `&mut` valide) ; le kernel y
3650    // écrit au plus `size` octets.
3651    let ret = unsafe { raw_syscall_getcwd(ptr, size) };
3652    if ret < 0 {
3653        return Err(errno_from_negative_syscall_ret(ret));
3654    }
3655    // `getcwd(2)` rend la longueur **NUL compris** (≥ 1) ; on retourne les
3656    // octets sans le NUL final.
3657    let total = usize::try_from(ret).expect("getcwd : longueur non négative");
3658    let path_length = total
3659        .checked_sub(1)
3660        .expect("getcwd : longueur ≥ 1 (NUL terminal compris)");
3661    // TODO(ADR-029/Principe 3) : remplacer l'indexation directe `&buf[..path_length]`
3662    // par `buf.get(..path_length)` (slicing via `Option`, jamais d'indexation qui
3663    // panique). Ici `path_length < total ≤ buf.len()` ⇒ borné par construction, donc
3664    // sûr ; reste à convertir le `Option<&[u8]>` rendu en `Result` (p. ex.
3665    // `.expect("getcwd : tranche bornée par construction")` ou un bras d'erreur).
3666    Ok(&buf[..path_length])
3667}
3668
3669#[cfg(target_arch = "x86_64")]
3670#[inline]
3671unsafe fn raw_syscall_getcwd(buf: usize, size: u64) -> i64 {
3672    let ret: i64;
3673    // SAFETY: SYS_getcwd (x86_64 = 79) écrit jusqu'à `size` octets à `buf`.
3674    // Validité de `buf`/`size` : contrat de l'appelant. Pas de `readonly` (écrit
3675    // la mémoire pointée).
3676    unsafe {
3677        core::arch::asm!(
3678            "syscall",
3679            in("rax") 79_i64,
3680            in("rdi") buf,
3681            in("rsi") size,
3682            lateout("rax") ret,
3683            lateout("rcx") _,
3684            lateout("r11") _,
3685            options(nostack),
3686        );
3687    }
3688    ret
3689}
3690
3691#[cfg(target_arch = "aarch64")]
3692#[inline]
3693unsafe fn raw_syscall_getcwd(buf: usize, size: u64) -> i64 {
3694    let ret: i64;
3695    // SAFETY: SYS_getcwd (aarch64 = 17). Voir la branche x86_64.
3696    unsafe {
3697        core::arch::asm!(
3698            "svc 0",
3699            in("x8") 17_i64,
3700            in("x0") buf,
3701            in("x1") size,
3702            lateout("x0") ret,
3703            options(nostack),
3704        );
3705    }
3706    ret
3707}
3708
3709/// Renseigne les **statistiques de ressources** (`getrusage(2)`) pour la cible
3710/// [`RusageWho`] demandée.
3711///
3712/// # Errors
3713///
3714/// `getrusage(2)` ne peut échouer que sur `who` invalide (`EINVAL`) ou pointeur
3715/// invalide (`EFAULT`) — deux cas **rendus impossibles** par l'API typée
3716/// (`RusageWho` est un enum, le buffer est interne et valide). Le bras `Err` est
3717/// donc inatteignable via cette fonction (cf. `COVERAGE-EXCEPTIONS.md`,
3718/// STRUCTURAL/EFAULT-SAFE) ; la signature reste `Result` par honnêteté kernel.
3719pub fn getrusage(who: RusageWho) -> Result<Rusage, Errno> {
3720    let who_raw: i64 = match who {
3721        RusageWho::SelfProcess => 0,
3722        RusageWho::Children => -1,
3723        RusageWho::Thread => 1,
3724    };
3725    let mut usage = Rusage::default();
3726    let ptr = core::ptr::from_mut(&mut usage) as usize;
3727    // SAFETY: `ptr` désigne `usage` (local valide, taille `struct rusage`) ; le
3728    // kernel y écrit la structure. `who_raw` provient d'un enum ⇒ toujours valide.
3729    let ret = unsafe { raw_syscall_getrusage(who_raw, ptr) };
3730    if ret < 0 {
3731        return Err(errno_from_negative_syscall_ret(ret));
3732    }
3733    Ok(usage)
3734}
3735
3736#[cfg(target_arch = "x86_64")]
3737#[inline]
3738unsafe fn raw_syscall_getrusage(who: i64, usage: usize) -> i64 {
3739    let ret: i64;
3740    // SAFETY: SYS_getrusage (x86_64 = 98) écrit une `struct rusage` à `usage`.
3741    // Validité : contrat de l'appelant. Pas de `readonly` (écrit la mémoire).
3742    unsafe {
3743        core::arch::asm!(
3744            "syscall",
3745            in("rax") 98_i64,
3746            in("rdi") who,
3747            in("rsi") usage,
3748            lateout("rax") ret,
3749            lateout("rcx") _,
3750            lateout("r11") _,
3751            options(nostack),
3752        );
3753    }
3754    ret
3755}
3756
3757#[cfg(target_arch = "aarch64")]
3758#[inline]
3759unsafe fn raw_syscall_getrusage(who: i64, usage: usize) -> i64 {
3760    let ret: i64;
3761    // SAFETY: SYS_getrusage (aarch64 = 165). Voir la branche x86_64.
3762    unsafe {
3763        core::arch::asm!(
3764            "svc 0",
3765            in("x8") 165_i64,
3766            in("x0") who,
3767            in("x1") usage,
3768            lateout("x0") ret,
3769            options(nostack),
3770        );
3771    }
3772    ret
3773}
3774
3775#[cfg(test)]
3776mod tests {
3777    use super::*;
3778    use crate::test_support::{sfd, sraw};
3779
3780    // ── Affinité CPU (sched_set/getaffinity) ──────────────────────────────
3781    use core::num::NonZeroI32;
3782
3783    #[test]
3784    #[cfg_attr(miri, ignore = "syscalls non supportés par Miri")]
3785    fn cpu_affinity_round_trips_on_calling_task() {
3786        let mut original = CpuSet::new();
3787        get_cpu_affinity(None, &mut original).expect("get affinité courante");
3788        assert!(original.count() >= 1, "au moins un CPU en ligne");
3789        // Restreint à CPU 0 (toujours en ligne) puis relit.
3790        let mut only0 = CpuSet::new();
3791        assert!(only0.set(0));
3792        set_cpu_affinity(None, &only0).expect("set CPU 0");
3793        let mut read_back = CpuSet::new();
3794        get_cpu_affinity(None, &mut read_back).expect("get après set");
3795        assert!(read_back.contains(0));
3796        assert_eq!(read_back.count(), 1, "exactement CPU 0");
3797        // Restaure l'affinité d'origine (ne pas perturber le runner).
3798        set_cpu_affinity(None, &original).expect("restauration");
3799    }
3800
3801    #[test]
3802    #[cfg_attr(miri, ignore = "syscalls non supportés par Miri")]
3803    fn cpu_affinity_by_explicit_calling_tid() {
3804        // Branche `Some(tid)` d'`affinity_pid` (tâche appelante explicite).
3805        let tid = gettid();
3806        let mut current = CpuSet::new();
3807        get_cpu_affinity(Some(tid), &mut current).expect("get par tid");
3808        assert!(current.count() >= 1);
3809        set_cpu_affinity(Some(tid), &current).expect("set par tid (idempotent)");
3810    }
3811
3812    #[test]
3813    #[cfg_attr(miri, ignore = "syscalls non supportés par Miri")]
3814    fn empty_cpu_set_is_einval() {
3815        // Aucun CPU autorisé → le kernel refuse (EINVAL).
3816        let empty = CpuSet::new();
3817        assert!(matches!(set_cpu_affinity(None, &empty), Err(Errno::EINVAL)));
3818    }
3819
3820    #[test]
3821    #[cfg_attr(miri, ignore = "syscalls non supportés par Miri")]
3822    fn nonexistent_tid_is_esrch() {
3823        // `Tid` très improbable d'exister → ESRCH (couvre la branche d'erreur).
3824        let bogus = Tid::from_nonzero(NonZeroI32::new(0x3FFF_FFFF).expect("nz"));
3825        let mut cpus = CpuSet::new();
3826        assert!(matches!(
3827            get_cpu_affinity(Some(bogus), &mut cpus),
3828            Err(Errno::ESRCH)
3829        ));
3830    }
3831
3832    use crate::fs::dup_fd;
3833    use std::sync::{Arc, Barrier, Mutex};
3834    use std::thread;
3835
3836    /// Écrit le profil de couverture LLVM de l'enfant forké avant `exit_group`
3837    /// (qui court-circuite l'atexit LLVM). Sans flush, le corps des wrappers
3838    /// exercés dans l'enfant (clone3/prctl/…) apparaît non couvert.
3839    /// `LLVM_PROFILE_FILE` contient `%p` → un `.profraw` par enfant, fusionné.
3840    /// Aucun verrou I/O dans cette famille → le flush en fin d'enfant suffit.
3841    // Intentionnellement VIDE — **async-signal-safety**. Un enfant forké via
3842    // `clone3` hérite d'un espace d'adressage COPIÉ : si un thread frère tenait le
3843    // lock interne de `malloc` au moment du `clone3`, ce lock est copié VERROUILLÉ
3844    // (son détenteur n'existe pas dans l'enfant) ; `__llvm_profile_write_file`
3845    // **alloue** → le premier `malloc` de l'enfant deadlocke sur ce futex. On NE
3846    // flushe donc PAS dans l'enfant : ses lignes sont des exceptions **CHILD-EXIT**
3847    // (ADR-035), prouvées par le code de sortie observé via `waitid`. NE PAS
3848    // réintroduire de flush ici (flaky deadlock couche 0).
3849    fn flush_child_coverage() {}
3850
3851    /// Sortie d'un enfant forké de test : termine via `exit_group`. NE FLUSHE PLUS
3852    /// le profil (cf. [`flush_child_coverage`], désormais vide pour rester
3853    /// async-signal-safe). Les lignes de l'enfant sont des exceptions CHILD-EXIT.
3854    fn child_exit(status: i32) -> ! {
3855        flush_child_coverage();
3856        exit_group(status)
3857    }
3858
3859    /// Sérialise les tests qui **observent la fermeture d'un fd** (drop puis
3860    /// re-`borrow_raw` du même numéro pour vérifier `EBADF`).
3861    ///
3862    /// Défense en deux temps, complémentaire de [`high_unreusable_fd_target`] :
3863    /// le fd HAUT écarte la réutilisation par le churn de fd des AUTRES tests
3864    /// (qui allouent toujours des numéros BAS) ; ce verrou empêche les
3865    /// tests-frères de viser le MÊME numéro haut au même instant — sinon, l'un
3866    /// libère ce numéro pendant que l'autre le ré-alloue, et la course revient
3867    /// (déplacée sur le fd haut). Zéro dépendance externe (pas de `serial_test`),
3868    /// sans `--test-threads=1`. Cf. `docs/CI.md`.
3869    static FD_OBSERVATION_LOCK: Mutex<()> = Mutex::new(());
3870
3871    /// Prend [`FD_OBSERVATION_LOCK`] en ignorant l'empoisonnement : si un test
3872    /// du groupe a paniqué en tenant le verrou, on veut voir CET échec, pas une
3873    /// cascade d'erreurs « poisoned » sur les tests suivants.
3874    fn fd_observation_lock() -> std::sync::MutexGuard<'static, ()> {
3875        FD_OBSERVATION_LOCK
3876            .lock()
3877            .unwrap_or_else(std::sync::PoisonError::into_inner)
3878    }
3879
3880    /// Numéro de fd « haut », **non réutilisable**, pour observer une fermeture
3881    /// de fd sans flakiness sous instrumentation de couverture.
3882    ///
3883    /// Un `open`/`dup` concurrent alloue **toujours** le plus petit fd libre
3884    /// (numéros bas). Un fd proche de la limite douce `RLIMIT_NOFILE` ne peut
3885    /// donc jamais être réutilisé tant que le processus n'est pas en épuisement
3886    /// de fd (jamais en test). On vise `min(soft - 1, 4096)` : assez haut pour
3887    /// être hors d'atteinte de toute allocation concurrente, assez bas pour
3888    /// rester légal (< `soft`) et ne pas dimensionner inutilement la table de
3889    /// fd du kernel. Cf. `docs/CI.md` (course de réutilisation de fd).
3890    fn high_unreusable_fd_target() -> RawFd {
3891        const CAP: u64 = 4096;
3892        let soft = match getrlimit(Resource::NoFile) {
3893            Ok(Rlimit {
3894                soft: RlimitValue::Finite(n),
3895                ..
3896            }) => n,
3897            // Limite illimitée ou illisible : le plafond `CAP` reste sûr.
3898            _ => CAP.saturating_add(1),
3899        };
3900        let target = soft.saturating_sub(1).min(CAP);
3901        RawFd::try_from(target).unwrap_or(RawFd::MAX)
3902    }
3903
3904    // ── getpid ────────────────────────────────────────────────────────
3905
3906    #[test]
3907    fn getpid_returns_valid_pid() {
3908        let pid = getpid();
3909        assert!(pid.as_raw() > 0, "PID doit être strictement positif");
3910    }
3911
3912    #[test]
3913    fn getpid_is_consistent_across_calls() {
3914        // Invariant : `getpid` lit l'identité du processus appelant ; deux
3915        // appels consécutifs depuis le même processus doivent renvoyer la
3916        // même valeur.
3917        let p1 = getpid();
3918        let p2 = getpid();
3919        assert_eq!(p1, p2);
3920    }
3921
3922    // ── gettid ────────────────────────────────────────────────────────
3923
3924    #[test]
3925    fn gettid_returns_valid_tid() {
3926        let tid = gettid();
3927        assert!(tid.as_raw() > 0, "TID doit être strictement positif");
3928    }
3929
3930    #[test]
3931    fn gettid_is_consistent_within_thread() {
3932        let t1 = gettid();
3933        let t2 = gettid();
3934        assert_eq!(t1, t2, "le TID est stable pour un thread donné");
3935    }
3936
3937    #[test]
3938    fn gettid_distinguishes_distinct_threads() {
3939        // Invariant testé : deux threads vivants simultanément ont des
3940        // TIDs distincts. La barrière garantit que les deux threads sont
3941        // encore en vie au moment où chacun lit son TID — donc aucun TID
3942        // ne peut être recyclé par le kernel.
3943        let barrier = Arc::new(Barrier::new(2));
3944
3945        let b1 = Arc::clone(&barrier);
3946        let h1 = thread::spawn(move || {
3947            let tid = gettid();
3948            b1.wait();
3949            tid
3950        });
3951
3952        let b2 = Arc::clone(&barrier);
3953        let h2 = thread::spawn(move || {
3954            let tid = gettid();
3955            b2.wait();
3956            tid
3957        });
3958
3959        let tid1 = h1.join().expect("le thread 1 ne doit pas paniquer");
3960        let tid2 = h2.join().expect("le thread 2 ne doit pas paniquer");
3961        // Invariant kernel : deux threads vivants ont des TIDs distincts.
3962        assert_ne!(tid1, tid2);
3963    }
3964
3965    #[test]
3966    fn worker_thread_tid_differs_from_process_pid() {
3967        // Corollaire de l'invariant « gettid == getpid pour le thread
3968        // principal » : pour un thread *non* principal, gettid != getpid.
3969        // Le thread principal du processus est vérifié séparément par
3970        // l'intégration `tests/main_thread_invariant.rs` (binaire avec
3971        // `harness = false`, ce qui place le code sur le thread principal).
3972        let pid = getpid();
3973        let handle = thread::spawn(move || (gettid(), getpid()));
3974        let (worker_tid, worker_pid) = handle.join().expect("le worker ne doit pas paniquer");
3975        // Le PID est global au processus : tous les threads observent le même.
3976        assert_eq!(worker_pid, pid);
3977        // Un thread worker (non-principal) doit avoir un TID distinct du PID.
3978        assert_ne!(worker_tid.as_raw(), pid.as_raw());
3979    }
3980
3981    // ── clone3 / waitid : tests bout-en-bout ─────────────────────────
3982    //
3983    // Discipline de sortie de l'enfant (CRITIQUE, cf. exit_group ci-dessus) :
3984    // - Chaque test enfant termine via `child_exit(N)` (flush du profil de
3985    //   couverture LLVM puis `exit_group(N)`) — un syscall direct, sans atexit
3986    //   handler ni flush userspace (jamais `execve` dans cette PR ; le 5e test
3987    //   de la spec — exec dans l'enfant — est déféré à la PR `execve`).
3988    // - On **ne** peut **pas** utiliser `std::process::exit` ici : dans
3989    //   l'enfant d'un `clone3` fork issu du harnais de test multi-thread,
3990    //   les atexit handlers (flush stdio, capture du harnais) deadlockent
3991    //   sur des mutex hérités verrouillés par les threads du parent absents
3992    //   dans l'enfant. Reproduit sur aarch64 (`futex_do_wait`), masqué sur
3993    //   x86_64.
3994    // - Conséquence coverage : l'enfant n'écrit plus de `.profraw` (pas
3995    //   d'atexit LLVM). Les rares lignes exécutées **uniquement** côté enfant
3996    //   (le bras `CloneResult::Child` et la ligne `ret == 0` de `clone3`)
3997    //   ne sont donc pas comptées par la couverture. Couverture éventuelle de
3998    //   ces lignes : harnais dédié hors `cargo test` parallèle, ou exception
3999    //   documentée (cf. docs/COVERAGE-EXCEPTIONS.md).
4000    //
4001    // Tests environnement-dépendants :
4002    // - Le test namespaces (NEWUSER) est skippé si AppArmor restreint les
4003    //   user namespaces non privilégiés (cas Ubuntu 24.04 par défaut).
4004    // - Le test capabilities (NEWPID sans NEWUSER) est skippé si on est
4005    //   root (auquel cas le syscall réussirait au lieu de retourner EPERM).
4006    // - Détails : `apparmor_restricts_userns()` et `is_root()` ci-dessous.
4007
4008    /// Vrai si `kernel.apparmor_restrict_unprivileged_userns` vaut 1.
4009    /// Sur Ubuntu 24.04+, ce sysctl est positionné par défaut et bloque
4010    /// `CLONE_NEWUSER` pour les processus non privilégiés (renvoie EPERM
4011    /// avant même l'exécution du syscall, via une hook AppArmor).
4012    fn apparmor_restricts_userns() -> bool {
4013        std::fs::read_to_string("/proc/sys/kernel/apparmor_restrict_unprivileged_userns")
4014            .map(|s| s.trim() == "1")
4015            .unwrap_or(false)
4016    }
4017
4018    /// Vrai si l'EUID courant est 0 (root). Lecture via `/proc/self/status`
4019    /// — pas de wrapper `geteuid` côté Air à ce stade. Les `.expect()`
4020    /// portent sur des invariants Linux solides (procfs présent, format
4021    /// stable de `/proc/PID/status`) ; leur branche d'échec est
4022    /// inatteignable en pratique.
4023    fn is_root() -> bool {
4024        let status = std::fs::read_to_string("/proc/self/status")
4025            .expect("/proc/self/status doit être lisible sur Linux");
4026        let uid_line = status
4027            .lines()
4028            .find(|l| l.starts_with("Uid:"))
4029            .expect("/proc/self/status doit contenir une ligne 'Uid:'");
4030        // Format : "Uid:\tREAL\tEFFECTIVE\tSAVED\tFS".
4031        let euid: u32 = uid_line
4032            .split_whitespace()
4033            .nth(2)
4034            .and_then(|s| s.parse().ok())
4035            .expect("EUID parsable dans /proc/self/status");
4036        euid == 0
4037    }
4038
4039    #[test]
4040    fn clone3_fork_exit_observed_by_waitid_exited() {
4041        let args = CloneArgs {
4042            flags: CloneFlags::empty(),
4043            exit_signal: Some(Signal::SIGCHLD),
4044            stack: None,
4045            child_tid: None,
4046            parent_tid: None,
4047            tls: None,
4048        };
4049        // SAFETY: fork classique (pas de CLONE_VM, pas de namespaces).
4050        // Aucune mémoire partagée, aucune capability requise.
4051        let result = unsafe { clone3(&args) }.expect("clone3 doit réussir");
4052        match result {
4053            CloneResult::Child => {
4054                // L'enfant écrit son profil LLVM (atexit) puis termine.
4055                child_exit(42);
4056            }
4057            CloneResult::Parent {
4058                child_pid,
4059                child_pidfd,
4060            } => {
4061                assert!(child_pidfd.is_none());
4062                let status = waitid(WaitTarget::Pid(child_pid), WaitOptions::EXITED)
4063                    .expect("waitid doit réussir");
4064                let s = status.expect("événement EXITED disponible");
4065                assert_eq!(s.pid, child_pid);
4066                assert!(matches!(s.event, WaitEvent::Exited { code: 42 }));
4067            }
4068        }
4069    }
4070
4071    #[test]
4072    fn build_kernel_clone_args_maps_exit_signal_some_and_none() {
4073        // Branche `Some(sig)` : le signal est encodé sur les 8 bits de
4074        // poids faible de `clone_args.exit_signal`.
4075        let args_some = CloneArgs {
4076            flags: CloneFlags::empty(),
4077            exit_signal: Some(Signal::SIGCHLD),
4078            stack: None,
4079            child_tid: None,
4080            parent_tid: None,
4081            tls: None,
4082        };
4083        let k = build_kernel_clone_args(&args_some, 0);
4084        assert_eq!(
4085            k.exit_signal,
4086            u64::from(Signal::SIGCHLD.as_raw().unsigned_abs())
4087        );
4088
4089        // Branche `None` : 0 transmis au kernel — Air ne reproduit pas le
4090        // comportement de fork() qui force SIGCHLD ; l'appelant choisit.
4091        // Cette branche n'est pas testable bout-en-bout via clone3+waitid
4092        // sur Linux : avec `exit_signal=0`, le kernel détache l'enfant et
4093        // `waitid` retourne `ECHILD`. Cf. JOURNAL.md session
4094        // 2026-05-22 (suite clone3+waitid).
4095        let args_none = CloneArgs {
4096            flags: CloneFlags::empty(),
4097            exit_signal: None,
4098            stack: None,
4099            child_tid: None,
4100            parent_tid: None,
4101            tls: None,
4102        };
4103        let k = build_kernel_clone_args(&args_none, 0);
4104        assert_eq!(k.exit_signal, 0);
4105    }
4106
4107    #[test]
4108    fn build_kernel_clone_args_propagates_flags_and_pidfd_addr() {
4109        let args = CloneArgs {
4110            flags: CloneFlags::PIDFD | CloneFlags::NEWUSER,
4111            exit_signal: Some(Signal::SIGCHLD),
4112            stack: None,
4113            child_tid: None,
4114            parent_tid: None,
4115            tls: None,
4116        };
4117        let k = build_kernel_clone_args(&args, 0xdead_beef_u64);
4118        assert_eq!(k.flags, (CloneFlags::PIDFD | CloneFlags::NEWUSER).bits());
4119        assert_eq!(k.pidfd, 0xdead_beef_u64);
4120        // Champs non utilisés par cette PR : tous à zéro.
4121        assert_eq!(k.child_tid, 0);
4122        assert_eq!(k.parent_tid, 0);
4123        assert_eq!(k.stack, 0);
4124        assert_eq!(k.stack_size, 0);
4125        assert_eq!(k.tls, 0);
4126        assert_eq!(k.set_tid, 0);
4127        assert_eq!(k.set_tid_size, 0);
4128        assert_eq!(k.cgroup, 0);
4129    }
4130
4131    #[test]
4132    fn clone3_with_pidfd_yields_owned_pidfd_usable_for_waitid() {
4133        let args = CloneArgs {
4134            flags: CloneFlags::PIDFD,
4135            exit_signal: Some(Signal::SIGCHLD),
4136            stack: None,
4137            child_tid: None,
4138            parent_tid: None,
4139            tls: None,
4140        };
4141        // SAFETY: fork classique + CLONE_PIDFD ; pas de mémoire partagée.
4142        let result = unsafe { clone3(&args) }.expect("clone3 doit réussir");
4143        match result {
4144            CloneResult::Child => {
4145                child_exit(7);
4146            }
4147            CloneResult::Parent {
4148                child_pid,
4149                child_pidfd,
4150            } => {
4151                let pidfd = child_pidfd.expect("CLONE_PIDFD était demandé");
4152                let status = waitid(WaitTarget::PidFd(pidfd.as_fd()), WaitOptions::EXITED)
4153                    .expect("waitid via pidfd doit réussir");
4154                let s = status.expect("événement EXITED disponible");
4155                assert_eq!(s.pid, child_pid);
4156                assert!(matches!(s.event, WaitEvent::Exited { code: 7 }));
4157            }
4158        }
4159    }
4160
4161    /// `pidfd_send_signal` avec un **`siginfo` explicite** (`Some(&info)`) : couvre
4162    /// le bras `Some(_)` du wrapper (jadis value-unreachable faute de constructeur
4163    /// `SignalInfo` public — désormais disponible via `SignalInfo::new_queue`,
4164    /// **sans aucun ajout de surface**).
4165    ///
4166    /// Le bras `Some(i) => …` est **exercé** dès qu'on passe `Some(&info)` (il
4167    /// marshale `info_ptr` **avant** le syscall). On envoie vers un pidfd **de
4168    /// soi-même** : `pidfd_send_signal` exige `si_signo == sig`, or `new_queue`
4169    /// (destiné à `rt_sigqueueinfo`, où le kernel **écrase** `si_signo`) le laisse
4170    /// à 0 ⇒ le kernel **rejette par `EINVAL` AVANT toute délivrance** — l'appel est
4171    /// donc **sûr même vers soi-même** (aucun signal n'est envoyé). Vérifié
4172    /// empiriquement (un envoi `SIGKILL`+`new_queue` ne tue pas la cible).
4173    #[test]
4174    #[cfg_attr(miri, ignore = "pidfd/signaux non supportés par Miri")]
4175    fn pidfd_send_signal_with_explicit_siginfo_exercises_some_arm() {
4176        use air_sys_types::{SignalInfo, SignalValue};
4177
4178        let self_pidfd = pidfd_open(getpid(), PidFdOpenFlags::empty()).expect("pidfd_open self");
4179        let info = SignalInfo::new_queue(SignalValue::Integer(0x5A5A));
4180        // ── CIBLE : bras `Some(&info)` (info_ptr non nul) marshalé. ──
4181        let result = pidfd_send_signal(self_pidfd.as_fd(), Signal::SIGUSR1, Some(&info));
4182        assert_eq!(
4183            result,
4184            Err(Errno::EINVAL),
4185            "siginfo explicite : bras Some exercé ; rejet EINVAL (si_signo=0 ≠ sig), \
4186             aucun signal délivré"
4187        );
4188    }
4189
4190    #[test]
4191    fn waitid_decodes_child_killed_by_signal() {
4192        // **Ce que ce test prouve.** `waitid` décode correctement un
4193        // événement enfant terminé par signal en `WaitEvent::Killed`
4194        // (branche `CLD_KILLED` ou `CLD_DUMPED` du dispatch
4195        // `siginfo_to_wait_event`). L'enfant produit l'événement en
4196        // s'auto-`abort()` — pour le kernel et pour `waitid`, le siginfo
4197        // résultant est strictement équivalent à « le parent a envoyé
4198        // SIGABRT » : `si_code = CLD_KILLED`/`CLD_DUMPED`,
4199        // `si_status = SIGABRT`. La branche du dispatch exercée est la
4200        // même que pour le scénario canonique.
4201        //
4202        // **Scénario canonique disponible depuis la PR pidfd_*.** Pour
4203        // une orchestration parent → enfant réelle (parent envoie un
4204        // signal qui tue l'enfant), voir le test
4205        // `pidfd_send_signal_canonical_parent_kills_child_with_sigkill`
4206        // ci-dessous. Ce test-ci reste utile : il exerce le décodage
4207        // `CLD_DUMPED` (SIGABRT dump-friendly) alors que le canonique
4208        // exerce `CLD_KILLED` (SIGKILL pur).
4209        let args = CloneArgs {
4210            flags: CloneFlags::empty(),
4211            exit_signal: Some(Signal::SIGCHLD),
4212            stack: None,
4213            child_tid: None,
4214            parent_tid: None,
4215            tls: None,
4216        };
4217        // SAFETY: fork classique, pas de mémoire partagée.
4218        let result = unsafe { clone3(&args) }.expect("clone3 doit réussir");
4219        match result {
4220            CloneResult::Child => {
4221                // SIGABRT raised; child dies. `abort()` bypasses atexit,
4222                // donc cette ligne n'incrémente PAS le compteur LLVM côté
4223                // child (trou de couverture acté dans JOURNAL).
4224                std::process::abort();
4225            }
4226            CloneResult::Parent { child_pid, .. } => {
4227                let status = waitid(WaitTarget::Pid(child_pid), WaitOptions::EXITED)
4228                    .expect("waitid doit réussir");
4229                let s = status.expect("événement EXITED disponible");
4230                assert!(matches!(
4231                    s.event,
4232                    WaitEvent::Killed { signal, .. } if signal.as_raw() == Signal::SIGABRT.as_raw()
4233                ));
4234            }
4235        }
4236    }
4237
4238    #[test]
4239    fn waitid_dispatches_processgroup_and_anyprocessgroup() {
4240        // Exerce les bras `WaitTarget::ProcessGroup(_)` et
4241        // `WaitTarget::AnyProcessGroup` du dispatch interne de `waitid`.
4242        // NOHANG garantit un retour immédiat. **NOWAIT** (peek) est
4243        // crucial : sans lui, cet appel concurrent à d'autres tests qui
4244        // forkent et reapent leurs enfants risquerait de **consommer**
4245        // un événement appartenant à un autre test, lui faisant voir
4246        // `ECHILD`. NOWAIT garde l'événement waitable pour son
4247        // propriétaire légitime.
4248        let pid = getpid();
4249        let opts = WaitOptions::EXITED | WaitOptions::NOHANG | WaitOptions::NOWAIT;
4250        let _ = waitid(WaitTarget::ProcessGroup(pid), opts);
4251        let _ = waitid(WaitTarget::AnyProcessGroup, opts);
4252    }
4253
4254    #[test]
4255    fn waitid_nohang_returns_none_when_no_event_yet() {
4256        let args = CloneArgs {
4257            flags: CloneFlags::empty(),
4258            exit_signal: Some(Signal::SIGCHLD),
4259            stack: None,
4260            child_tid: None,
4261            parent_tid: None,
4262            tls: None,
4263        };
4264        // SAFETY: fork classique.
4265        let result = unsafe { clone3(&args) }.expect("clone3 doit réussir");
4266        match result {
4267            CloneResult::Child => {
4268                // Sleep long pour laisser au parent le temps de la fenêtre
4269                // NOHANG. 200 ms est largement suffisant sur n'importe
4270                // quelle CI réaliste.
4271                std::thread::sleep(std::time::Duration::from_millis(200));
4272                child_exit(0);
4273            }
4274            CloneResult::Parent { child_pid, .. } => {
4275                let status = waitid(
4276                    WaitTarget::Pid(child_pid),
4277                    WaitOptions::EXITED | WaitOptions::NOHANG,
4278                )
4279                .expect("waitid avec NOHANG doit réussir");
4280                assert!(status.is_none());
4281                // Cleanup : on attend l'enfant bloquant pour ne pas laisser
4282                // de zombie aux tests suivants.
4283                let _ = waitid(WaitTarget::Pid(child_pid), WaitOptions::EXITED)
4284                    .expect("waitid bloquant final doit réussir");
4285            }
4286        }
4287    }
4288
4289    #[test]
4290    fn clone3_rejects_stack_argument() {
4291        // `clone3` est dédié au fork : une pile dédiée (création de thread) doit
4292        // être refusée en amont, sans toucher au kernel (Principe 4 : validation
4293        // amont). La création de thread avec pile passe par `clone_thread`.
4294        let dummy_buf: [u8; 16] = [0; 16];
4295        let mut buffer = dummy_buf;
4296        let args = CloneArgs {
4297            flags: CloneFlags::empty(),
4298            exit_signal: Some(Signal::SIGCHLD),
4299            stack: Some(air_sys_types::StackSpecification {
4300                addr: buffer.as_mut_ptr(),
4301                size: buffer.len(),
4302            }),
4303            child_tid: None,
4304            parent_tid: None,
4305            tls: None,
4306        };
4307        // SAFETY: le wrapper rejette `stack` avant tout syscall.
4308        let result = unsafe { clone3(&args) };
4309        assert_eq!(result.err(), Some(Errno::EINVAL));
4310    }
4311
4312    #[test]
4313    fn clone3_rejects_thread_creation_flags() {
4314        // CLONE_VM / CLONE_THREAD / CLONE_SIGHAND ne sont pas supportés
4315        // dans cette PR ; le wrapper doit les refuser en amont.
4316        let args = CloneArgs {
4317            flags: CloneFlags::VM,
4318            exit_signal: None,
4319            stack: None,
4320            child_tid: None,
4321            parent_tid: None,
4322            tls: None,
4323        };
4324        // SAFETY: refus en amont, sans syscall.
4325        let result = unsafe { clone3(&args) };
4326        assert_eq!(result.err(), Some(Errno::EINVAL));
4327    }
4328
4329    // ── clone_thread : création de thread (CLONE_VM) + join par CHILD_CLEARTID ─
4330    //
4331    // Marqueur écrit par l'enfant dans la VM partagée ; valeur arbitraire non
4332    // nulle, distincte de l'état initial (0) et d'un TID plausible.
4333    const THREAD_MARKER: u32 = 0xABCD_1234;
4334
4335    /// Point d'entrée du thread de test : écrit `THREAD_MARKER` dans le mot
4336    /// atomique pointé par `arg` (mémoire partagée via `CLONE_VM`).
4337    ///
4338    /// **Discipline côté enfant** : opération **brute**, async-signal-safe — une
4339    /// seule écriture atomique, **aucun** accès TLS ni allocation. Côté kernel,
4340    /// le trampoline `clone_thread` termine le thread via `SYS_exit` au retour.
4341    ///
4342    /// Cette fonction est **aussi** appelée directement côté parent par
4343    /// `thread_marker_entry_writes_marker_directly` (couverture de son corps :
4344    /// l'invocation enfant ne flushe pas le profil LLVM).
4345    extern "C" fn thread_marker_entry(arg: usize) {
4346        // SAFETY (test) : `arg` est l'adresse d'un `AtomicU32` vivant fourni par
4347        // l'appelant (parent), partagé via la VM commune. Alignement et validité
4348        // garantis par le site d'appel.
4349        let marker = unsafe { &*(arg as *const core::sync::atomic::AtomicU32) };
4350        marker.store(THREAD_MARKER, core::sync::atomic::Ordering::Release);
4351    }
4352
4353    #[test]
4354    fn thread_marker_entry_writes_marker_directly() {
4355        // Couvre le corps de `thread_marker_entry` côté parent (le profil LLVM
4356        // de son exécution enfant n'est pas flushable : SYS_exit direct).
4357        use core::sync::atomic::{AtomicU32, Ordering};
4358        let word = AtomicU32::new(0);
4359        let ptr = core::ptr::addr_of!(word) as usize;
4360        thread_marker_entry(ptr);
4361        assert_eq!(word.load(Ordering::Acquire), THREAD_MARKER);
4362    }
4363
4364    #[test]
4365    #[cfg_attr(miri, ignore = "clone3/futex non supportés par Miri")]
4366    fn clone_thread_spawns_runs_and_joins_via_child_cleartid() {
4367        use crate::futex::futex_wait;
4368        use crate::mem::{mmap_anonymous, munmap};
4369        use air_sys_types::mem::{MapFlags, ProtectionFlags};
4370        use air_sys_types::{FutexScope, TidReceiver};
4371        use core::sync::atomic::{AtomicU32, Ordering};
4372
4373        // Pile dédiée du thread : 64 KiB, page-alignée (mmap) ⇒ sommet aligné 16.
4374        const STACK_SIZE: usize = 64 * 1024;
4375        let mut stack = mmap_anonymous(
4376            STACK_SIZE,
4377            ProtectionFlags::READ | ProtectionFlags::WRITE,
4378            MapFlags::PRIVATE | MapFlags::STACK,
4379        )
4380        .expect("mmap pile thread");
4381
4382        // Marqueur partagé (VM commune) écrit par l'enfant, lu par le parent.
4383        let marker = AtomicU32::new(0);
4384        // Mot du join : `PARENT_SETTID` y écrit le TID (sans course, côté parent
4385        // avant retour) ; `CHILD_CLEARTID` le remet à 0 + `FUTEX_WAKE` à la mort
4386        // du thread.
4387        let tid_word = AtomicU32::new(0);
4388
4389        let flags = CloneFlags::VM
4390            | CloneFlags::FS
4391            | CloneFlags::FILES
4392            | CloneFlags::SIGHAND
4393            | CloneFlags::THREAD
4394            | CloneFlags::SYSVSEM
4395            | CloneFlags::PARENT_SETTID
4396            | CloneFlags::CHILD_CLEARTID;
4397
4398        let args = CloneArgs {
4399            flags,
4400            // CLONE_THREAD interdit tout signal de sortie.
4401            exit_signal: None,
4402            stack: Some(StackSpecification {
4403                addr: stack.as_mut_ptr(),
4404                size: STACK_SIZE,
4405            }),
4406            child_tid: Some(TidReceiver::new(&tid_word)),
4407            parent_tid: Some(TidReceiver::new(&tid_word)),
4408            tls: None,
4409        };
4410
4411        let marker_ptr = core::ptr::addr_of!(marker) as usize;
4412        // SAFETY: pile dédiée valide, non libérée avant le join ; `thread_marker_entry`
4413        // n'utilise ni TLS ni alloc et n'écrit qu'un mot atomique partagé vivant ;
4414        // `tid_word` reste vivant jusqu'à la fin du join.
4415        let tid = unsafe { clone_thread(&args, thread_marker_entry, marker_ptr) }
4416            .expect("clone_thread doit réussir");
4417        assert!(tid.as_raw() > 0, "TID enfant strictement positif");
4418
4419        // Join : le kernel a écrit le TID dans `tid_word` (PARENT_SETTID) ; il le
4420        // remet à 0 + `FUTEX_WAKE` (portée **partagée**) à la mort du thread.
4421        // Robustesse : `futex_wait` borné par un court timeout, puis relecture du
4422        // mot — le kernel écrit toujours 0, indépendamment du réveil ; la boucle
4423        // converge même si la portée du futex différait.
4424        loop {
4425            let v = tid_word.load(Ordering::Acquire);
4426            if v == 0 {
4427                break;
4428            }
4429            let _ = futex_wait(
4430                &tid_word,
4431                v,
4432                Some(core::time::Duration::from_millis(100)),
4433                FutexScope::Shared,
4434            );
4435        }
4436
4437        assert_eq!(
4438            marker.load(Ordering::Acquire),
4439            THREAD_MARKER,
4440            "l'enfant doit avoir écrit le marqueur dans la VM partagée"
4441        );
4442
4443        // La pile n'est libérée qu'après le join (thread mort) : pas d'UAF.
4444        munmap(stack).expect("munmap pile thread");
4445    }
4446
4447    #[test]
4448    #[cfg_attr(miri, ignore = "clone3 non supporté par Miri")]
4449    fn clone_thread_kernel_rejection_surfaces_errno() {
4450        // Couvre le bras d'erreur de `clone_thread` (échec AU SYSCALL, après la
4451        // validation amont). `CLONE_THREAD` sans `CLONE_SIGHAND` viole un
4452        // invariant *inconditionnel* de `copy_process`
4453        // (`THREAD ⟹ SIGHAND ⟹ VM`) : le kernel rend `EINVAL` **avant** de créer
4454        // quoi que ce soit — aucun thread n'est lancé, `entry` n'est jamais
4455        // exécuté. Déterministe sur tout Linux.
4456        use crate::mem::{mmap_anonymous, munmap};
4457        use air_sys_types::mem::{MapFlags, ProtectionFlags};
4458
4459        const STACK_SIZE: usize = 64 * 1024;
4460        let mut stack = mmap_anonymous(
4461            STACK_SIZE,
4462            ProtectionFlags::READ | ProtectionFlags::WRITE,
4463            MapFlags::PRIVATE | MapFlags::STACK,
4464        )
4465        .expect("mmap pile thread");
4466
4467        let args = CloneArgs {
4468            // VM présent (passe la validation amont) mais THREAD sans SIGHAND.
4469            flags: CloneFlags::VM | CloneFlags::THREAD,
4470            exit_signal: None,
4471            stack: Some(StackSpecification {
4472                addr: stack.as_mut_ptr(),
4473                size: STACK_SIZE,
4474            }),
4475            child_tid: None,
4476            parent_tid: None,
4477            tls: None,
4478        };
4479        // SAFETY: le kernel rejette la combinaison de drapeaux (EINVAL) avant
4480        // toute création de thread ; `entry` n'est jamais appelé. La pile reste
4481        // intacte et est libérée ensuite.
4482        let r = unsafe { clone_thread(&args, thread_marker_entry, 0) };
4483        assert_eq!(r.err(), Some(Errno::EINVAL));
4484
4485        munmap(stack).expect("munmap pile thread");
4486    }
4487
4488    /// Construit une `StackSpecification` à adresse/taille **contrôlées** pour
4489    /// les tests de validation (jamais déréférencée : rejet en amont).
4490    fn fake_stack(addr: usize, size: usize) -> StackSpecification {
4491        StackSpecification {
4492            addr: core::ptr::without_provenance_mut::<u8>(addr),
4493            size,
4494        }
4495    }
4496
4497    #[test]
4498    fn clone_thread_validation_rejects_bad_inputs() {
4499        let entry = thread_marker_entry;
4500        // (a) pile absente (avec CLONE_VM) → EINVAL.
4501        let a = CloneArgs {
4502            flags: CloneFlags::VM,
4503            exit_signal: None,
4504            stack: None,
4505            child_tid: None,
4506            parent_tid: None,
4507            tls: None,
4508        };
4509        assert_eq!(
4510            unsafe { clone_thread(&a, entry, 0) }.err(),
4511            Some(Errno::EINVAL),
4512            "pile absente"
4513        );
4514
4515        // (b) pile présente mais CLONE_VM absent → EINVAL.
4516        let b = CloneArgs {
4517            flags: CloneFlags::empty(),
4518            exit_signal: None,
4519            stack: Some(fake_stack(0x10000, 8192)),
4520            child_tid: None,
4521            parent_tid: None,
4522            tls: None,
4523        };
4524        assert_eq!(
4525            unsafe { clone_thread(&b, entry, 0) }.err(),
4526            Some(Errno::EINVAL),
4527            "CLONE_VM absent"
4528        );
4529
4530        // Base commune pour les cas (c)..(g) : CLONE_VM présent.
4531        let with_stack = |s: StackSpecification| CloneArgs {
4532            flags: CloneFlags::VM,
4533            exit_signal: None,
4534            stack: Some(s),
4535            child_tid: None,
4536            parent_tid: None,
4537            tls: None,
4538        };
4539
4540        // (c) adresse de pile nulle → EINVAL.
4541        let c = with_stack(fake_stack(0, 8192));
4542        assert_eq!(
4543            unsafe { clone_thread(&c, entry, 0) }.err(),
4544            Some(Errno::EINVAL),
4545            "addr nulle"
4546        );
4547
4548        // (d) taille < MIN_THREAD_STACK_SIZE → EINVAL.
4549        let d = with_stack(fake_stack(0x10000, 16));
4550        assert_eq!(
4551            unsafe { clone_thread(&d, entry, 0) }.err(),
4552            Some(Errno::EINVAL),
4553            "taille trop petite"
4554        );
4555
4556        // (e) `addr` non alignée sur 16 → EINVAL (1er opérande du `||`).
4557        let e = with_stack(fake_stack(0x10001, 8192));
4558        assert_eq!(
4559            unsafe { clone_thread(&e, entry, 0) }.err(),
4560            Some(Errno::EINVAL),
4561            "addr non alignée 16"
4562        );
4563
4564        // (f) `size` non alignée sur 16 (addr alignée) → EINVAL (2e opérande).
4565        let f = with_stack(fake_stack(0x10000, 8192 + 1));
4566        assert_eq!(
4567            unsafe { clone_thread(&f, entry, 0) }.err(),
4568            Some(Errno::EINVAL),
4569            "size non alignée 16"
4570        );
4571
4572        // (g) sommet `addr + size` en débordement d'adresse → EINVAL.
4573        let g = with_stack(fake_stack(0xFFFF_FFFF_FFFF_F000, 0x2000));
4574        assert_eq!(
4575            unsafe { clone_thread(&g, entry, 0) }.err(),
4576            Some(Errno::EINVAL),
4577            "débordement du sommet de pile"
4578        );
4579    }
4580
4581    #[test]
4582    fn build_kernel_clone_args_maps_thread_fields() {
4583        // Mapping des champs de thread : stack/stack_size, tls, child_tid, parent_tid.
4584        use air_sys_types::TidReceiver;
4585        use core::sync::atomic::AtomicU32;
4586        let word = AtomicU32::new(0);
4587        let recv = TidReceiver::new(&word);
4588        let args = CloneArgs {
4589            flags: CloneFlags::VM | CloneFlags::THREAD,
4590            exit_signal: None,
4591            stack: Some(fake_stack(0x4000, 0x8000)),
4592            child_tid: Some(recv),
4593            parent_tid: Some(recv),
4594            tls: Some(0xDEAD_0000),
4595        };
4596        let k = build_kernel_clone_args(&args, 0);
4597        assert_eq!(k.flags, (CloneFlags::VM | CloneFlags::THREAD).bits());
4598        assert_eq!(k.stack, 0x4000);
4599        assert_eq!(k.stack_size, 0x8000);
4600        assert_eq!(k.tls, 0xDEAD_0000);
4601        assert_eq!(k.child_tid, recv.as_ptr() as u64);
4602        assert_eq!(k.parent_tid, recv.as_ptr() as u64);
4603        // Champs hors périmètre : toujours nuls.
4604        assert_eq!(k.pidfd, 0);
4605        assert_eq!(k.set_tid, 0);
4606        assert_eq!(k.set_tid_size, 0);
4607        assert_eq!(k.cgroup, 0);
4608    }
4609
4610    #[test]
4611    fn clone3_with_new_user_namespace_runs_or_skips() {
4612        if apparmor_restricts_userns() {
4613            eprintln!(
4614                "SKIP clone3_with_new_user_namespace_runs_or_skips : \
4615                 kernel.apparmor_restrict_unprivileged_userns=1 sur cet \
4616                 environnement (Ubuntu 24.04+ par défaut)."
4617            );
4618            return;
4619        }
4620        let args = CloneArgs {
4621            flags: CloneFlags::NEWUSER,
4622            exit_signal: Some(Signal::SIGCHLD),
4623            stack: None,
4624            child_tid: None,
4625            parent_tid: None,
4626            tls: None,
4627        };
4628        // SAFETY: création de processus dans un user namespace ;
4629        // l'appelant n'a pas besoin de capability sur un système qui
4630        // autorise les userns non privilégiés.
4631        let result = unsafe { clone3(&args) }
4632            .expect("clone3 NEWUSER doit réussir sans restriction AppArmor");
4633        match result {
4634            CloneResult::Child => {
4635                child_exit(0);
4636            }
4637            CloneResult::Parent { child_pid, .. } => {
4638                let status = waitid(WaitTarget::Pid(child_pid), WaitOptions::EXITED)
4639                    .expect("waitid doit réussir");
4640                let s = status.expect("événement disponible");
4641                assert!(matches!(s.event, WaitEvent::Exited { code: 0 }));
4642            }
4643        }
4644    }
4645
4646    #[test]
4647    fn clone3_newpid_without_caps_returns_eperm() {
4648        if is_root() {
4649            eprintln!(
4650                "SKIP clone3_newpid_without_caps_returns_eperm : \
4651                 le test runner s'exécute en root, le scénario \
4652                 « capabilities insuffisantes » est inapplicable."
4653            );
4654            return;
4655        }
4656        let args = CloneArgs {
4657            // NEWPID seul (sans NEWUSER) exige CAP_SYS_ADMIN dans le
4658            // namespace utilisateur courant ; un non-root est refusé.
4659            flags: CloneFlags::NEWPID,
4660            exit_signal: Some(Signal::SIGCHLD),
4661            stack: None,
4662            child_tid: None,
4663            parent_tid: None,
4664            tls: None,
4665        };
4666        // SAFETY: si EPERM est retourné, aucun fork n'a eu lieu. Le test
4667        // a été skippé en amont si on est root, donc la branche `Ok(_)`
4668        // est inatteignable sur un environnement conforme.
4669        let result = unsafe { clone3(&args) };
4670        assert_eq!(result.err(), Some(Errno::EPERM));
4671    }
4672
4673    #[test]
4674    fn waitid_invalid_options_returns_einval() {
4675        // Le kernel exige au moins un parmi EXITED/STOPPED/CONTINUED.
4676        let result = waitid(WaitTarget::AnyChild, WaitOptions::empty());
4677        assert_eq!(result.err(), Some(Errno::EINVAL));
4678    }
4679
4680    // ── siginfo_to_wait_event : dispatch testé sur KernelSiginfo synthétiques ─
4681    //
4682    // Voir docs/JOURNAL.md (session 2026-05-22, suite clone3+waitid) :
4683    // ces tests couvrent la **logique de dispatch** des 5 variantes de
4684    // WaitEvent. Le remplissage réel de `siginfo_t` par le kernel n'est
4685    // testé bout-en-bout que pour Exited et Killed (les deux tests
4686    // ci-dessus). Stopped/Continued/Trapped restent un trou de couverture
4687    // assumé pour ce périmètre.
4688
4689    fn make_siginfo(si_code: i32, si_status: i32) -> KernelSiginfo {
4690        let mut info = KernelSiginfo::zeroed();
4691        info.si_signo = Signal::SIGCHLD.as_raw();
4692        info.si_code = si_code;
4693        info.si_pid = 1234;
4694        info.si_uid = 1000;
4695        info.si_status = si_status;
4696        info
4697    }
4698
4699    #[test]
4700    fn dispatch_cld_exited() {
4701        let info = make_siginfo(CLD_EXITED, 42);
4702        assert_eq!(siginfo_to_wait_event(&info), WaitEvent::Exited { code: 42 });
4703    }
4704
4705    #[test]
4706    fn dispatch_cld_killed_no_dump() {
4707        let info = make_siginfo(CLD_KILLED, Signal::SIGKILL.as_raw());
4708        assert_eq!(
4709            siginfo_to_wait_event(&info),
4710            WaitEvent::Killed {
4711                signal: Signal::SIGKILL,
4712                core_dumped: false,
4713            }
4714        );
4715    }
4716
4717    #[test]
4718    fn dispatch_cld_dumped_sets_core_dumped_true() {
4719        let info = make_siginfo(CLD_DUMPED, Signal::SIGSEGV.as_raw());
4720        assert_eq!(
4721            siginfo_to_wait_event(&info),
4722            WaitEvent::Killed {
4723                signal: Signal::SIGSEGV,
4724                core_dumped: true,
4725            }
4726        );
4727    }
4728
4729    #[test]
4730    fn dispatch_cld_stopped() {
4731        let info = make_siginfo(CLD_STOPPED, Signal::SIGSTOP.as_raw());
4732        assert_eq!(
4733            siginfo_to_wait_event(&info),
4734            WaitEvent::Stopped {
4735                signal: Signal::SIGSTOP,
4736            }
4737        );
4738    }
4739
4740    #[test]
4741    fn dispatch_cld_trapped() {
4742        let info = make_siginfo(CLD_TRAPPED, Signal::SIGTRAP.as_raw());
4743        assert_eq!(
4744            siginfo_to_wait_event(&info),
4745            WaitEvent::Trapped {
4746                signal: Signal::SIGTRAP,
4747            }
4748        );
4749    }
4750
4751    #[test]
4752    fn dispatch_cld_continued() {
4753        // si_status est ignoré pour CLD_CONTINUED ; on passe 0.
4754        let info = make_siginfo(CLD_CONTINUED, 0);
4755        assert_eq!(siginfo_to_wait_event(&info), WaitEvent::Continued);
4756    }
4757
4758    #[test]
4759    #[should_panic(expected = "si_code inconnu")]
4760    fn dispatch_unknown_cld_code_panics() {
4761        // Couvre la branche `unreachable!` de siginfo_to_wait_event.
4762        // Cette branche ne peut être atteinte que si le kernel renseigne
4763        // un si_code hors {1..=6} — situation impossible en pratique mais
4764        // testée pour fermer la couverture du match.
4765        let info = make_siginfo(99, 0);
4766        let _ = siginfo_to_wait_event(&info);
4767    }
4768
4769    // ── errno_from_negative_syscall_ret : conversion brute → typée ─
4770
4771    #[test]
4772    fn errno_from_negative_syscall_ret_maps_known_codes() {
4773        assert_eq!(errno_from_negative_syscall_ret(-1), Errno::EPERM);
4774        assert_eq!(errno_from_negative_syscall_ret(-3), Errno::ESRCH);
4775        assert_eq!(errno_from_negative_syscall_ret(-4), Errno::EINTR);
4776        assert_eq!(errno_from_negative_syscall_ret(-9), Errno::EBADF);
4777        assert_eq!(errno_from_negative_syscall_ret(-10), Errno::ECHILD);
4778        assert_eq!(errno_from_negative_syscall_ret(-22), Errno::EINVAL);
4779    }
4780
4781    // Couvrent les deux opérandes du `debug_assert!(ret < 0 && ret > -4096)` :
4782    // `ret == 0` viole `ret < 0` ; `ret == -5000` viole `ret > -4096`.
4783    #[test]
4784    #[should_panic(expected = "ret < 0 && ret > -4096")]
4785    fn errno_from_negative_syscall_ret_panics_on_non_negative() {
4786        let _ = errno_from_negative_syscall_ret(0);
4787    }
4788
4789    #[test]
4790    #[should_panic(expected = "ret < 0 && ret > -4096")]
4791    fn errno_from_negative_syscall_ret_panics_below_errno_range() {
4792        let _ = errno_from_negative_syscall_ret(-5000);
4793    }
4794
4795    // ── pidfd_open / pidfd_send_signal / pidfd_getfd ─────────────────
4796    //
4797    // Stratégie de coverage post-fork (identique aux PRs précédentes) :
4798    // les enfants terminent par `exit_group(N)` quand c'est
4799    // possible (flush LLVM via atexit). Pour le test canonique SIGKILL,
4800    // la ligne `loop { sleep }` côté enfant reste non-couverte (SIGKILL
4801    // bypasse atexit), trou acté dans JOURNAL session clone3+waitid.
4802
4803    /// Vrai si Yama interdit le ptrace même sur un enfant direct (scope ≥ 2).
4804    /// Sur Ubuntu, ptrace_scope vaut 1 par défaut (parent → enfant
4805    /// autorisé), donc `pidfd_getfd` sur notre propre enfant passe.
4806    fn yama_blocks_ptrace_on_own_child() -> bool {
4807        let Ok(s) = std::fs::read_to_string("/proc/sys/kernel/yama/ptrace_scope") else {
4808            return false;
4809        };
4810        let scope: u32 = s.trim().parse().unwrap_or(0);
4811        scope >= 2
4812    }
4813
4814    #[test]
4815    fn pidfd_open_returns_pidfd_usable_for_waitid_over_child_lifetime() {
4816        // Couvre : (a) `pidfd_open` happy path ; (b) le pidfd reste valide
4817        // pendant *et après* la mort du processus cible (jusqu'au reap).
4818        let args = CloneArgs {
4819            flags: CloneFlags::empty(),
4820            exit_signal: Some(Signal::SIGCHLD),
4821            stack: None,
4822            child_tid: None,
4823            parent_tid: None,
4824            tls: None,
4825        };
4826        // SAFETY: fork classique, pas de mémoire partagée.
4827        let result = unsafe { clone3(&args) }.expect("clone3 doit réussir");
4828        match result {
4829            CloneResult::Child => {
4830                // Petit délai : laisse au parent le temps d'ouvrir le
4831                // pidfd avant qu'on exit (pas strictement nécessaire — le
4832                // kernel garde l'entrée en process table jusqu'au reap —
4833                // mais rend la nature « post-mortem » du pidfd observable).
4834                std::thread::sleep(std::time::Duration::from_millis(20));
4835                child_exit(11);
4836            }
4837            CloneResult::Parent { child_pid, .. } => {
4838                let pidfd = pidfd_open(child_pid, PidFdOpenFlags::empty())
4839                    .expect("pidfd_open sur enfant vivant doit réussir");
4840                let status = waitid(WaitTarget::PidFd(pidfd.as_fd()), WaitOptions::EXITED)
4841                    .expect("waitid via pidfd post-mortem doit réussir");
4842                let s = status.expect("événement EXITED disponible");
4843                assert_eq!(s.pid, child_pid);
4844                assert!(matches!(s.event, WaitEvent::Exited { code: 11 }));
4845            }
4846        }
4847    }
4848
4849    #[test]
4850    fn pidfd_open_returns_esrch_for_nonexistent_pid() {
4851        // PID_MAX_LIMIT sur Linux 64-bit est typiquement 2^22 = 4 194 304.
4852        // 0x7FFF_FFFE (~2.1 G) est très au-delà : aucun processus ne peut
4853        // jamais avoir ce PID. Le kernel retourne ESRCH.
4854        let nonexistent = Pid::try_from_raw(0x7FFF_FFFE).expect("PID littéral valide");
4855        let result = pidfd_open(nonexistent, PidFdOpenFlags::empty());
4856        assert_eq!(result.err(), Some(Errno::ESRCH));
4857    }
4858
4859    #[test]
4860    fn pidfd_drop_closes_underlying_fd() {
4861        // **Ce que ce test prouve.** Le `Drop` du wrapper RAII — `PidFd` est un
4862        // `PidFd(OwnedFd)` sans `Drop` propre, son `Drop` EST donc celui
4863        // d'`OwnedFd` qui appelle `close(fd)` — ferme bien le descripteur
4864        // sous-jacent. Vérifié en post-drop : on tente `waitid` sur le numéro
4865        // fermé via `BorrowedFd::borrow_raw` ; le kernel doit retourner `EBADF`.
4866        //
4867        // **Immunité à la course de réutilisation de fd (sinon flaky sous
4868        // couverture).** Cargo teste en parallèle ; un autre thread peut
4869        // allouer un fd qui RÉUTILISE le numéro qu'on vient de fermer, dans la
4870        // fenêtre entre `drop` et l'observation — `waitid` ne verrait alors pas
4871        // `EBADF` mais le nouveau fd valide (faux négatif). L'instrumentation
4872        // de couverture élargit cette fenêtre et rendait le test flaky
4873        // (cf. `docs/CI.md`). Parade *par construction* : on duplique le pidfd
4874        // vers un numéro de fd HAUT (`high_unreusable_fd_target`), puis on
4875        // observe la fermeture de CE numéro. Une allocation concurrente prend
4876        // toujours le plus PETIT fd libre, jamais ce numéro haut → réutilisation
4877        // impossible — et `FD_OBSERVATION_LOCK` empêche les tests-frères de
4878        // viser ce même numéro haut au même instant. Pas de `--test-threads=1`.
4879        let _obs = fd_observation_lock();
4880        let pidfd = pidfd_open(getpid(), PidFdOpenFlags::empty())
4881            .expect("pidfd_open sur self doit réussir");
4882        let high = dup_fd(pidfd.as_fd(), high_unreusable_fd_target())
4883            .expect("dup_fd vers un fd haut doit réussir");
4884        // Le fd bas du pidfd peut être réutilisé après ce drop : sans effet, on
4885        // n'observe que le fd HAUT ci-dessous.
4886        drop(pidfd);
4887        let raw_fd: RawFd = high.as_raw_fd();
4888        // Ferme le fd HAUT : c'est le sujet de l'observation, et il est hors
4889        // d'atteinte de toute réutilisation concurrente.
4890        drop(high);
4891        // SAFETY: on re-borrow un fd qu'on sait fermé ET non réutilisable
4892        // (numéro haut). Usage strictement limité à la transmission au kernel
4893        // pour vérifier `EBADF` ; on ne lit ni n'écrit aucune ressource.
4894        let dangling = unsafe { BorrowedFd::borrow_raw(raw_fd) };
4895        let err = waitid(WaitTarget::PidFd(dangling), WaitOptions::EXITED)
4896            .expect_err("waitid sur fd fermé doit échouer");
4897        assert_eq!(err, Errno::EBADF);
4898    }
4899
4900    #[test]
4901    fn pidfd_send_signal_on_closed_pidfd_returns_ebadf() {
4902        // Couvre le chemin d'erreur de `pidfd_send_signal` (le scénario
4903        // canonique SIGKILL ci-dessous exerce seulement le chemin happy).
4904        // Immunité réutilisation de fd : même technique de fd HAUT que
4905        // `pidfd_drop_closes_underlying_fd` (cf. son commentaire).
4906        let _obs = fd_observation_lock();
4907        let pidfd = pidfd_open(getpid(), PidFdOpenFlags::empty())
4908            .expect("pidfd_open sur self doit réussir");
4909        let high = dup_fd(pidfd.as_fd(), high_unreusable_fd_target())
4910            .expect("dup_fd vers un fd haut doit réussir");
4911        drop(pidfd);
4912        let raw_fd: RawFd = high.as_raw_fd();
4913        drop(high);
4914        // SAFETY: re-borrow d'un fd fermé ET non réutilisable (numéro haut),
4915        // utilisé strictement pour vérifier le retour kernel `EBADF`.
4916        let dangling = unsafe { BorrowedFd::borrow_raw(raw_fd) };
4917        let err = pidfd_send_signal(dangling, Signal::SIGKILL, None)
4918            .expect_err("pidfd_send_signal sur fd fermé doit échouer");
4919        assert_eq!(err, Errno::EBADF);
4920    }
4921
4922    #[test]
4923    fn pidfd_getfd_on_closed_pidfd_returns_ebadf() {
4924        // Couvre le chemin d'erreur de `pidfd_getfd`. Même technique de fd HAUT
4925        // immunisée contre la réutilisation que les tests `EBADF` ci-dessus.
4926        let _obs = fd_observation_lock();
4927        let pidfd = pidfd_open(getpid(), PidFdOpenFlags::empty())
4928            .expect("pidfd_open sur self doit réussir");
4929        let high = dup_fd(pidfd.as_fd(), high_unreusable_fd_target())
4930            .expect("dup_fd vers un fd haut doit réussir");
4931        drop(pidfd);
4932        let raw_fd: RawFd = high.as_raw_fd();
4933        drop(high);
4934        // SAFETY: re-borrow d'un fd fermé ET non réutilisable (numéro haut).
4935        let dangling = unsafe { BorrowedFd::borrow_raw(raw_fd) };
4936        // `0` est un fd potentiellement ouvert côté cible (stdin) — peu importe
4937        // ici, le kernel rejette d'abord sur le pidfd fermé avec EBADF.
4938        let err = pidfd_getfd(dangling, 0, 0).expect_err("pidfd_getfd sur fd fermé doit échouer");
4939        assert_eq!(err, Errno::EBADF);
4940    }
4941
4942    #[test]
4943    fn pidfd_send_signal_canonical_parent_kills_child_with_sigkill() {
4944        // **Scénario canonique.** Le parent fork un enfant qui dort, lui
4945        // envoie SIGKILL via `pidfd_send_signal`, puis `waitid` observe
4946        // `WaitEvent::Killed { signal: SIGKILL, .. }`. C'est ce qui se
4947        // passe en production réelle (orchestration parent → enfant),
4948        // contrairement au scénario d'équivalence-kernel de
4949        // `waitid_decodes_child_killed_by_signal` (auto-abort).
4950        let args = CloneArgs {
4951            flags: CloneFlags::PIDFD,
4952            exit_signal: Some(Signal::SIGCHLD),
4953            stack: None,
4954            child_tid: None,
4955            parent_tid: None,
4956            tls: None,
4957        };
4958        // SAFETY: fork classique avec pidfd, pas de mémoire partagée.
4959        let result = unsafe { clone3(&args) }.expect("clone3 doit réussir");
4960        match result {
4961            CloneResult::Child => {
4962                // Le parent va nous tuer via SIGKILL. SIGKILL est
4963                // uncaught, donc on ne reprendra jamais d'ici. La ligne
4964                // n'est PAS couverte par LLVM (le profile n'est pas
4965                // flushé avant SIGKILL), trou acté.
4966                loop {
4967                    std::thread::sleep(std::time::Duration::from_secs(60));
4968                }
4969            }
4970            CloneResult::Parent {
4971                child_pid,
4972                child_pidfd,
4973            } => {
4974                let pidfd = child_pidfd.expect("CLONE_PIDFD demandé");
4975                // Brève attente pour que l'enfant atteigne sa boucle
4976                // (réduit la probabilité d'envoyer SIGKILL avant que
4977                // l'enfant ait commencé son sleep — sans incidence sur
4978                // l'observation `waitid`).
4979                std::thread::sleep(std::time::Duration::from_millis(20));
4980                pidfd_send_signal(pidfd.as_fd(), Signal::SIGKILL, None)
4981                    .expect("pidfd_send_signal SIGKILL doit réussir");
4982                let status = waitid(WaitTarget::Pid(child_pid), WaitOptions::EXITED)
4983                    .expect("waitid doit réussir");
4984                let s = status.expect("événement disponible");
4985                assert!(matches!(
4986                    s.event,
4987                    WaitEvent::Killed { signal, .. } if signal.as_raw() == Signal::SIGKILL.as_raw()
4988                ));
4989            }
4990        }
4991    }
4992
4993    #[test]
4994    fn pidfd_getfd_duplicates_fd_from_child() {
4995        if yama_blocks_ptrace_on_own_child() {
4996            eprintln!(
4997                "SKIP pidfd_getfd_duplicates_fd_from_child : \
4998                 kernel.yama.ptrace_scope >= 2 sur cet environnement \
4999                 (blocage explicite, indépendant de parent → enfant)."
5000            );
5001            return;
5002        }
5003
5004        // On ouvre `/dev/null` AVANT le fork : parent et enfant héritent
5005        // tous deux du même fd (numéro identique, même entrée en file
5006        // table). Après le fork, le parent demande au kernel — via
5007        // `pidfd_getfd` ciblé sur l'enfant — un duplicata de CE numéro
5008        // de fd côté enfant. Le duplicata est un nouveau fd côté parent
5009        // pointant sur la même file que le fd cible.
5010        use std::os::fd::IntoRawFd;
5011        let placeholder = std::fs::File::open("/dev/null").expect("open /dev/null");
5012        let placeholder_raw: RawFd = placeholder.into_raw_fd();
5013
5014        let args = CloneArgs {
5015            flags: CloneFlags::PIDFD,
5016            exit_signal: Some(Signal::SIGCHLD),
5017            stack: None,
5018            child_tid: None,
5019            parent_tid: None,
5020            tls: None,
5021        };
5022        // SAFETY: fork classique avec pidfd.
5023        let result = unsafe { clone3(&args) }.expect("clone3 doit réussir");
5024        match result {
5025            CloneResult::Child => {
5026                // Sleep assez long pour que le parent ait le temps
5027                // d'invoquer pidfd_getfd avant qu'on exit (relâcherait
5028                // le fd côté enfant).
5029                std::thread::sleep(std::time::Duration::from_millis(80));
5030                child_exit(0);
5031            }
5032            CloneResult::Parent {
5033                child_pid,
5034                child_pidfd,
5035            } => {
5036                let pidfd = child_pidfd.expect("CLONE_PIDFD demandé");
5037                let dup: OwnedFd = pidfd_getfd(pidfd.as_fd(), placeholder_raw, 0)
5038                    .expect("pidfd_getfd doit réussir");
5039                // `dup` est un fd côté parent pointant sur le même
5040                // `/dev/null` que celui qu'on avait ouvert avant fork.
5041                // Son numéro est nouveau (différent de `placeholder_raw`,
5042                // sauf collision improbable). Le simple fait que le
5043                // syscall ait réussi prouve la duplication ; on libère.
5044                let dup_raw = dup.as_raw_fd();
5045                assert!(dup_raw >= 0, "fd dupliqué doit être valide");
5046                drop(dup);
5047                // On nettoie aussi le `placeholder_raw` côté parent, qui
5048                // n'a jamais été wrappé dans un OwnedFd après le
5049                // `into_raw_fd()` initial.
5050                // SAFETY: `placeholder_raw` est un fd valide que nous
5051                // détenons (créé par `File::into_raw_fd`, transféré).
5052                drop(unsafe { OwnedFd::from_raw_fd(placeholder_raw) });
5053                let _ = waitid(WaitTarget::Pid(child_pid), WaitOptions::EXITED)
5054                    .expect("waitid final pour reaper");
5055            }
5056        }
5057    }
5058
5059    // ── Groupes et sessions ───────────────────────────────────────────
5060
5061    #[test]
5062    fn getpgid_none_returns_positive_pgid() {
5063        let pgid = getpgid(None).expect("getpgid(None) doit réussir");
5064        assert!(pgid.as_raw() > 0);
5065    }
5066
5067    #[test]
5068    fn getpgid_self_equals_getpgid_none() {
5069        let self_pid = getpid();
5070        let via_none = getpgid(None).expect("getpgid(None)");
5071        let via_self = getpgid(Some(self_pid)).expect("getpgid(self)");
5072        assert_eq!(via_none, via_self);
5073    }
5074
5075    #[test]
5076    fn getsid_none_returns_positive_sid() {
5077        let sid = getsid(None).expect("getsid(None) doit réussir");
5078        assert!(sid.as_raw() > 0);
5079    }
5080
5081    #[test]
5082    fn getpgid_on_nonexistent_pid_returns_esrch() {
5083        // PID > PID_MAX_LIMIT (~2^22) ; couvre la branche d'erreur.
5084        let nope = Pid::try_from_raw(0x7FFF_FFFE).expect("pid littéral");
5085        let err = getpgid(Some(nope)).expect_err("getpgid PID inexistant");
5086        assert_eq!(err, Errno::ESRCH);
5087    }
5088
5089    #[test]
5090    fn getsid_on_nonexistent_pid_returns_esrch() {
5091        let nope = Pid::try_from_raw(0x7FFF_FFFE).expect("pid littéral");
5092        let err = getsid(Some(nope)).expect_err("getsid PID inexistant");
5093        assert_eq!(err, Errno::ESRCH);
5094    }
5095
5096    #[test]
5097    fn setsid_returns_eperm_when_caller_is_pgrp_leader() {
5098        // Le test runner cargo est lancé typiquement comme leader de son
5099        // process group ; setsid échoue alors avec EPERM. C'est le cas
5100        // canonique vérifié ici. Si l'environnement nous a placés dans
5101        // une situation où setsid réussit, on serait propriétaire d'une
5102        // nouvelle session — ce qui contaminerait les tests suivants.
5103        // On skippe proprement dans ce cas hypothétique.
5104        let self_pid = getpid();
5105        let pgid = getpgid(None).expect("getpgid");
5106        if self_pid != pgid {
5107            eprintln!(
5108                "SKIP setsid_returns_eperm_when_caller_is_pgrp_leader : \
5109                 l'appelant n'est pas pgrp leader (pid={} != pgid={}), \
5110                 scénario EPERM inapplicable",
5111                self_pid.as_raw(),
5112                pgid.as_raw()
5113            );
5114            return;
5115        }
5116        let err = setsid().expect_err("setsid sur pgrp leader doit échouer");
5117        assert_eq!(err, Errno::EPERM);
5118    }
5119
5120    #[test]
5121    fn setpgid_self_to_own_pgrp_is_idempotent() {
5122        // Place self dans son propre process group (déjà le cas). C'est
5123        // une opération idempotente qui exerce le wrapper sans changer
5124        // d'état observable.
5125        let our_pgid = getpgid(None).expect("getpgid");
5126        setpgid(None, Some(our_pgid)).expect("setpgid(None, our_pgid) doit réussir");
5127    }
5128
5129    #[test]
5130    fn setpgid_on_nonexistent_pid_returns_esrch() {
5131        // Couvre la branche d'erreur de setpgid.
5132        let nope = Pid::try_from_raw(0x7FFF_FFFE).expect("pid littéral");
5133        let err = setpgid(Some(nope), None).expect_err("setpgid PID inexistant");
5134        assert_eq!(err, Errno::ESRCH);
5135    }
5136
5137    #[test]
5138    fn setsid_succeeds_in_forked_child() {
5139        // Le test runner cargo n'est typiquement PAS pgrp leader → on ne
5140        // peut pas tester `setsid` happy-path directement (cf. test
5141        // `setsid_returns_eperm_when_caller_is_pgrp_leader`).
5142        // On fork donc un enfant : l'enfant hérite du pgrp du parent
5143        // (n'est pas leader), et `setsid()` y SUCCÈDE en créant une
5144        // nouvelle session avec lui-même comme leader+SID. L'enfant
5145        // encode le résultat dans son code de sortie.
5146        let args = CloneArgs {
5147            flags: CloneFlags::empty(),
5148            exit_signal: Some(Signal::SIGCHLD),
5149            stack: None,
5150            child_tid: None,
5151            parent_tid: None,
5152            tls: None,
5153        };
5154        // SAFETY: fork classique, pas de mémoire partagée.
5155        let result = unsafe { clone3(&args) }.expect("clone3");
5156        match result {
5157            CloneResult::Child => match setsid() {
5158                Ok(new_sid) => {
5159                    // L'enfant doit voir new_sid == son propre PID (il
5160                    // devient session leader). Encoder dans le code de
5161                    // sortie permet au parent de vérifier.
5162                    let child_pid = getpid();
5163                    if new_sid == child_pid {
5164                        child_exit(0);
5165                    } else {
5166                        child_exit(1);
5167                    }
5168                }
5169                Err(_) => child_exit(2),
5170            },
5171            CloneResult::Parent { child_pid, .. } => {
5172                let status =
5173                    waitid(WaitTarget::Pid(child_pid), WaitOptions::EXITED).expect("waitid");
5174                let s = status.expect("event");
5175                assert!(
5176                    matches!(s.event, WaitEvent::Exited { code: 0 }),
5177                    "setsid dans enfant doit réussir (event={:?})",
5178                    s.event
5179                );
5180            }
5181        }
5182    }
5183
5184    #[test]
5185    fn setsid_returns_eperm_when_already_pgrp_leader_in_forked_child() {
5186        // Couvre la branche d'erreur (EPERM) de `setsid`. L'enfant fork'é
5187        // se rend pgrp leader (`setpgid(None, None)`) puis appelle
5188        // `setsid` qui DOIT échouer avec EPERM (un pgrp leader ne peut
5189        // pas créer une nouvelle session). Le code de sortie de l'enfant
5190        // encode le résultat.
5191        let args = CloneArgs {
5192            flags: CloneFlags::empty(),
5193            exit_signal: Some(Signal::SIGCHLD),
5194            stack: None,
5195            child_tid: None,
5196            parent_tid: None,
5197            tls: None,
5198        };
5199        // SAFETY: fork classique.
5200        let result = unsafe { clone3(&args) }.expect("clone3");
5201        match result {
5202            CloneResult::Child => {
5203                if setpgid(None, None).is_err() {
5204                    child_exit(40);
5205                }
5206                match setsid() {
5207                    Ok(_) => child_exit(41),
5208                    Err(e) if e == Errno::EPERM => child_exit(0),
5209                    Err(_) => child_exit(42),
5210                }
5211            }
5212            CloneResult::Parent { child_pid, .. } => {
5213                let status =
5214                    waitid(WaitTarget::Pid(child_pid), WaitOptions::EXITED).expect("waitid");
5215                let s = status.expect("event");
5216                assert!(
5217                    matches!(s.event, WaitEvent::Exited { code: 0 }),
5218                    "setsid sur pgrp leader doit retourner EPERM, event={:?}",
5219                    s.event
5220                );
5221            }
5222        }
5223    }
5224
5225    #[test]
5226    fn set_dumpable_round_trip_in_forked_child() {
5227        // `set_dumpable` est process-wide. Pour ne pas contaminer les
5228        // autres tests, on round-trippe dans un enfant fork'é qui meurt
5229        // immédiatement après vérification.
5230        let args = CloneArgs {
5231            flags: CloneFlags::empty(),
5232            exit_signal: Some(Signal::SIGCHLD),
5233            stack: None,
5234            child_tid: None,
5235            parent_tid: None,
5236            tls: None,
5237        };
5238        // SAFETY: fork classique.
5239        let result = unsafe { clone3(&args) }.expect("clone3");
5240        match result {
5241            CloneResult::Child => {
5242                // Round-trip : NotDumpable ↔ Dumpable. `SuidDumpable` (=2)
5243                // est déprécié et rejeté par les kernels Linux récents
5244                // (EINVAL), donc non testé ici — le wrapper le supporte
5245                // pour conformité spec mais sa branche kernel reste
5246                // hors test sur ces noyaux.
5247                if set_dumpable(DumpableMode::NotDumpable).is_err() {
5248                    child_exit(10);
5249                }
5250                if !matches!(get_dumpable(), Ok(DumpableMode::NotDumpable)) {
5251                    child_exit(11);
5252                }
5253                if set_dumpable(DumpableMode::Dumpable).is_err() {
5254                    child_exit(12);
5255                }
5256                if !matches!(get_dumpable(), Ok(DumpableMode::Dumpable)) {
5257                    child_exit(13);
5258                }
5259                child_exit(0);
5260            }
5261            CloneResult::Parent { child_pid, .. } => {
5262                let status =
5263                    waitid(WaitTarget::Pid(child_pid), WaitOptions::EXITED).expect("waitid");
5264                let s = status.expect("event");
5265                assert!(
5266                    matches!(s.event, WaitEvent::Exited { code: 0 }),
5267                    "round-trip dumpable doit réussir, event={:?}",
5268                    s.event
5269                );
5270            }
5271        }
5272    }
5273
5274    #[test]
5275    fn set_keep_caps_round_trip_in_forked_child() {
5276        // `set_keep_caps` est process-wide (touche les credentials du
5277        // process). Même technique : round-trip dans un enfant fork'é.
5278        let args = CloneArgs {
5279            flags: CloneFlags::empty(),
5280            exit_signal: Some(Signal::SIGCHLD),
5281            stack: None,
5282            child_tid: None,
5283            parent_tid: None,
5284            tls: None,
5285        };
5286        // SAFETY: fork classique.
5287        let result = unsafe { clone3(&args) }.expect("clone3");
5288        match result {
5289            CloneResult::Child => {
5290                if set_keep_caps(true).is_err() {
5291                    child_exit(20);
5292                }
5293                if !matches!(get_keep_caps(), Ok(true)) {
5294                    child_exit(21);
5295                }
5296                if set_keep_caps(false).is_err() {
5297                    child_exit(22);
5298                }
5299                if !matches!(get_keep_caps(), Ok(false)) {
5300                    child_exit(23);
5301                }
5302                child_exit(0);
5303            }
5304            CloneResult::Parent { child_pid, .. } => {
5305                let status =
5306                    waitid(WaitTarget::Pid(child_pid), WaitOptions::EXITED).expect("waitid");
5307                let s = status.expect("event");
5308                assert!(
5309                    matches!(s.event, WaitEvent::Exited { code: 0 }),
5310                    "round-trip keep_caps doit réussir, event={:?}",
5311                    s.event
5312                );
5313            }
5314        }
5315    }
5316
5317    // ── prctl ─────────────────────────────────────────────────────────
5318
5319    #[test]
5320    fn get_no_new_privs_returns_ok() {
5321        // Lecture seule — on ne SET PAS (irréversible côté process,
5322        // contaminerait les tests suivants). Le wrapper renvoie Ok(bool).
5323        let _value = get_no_new_privs().expect("get_no_new_privs doit réussir");
5324    }
5325
5326    #[test]
5327    fn get_dumpable_returns_known_mode() {
5328        // Lecture seule pour éviter de contaminer d'autres tests (le bit
5329        // dumpable est process-wide).
5330        let mode = get_dumpable().expect("get_dumpable doit réussir");
5331        assert!(matches!(
5332            mode,
5333            DumpableMode::NotDumpable | DumpableMode::Dumpable | DumpableMode::SuidDumpable
5334        ));
5335    }
5336
5337    #[test]
5338    fn thread_name_round_trip() {
5339        // PR_SET_NAME / PR_GET_NAME sont per-thread. cargo test exécute
5340        // chaque #[test] sur son propre worker thread, donc on peut
5341        // round-tripper sans contaminer les autres.
5342        let new_name = c"air-prctl-test";
5343        set_thread_name(new_name).expect("set_thread_name");
5344        let read_back = get_thread_name().expect("get_thread_name");
5345        assert_eq!(read_back.as_c_str(), new_name);
5346    }
5347
5348    #[test]
5349    fn thread_name_kernel_truncates_at_15_chars() {
5350        // Le kernel tronque à 15 caractères + NUL. On passe 20 caractères
5351        // et on vérifie que get_thread_name récupère les 15 premiers.
5352        let long_name = c"abcdefghijklmnopqrst"; // 20 caractères
5353        set_thread_name(long_name).expect("set_thread_name long");
5354        let read_back = get_thread_name().expect("get_thread_name long");
5355        assert_eq!(read_back.to_bytes(), b"abcdefghijklmno"); // 15 premiers
5356    }
5357
5358    #[test]
5359    fn parent_death_signal_round_trip() {
5360        // Per-thread sur Linux moderne. On utilise SIGKILL qui est déjà
5361        // dans le stub Signal ; round-trip Some/None/Some.
5362        set_parent_death_signal(Some(Signal::SIGKILL)).expect("set Some(SIGKILL)");
5363        let got = get_parent_death_signal().expect("get");
5364        assert_eq!(got, Some(Signal::SIGKILL));
5365
5366        set_parent_death_signal(None).expect("set None");
5367        let got = get_parent_death_signal().expect("get after clear");
5368        assert_eq!(got, None);
5369    }
5370
5371    #[test]
5372    fn set_timer_slack_accepts_zero_and_finite_values() {
5373        // 0 réinitialise à la valeur par défaut héritée. Toute autre
5374        // valeur est acceptée. On n'a pas get_timer_slack côté Air
5375        // (non listé par la spec) ; on vérifie juste l'absence d'erreur.
5376        set_timer_slack(0).expect("reset to default");
5377        set_timer_slack(50_000).expect("50 µs slack");
5378    }
5379
5380    #[test]
5381    fn cap_ambient_is_set_returns_ok_for_known_capability() {
5382        // Un thread non-root n'a aucune capability ambiante par défaut ;
5383        // le test vérifie que le wrapper retourne Ok(false), pas qu'il
5384        // donne une valeur particulière.
5385        let _value =
5386            cap_ambient_is_set(Capability::SysAdmin).expect("cap_ambient_is_set doit réussir");
5387    }
5388
5389    #[test]
5390    fn cap_ambient_lower_succeeds_unconditionally() {
5391        // PR_CAP_AMBIENT_LOWER clear le bit sans erreur même s'il n'était
5392        // pas positionné. Per-thread, donc safe.
5393        cap_ambient_lower(Capability::SysAdmin).expect("cap_ambient_lower doit réussir");
5394    }
5395
5396    #[test]
5397    fn cap_ambient_clear_all_succeeds() {
5398        // Per-thread, idempotent.
5399        cap_ambient_clear_all().expect("cap_ambient_clear_all doit réussir");
5400    }
5401
5402    #[test]
5403    fn cap_ambient_raise_non_permitted_returns_eperm() {
5404        if is_root() {
5405            eprintln!(
5406                "SKIP cap_ambient_raise_non_permitted_returns_eperm : \
5407                 le runner s'exécute en root, scénario EPERM inapplicable."
5408            );
5409            return;
5410        }
5411        // SysAdmin n'est jamais dans permitted∩inheritable pour un user
5412        // non-root standard → raise échoue avec EPERM.
5413        let err = cap_ambient_raise(Capability::SysAdmin)
5414            .expect_err("cap_ambient_raise non-root doit échouer");
5415        assert_eq!(err, Errno::EPERM);
5416    }
5417
5418    // ── rlimits ───────────────────────────────────────────────────────
5419
5420    #[test]
5421    fn prlimit_no_op_round_trip_returns_current_value() {
5422        // Lit la valeur courante, la ré-écrit (no-op), vérifie qu'on
5423        // retrouve la même valeur. Exerce getrlimit ET setrlimit côtés
5424        // wrapper, sans modifier l'état process-wide.
5425        let original = prlimit(None, Resource::NoFile, None).expect("prlimit read");
5426        let _back = prlimit(None, Resource::NoFile, Some(original)).expect("prlimit noop set");
5427        let verified = prlimit(None, Resource::NoFile, None).expect("prlimit verify");
5428        assert_eq!(verified, original);
5429    }
5430
5431    #[test]
5432    fn getrlimit_legacy_alias_returns_same_as_prlimit() {
5433        let via_legacy = getrlimit(Resource::NoFile).expect("getrlimit");
5434        let via_prlimit = prlimit(None, Resource::NoFile, None).expect("prlimit");
5435        assert_eq!(via_legacy, via_prlimit);
5436    }
5437
5438    #[test]
5439    fn setrlimit_legacy_alias_lower_soft_round_trip_then_restore() {
5440        // setrlimit modifie l'état process-wide ; on restore en fin de
5441        // test. Le test exerce la branche `setrlimit -> prlimit(Some)`
5442        // et la lecture suivante.
5443        let original = getrlimit(Resource::NoFile).expect("get original");
5444        let lowered_soft = match original.soft {
5445            RlimitValue::Finite(n) => match n.checked_sub(1) {
5446                Some(m) if m > 0 => RlimitValue::Finite(m),
5447                _ => {
5448                    eprintln!(
5449                        "SKIP setrlimit_legacy_alias_lower_soft_round_trip_then_restore : \
5450                         soft NoFile trop basse pour décrémenter"
5451                    );
5452                    return;
5453                }
5454            },
5455            RlimitValue::Infinity => {
5456                eprintln!(
5457                    "SKIP setrlimit_legacy_alias_lower_soft_round_trip_then_restore : \
5458                     soft NoFile est Infinity, scénario décrément inapplicable"
5459                );
5460                return;
5461            }
5462        };
5463
5464        let lowered = Rlimit {
5465            soft: lowered_soft,
5466            hard: original.hard,
5467        };
5468        setrlimit(Resource::NoFile, lowered).expect("setrlimit lowered");
5469        let verified = getrlimit(Resource::NoFile).expect("verify lowered");
5470        assert_eq!(verified.soft, lowered.soft);
5471
5472        // Restauration.
5473        setrlimit(Resource::NoFile, original).expect("restore original");
5474    }
5475
5476    #[test]
5477    fn setrlimit_raising_hard_limit_returns_eperm_for_non_root() {
5478        if is_root() {
5479            eprintln!(
5480                "SKIP setrlimit_raising_hard_limit_returns_eperm_for_non_root : \
5481                 runner en root, scénario EPERM inapplicable."
5482            );
5483            return;
5484        }
5485        let original = getrlimit(Resource::NoFile).expect("get");
5486        let raised_hard = match original.hard {
5487            RlimitValue::Finite(h) => match h.checked_add(1) {
5488                Some(new_h) => RlimitValue::Finite(new_h),
5489                None => {
5490                    eprintln!("SKIP : hard NoFile = u64::MAX, ne peut être incrémentée");
5491                    return;
5492                }
5493            },
5494            RlimitValue::Infinity => {
5495                eprintln!("SKIP : hard NoFile = Infinity, impossible de la « relever »");
5496                return;
5497            }
5498        };
5499        let attempted = Rlimit {
5500            soft: original.soft,
5501            hard: raised_hard,
5502        };
5503        let err = setrlimit(Resource::NoFile, attempted)
5504            .expect_err("setrlimit raise hard doit échouer non-root");
5505        assert_eq!(err, Errno::EPERM);
5506    }
5507
5508    #[test]
5509    fn prlimit_on_nonexistent_pid_returns_esrch() {
5510        let nope = Pid::try_from_raw(0x7FFF_FFFE).expect("pid littéral");
5511        let err = prlimit(Some(nope), Resource::NoFile, None).expect_err("prlimit PID inexistant");
5512        assert_eq!(err, Errno::ESRCH);
5513    }
5514
5515    // ── capget / capset ────────────────────────────────────────────────
5516
5517    #[test]
5518    fn capget_current_thread_returns_ok() {
5519        let _set = capget(CapabilityTarget::CurrentThread).expect("capget(CurrentThread)");
5520    }
5521
5522    #[test]
5523    fn capget_via_process_target_returns_same_as_current_thread() {
5524        // Couvre le bras `CapabilityTarget::Process(_)` du helper de dispatch
5525        // `cap_target_to_kernel_pid` (les autres tests n'utilisent que
5526        // `CurrentThread` et `Thread`). Pour le thread principal du
5527        // processus, `Thread(getpid())` et `Process(getpid())` ciblent la
5528        // même tâche kernel — donc les capabilities retournées sont
5529        // identiques (à la nuance près que sur des threads worker, le
5530        // PID renvoie le thread principal).
5531        let self_pid = getpid();
5532        let via_current = capget(CapabilityTarget::CurrentThread).expect("current");
5533        let via_process = capget(CapabilityTarget::Process(self_pid)).expect("process");
5534        // On vérifie que l'opération réussit ; les capabilities entre
5535        // threads d'un même processus peuvent différer (CAP_AUDIT_WRITE
5536        // par exemple peut être thread-spécifique), donc on ne fait
5537        // qu'asserter la viabilité du chemin de dispatch.
5538        let _ = (via_current, via_process);
5539    }
5540
5541    #[test]
5542    fn rlim_value_infinity_round_trips_through_kernel_encoding() {
5543        // Couvre les deux branches `RlimitValue::Infinity` du codec
5544        // `rlim_value_to_kernel` / `rlim_value_from_kernel`. Sur Ubuntu
5545        // (et la plupart des distros), `Resource::Stack` a `hard =
5546        // Infinity` par défaut — donc lire+écrire+relire exerce les
5547        // deux conversions.
5548        let original = getrlimit(Resource::Stack).expect("getrlimit Stack");
5549        // Écrire la même valeur (no-op) : exerce `rlim_value_to_kernel`
5550        // pour les deux variantes (Finite pour soft, Infinity pour hard
5551        // si c'est le cas).
5552        setrlimit(Resource::Stack, original).expect("setrlimit no-op Stack");
5553        let verified = getrlimit(Resource::Stack).expect("re-read");
5554        assert_eq!(verified, original);
5555
5556        // Vérification directe que au moins une des deux limites est
5557        // Infinity dans l'environnement (sinon le test ne couvre pas la
5558        // branche visée).
5559        let touches_infinity = matches!(
5560            (original.soft, original.hard),
5561            (RlimitValue::Infinity, _) | (_, RlimitValue::Infinity)
5562        );
5563        if !touches_infinity {
5564            eprintln!(
5565                "SKIP rlim_value_infinity_round_trips_through_kernel_encoding : \
5566                 Stack rlimit ne contient pas Infinity sur cet environnement \
5567                 (soft={:?}, hard={:?}), la branche Infinity n'est pas couverte.",
5568                original.soft, original.hard
5569            );
5570        }
5571    }
5572
5573    #[test]
5574    fn capget_self_thread_equals_capget_current() {
5575        let self_tid = gettid();
5576        let via_current = capget(CapabilityTarget::CurrentThread).expect("capget current");
5577        let via_thread = capget(CapabilityTarget::Thread(self_tid)).expect("capget self thread");
5578        assert_eq!(via_current, via_thread);
5579    }
5580
5581    #[test]
5582    fn capset_no_op_with_current_values_succeeds() {
5583        let current = capget(CapabilityTarget::CurrentThread).expect("capget");
5584        // Réécrire les mêmes valeurs : opération autorisée pour tout
5585        // thread, n'élève rien.
5586        capset(CapabilityTarget::CurrentThread, &current).expect("capset no-op");
5587    }
5588
5589    #[test]
5590    fn capset_raise_non_permitted_returns_eperm() {
5591        if is_root() {
5592            eprintln!(
5593                "SKIP capset_raise_non_permitted_returns_eperm : \
5594                 runner en root, scénario EPERM inapplicable."
5595            );
5596            return;
5597        }
5598        let current = capget(CapabilityTarget::CurrentThread).expect("capget");
5599        if current.permitted.contains(Capability::SysAdmin) {
5600            eprintln!(
5601                "SKIP : SysAdmin est déjà dans permitted (file capabilities ?), \
5602                 scénario EPERM inapplicable."
5603            );
5604            return;
5605        }
5606        // Tente d'ajouter SysAdmin à effective alors qu'il n'est pas
5607        // dans permitted → kernel refuse (effective ⊆ permitted).
5608        let attempted = CapabilitySet {
5609            effective: current.effective.with(Capability::SysAdmin),
5610            permitted: current.permitted,
5611            inheritable: current.inheritable,
5612        };
5613        let err = capset(CapabilityTarget::CurrentThread, &attempted)
5614            .expect_err("capset élévation doit échouer non-root");
5615        assert_eq!(err, Errno::EPERM);
5616    }
5617
5618    // ── mask_to_words / words_to_mask : placement absolu lo/hi ────────
5619    //
5620    // Une éventuelle inversion lo/hi dans `mask_to_words` se compenserait
5621    // dans un simple round-trip via `words_to_mask`. Ces tests vérifient
5622    // le placement ABSOLU des bits dans chaque mot, indépendamment du
5623    // round-trip — ils catchent l'inversion.
5624
5625    #[test]
5626    fn mask_to_words_places_bit_0_in_lo() {
5627        let mask = CapabilityMask::from_bits(1_u64);
5628        let (lo, hi) = mask_to_words(mask);
5629        assert_eq!(lo, 1, "bit 0 doit aller en mot bas");
5630        assert_eq!(hi, 0, "bit 0 NE DOIT PAS aller en mot haut");
5631    }
5632
5633    #[test]
5634    fn mask_to_words_places_bit_32_in_hi() {
5635        let mask = CapabilityMask::from_bits(1_u64 << 32);
5636        let (lo, hi) = mask_to_words(mask);
5637        assert_eq!(lo, 0, "bit 32 NE DOIT PAS aller en mot bas");
5638        assert_eq!(hi, 1, "bit 32 doit aller en mot haut");
5639    }
5640
5641    #[test]
5642    fn words_to_mask_recombines_with_correct_placement() {
5643        // (lo=1, hi=0) doit donner bit 0 dans le mask, pas bit 32.
5644        let m1 = words_to_mask(1, 0);
5645        assert_eq!(m1.bits(), 1_u64);
5646
5647        // (lo=0, hi=1) doit donner bit 32 dans le mask, pas bit 0.
5648        let m2 = words_to_mask(0, 1);
5649        assert_eq!(m2.bits(), 1_u64 << 32);
5650
5651        // Combiné : bits dans les deux mots à la fois.
5652        let m3 = words_to_mask(0xFF, 0x01);
5653        assert_eq!(m3.bits(), (1_u64 << 32) | 0xFF_u64);
5654    }
5655
5656    #[test]
5657    fn mask_words_round_trip_preserves_known_capability() {
5658        // Sanity check : Capability::SysAdmin (bit 21, mot bas) survit
5659        // un round-trip complet.
5660        let original = CapabilityMask::empty().with(Capability::SysAdmin);
5661        let (lo, hi) = mask_to_words(original);
5662        let recovered = words_to_mask(lo, hi);
5663        assert_eq!(recovered, original);
5664        assert!(recovered.contains(Capability::SysAdmin));
5665        // SysAdmin = 21, donc mot bas exclusivement.
5666        assert_eq!(lo, 1_u32 << 21);
5667        assert_eq!(hi, 0);
5668    }
5669
5670    // ── Séparation de privilèges (privsep) ────────────────────────────────
5671    //
5672    // Les mutations de credentials sont **irréversibles** (saved-set) : on les
5673    // exécute dans des **enfants forkés** disposables (correctness via le code
5674    // de sortie observé par `waitid`). Les *getters* (read-only) et l'encodage
5675    // `Option → -1` sont couverts **en processus**. Tests privilégiés gardés par
5676    // `is_root()` (skip propre sinon → exception PRIVILEGE).
5677    //
5678    // Couverture des *setters* : un enfant qui **reste root** jusqu'au
5679    // `child_exit` voit son profil LLVM flushé (cf. `flush_child_coverage`), donc
5680    // leurs chemins **succès** sont comptabilisés ; leurs chemins **erreur**
5681    // (EPERM après réduction) sont prouvés fonctionnellement par l'enfant
5682    // « drop » (flush impossible une fois non-root → exception).
5683
5684    /// Forke un enfant (fork classique), exécute `body` dans l'enfant (qui
5685    /// retourne son **code de sortie** : `0` = succès, `N != 0` = étape `N`
5686    /// échouée), termine l'enfant via `child_exit` (flush de couverture), et
5687    /// retourne le code observé par le parent via `waitid`.
5688    fn run_child(body: impl FnOnce() -> i32) -> i32 {
5689        let args = CloneArgs {
5690            flags: CloneFlags::empty(),
5691            exit_signal: Some(Signal::SIGCHLD),
5692            stack: None,
5693            child_tid: None,
5694            parent_tid: None,
5695            tls: None,
5696        };
5697        // SAFETY: fork classique (pas de CLONE_VM, pas de namespaces) ; aucune
5698        // mémoire partagée, aucune capability requise pour le fork lui-même.
5699        match unsafe { clone3(&args) }.expect("clone3 doit réussir") {
5700            CloneResult::Child => child_exit(body()),
5701            CloneResult::Parent { child_pid, .. } => {
5702                let status = waitid(WaitTarget::Pid(child_pid), WaitOptions::EXITED)
5703                    .expect("waitid")
5704                    .expect("événement EXITED disponible");
5705                match status.event {
5706                    WaitEvent::Exited { code } => code,
5707                    other => panic!("événement de sortie inattendu : {other:?}"),
5708                }
5709            }
5710        }
5711    }
5712
5713    #[test]
5714    fn privsep_opt_encoding_round_trips() {
5715        // `None` → sentinelle `-1` ; `Some(id)` → sa valeur brute.
5716        assert_eq!(opt_gid_to_raw(None), u32::MAX);
5717        assert_eq!(opt_uid_to_raw(None), u32::MAX);
5718        assert_eq!(opt_gid_to_raw(Some(Gid::ROOT)), 0);
5719        assert_eq!(opt_uid_to_raw(Some(Uid::from_raw(1000))), 1000);
5720        assert_eq!(opt_gid_to_raw(Some(Gid::from_raw(u32::MAX))), u32::MAX);
5721    }
5722
5723    proptest::proptest! {
5724        /// Encodage `Option<Uid/Gid>` → `u32` : `None` ⇒ `-1`, `Some(v)` ⇒ `v`.
5725        #[test]
5726        fn prop_opt_encoding(value in proptest::option::of(0u32..=u32::MAX)) {
5727            let expected = value.unwrap_or(u32::MAX);
5728            proptest::prop_assert_eq!(opt_gid_to_raw(value.map(Gid::from_raw)), expected);
5729            proptest::prop_assert_eq!(opt_uid_to_raw(value.map(Uid::from_raw)), expected);
5730        }
5731    }
5732
5733    #[test]
5734    fn set_groups_too_many_is_einval() {
5735        // > NGROUPS_MAX ⇒ EINVAL **en amont** (Principe 4), sans privilège ni
5736        // syscall ni mutation : ne touche pas les credentials du test.
5737        let groups = vec![Gid::ROOT; NGROUPS_MAX + 1];
5738        assert_eq!(set_groups(&groups), Err(Errno::EINVAL));
5739    }
5740
5741    #[test]
5742    fn privsep_getters_read_own_credentials() {
5743        // Read-only : couvre les chemins **succès** de getres*/getgroups sans
5744        // privilège ni mutation. Buffer généreux (≤ 1024 groupes supplémentaires
5745        // — au-delà de tout utilisateur réel).
5746        let ru = get_resuid().expect("getresuid");
5747        let rg = get_resgid().expect("getresgid");
5748        // Un processus de test normal a real == effective.
5749        assert_eq!(ru.real, ru.effective);
5750        assert_eq!(rg.real, rg.effective);
5751        let mut buf = vec![Gid::ROOT; 1024];
5752        let groups = get_groups(&mut buf).expect("getgroups (≤ 1024 groupes)");
5753        assert!(groups.len() <= 1024);
5754    }
5755
5756    // Les corps d'enfant ci-dessous sont **sans branche locale** : conditions
5757    // combinées par `&` (ET bit-à-bit, non court-circuitant) et `==`/`is_ok`, de
5758    // sorte qu'un enfant qui largue ses privilèges (flush de couverture
5759    // impossible) n'introduise **aucune** branche non couverte. La correction est
5760    // prouvée par le code de sortie (`0` = succès) observé par le parent.
5761
5762    #[test]
5763    fn privsep_setters_succeed_as_root() {
5764        if !is_root() {
5765            return; // PRIVILEGE : nécessite CAP_SETUID/CAP_SETGID.
5766        }
5767        // Enfant qui **reste root** (uid 0) : son profil de couverture est flushé,
5768        // couvrant les chemins **succès** des setters (no-op root → root) et
5769        // l'erreur EINVAL de `get_groups` (buffer trop petit).
5770        let code = run_child(|| {
5771            let mut small = [Gid::ROOT; 1];
5772            let mut buf = [Gid::ROOT; 8];
5773            let ok = set_groups(&[]).is_ok()
5774                & set_groups(&[Gid::from_raw(1), Gid::from_raw(2)]).is_ok()
5775                & (get_groups(&mut small) == Err(Errno::EINVAL))
5776                & (get_groups(&mut buf).map(<[Gid]>::len) == Ok(2))
5777                & set_resgid(Some(Gid::ROOT), Some(Gid::ROOT), Some(Gid::ROOT)).is_ok()
5778                & set_resuid(Some(Uid::ROOT), Some(Uid::ROOT), Some(Uid::ROOT)).is_ok()
5779                & (get_resuid()
5780                    == Ok(ResUid {
5781                        real: Uid::ROOT,
5782                        effective: Uid::ROOT,
5783                        saved: Uid::ROOT,
5784                    }))
5785                & (get_resgid()
5786                    == Ok(ResGid {
5787                        real: Gid::ROOT,
5788                        effective: Gid::ROOT,
5789                        saved: Gid::ROOT,
5790                    }));
5791            i32::from(!ok)
5792        });
5793        assert_eq!(code, 0, "séquence privsep (root) échouée");
5794    }
5795
5796    #[test]
5797    fn privsep_drop_is_irreversible() {
5798        if !is_root() {
5799            return; // PRIVILEGE.
5800        }
5801        // `nobody`/`nogroup` (ids numériques standard, pas besoin d'entrée passwd).
5802        const NOBODY_UID: u32 = 65534;
5803        const NOBODY_GID: u32 = 65534;
5804        let code = run_child(|| {
5805            let gid = Gid::from_raw(NOBODY_GID);
5806            let uid = Uid::from_raw(NOBODY_UID);
5807            // Ordre **correct** du privsep : groupes → GID → UID.
5808            let dropped = set_groups(&[]).is_ok()
5809                & set_resgid(Some(gid), Some(gid), Some(gid)).is_ok()
5810                & set_resuid(Some(uid), Some(uid), Some(uid)).is_ok();
5811            // **Le cœur** : saved-set fixé ⇒ regain root impossible, et plus de
5812            // `CAP_SETGID` ⇒ pas de réajout de groupe ; tout figé sur la cible.
5813            let locked = (set_resuid(Some(Uid::ROOT), None, None) == Err(Errno::EPERM))
5814                & (set_resgid(Some(Gid::ROOT), None, None) == Err(Errno::EPERM))
5815                & (set_groups(&[Gid::ROOT]) == Err(Errno::EPERM))
5816                & (get_resuid()
5817                    == Ok(ResUid {
5818                        real: uid,
5819                        effective: uid,
5820                        saved: uid,
5821                    }))
5822                & (get_resgid()
5823                    == Ok(ResGid {
5824                        real: gid,
5825                        effective: gid,
5826                        saved: gid,
5827                    }));
5828            i32::from(!(dropped & locked))
5829        });
5830        assert_eq!(code, 0, "regain de privilège possible ou drop incorrect");
5831    }
5832
5833    #[test]
5834    fn privsep_resuid_none_changes_only_effective() {
5835        if !is_root() {
5836            return; // PRIVILEGE.
5837        }
5838        const NOBODY_UID: u32 = 65534;
5839        let code = run_child(|| {
5840            let uid = Uid::from_raw(NOBODY_UID);
5841            // `None` pour real et saved : seul l'effective change (root le peut).
5842            let ok = set_resuid(None, Some(uid), None).is_ok()
5843                & (get_resuid()
5844                    == Ok(ResUid {
5845                        real: Uid::ROOT,
5846                        effective: uid,
5847                        saved: Uid::ROOT,
5848                    }));
5849            i32::from(!ok)
5850        });
5851        assert_eq!(code, 0, "None ne devrait changer que l'effective");
5852    }
5853
5854    // ── exec / redirection : execve, execveat, dup3, fchdir, chdir ────────
5855    //
5856    // Stratégie de test (cf. CLAUDE.md, rigueur couche 0) :
5857    // - `execve`/`execveat` ne peuvent PAS s'auto-tester dans le process de
5858    //   test : un exec réussi REMPLACE l'image du process et n'en revient
5859    //   jamais. On teste donc :
5860    //   * les chemins d'ERREUR **en process** (ENOENT/EACCES/ENOEXEC) — l'exec
5861    //     échoué retourne, ce qui couvre le corps complet du wrapper ;
5862    //   * un exec RÉUSSI dans un **enfant forké** (`clone3`) qui exécute
5863    //     `/usr/bin/true` (sortie 0) ou ouvre un fd dessus ; le parent vérifie
5864    //     le code de sortie via `waitid`.
5865    // - `dup3`/`fchdir`/`chdir` sont testables directement. Les SUCCÈS de
5866    //   `fchdir`/`chdir` mutent le cwd (état **process-global**) : ils sont donc
5867    //   exécutés dans un enfant forké pour ne pas perturber les tests `fs`
5868    //   concurrents qui résolvent des chemins relatifs depuis le cwd.
5869
5870    use air_sys_types::fd::AsFd;
5871
5872    // Vérification alloc-free du cwd dans les enfants forkés (cf. `fork_run`) :
5873    // `statx` remplit une structure sur la pile (aucun heap), donc utilisable
5874    // post-fork là où `std::fs::read_link` (qui alloue) ne l'est pas.
5875    use crate::fs::statx;
5876    use air_sys_types::fs::{StatxFlags, StatxMask};
5877
5878    // Création de fichier alloc-free dans un enfant forké (cf. `fork_run`) :
5879    // syscalls bruts au lieu de `std::fs::write`/`set_permissions` (qui allouent
5880    // ET dont le fd en écriture, ouvert côté parent, exposerait une course
5881    // `ETXTBSY` via héritage à un `clone3` concurrent).
5882    use crate::fs::{close, fchmodat, openat, write};
5883    use air_sys_types::fs::OpenFlags;
5884
5885    /// Forke via `clone3`, exécute `child` dans l'enfant (qui termine via
5886    /// `child_exit` avec le code retourné), et renvoie le code de sortie observé
5887    /// par le parent via `waitid`. `child` ne doit JAMAIS paniquer — un `panic`
5888    /// dans un enfant forké d'un harnais multi-thread corromprait le protocole
5889    /// de sortie enfant→parent ; il rapporte ses échecs par un code ≠ 0.
5890    ///
5891    /// **La closure `child` DOIT être async-signal-safe : aucune allocation,
5892    /// aucun verrou.** Le harnais de test est multi-threadé (tests `io_uring`
5893    /// concurrents : workers/SQPOLL). Si le `clone3` survient pendant qu'un
5894    /// thread sibling tient le **verrou d'allocateur de la glibc**, le premier
5895    /// `malloc` de l'enfant deadlock en `futex_wait` (l'état du verrou est copié
5896    /// *locked*, et le thread qui le détenait n'existe pas dans l'enfant) → le
5897    /// parent `waitid` à l'infini. La closure ne doit donc faire QUE des
5898    /// syscalls sur des données **pré-construites dans le parent avant le fork**
5899    /// (`execve_prepared`/[`execveat_prepared`] sur des `CStrArray` matérialisés,
5900    /// `chdir`/`fchdir`/`statx`, comparaisons d'`Errno`) : zéro `format!`,
5901    /// `String`, `Vec`, `CString::new`, `std::fs::*`, `File::open` ou `println!`.
5902    /// Tout setup allouant (création/écriture de fichier, ouverture de fd,
5903    /// marshalling argv/envp) se fait côté parent ; le nettoyage aussi.
5904    fn fork_run(child: impl FnOnce() -> i32) -> i32 {
5905        let args = CloneArgs {
5906            flags: CloneFlags::empty(),
5907            exit_signal: Some(Signal::SIGCHLD),
5908            stack: None,
5909            child_tid: None,
5910            parent_tid: None,
5911            tls: None,
5912        };
5913        // SAFETY: fork classique (pas de CLONE_VM ni de namespace) : aucune
5914        // mémoire partagée, aucune capability requise.
5915        match unsafe { clone3(&args) }.expect("clone3 doit réussir") {
5916            CloneResult::Child => child_exit(child()),
5917            CloneResult::Parent { child_pid, .. } => {
5918                let status = waitid(WaitTarget::Pid(child_pid), WaitOptions::EXITED)
5919                    .expect("waitid doit réussir")
5920                    .expect("événement EXITED disponible");
5921                match status.event {
5922                    WaitEvent::Exited { code } => code,
5923                    other => panic!("attendu Exited, obtenu : {other:?}"),
5924                }
5925            }
5926        }
5927    }
5928
5929    /// Invoque `execveat` **sans allocation**, depuis des [`CStrArray`]
5930    /// pré-construits dans le parent. Pendant qu'[`execveat`] (production)
5931    /// alloue ses tableaux argv/envp via [`CStrArray::new`], ce shim de test
5932    /// reproduit son cœur (le seul syscall) en sautant cette construction : il
5933    /// est donc utilisable depuis la closure de [`fork_run`], qui doit rester
5934    /// async-signal-safe. Renvoie toujours l'`Errno` d'échec — un succès remplace
5935    /// l'image du process et ne revient jamais.
5936    fn execveat_prepared(
5937        dirfd: DirFd<'_>,
5938        path: &CStr,
5939        argv: &CStrArray<'_>,
5940        envp: &CStrArray<'_>,
5941        flags: ExecveatFlags,
5942    ) -> Errno {
5943        // SAFETY: même contrat que `execveat` — `path` est un `CStr` valide
5944        // NUL-terminé ; `argv`/`envp` exposent des tableaux de pointeurs C
5945        // valides, NULL-terminés, vivants pour l'appel (matérialisés AVANT le
5946        // fork dans le parent) ; `dirfd` est `AT_FDCWD` ou un fd valide
5947        // emprunté ; `flags` est un masque `AT_*` typé. Le kernel ne fait que
5948        // lire ces données.
5949        let ret = unsafe {
5950            raw_syscall_execveat(
5951                dirfd_to_raw(dirfd),
5952                path.as_ptr() as u64,
5953                argv.as_ptr() as u64,
5954                envp.as_ptr() as u64,
5955                flags.bits(),
5956            )
5957        };
5958        errno_from_negative_syscall_ret(ret)
5959    }
5960
5961    // ── CStrArray (marshalling argv/envp, pur — aussi fuzzé) ──────────────
5962
5963    #[test]
5964    fn cstr_array_empty_contains_only_null() {
5965        let arr = CStrArray::new(&[]);
5966        assert_eq!(arr.len(), 0);
5967        assert!(arr.is_empty());
5968        // SAFETY: `as_ptr` renvoie un tableau valide d'au moins un pointeur
5969        // (le terminateur). On lit le premier élément.
5970        let first = unsafe { *arr.as_ptr() };
5971        assert!(first.is_null(), "tableau vide ⇒ uniquement le NULL final");
5972    }
5973
5974    #[test]
5975    fn cstr_array_preserves_order_and_null_terminates() {
5976        let items: [&CStr; 3] = [c"argv0", c"deux", c"trois"];
5977        let arr = CStrArray::new(&items);
5978        assert_eq!(arr.len(), 3);
5979        assert!(!arr.is_empty());
5980        let base = arr.as_ptr();
5981        for (i, item) in items.iter().enumerate() {
5982            // SAFETY: `i < 3` ⇒ dans les bornes du tableau (3 pointeurs + NULL).
5983            let ptr = unsafe { *base.add(i) };
5984            // SAFETY: `ptr` pointe la i-ème chaîne C, vivante (empruntée à
5985            // `items`), NUL-terminée par construction de `CStr`.
5986            let got = unsafe { CStr::from_ptr(ptr) };
5987            assert_eq!(got, *item, "ordre préservé en position {i}");
5988        }
5989        // Terminateur NULL en position 3.
5990        // SAFETY: position du terminateur, dans les bornes (4 éléments).
5991        let term = unsafe { *base.add(3) };
5992        assert!(term.is_null(), "terminaison NULL présente");
5993    }
5994
5995    #[test]
5996    fn fuzz_api_marshal_exec_args_is_total() {
5997        // Liste vide (le tableau ne contient que le NULL : boucle s'arrête en 0).
5998        fuzz_api::marshal_exec_args(vec![]);
5999        // Morceaux valides + un morceau à NUL embarqué (écarté proprement) +
6000        // un argument vide (valide). Couvre le parcours non vide.
6001        fuzz_api::marshal_exec_args(vec![
6002            b"prog".to_vec(),
6003            b"--flag".to_vec(),
6004            b"avec\0nul".to_vec(),
6005            b"".to_vec(),
6006        ]);
6007    }
6008
6009    // ── execve / execveat ────────────────────────────────────────────────
6010
6011    #[test]
6012    #[cfg_attr(miri, ignore = "syscalls non supportés par Miri")]
6013    fn execve_enoent_on_missing_path() {
6014        // En process : un exec échoué RETOURNE, couvrant tout le corps du
6015        // wrapper (marshalling + syscall + conversion errno).
6016        let err = execve(c"/nonexistent/air-exec-test-xyz", &[c"x"], &[]).unwrap_err();
6017        assert_eq!(err, Errno::ENOENT);
6018    }
6019
6020    #[test]
6021    #[cfg_attr(miri, ignore = "syscalls non supportés par Miri")]
6022    fn execve_eacces_on_non_executable_file() {
6023        // `/proc/self/status` est un fichier régulier sans aucun bit `x`
6024        // (toujours présent sur Linux, jamais ouvert en écriture ⇒ pas de
6025        // course `ETXTBSY`). L'exécution exige au moins un bit `x`, même pour
6026        // root (CAP_DAC_OVERRIDE n'accorde l'exécution que si un bit `x` existe)
6027        // ⇒ EACCES de façon déterministe.
6028        let err = execve(c"/proc/self/status", &[c"x"], &[]).unwrap_err();
6029        assert_eq!(err, Errno::EACCES);
6030    }
6031
6032    #[test]
6033    #[cfg_attr(miri, ignore = "syscalls non supportés par Miri")]
6034    fn execve_enoexec_on_unknown_format() {
6035        // Préparation côté PARENT (allocation autorisée ; cf. la règle
6036        // async-signal-safe de `fork_run`) : on construit le `CString` du chemin
6037        // et les tableaux argv/envp. Le fichier au format invalide (ni ELF, ni
6038        // `#!`) est en revanche créé **dans l'enfant**, via des **syscalls bruts**
6039        // (`openat`/`write`/`fchmodat`/`close`) qui n'allouent pas : le fd en
6040        // écriture reste donc **privé à cet enfant mono-thread** (aucun `clone3`
6041        // concurrent ne peut l'hériter) et il est **fermé avant l'`execve`** ⇒
6042        // pas de course `ETXTBSY`. `std::fs::write`/`set_permissions` sont
6043        // proscrits ici : ils allouent (deadlock allocateur post-fork) et, si on
6044        // les déportait côté parent, leur fd en écriture rouvrirait la course
6045        // `ETXTBSY`. Le format invalide donne ENOEXEC, rapporté par le code de
6046        // sortie.
6047        let path = format!("/tmp/air-enoexec-{}", std::process::id());
6048        let cpath = CString::new(path.clone().into_bytes()).expect("chemin /tmp sans NUL");
6049        let argv: [&CStr; 1] = [c"x"];
6050        let argv_array = CStrArray::new(&argv);
6051        let envp: [&CStr; 0] = [];
6052        let envp_array = CStrArray::new(&envp);
6053
6054        let code = fork_run(|| {
6055            let bytes: &[u8] = b"\xff\xfe pas un binaire";
6056            // Les erreurs de setup sont ignorées (comme l'étaient les
6057            // `std::fs::write`/`set_permissions` de la version précédente) : un
6058            // échec de création laisserait l'`execve` renvoyer ENOENT/EACCES, et
6059            // l'assertion finale tomberait avec un message clair. `fchmodat`
6060            // force 0o755 indépendamment de l'umask (qui pourrait masquer le bit
6061            // `x` du propriétaire et donner EACCES au lieu d'ENOEXEC). Le fd en
6062            // écriture est fermé AVANT l'`execve` ⇒ pas de course `ETXTBSY`.
6063            // `.map` (combinateur de `core`) plutôt qu'un `if let` : la
6064            // discrimination Ok/Err vit dans la bibliothèque (hors couverture de
6065            // ce fichier), comme l'étaient les branches internes du
6066            // `std::fs::write` précédent — pas de branche `process.rs` ajoutée.
6067            let _ = openat(
6068                DirFd::Cwd,
6069                &cpath,
6070                OpenFlags::WRONLY | OpenFlags::CREAT | OpenFlags::TRUNC,
6071                0o755,
6072            )
6073            .map(|fd| {
6074                let _ = write(fd.as_fd(), bytes);
6075                let _ = fchmodat(DirFd::Cwd, &cpath, 0o755, 0);
6076                let _ = close(fd);
6077            });
6078            let err = execve_prepared(&cpath, &argv_array, &envp_array).unwrap_err();
6079            i32::from(err != Errno::ENOEXEC)
6080        });
6081
6082        let _ = std::fs::remove_file(&path); // nettoyage côté PARENT
6083        assert_eq!(code, 0, "execve d'un format inconnu doit donner ENOEXEC");
6084    }
6085
6086    #[test]
6087    #[cfg_attr(miri, ignore = "syscalls non supportés par Miri")]
6088    fn execve_runs_binary_in_forked_child() {
6089        // Tableaux argv/envp matérialisés côté PARENT ; l'enfant ne fait qu'un
6090        // `execve_prepared` (alloc-free, async-signal-safe — cf. `fork_run`).
6091        let argv: [&CStr; 1] = [c"true"];
6092        let argv_array = CStrArray::new(&argv);
6093        let envp: [&CStr; 0] = [];
6094        let envp_array = CStrArray::new(&envp);
6095        let code = fork_run(|| {
6096            // Succès ⇒ `/usr/bin/true` s'exécute et sort 0 ; on n'arrive après
6097            // l'exec QUE s'il a échoué.
6098            let _ = execve_prepared(c"/usr/bin/true", &argv_array, &envp_array);
6099            1
6100        });
6101        assert_eq!(code, 0, "execve(/usr/bin/true) doit aboutir à un exit 0");
6102    }
6103
6104    #[test]
6105    #[cfg_attr(miri, ignore = "syscalls non supportés par Miri")]
6106    fn execveat_enoent_on_missing_path() {
6107        // En process (échec retourné) : couvre le corps d'`execveat` et le bras
6108        // `DirFd::Cwd` de `dirfd_to_raw`.
6109        let err = execveat(
6110            DirFd::Cwd,
6111            c"/nonexistent/air-execveat-xyz",
6112            &[c"x"],
6113            &[],
6114            ExecveatFlags::empty(),
6115        )
6116        .unwrap_err();
6117        assert_eq!(err, Errno::ENOENT);
6118    }
6119
6120    #[test]
6121    #[cfg_attr(miri, ignore = "syscalls non supportés par Miri")]
6122    fn execveat_empty_path_executes_open_fd() {
6123        // EMPTY_PATH : l'exécutable est désigné directement par le fd ouvert.
6124        // Ouverture du fd et tableaux argv/envp matérialisés côté PARENT ;
6125        // l'enfant ne fait qu'un `execveat_prepared` (alloc-free) sur le
6126        // `BorrowedFd` emprunté — cf. la règle async-signal-safe de `fork_run`.
6127        let file = std::fs::File::open("/usr/bin/true").expect("ouverture de /usr/bin/true");
6128        let fd = sfd(&file);
6129        let argv: [&CStr; 1] = [c"true"];
6130        let argv_array = CStrArray::new(&argv);
6131        let envp: [&CStr; 0] = [];
6132        let envp_array = CStrArray::new(&envp);
6133        let code = fork_run(|| {
6134            let _ = execveat_prepared(
6135                DirFd::Fd(fd),
6136                c"",
6137                &argv_array,
6138                &envp_array,
6139                ExecveatFlags::EMPTY_PATH,
6140            );
6141            1 // execveat a échoué
6142        });
6143        assert_eq!(
6144            code, 0,
6145            "execveat(EMPTY_PATH, fd /usr/bin/true) doit sortir 0"
6146        );
6147    }
6148
6149    #[test]
6150    #[cfg_attr(miri, ignore = "syscalls non supportés par Miri")]
6151    fn execveat_relative_path_executes() {
6152        // Résolution relative : dirfd sur /usr/bin, chemin « true ». Couvre le
6153        // bras `DirFd::Fd` de `dirfd_to_raw`. Ouverture du dirfd et tableaux
6154        // argv/envp côté PARENT ; l'enfant ne fait qu'un `execveat_prepared`
6155        // (alloc-free) — cf. la règle async-signal-safe de `fork_run`.
6156        let dir = std::fs::File::open("/usr/bin").expect("ouverture de /usr/bin");
6157        let fd = sfd(&dir);
6158        let argv: [&CStr; 1] = [c"true"];
6159        let argv_array = CStrArray::new(&argv);
6160        let envp: [&CStr; 0] = [];
6161        let envp_array = CStrArray::new(&envp);
6162        let code = fork_run(|| {
6163            let _ = execveat_prepared(
6164                DirFd::Fd(fd),
6165                c"true",
6166                &argv_array,
6167                &envp_array,
6168                ExecveatFlags::empty(),
6169            );
6170            1
6171        });
6172        assert_eq!(code, 0, "execveat(dirfd=/usr/bin, \"true\") doit sortir 0");
6173    }
6174
6175    #[test]
6176    #[cfg_attr(miri, ignore = "syscalls non supportés par Miri")]
6177    fn execveat_with_dir_fd_enoent() {
6178        // En process (échec retourné) : couvre le bras `DirFd::Fd` de
6179        // `dirfd_to_raw`. Chemin relatif inexistant sous un dirfd ouvert ⇒
6180        // ENOENT, sans remplacer l'image du process.
6181        let root = std::fs::File::open("/").expect("ouverture de /");
6182        let err = execveat(
6183            DirFd::Fd(sfd(&root)),
6184            c"air-execveat-no-such-relative",
6185            &[c"x"],
6186            &[],
6187            ExecveatFlags::empty(),
6188        )
6189        .unwrap_err();
6190        assert_eq!(err, Errno::ENOENT);
6191    }
6192
6193    #[test]
6194    #[cfg_attr(miri, ignore = "syscalls non supportés par Miri")]
6195    fn execveat_prepared_returns_errno_in_process() {
6196        // Couvre le shim de test `execveat_prepared` **en process** : dans les
6197        // enfants forkés, un `execveat` réussi remplace l'image AVANT
6198        // `child_exit`, donc la couverture du shim n'y est jamais flushée. Ici,
6199        // un chemin inexistant fait RETOURNER le shim (ENOENT) — exactement le
6200        // même appel (tableaux pré-construits) que côté enfant, sans remplacer
6201        // l'image. Analogue de la couverture d'`execve_prepared` via `execve`.
6202        let argv: [&CStr; 1] = [c"x"];
6203        let argv_array = CStrArray::new(&argv);
6204        let envp: [&CStr; 0] = [];
6205        let envp_array = CStrArray::new(&envp);
6206        let err = execveat_prepared(
6207            DirFd::Cwd,
6208            c"/nonexistent/air-execveat-prepared-xyz",
6209            &argv_array,
6210            &envp_array,
6211            ExecveatFlags::empty(),
6212        );
6213        assert_eq!(err, Errno::ENOENT);
6214    }
6215
6216    // ── dup3 ──────────────────────────────────────────────────────────────
6217
6218    #[test]
6219    #[cfg_attr(miri, ignore = "syscalls non supportés par Miri")]
6220    fn dup3_redirects_onto_target_and_owns_result() {
6221        // Sérialise l'usage d'un fd HAUT non réutilisable (cf. doc du lock).
6222        let _guard = fd_observation_lock();
6223        let target = high_unreusable_fd_target();
6224        let null = std::fs::OpenOptions::new()
6225            .read(true)
6226            .write(true)
6227            .open("/dev/null")
6228            .expect("ouverture de /dev/null");
6229        let dup = dup3(sfd(&null), target, Dup3Flags::CLOEXEC).expect("dup3 doit réussir");
6230        assert_eq!(
6231            dup.as_raw_fd(),
6232            target,
6233            "dup3 installe le nouveau fd au numéro demandé"
6234        );
6235        // `drop(dup)` ferme `target` ; `drop(null)` ferme l'original.
6236    }
6237
6238    #[test]
6239    #[cfg_attr(miri, ignore = "syscalls non supportés par Miri")]
6240    fn dup3_same_old_new_is_einval() {
6241        let null = std::fs::File::open("/dev/null").expect("ouverture de /dev/null");
6242        let raw = sraw(&null);
6243        // `dup3` exige `old != new` (différence avec `dup2`) : EINVAL.
6244        let err = dup3(sfd(&null), raw, Dup3Flags::empty()).unwrap_err();
6245        assert_eq!(err, Errno::EINVAL);
6246    }
6247
6248    // ── fchdir / chdir ────────────────────────────────────────────────────
6249
6250    #[test]
6251    #[cfg_attr(miri, ignore = "syscalls non supportés par Miri")]
6252    fn fchdir_error_on_non_directory() {
6253        // /dev/null n'est pas un répertoire ⇒ ENOTDIR (cwd inchangé : sûr en
6254        // process). Couvre le corps + le bras d'erreur de `fchdir`.
6255        let null = std::fs::File::open("/dev/null").expect("ouverture de /dev/null");
6256        let err = fchdir(sfd(&null)).unwrap_err();
6257        assert_eq!(err, Errno::ENOTDIR);
6258    }
6259
6260    #[test]
6261    #[cfg_attr(miri, ignore = "syscalls non supportés par Miri")]
6262    fn chdir_error_on_missing_path() {
6263        // Chemin inexistant ⇒ ENOENT (cwd inchangé : sûr en process).
6264        let err = chdir(c"/nonexistent/air-chdir-test").unwrap_err();
6265        assert_eq!(err, Errno::ENOENT);
6266    }
6267
6268    #[test]
6269    #[cfg_attr(miri, ignore = "syscalls non supportés par Miri")]
6270    fn chroot_root_noop_or_eperm() {
6271        // `chroot("/")` : sur un runner **privilégié** (CI root), la racine est déjà `/`
6272        // ⇒ **no-op inoffensif** (`Ok`) ; sinon `EPERM`. La ligne est exercée dans les
6273        // deux cas. Chemin **succès** = exception PRIVILEGE (`CAP_SYS_CHROOT`).
6274        let result = chroot(c"/");
6275        assert!(
6276            result.is_ok() || result == Err(Errno::EPERM),
6277            "chroot(/) doit rendre Ok (root) ou EPERM (non privilégié) : {result:?}"
6278        );
6279        // Chemin **inexistant** : `ENOENT` (root) ou `EPERM` (non-root) ⇒ toujours `Err`.
6280        assert!(chroot(c"/air/no/such/root-xyz").is_err());
6281    }
6282
6283    #[test]
6284    #[cfg_attr(miri, ignore = "syscalls non supportés par Miri")]
6285    fn chdir_changes_cwd_in_forked_child() {
6286        // Mutation du cwd (process-global) ⇒ enfant forké. Vérifie le succès ET
6287        // l'effet réel **sans allocation** (async-signal-safe — cf. `fork_run`) :
6288        // après `chdir("/")`, le cwd (".") doit désigner la même inode (ino +
6289        // dev) que "/". `statx` remplit une structure sur la pile (zéro heap),
6290        // contrairement à `std::fs::read_link` qui alloue un `PathBuf`.
6291        // Combinateurs de `core` (`.ok()`/`.zip()`/`.is_some_and()`) plutôt
6292        // qu'un `match` : la discrimination Ok/Err des deux `statx` et le `&&` de
6293        // la comparaison vivent dans la bibliothèque (hors couverture de ce
6294        // fichier). Seul subsiste le `&&` du `chdir` (sa branche d'échec, comme
6295        // dans la version `read_link` précédente).
6296        let code = fork_run(|| {
6297            let same = chdir(c"/").is_ok()
6298                && statx(DirFd::Cwd, c".", StatxFlags::empty(), StatxMask::INO)
6299                    .ok()
6300                    .zip(statx(DirFd::Cwd, c"/", StatxFlags::empty(), StatxMask::INO).ok())
6301                    .is_some_and(|(cwd, root)| {
6302                        (cwd.ino, cwd.dev_major, cwd.dev_minor)
6303                            == (root.ino, root.dev_major, root.dev_minor)
6304                    });
6305            i32::from(!same)
6306        });
6307        assert_eq!(code, 0, "chdir(/) doit positionner le cwd à /");
6308    }
6309
6310    #[test]
6311    #[cfg_attr(miri, ignore = "syscalls non supportés par Miri")]
6312    fn fchdir_changes_cwd_in_forked_child() {
6313        // Ouverture du fd sur "/" côté PARENT ; l'enfant ne fait que `fchdir`
6314        // puis vérifie l'effet via `statx` (alloc-free), sans `read_link` qui
6315        // allouerait — cf. la règle async-signal-safe de `fork_run`.
6316        let root = std::fs::File::open("/").expect("ouverture de /");
6317        let fd = sfd(&root);
6318        // Combinateurs de `core` (cf. `chdir_changes_cwd_in_forked_child`) :
6319        // pas de branche `process.rs` au-delà de l'échec de `fchdir`.
6320        let code = fork_run(|| {
6321            let same = fchdir(fd).is_ok()
6322                && statx(DirFd::Cwd, c".", StatxFlags::empty(), StatxMask::INO)
6323                    .ok()
6324                    .zip(statx(DirFd::Cwd, c"/", StatxFlags::empty(), StatxMask::INO).ok())
6325                    .is_some_and(|(cwd, target)| {
6326                        (cwd.ino, cwd.dev_major, cwd.dev_minor)
6327                            == (target.ino, target.dev_major, target.dev_minor)
6328                    });
6329            i32::from(!same)
6330        });
6331        assert_eq!(code, 0, "fchdir(fd /) doit positionner le cwd à /");
6332    }
6333}
6334
6335#[cfg(test)]
6336mod v1_7_runtime_libc_tests {
6337    use super::*;
6338
6339    #[test]
6340    fn getppid_returns_some_positive() {
6341        // Le parent du runner de test est toujours visible dans son namespace.
6342        let ppid = getppid().expect("parent visible dans le PID namespace");
6343        assert!(ppid.as_raw() > 0);
6344    }
6345
6346    #[test]
6347    fn sched_yield_is_callable() {
6348        // Fonction totale : ne panique pas, ne retourne rien.
6349        sched_yield();
6350    }
6351
6352    #[test]
6353    fn umask_roundtrip_restores_previous() {
6354        // umask rend l'ANCIEN masque. On pose 0o022, on lit (et restaure) :
6355        let previous = umask(0o022);
6356        let seen = umask(previous); // rend 0o022 (ce qu'on venait de poser) + restaure
6357        assert_eq!(seen, 0o022);
6358        // Filet : on remet bien l'état initial.
6359        let _ = umask(previous);
6360    }
6361
6362    #[test]
6363    fn getcwd_matches_std_current_dir() {
6364        let mut buf = [0_u8; 4096];
6365        let cwd = getcwd(&mut buf).expect("getcwd dans un buffer large");
6366        let expected = std::env::current_dir().expect("cwd std");
6367        assert_eq!(cwd, expected.as_os_str().as_encoded_bytes());
6368    }
6369
6370    #[test]
6371    fn getcwd_buffer_too_small_is_erange() {
6372        let mut tiny = [0_u8; 1];
6373        // ERANGE == 34 (Linux) ; l'enum Errno partiel ne l'expose pas forcément
6374        // en constante nommée → on le construit explicitement.
6375        let erange = Errno::from_nonzero(NonZeroI32::new(34).expect("34 != 0"));
6376        assert_eq!(getcwd(&mut tiny), Err(erange));
6377    }
6378
6379    #[test]
6380    fn getrusage_self_succeeds_and_fills() {
6381        let usage = getrusage(RusageWho::SelfProcess).expect("getrusage(SELF)");
6382        // Un processus en cours a forcément déclenché des fautes de page mineures.
6383        assert!(usage.ru_minflt >= 0);
6384    }
6385
6386    #[test]
6387    fn getrusage_children_and_thread_variants() {
6388        // Couvre les deux autres bras de l'enum `who`.
6389        let _ = getrusage(RusageWho::Children).expect("getrusage(CHILDREN)");
6390        let _ = getrusage(RusageWho::Thread).expect("getrusage(THREAD)");
6391    }
6392
6393    /// Emplacements partagés `'static` (VM commune via `CLONE_VM`) que l'enfant
6394    /// du test `None` de [`set_tid_address`] publie : le TID rendu par le syscall
6395    /// et un drapeau de fin. `None` ne fournit **aucun** mot kernel de fin de
6396    /// thread (pas de `clear_child_tid` ré-mis à 0) — l'enfant signale donc
6397    /// lui-même sa progression au parent via ces atomiques.
6398    struct SetTidNoneSlots {
6399        /// TID rendu par `set_tid_address(None)` (l'appelant = le thread enfant).
6400        tid: core::sync::atomic::AtomicI32,
6401        /// `0` tant que l'enfant n'a pas publié `tid` ; `1` une fois écrit.
6402        done: core::sync::atomic::AtomicU32,
6403    }
6404
6405    /// Point d'entrée du thread de test pour le chemin `None` de
6406    /// [`set_tid_address`] : interprète `arg` comme l'adresse d'un
6407    /// [`SetTidNoneSlots`] `'static` partagé, appelle `set_tid_address(None)`
6408    /// (qui **désenregistre** `clear_child_tid` et **rend** le TID appelant),
6409    /// publie ce TID, lève le drapeau `done`, réveille le parent, puis
6410    /// **retourne** — le trampoline [`clone_thread`] termine alors le thread
6411    /// (`SYS_exit`).
6412    ///
6413    /// **Discipline côté enfant** : async-signal-safe, **zéro glibc** — deux
6414    /// syscalls seulement (`set_tid_address`, `futex_wake`), aucun accès TLS,
6415    /// aucune allocation.
6416    extern "C" fn set_tid_address_none_entry(arg: usize) {
6417        use air_sys_types::{FutexScope, FutexWakeCount};
6418        use core::sync::atomic::Ordering;
6419        // SAFETY (test) : `arg` est l'adresse d'un `SetTidNoneSlots` `'static`
6420        // (`Box::leak`, jamais libéré) fourni par le parent et partagé via la VM
6421        // commune. Alignement et validité garantis par le site d'appel.
6422        let slots = unsafe { &*(arg as *const SetTidNoneSlots) };
6423        // SAFETY: thread possédé par Air (jamais réapé par glibc, contrairement à
6424        // un `std::thread` détaché) ; `set_tid_address(None)` désenregistre
6425        // `clear_child_tid` — sans effet ici car aucun `CHILD_CLEARTID` n'a été
6426        // enregistré pour ce thread (pas de mot de join kernel concurrent à
6427        // écraser). Le syscall est total : il rend le TID de l'appelant.
6428        let tid = unsafe { set_tid_address(None) };
6429        slots.tid.store(tid.as_raw(), Ordering::SeqCst);
6430        // `Release` : apparie le `Acquire` du parent — `tid` est visible avant
6431        // que `done` ne passe à 1.
6432        slots.done.store(1, Ordering::Release);
6433        // Réveille le parent éventuellement endormi dans `futex_wait` sur `done`
6434        // (le timeout du parent le ferait converger même sans ce réveil).
6435        let _ = crate::futex::futex_wake(&slots.done, FutexWakeCount::ALL, FutexScope::Shared);
6436        // Retour → trampoline `clone_thread` → `SYS_exit`.
6437    }
6438
6439    #[test]
6440    #[cfg_attr(miri, ignore = "clone3/futex non supportés par Miri")]
6441    fn set_tid_address_none_returns_caller_tid() {
6442        use crate::futex::futex_wait;
6443        use crate::mem::mmap_anonymous;
6444        use air_sys_types::FutexScope;
6445        use air_sys_types::mem::{MapFlags, ProtectionFlags};
6446        use core::sync::atomic::{AtomicI32, AtomicU32, Ordering};
6447        use core::time::Duration;
6448
6449        // Test SOUND du chemin `None` : on prouve que `set_tid_address(None)`
6450        // rend le TID de l'appelant **sur un thread possédé par Air**
6451        // (`clone_thread`, couche 0), **sans pthread glibc**. L'ancienne version
6452        // spawnait un `std::thread` détaché et y appelait `set_tid_address(None)`,
6453        // ce qui mettait à NULL le `clear_child_tid` que glibc utilise pour réaper
6454        // ses threads détachés — détournant le teardown glibc et pendant par
6455        // intermittence sous charge ARM (cohabitation Air/glibc unsound, même
6456        // classe de bug que le frère `_some`). Ici aucun `CHILD_CLEARTID` n'est
6457        // enregistré (pas de conflit), et l'attente parent est **bornée** par une
6458        // deadline (échec rapide et diagnostique, jamais un hang de 30 min).
6459
6460        // `None` ne fournit AUCUN mot kernel de fin de thread : l'enfant publie
6461        // lui-même `tid` + `done` dans des emplacements `'static` partagés
6462        // (`Box::leak`, jamais libérés ⇒ le kernel/enfant peut y écrire sans
6463        // risque d'UAF), et le parent attend `done` via un `futex_wait` borné.
6464        let slots: &'static SetTidNoneSlots = Box::leak(Box::new(SetTidNoneSlots {
6465            tid: AtomicI32::new(0),
6466            done: AtomicU32::new(0),
6467        }));
6468        let slots_ptr = core::ptr::from_ref(slots) as usize;
6469
6470        // Pile dédiée du thread : 64 KiB, page-alignée (mmap) ⇒ sommet aligné 16.
6471        const STACK_SIZE: usize = 64 * 1024;
6472        let mut stack = mmap_anonymous(
6473            STACK_SIZE,
6474            ProtectionFlags::READ | ProtectionFlags::WRITE,
6475            MapFlags::PRIVATE | MapFlags::STACK,
6476        )
6477        .expect("mmap pile thread");
6478        let stack_ptr = stack.as_mut_ptr();
6479
6480        // PAS de `CHILD_CLEARTID`/`PARENT_SETTID` : on n'enregistre aucun mot de
6481        // join kernel (il entrerait en conflit avec le `set_tid_address(None)` du
6482        // child).
6483        let flags = CloneFlags::VM
6484            | CloneFlags::FS
6485            | CloneFlags::FILES
6486            | CloneFlags::SIGHAND
6487            | CloneFlags::THREAD
6488            | CloneFlags::SYSVSEM;
6489
6490        let args = CloneArgs {
6491            flags,
6492            // CLONE_THREAD interdit tout signal de sortie.
6493            exit_signal: None,
6494            stack: Some(StackSpecification {
6495                addr: stack_ptr,
6496                size: STACK_SIZE,
6497            }),
6498            child_tid: None,
6499            parent_tid: None,
6500            tls: None,
6501        };
6502
6503        // SAFETY: pile dédiée valide et **jamais libérée** (voir le `forget`
6504        // ci-dessous) → vivante au-delà de la durée de vie du thread ;
6505        // `set_tid_address_none_entry` n'utilise ni TLS ni alloc et n'opère que
6506        // sur `slots` (`'static`, partagé via la VM commune) ; aucun mot de join
6507        // kernel n'est enregistré.
6508        let child_tid = unsafe { clone_thread(&args, set_tid_address_none_entry, slots_ptr) }
6509            .expect("clone_thread doit réussir");
6510        assert!(child_tid.as_raw() > 0, "TID enfant strictement positif");
6511
6512        // Fuite **délibérée** de la pile : `set_tid_address(None)` ne laisse aucun
6513        // mot kernel pour savoir quand le thread a fini d'utiliser sa pile (le
6514        // trampoline s'exécute encore dessus juste avant `SYS_exit`) → la libérer
6515        // serait un UAF. `forget` neutralise le `Drop` (munmap) de `Mapping` :
6516        // la région reste mappée à vie. Le leak de pile (test-only) est la
6517        // discipline déjà retenue pour ce motif (cf. frère `_some`).
6518        core::mem::forget(stack);
6519
6520        // Attente **bornée** de la publication : `futex_wait` borné à 100 ms ; la
6521        // deadline absolue (50 réveils ⇒ ≤ 5 s) garantit un échec **rapide**
6522        // plutôt qu'un hang. Le child fait `store(done, 1)` + `futex_wake` ; un
6523        // réveil manqué est rattrapé par le re-poll après timeout — la boucle
6524        // converge dans tous les cas.
6525        const MAX_WAITS: u32 = 50; // 50 × 100 ms = 5 s de borne.
6526        let mut waits = 0_u32;
6527        while slots.done.load(Ordering::Acquire) == 0 {
6528            let _ = futex_wait(
6529                &slots.done,
6530                0,
6531                Some(Duration::from_millis(100)),
6532                FutexScope::Shared,
6533            );
6534            waits = waits.saturating_add(1);
6535            assert!(
6536                waits < MAX_WAITS,
6537                "l'enfant doit publier son TID via set_tid_address(None) \
6538                 (deadline {MAX_WAITS} × 100 ms expirée — publication non observée)"
6539            );
6540        }
6541
6542        // `set_tid_address(None)` rend le TID de l'appelant (le thread enfant) :
6543        // c'est exactement le TID rendu par `clone_thread` au parent.
6544        let observed = slots.tid.load(Ordering::Acquire);
6545        assert!(
6546            observed > 0,
6547            "set_tid_address(None) rend le TID appelant (> 0)"
6548        );
6549        assert_eq!(
6550            observed,
6551            child_tid.as_raw(),
6552            "le TID rendu par set_tid_address(None) doit être celui du thread enfant"
6553        );
6554    }
6555
6556    /// Point d'entrée du thread de test pour le chemin `Some` de
6557    /// [`set_tid_address`] : interprète `arg` comme l'adresse d'un `AtomicU32`
6558    /// `'static` partagé (VM commune via `CLONE_VM`), y **enregistre**
6559    /// `clear_child_tid`, puis **retourne** — le trampoline [`clone_thread`]
6560    /// termine alors le thread (`SYS_exit`) et le kernel remet le mot à `0` +
6561    /// `FUTEX_WAKE`.
6562    ///
6563    /// **Discipline côté enfant** : async-signal-safe, **zéro glibc** — un seul
6564    /// syscall (`set_tid_address`), aucun accès TLS, aucune allocation.
6565    extern "C" fn set_tid_address_some_entry(arg: usize) {
6566        // SAFETY (test) : `arg` est l'adresse d'un `AtomicU32` `'static`
6567        // (`Box::leak`, jamais libéré) fourni par le parent et partagé via la VM
6568        // commune. Alignement et validité garantis par le site d'appel.
6569        let slot = unsafe { &*(arg as *const core::sync::atomic::AtomicU32) };
6570        // SAFETY: `slot` est `'static` : le kernel pourra y écrire `0` à la mort
6571        // du thread sans risque d'usage-après-libération (contrat de
6572        // `set_tid_address`). Aucun mot de join kernel (`CHILD_CLEARTID`) n'a été
6573        // enregistré pour ce thread → pas de conflit avec ce `clear_child_tid`.
6574        let _ = unsafe { set_tid_address(Some(slot)) };
6575        // Retour → trampoline `clone_thread` → `SYS_exit` → clear + FUTEX_WAKE.
6576    }
6577
6578    #[test]
6579    #[cfg_attr(miri, ignore = "clone3/futex non supportés par Miri")]
6580    fn set_tid_address_some_is_cleared_by_kernel_on_thread_exit() {
6581        use crate::futex::futex_wait;
6582        use crate::mem::{mmap_anonymous, munmap};
6583        use air_sys_types::FutexScope;
6584        use air_sys_types::mem::{MapFlags, ProtectionFlags};
6585        use core::sync::atomic::{AtomicU32, Ordering};
6586        use core::time::Duration;
6587
6588        // Test SOUND du chemin `Some` : on prouve la sémantique kernel
6589        // (`set_tid_address(Some(ptr))` ⇒ le kernel remet `ptr` à 0 +
6590        // `FUTEX_WAKE` à la sortie du thread) **sans pthread glibc**. L'ancienne
6591        // version spawnait un `std::thread` détaché et écrasait son
6592        // `clear_child_tid` — celui que glibc utilise pour réaper ses threads
6593        // détachés —, ce qui détournait le teardown glibc et pendait par
6594        // intermittence sous charge ARM (cohabitation Air/glibc unsound). Ici le
6595        // thread est créé par `clone_thread` (couche 0), **sans** `CHILD_CLEARTID`
6596        // (aucun mot de join kernel concurrent), et l'attente parent est **bornée**
6597        // par une deadline (échec rapide et diagnostique, jamais un hang).
6598
6599        // Pile dédiée du thread : 64 KiB, page-alignée (mmap) ⇒ sommet aligné 16.
6600        const STACK_SIZE: usize = 64 * 1024;
6601        let mut stack = mmap_anonymous(
6602            STACK_SIZE,
6603            ProtectionFlags::READ | ProtectionFlags::WRITE,
6604            MapFlags::PRIVATE | MapFlags::STACK,
6605        )
6606        .expect("mmap pile thread");
6607
6608        // `slot` à adresse **stable** et durée de vie `'static` (`Box::leak`) :
6609        // l'enfant y enregistre `clear_child_tid` et le kernel y écrit `0` à la
6610        // mort du thread. Le leak (test-only) garantit qu'aucun chemin — y compris
6611        // une deadline expirée qui dépile par `panic!` — ne libère la mémoire
6612        // pendant que le kernel peut encore y écrire (pas d'UAF).
6613        let slot: &'static AtomicU32 = Box::leak(Box::new(AtomicU32::new(0xFFFF_FFFF)));
6614        let slot_ptr = core::ptr::from_ref(slot) as usize;
6615
6616        // PAS de `CHILD_CLEARTID`/`PARENT_SETTID` : on n'enregistre aucun mot de
6617        // join kernel (il entrerait en conflit avec le `set_tid_address` du child).
6618        let flags = CloneFlags::VM
6619            | CloneFlags::FS
6620            | CloneFlags::FILES
6621            | CloneFlags::SIGHAND
6622            | CloneFlags::THREAD
6623            | CloneFlags::SYSVSEM;
6624
6625        let args = CloneArgs {
6626            flags,
6627            // CLONE_THREAD interdit tout signal de sortie.
6628            exit_signal: None,
6629            stack: Some(StackSpecification {
6630                addr: stack.as_mut_ptr(),
6631                size: STACK_SIZE,
6632            }),
6633            child_tid: None,
6634            parent_tid: None,
6635            tls: None,
6636        };
6637
6638        // SAFETY: pile dédiée valide (libérée seulement après la mort du thread) ;
6639        // `set_tid_address_some_entry` n'utilise ni TLS ni alloc et n'opère que sur
6640        // `slot` (`'static`, partagé via la VM commune) ; aucun mot de join kernel
6641        // n'est enregistré.
6642        let tid = unsafe { clone_thread(&args, set_tid_address_some_entry, slot_ptr) }
6643            .expect("clone_thread doit réussir");
6644        assert!(tid.as_raw() > 0, "TID enfant strictement positif");
6645
6646        // Attente **bornée** : le kernel remet `slot` à 0 (+ `FUTEX_WAKE`) à la
6647        // mort du thread. `futex_wait` est borné par 100 ms ; la deadline absolue
6648        // (50 réveils ⇒ ≤ 5 s) garantit un échec **rapide** plutôt qu'un hang de
6649        // 30 min. Si la portée du futex différait, le timeout fait re-poller le
6650        // mot — le kernel écrit toujours 0, donc la boucle converge.
6651        const MAX_WAITS: u32 = 50; // 50 × 100 ms = 5 s de borne.
6652        let mut waits = 0_u32;
6653        loop {
6654            let v = slot.load(Ordering::Acquire);
6655            if v == 0 {
6656                break;
6657            }
6658            let _ = futex_wait(
6659                slot,
6660                v,
6661                Some(Duration::from_millis(100)),
6662                FutexScope::Shared,
6663            );
6664            waits = waits.saturating_add(1);
6665            assert!(
6666                waits < MAX_WAITS,
6667                "clear_child_tid doit être remis à 0 à la sortie du thread \
6668                 (deadline {MAX_WAITS} × 100 ms expirée — sémantique kernel \
6669                 `set_tid_address(Some)` non observée)"
6670            );
6671        }
6672
6673        // Le mot a été remis à 0 ⇒ le thread est mort : la pile n'est plus
6674        // utilisée, on peut la libérer sans UAF.
6675        munmap(stack).expect("munmap pile thread");
6676    }
6677}