Skip to main content

air_sys_syscall/io_uring/
sandbox.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//! **Confinement d'un ring** (Temps 3f) — sous-module
6//! `air-sys-syscall::io_uring::sandbox`. Matérialise la décision de soundness
7//! **S3** : un ring qui ne peut émettre que les opérations explicitement mises
8//! en liste blanche, **imposé par le kernel**.
9//!
10//! Référence normative : `docs/specs/layer-0/io-uring-3f-sandbox.md`.
11//!
12//! ## Pourquoi
13//!
14//! io_uring exécute des opérations (`read`, `openat2`, `connect`…) qui **ne
15//! passent pas par l'interface syscall classique** : un filtre **seccomp** qui
16//! bloque le syscall `openat2` n'empêche **pas** un `IORING_OP_OPENAT2` soumis
17//! via le ring — voie de contournement historique des bacs à sable. La réponse :
18//! **restreindre le ring lui-même**. Les restrictions io_uring **complètent**
19//! seccomp/Landlock (`family-security`), en défense en profondeur ; elles ne les
20//! remplacent pas. C'est la brique io_uring du modèle de capabilities d'Air
21//! (ADR-001 AirCom, ADR-010 entitlements signés) — la couche 0 fournit le
22//! **mécanisme**, la **politique** vit en couche 5.
23//!
24//! ## Le flux en trois temps (imposé kernel, reflété par l'API)
25//!
26//! 1. **Créer désactivé** : [`IoUringBuilder::restrict`](super::IoUringBuilder::restrict)
27//!    crée le ring `IORING_SETUP_R_DISABLED` et applique la liste blanche
28//!    (`REGISTER_RESTRICTIONS`) — possible **uniquement** tant qu'il est désactivé.
29//! 2. **Activer** : [`IoUring::enable`](super::IoUring::enable) (`REGISTER_ENABLE_RINGS`).
30//! 3. **Immuable ensuite** : après `enable`, les restrictions ne peuvent plus être
31//!    assouplies (imposé kernel) ; toute soumission **avant** `enable` est refusée.
32//!
33//! **Default-deny** : dès qu'un opcode est mis en liste blanche, le kernel refuse
34//! **tout le reste** (`-EACCES`).
35//!
36//! ```
37//! use air_sys_syscall::io_uring::{IoUringOpcode, RegisterOp, RestrictionSet, SqeFlagSet};
38//!
39//! // Un service AirCom « réseau seul » : le ring ne pourra jamais émettre
40//! // openat2, unlinkat, etc. — garanti kernel.
41//! let policy = RestrictionSet::new()
42//!     .allow_op(IoUringOpcode::Socket)
43//!     .allow_op(IoUringOpcode::Connect)
44//!     .allow_op(IoUringOpcode::Send)
45//!     .allow_op(IoUringOpcode::Recv)
46//!     .allow_op(IoUringOpcode::Close)
47//!     .allow_register(RegisterOp::ProvidedBuffers)
48//!     .require_sqe_flags(SqeFlagSet::new().fixed_file());
49//! assert_eq!(policy.as_slice().len(), 7);
50//! // let mut ring = IoUringBuilder::new(entries).restrict(policy.as_slice()).build()?;
51//! // ring.enable()?;  // à partir d'ici, tout opcode hors liste échoue.
52//! ```
53
54use super::{IoUringOpcode, Restriction, SubmitOptions};
55use alloc::vec::Vec;
56
57// ───────────────────────────────────────────────────────────────────────────
58// RestrictionSet — liste blanche default-deny (§3.1)
59// ───────────────────────────────────────────────────────────────────────────
60
61/// Liste blanche de ce qu'un ring confiné pourra faire (décision **S3**).
62/// **Default-deny** : ce qui n'est pas explicitement autorisé est refusé par le
63/// kernel. Construite par combinateurs, puis passée à
64/// [`IoUringBuilder::restrict`](super::IoUringBuilder::restrict) via
65/// [`as_slice`](Self::as_slice).
66///
67/// `Default` = ensemble **vide** : aucune restriction (le ring n'est **pas**
68/// confiné). Le confinement commence dès la **première** entrée (`allow_*` /
69/// `require_*`).
70#[derive(Debug, Clone, Default)]
71pub struct RestrictionSet {
72    restrictions: Vec<Restriction>,
73}
74
75impl RestrictionSet {
76    /// Ensemble vide (équivaut à [`Default`]).
77    #[must_use]
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Autorise un **opcode de soumission** (`RESTRICTION_SQE_OP`). Dès le premier
83    /// `allow_op`, **seuls** les opcodes listés passent (default-deny).
84    #[must_use]
85    pub fn allow_op(mut self, op: IoUringOpcode) -> Self {
86        self.restrictions.push(Restriction::AllowOp(op));
87        self
88    }
89
90    /// Autorise un **register opcode** (`RESTRICTION_REGISTER_OP`).
91    #[must_use]
92    pub fn allow_register(mut self, op: RegisterOp) -> Self {
93        self.restrictions
94            .push(Restriction::AllowRegister(op.number()));
95        self
96    }
97
98    /// Déclare les **drapeaux SQE autorisés** (`RESTRICTION_SQE_FLAGS_ALLOWED`) :
99    /// un SQE ne peut porter que des drapeaux de cet ensemble.
100    #[must_use]
101    pub fn allow_sqe_flags(mut self, flags: SqeFlagSet) -> Self {
102        self.restrictions
103            .push(Restriction::SqeFlagsAllowed(flags.into_options()));
104        self
105    }
106
107    /// Déclare les **drapeaux SQE requis** sur **chaque** soumission
108    /// (`RESTRICTION_SQE_FLAGS_REQUIRED`) : un SQE sans ces drapeaux est refusé.
109    /// Combiné à [`SqeFlagSet::fixed_file`], impose l'usage de FD **enregistrés**
110    /// (interdit les FD bruts — moindre privilège).
111    #[must_use]
112    pub fn require_sqe_flags(mut self, flags: SqeFlagSet) -> Self {
113        self.restrictions
114            .push(Restriction::SqeFlagsRequired(flags.into_options()));
115        self
116    }
117
118    /// **Point d'intégration couche 5** (ADR-010). Un composant de confiance
119    /// (`air-launchd`/`air-trust`) traduit les **entitlements signés** du
120    /// manifeste d'un `.airservice`/`.airapp` en l'ensemble d'opcodes que le ring
121    /// pourra émettre, puis appelle ceci. La **politique** (quel entitlement ⇒
122    /// quels opcodes) vit en couche 5 ; la couche 0 ne fournit que le mécanisme
123    /// **default-deny**. Équivaut à un `allow_op` répété sur `allowed_ops`.
124    #[must_use]
125    pub fn from_entitlements<I>(allowed_ops: I) -> Self
126    where
127        I: IntoIterator<Item = IoUringOpcode>,
128    {
129        allowed_ops.into_iter().fold(Self::new(), Self::allow_op)
130    }
131
132    /// Vue des restrictions, à passer à
133    /// [`IoUringBuilder::restrict`](super::IoUringBuilder::restrict).
134    #[must_use]
135    pub fn as_slice(&self) -> &[Restriction] {
136        &self.restrictions
137    }
138
139    /// Nombre de restrictions accumulées.
140    #[must_use]
141    pub fn len(&self) -> usize {
142        self.restrictions.len()
143    }
144
145    /// `true` si aucune restriction (ring **non** confiné).
146    #[must_use]
147    pub fn is_empty(&self) -> bool {
148        self.restrictions.is_empty()
149    }
150}
151
152// ───────────────────────────────────────────────────────────────────────────
153// RegisterOp — register-ops autorisables (§4, RESTRICTION_REGISTER_OP)
154// ───────────────────────────────────────────────────────────────────────────
155
156/// Register opcode qu'un ring confiné peut être autorisé à effectuer
157/// (`RESTRICTION_REGISTER_OP`). **Newtype typé** plutôt qu'un `u8` magique
158/// (ADR-021 conv. 3 : chaque opération est une variante nommée). `#[non_exhaustive]`
159/// : de futurs kernels peuvent en ajouter.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161#[non_exhaustive]
162pub enum RegisterOp {
163    /// `IORING_REGISTER_BUFFERS2` (15) — buffers enregistrés (Temps 3a).
164    Buffers,
165    /// `IORING_REGISTER_FILES2` (13) — table de FD fixes (Temps 3a).
166    Files,
167    /// `IORING_REGISTER_EVENTFD` (4) — eventfd de complétion (Temps 3a).
168    Eventfd,
169    /// `IORING_REGISTER_PERSONALITY` (9) — identité enregistrée (Temps 3a).
170    Personality,
171    /// `IORING_REGISTER_PBUF_RING` (22) — buffers fournis ring-mapped (Temps 3b).
172    ProvidedBuffers,
173    /// `IORING_REGISTER_RING_FDS` (20) — enregistrement du ring fd (Temps 3a/3e).
174    RingFd,
175    /// `IORING_REGISTER_SYNC_CANCEL` (24) — annulation synchrone (Temps 2c/3d).
176    SyncCancel,
177    /// `IORING_REGISTER_PROBE` (8) — introspection des opcodes (Temps 1).
178    Probe,
179}
180
181impl RegisterOp {
182    /// Numéro kernel `IORING_REGISTER_*` (placé dans le champ `register_op` de
183    /// `io_uring_restriction`). Style aligné sur `opcode_number` : littéraux `u8`
184    /// (tous < 256), nom kernel en doc de variante.
185    fn number(self) -> u8 {
186        match self {
187            RegisterOp::Buffers => 15,
188            RegisterOp::Files => 13,
189            RegisterOp::Eventfd => 4,
190            RegisterOp::Personality => 9,
191            RegisterOp::ProvidedBuffers => 22,
192            RegisterOp::RingFd => 20,
193            RegisterOp::SyncCancel => 24,
194            RegisterOp::Probe => 8,
195        }
196    }
197}
198
199// ───────────────────────────────────────────────────────────────────────────
200// SqeFlagSet — drapeaux IOSQE_* pour les restrictions de drapeaux (§4)
201// ───────────────────────────────────────────────────────────────────────────
202
203/// Ensemble de drapeaux `IOSQE_*` pour les restrictions de drapeaux SQE
204/// ([`RestrictionSet::allow_sqe_flags`] / [`RestrictionSet::require_sqe_flags`]).
205/// `Default` = aucun drapeau. Builder chaînable. Partage la représentation des
206/// drapeaux avec [`SubmitOptions`](super::SubmitOptions).
207#[derive(Debug, Clone, Copy, Default)]
208pub struct SqeFlagSet {
209    options: SubmitOptions,
210}
211
212impl SqeFlagSet {
213    /// Ensemble vide (équivaut à [`Default`]).
214    #[must_use]
215    pub fn new() -> Self {
216        Self::default()
217    }
218
219    /// `IOSQE_FIXED_FILE` : le `fd` du SQE est un **index de slot fixe**. En
220    /// `require_sqe_flags`, **interdit les FD bruts** (force les FD enregistrés).
221    #[must_use]
222    pub fn fixed_file(mut self) -> Self {
223        self.options = self.options.fixed_file();
224        self
225    }
226
227    /// `IOSQE_IO_DRAIN` : draine les opérations précédentes avant celle-ci.
228    #[must_use]
229    pub fn drain(mut self) -> Self {
230        self.options = self.options.drain();
231        self
232    }
233
234    /// `IOSQE_ASYNC` : force l'exécution sur le pool io-wq.
235    #[must_use]
236    pub fn force_async(mut self) -> Self {
237        self.options = self.options.force_async();
238        self
239    }
240
241    /// `IOSQE_BUFFER_SELECT` : sélection automatique d'un buffer fourni (Temps 3b).
242    #[must_use]
243    pub fn buffer_select(mut self) -> Self {
244        self.options = self.options.buffer_select();
245        self
246    }
247
248    /// `IOSQE_CQE_SKIP_SUCCESS` : pas de CQE en cas de succès.
249    #[must_use]
250    pub fn skip_cqe_on_success(mut self) -> Self {
251        self.options = self.options.skip_cqe_on_success();
252        self
253    }
254
255    /// Conversion interne vers la représentation [`SubmitOptions`] portée par
256    /// [`Restriction`].
257    fn into_options(self) -> SubmitOptions {
258        self.options
259    }
260}
261
262// ───────────────────────────────────────────────────────────────────────────
263// Tests
264// ───────────────────────────────────────────────────────────────────────────
265//
266// Intégration kernel (io_uring non modélisé par Miri) → `#[cfg_attr(miri, ignore)]`.
267// La preuve de sécurité (§8) est `openat2_refused_on_network_only_ring`.
268
269#[cfg(test)]
270mod tests {
271    use super::{RegisterOp, RestrictionSet, SqeFlagSet};
272    use crate::io_uring::{
273        IoUringBuilder, IoUringOpcode, Restriction, SubmitOptions, encode_restriction,
274        opcode_number, raw, syscall,
275    };
276    use air_sys_types::Errno;
277    use air_sys_types::fs::{DirFd, OpenHow};
278    use air_sys_types::net::{SocketDomain, SocketType};
279    use core::num::NonZeroU32;
280    use std::ffi::CString;
281
282    fn nz(n: u32) -> NonZeroU32 {
283        NonZeroU32::new(n).expect("n ≠ 0")
284    }
285
286    /// Liste candidate pour la propriété.
287    const CANDIDATE_OPS: &[IoUringOpcode] = &[
288        IoUringOpcode::Nop,
289        IoUringOpcode::Socket,
290        IoUringOpcode::Connect,
291        IoUringOpcode::Send,
292        IoUringOpcode::Recv,
293        IoUringOpcode::Close,
294        IoUringOpcode::Openat2,
295        IoUringOpcode::Read,
296    ];
297
298    const ALL_REGISTER_OPS: &[RegisterOp] = &[
299        RegisterOp::Buffers,
300        RegisterOp::Files,
301        RegisterOp::Eventfd,
302        RegisterOp::Personality,
303        RegisterOp::ProvidedBuffers,
304        RegisterOp::RingFd,
305        RegisterOp::SyncCancel,
306        RegisterOp::Probe,
307    ];
308
309    fn cpath(p: &std::path::Path) -> CString {
310        CString::new(p.as_os_str().as_encoded_bytes()).expect("chemin sans NUL")
311    }
312
313    // ── Unitaires (sans kernel) ───────────────────────────────────────────
314
315    #[test]
316    fn restriction_set_is_default_deny_and_accumulates() {
317        // Vide par défaut : ring non confiné.
318        let empty = RestrictionSet::new();
319        assert!(empty.is_empty());
320        assert_eq!(empty.len(), 0);
321        assert!(empty.as_slice().is_empty());
322
323        // Chaque combinateur ajoute exactement une restriction, dans l'ordre.
324        let set = RestrictionSet::new()
325            .allow_op(IoUringOpcode::Socket)
326            .allow_register(RegisterOp::ProvidedBuffers)
327            .allow_sqe_flags(SqeFlagSet::new().force_async())
328            .require_sqe_flags(SqeFlagSet::new().fixed_file());
329        assert!(!set.is_empty());
330        assert_eq!(set.len(), 4);
331
332        // `from_entitlements` = `allow_op` répété (point d'intégration couche 5).
333        let from_ent =
334            RestrictionSet::from_entitlements([IoUringOpcode::Send, IoUringOpcode::Recv]);
335        assert_eq!(from_ent.len(), 2);
336    }
337
338    #[test]
339    fn register_op_numbers_match_kernel_uapi() {
340        // Exhaustif : chaque variante → son numéro `IORING_REGISTER_*` (6.12).
341        let expected: &[(RegisterOp, u8)] = &[
342            (RegisterOp::Buffers, 15),
343            (RegisterOp::Files, 13),
344            (RegisterOp::Eventfd, 4),
345            (RegisterOp::Personality, 9),
346            (RegisterOp::ProvidedBuffers, 22),
347            (RegisterOp::RingFd, 20),
348            (RegisterOp::SyncCancel, 24),
349            (RegisterOp::Probe, 8),
350        ];
351        for &(op, number) in expected {
352            assert_eq!(op.number(), number, "{op:?}");
353        }
354        assert_eq!(ALL_REGISTER_OPS.len(), expected.len(), "liste exhaustive");
355    }
356
357    #[test]
358    fn sqe_flag_set_maps_to_submit_options() {
359        // SqeFlagSet partage la représentation des drapeaux avec SubmitOptions.
360        let from_set = SqeFlagSet::new()
361            .fixed_file()
362            .drain()
363            .force_async()
364            .buffer_select()
365            .skip_cqe_on_success()
366            .into_options();
367        let direct = SubmitOptions::default()
368            .fixed_file()
369            .drain()
370            .force_async()
371            .buffer_select()
372            .skip_cqe_on_success();
373        assert_eq!(from_set, direct);
374    }
375
376    proptest::proptest! {
377        /// **Propriété (§8)** : pour tout sous-ensemble d'opcodes autorisés, la
378        /// liste blanche encode **exactement** ces opcodes (un `RESTRICTION_SQE_OP`
379        /// par opcode, dans l'ordre, sans extra) — ni plus, ni moins. Le comportement
380        /// kernel default-deny est, lui, prouvé par les tests d'intégration et de
381        /// sécurité ci-dessous (un opcode hors liste **échoue** réellement).
382        #[test]
383        fn whitelisted_ops_encode_exactly_those(
384            mask in proptest::collection::vec(proptest::bool::ANY, CANDIDATE_OPS.len())
385        ) {
386            let subset: Vec<IoUringOpcode> = CANDIDATE_OPS
387                .iter()
388                .zip(&mask)
389                .filter(|(_, keep)| **keep)
390                .map(|(op, _)| *op)
391                .collect();
392            let set = RestrictionSet::from_entitlements(subset.iter().copied());
393            proptest::prop_assert_eq!(set.len(), subset.len());
394            for (restriction, expected) in set.as_slice().iter().zip(&subset) {
395                let encoded = encode_restriction(*restriction);
396                proptest::prop_assert_eq!(encoded.opcode, raw::IORING_RESTRICTION_SQE_OP);
397                proptest::prop_assert_eq!(encoded.op_or_flags, opcode_number(*expected));
398            }
399        }
400    }
401
402    // ── Intégration kernel ────────────────────────────────────────────────
403
404    /// Tous les types de restriction à la fois : `build()` applique réellement
405    /// `REGISTER_RESTRICTIONS` (4 variantes encodées) ; après `enable`, l'opcode
406    /// autorisé (Nop) passe.
407    #[test]
408    #[cfg_attr(miri, ignore = "io_uring non supporté par Miri")]
409    fn confined_ring_with_all_restriction_kinds_runs_whitelisted_op() {
410        let policy = RestrictionSet::new()
411            .allow_op(IoUringOpcode::Nop)
412            .allow_register(RegisterOp::Probe)
413            .allow_sqe_flags(SqeFlagSet::new().force_async())
414            .require_sqe_flags(SqeFlagSet::new()); // aucun drapeau imposé
415        let mut ring = IoUringBuilder::new(nz(8))
416            .restrict(policy.as_slice())
417            .build()
418            .expect("ring R_DISABLED + REGISTER_RESTRICTIONS");
419        ring.enable().expect("enable du ring confiné");
420        // Nop est en liste blanche → passe.
421        let tok = ring.submit_nop().expect("submit_nop autorisé");
422        ring.submit_and_wait(1).expect("submit_and_wait");
423        let completion = ring.wait_completion().expect("completion");
424        assert_eq!(completion.token(), tok);
425        completion.completed().expect("nop autorisé réussit");
426        assert_eq!(ring.in_flight(), 0);
427    }
428
429    /// **Preuve de sécurité (§8)** : un `openat2` soumis via un ring « réseau
430    /// seul » est **refusé** par le kernel, **alors même que le process a le droit
431    /// d'ouvrir le fichier**. Ferme la voie de contournement de seccomp (§1).
432    #[test]
433    #[cfg_attr(miri, ignore = "io_uring non supporté par Miri")]
434    fn openat2_refused_on_network_only_ring() {
435        // Fichier que le process PEUT ouvrir hors ring (preuve que le refus vient
436        // bien de la restriction du ring, pas d'un défaut de permission FS).
437        let path = std::env::temp_dir().join(format!("air-iou3f-{}", std::process::id()));
438        std::fs::write(&path, b"secret").expect("création fixture");
439        assert!(
440            std::fs::File::open(&path).is_ok(),
441            "le process a bien le droit d'ouvrir le fichier hors ring"
442        );
443
444        // Ring « réseau seul » : openat2 n'est PAS en liste blanche.
445        let policy = RestrictionSet::new()
446            .allow_op(IoUringOpcode::Socket)
447            .allow_op(IoUringOpcode::Connect)
448            .allow_op(IoUringOpcode::Send)
449            .allow_op(IoUringOpcode::Recv)
450            .allow_op(IoUringOpcode::Close);
451        let mut ring = IoUringBuilder::new(nz(8))
452            .restrict(policy.as_slice())
453            .build()
454            .expect("ring réseau seul");
455        ring.enable().expect("enable");
456
457        // Un opcode autorisé (socket) passe — le ring est fonctionnel.
458        let sock_tok = ring
459            .submit_socket(SocketDomain::Ipv4, SocketType::Stream, 0)
460            .expect("submit_socket autorisé");
461        ring.submit_and_wait(1).expect("submit_and_wait socket");
462        let sock = ring.wait_completion().expect("completion socket");
463        assert_eq!(sock.token(), sock_tok);
464        sock.completed().expect("socket autorisé réussit");
465
466        // openat2 (hors liste) → REFUSÉ par le kernel, malgré le droit FS.
467        let open_tok = ring
468            .submit_openat2(DirFd::Cwd, cpath(&path), OpenHow::default())
469            .expect("submit_openat2 (la soumission elle-même est acceptée)");
470        ring.submit_and_wait(1).expect("submit_and_wait openat2");
471        let opened = ring.wait_completion().expect("completion openat2");
472        assert_eq!(opened.token(), open_tok);
473        let err = opened
474            .completed()
475            .expect_err("openat2 doit être REFUSÉ sur un ring réseau seul");
476        assert!(
477            matches!(err, Errno::EACCES | Errno::EINVAL),
478            "refus default-deny attendu (EACCES/EINVAL), obtenu {err:?}"
479        );
480
481        let _ = std::fs::remove_file(&path);
482    }
483
484    /// `require_sqe_flags` imposé : une soumission **sans** le drapeau requis est
485    /// refusée par le kernel.
486    #[test]
487    #[cfg_attr(miri, ignore = "io_uring non supporté par Miri")]
488    fn required_sqe_flag_missing_is_refused() {
489        let policy = RestrictionSet::new()
490            .allow_op(IoUringOpcode::Nop)
491            .require_sqe_flags(SqeFlagSet::new().fixed_file());
492        let mut ring = IoUringBuilder::new(nz(8))
493            .restrict(policy.as_slice())
494            .build()
495            .expect("ring");
496        ring.enable().expect("enable");
497        // Nop SANS IOSQE_FIXED_FILE alors qu'il est requis → refusé.
498        let tok = ring.submit_nop().expect("submit_nop (soumission acceptée)");
499        ring.submit_and_wait(1).expect("submit_and_wait");
500        let completion = ring.wait_completion().expect("completion");
501        assert_eq!(completion.token(), tok);
502        let err = completion
503            .completed()
504            .expect_err("drapeau requis manquant → refus");
505        assert!(
506            matches!(err, Errno::EACCES | Errno::EINVAL),
507            "refus drapeau requis attendu, obtenu {err:?}"
508        );
509    }
510
511    /// Soumission **avant** `enable` : refusée (le ring `R_DISABLED` ne soumet rien).
512    #[test]
513    #[cfg_attr(miri, ignore = "io_uring non supporté par Miri")]
514    fn submission_before_enable_is_refused() {
515        let policy = RestrictionSet::new().allow_op(IoUringOpcode::Nop);
516        let mut ring = IoUringBuilder::new(nz(8))
517            .restrict(policy.as_slice())
518            .build()
519            .expect("ring R_DISABLED");
520        // PAS de enable(). La préparation du SQE réussit ; la soumission échoue.
521        ring.submit_nop().expect("préparation du SQE");
522        let err = ring
523            .submit()
524            .expect_err("soumission sur un ring désactivé doit échouer");
525        // EBADFD (77) sur ring désactivé : pas un Errno nommé → on vérifie le code.
526        assert_eq!(
527            err.as_raw(),
528            77,
529            "ring désactivé : EBADFD attendu, obtenu {err:?}"
530        );
531    }
532
533    /// **Immuabilité** après `enable` : l'API ne propose aucune re-restriction
534    /// (garantie par construction — `restrict` n'est que sur le builder) ; on
535    /// prouve ici que le **kernel** la refuse aussi (défense en profondeur) en
536    /// retentant `REGISTER_RESTRICTIONS` sur un ring déjà activé.
537    #[test]
538    #[cfg_attr(miri, ignore = "io_uring non supporté par Miri")]
539    fn restrictions_are_immutable_after_enable() {
540        let policy = RestrictionSet::new().allow_op(IoUringOpcode::Nop);
541        let mut ring = IoUringBuilder::new(nz(8))
542            .restrict(policy.as_slice())
543            .build()
544            .expect("ring");
545        ring.enable().expect("enable");
546
547        // Re-restriction d'un ring ACTIF, au niveau register brut.
548        let entry = encode_restriction(Restriction::AllowOp(IoUringOpcode::Read));
549        let arr = [entry];
550        // SAFETY: `arr` est un tableau d'une `io_uring_restriction` valide et
551        // vivante pour la durée de l'appel ; `fd_raw()` est le ring fd possédé.
552        // On ATTEND un échec (ring déjà activé) — aucune mutation d'état effective.
553        let ret = unsafe {
554            syscall::register(
555                ring.fd_raw(),
556                raw::IORING_REGISTER_RESTRICTIONS,
557                arr.as_ptr() as u64,
558                1,
559            )
560        };
561        assert!(
562            ret < 0,
563            "re-restriction d'un ring déjà activé doit être refusée par le kernel"
564        );
565    }
566
567    /// Couverture du bras d'erreur de `apply_restrictions` : si
568    /// `REGISTER_RESTRICTIONS` échoue (ici injecté), `build()` remonte l'`Errno`
569    /// et le ring `R_DISABLED` se ferme proprement (pas de fuite de FD).
570    #[test]
571    #[cfg_attr(miri, ignore = "io_uring_setup réel requis (register injecté)")]
572    fn build_propagates_register_restrictions_error() {
573        syscall::sim::clear();
574        // `setup` réussit (vrai syscall) ; `register` échoue (injecté -EINVAL).
575        syscall::sim::inject(syscall::sim::Syscall::Register, -22);
576        let policy = RestrictionSet::new().allow_op(IoUringOpcode::Nop);
577        let result = IoUringBuilder::new(nz(8))
578            .restrict(policy.as_slice())
579            .build();
580        assert_eq!(result.err(), Some(Errno::EINVAL));
581        syscall::sim::clear();
582    }
583}