Skip to main content

sandlock_core/seccomp/
dispatch.rs

1// Table-driven syscall dispatch — routes seccomp notifications to handler chains.
2//
3// Each syscall number maps to an ordered chain of handlers.  The chain is walked
4// until a handler returns a non-Continue action (or the chain is exhausted, in
5// which case Continue is returned).
6//
7// Continue safety (issue #27):
8//   - The chain walker treats Continue as "this handler did not intervene,
9//     try the next one." A final Continue (no handler intervened, or chain
10//     exhausted) means the syscall passes through to the kernel as-issued.
11//     The kernel still enforces Landlock and the BPF filter on the
12//     untouched syscall, so dispatch-level Continue is not a security
13//     decision — it's the absence of one.
14//   - The conditional shim closures (random/hostname/etc_hosts opens) that
15//     wrap an Option-returning helper translate `None` into Continue,
16//     which is the same "not my path, next handler" semantics. None of
17//     them approve a syscall based on user-memory contents.
18
19use std::collections::HashMap;
20use std::os::unix::io::RawFd;
21use std::sync::Arc;
22
23use super::ctx::SupervisorCtx;
24use super::notif::{NotifAction, NotifPolicy};
25use super::state::ResourceState;
26use super::syscall::SyscallError;
27use crate::arch;
28use crate::sys::structs::SeccompNotif;
29
30use thiserror::Error;
31use tokio::sync::Mutex;
32
33// ============================================================
34// Types
35// ============================================================
36
37// ============================================================
38// Handler trait — the new public extension API.
39// ============================================================
40
41/// Public extension trait for sandlock seccomp-notif handlers.
42///
43/// Each implementor is registered against a [`crate::seccomp::syscall::Syscall`]
44/// through [`crate::Sandbox::run_with_handlers`] /
45/// [`crate::Sandbox::run_interactive_with_handlers`].  Receives
46/// `&HandlerCtx` borrowed for the call; cannot outlive the dispatch
47/// invocation.
48///
49/// State lives on the implementor — no `Arc::clone` ladders, no
50/// closure ceremony at registration time.
51///
52/// `handle` returns a boxed `Future` so the trait stays dyn-compatible
53/// (the supervisor stores user handlers as `Vec<Arc<dyn Handler>>`,
54/// keyed by syscall number).  Returning `impl Future` directly via
55/// RPITIT would be more efficient but is not object-safe, and changing
56/// the storage to a non-erased shape would force a generic dispatch
57/// chain incompatible with arbitrary user handler types.
58pub trait Handler: Send + Sync + 'static {
59    fn handle<'a>(
60        &'a self,
61        cx: &'a HandlerCtx,
62    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = NotifAction> + Send + 'a>>;
63}
64
65/// Context passed to `Handler::handle`.
66///
67/// `notif` is the kernel notification (owned by value — it's a small
68/// `repr(C)` struct, cheap to copy).  `notif_fd` is the supervisor's
69/// seccomp listener fd, used by helpers like `read_child_mem` /
70/// `write_child_mem` / `read_child_cstr` for TOCTOU-safe child memory
71/// access.
72///
73/// Handler state lives on the implementor (`&self`).  Supervisor-internal
74/// state is intentionally not exposed here so the `SupervisorCtx`
75/// internal fields are not part of the downstream extension contract.
76pub struct HandlerCtx {
77    pub notif: SeccompNotif,
78    pub notif_fd: RawFd,
79}
80
81// Blanket impl: any Fn(&HandlerCtx) -> Future is a Handler.
82//
83// Lets lightweight closure-style handlers work without ceremony at the
84// call site.  Handlers that need state should use `struct + explicit
85// impl Handler` instead.
86impl<F, Fut> Handler for F
87where
88    F: Fn(&HandlerCtx) -> Fut + Send + Sync + 'static,
89    Fut: std::future::Future<Output = NotifAction> + Send + 'static,
90{
91    fn handle<'a>(
92        &'a self,
93        cx: &'a HandlerCtx,
94    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = NotifAction> + Send + 'a>> {
95        Box::pin((self)(cx))
96    }
97}
98
99// Concrete impls for `Box<dyn Handler>` and `Arc<dyn Handler>` so callers
100// can erase concrete handler types behind a smart pointer when mixing
101// different handler shapes in one `IntoIterator` passed to
102// `run_with_handlers` — e.g. `Vec<(i64, Box<dyn Handler>)>` lets a
103// downstream register handlers of different concrete types without
104// writing a per-crate wrapper enum.
105//
106// These are concrete `Box<dyn Handler>` / `Arc<dyn Handler>` rather than
107// `<H: Handler + ?Sized>` blankets to avoid coherence overlap with the
108// `impl<F, Fut> Handler for F where F: Fn(&HandlerCtx) -> Fut` blanket
109// above.
110impl Handler for Box<dyn Handler> {
111    fn handle<'a>(
112        &'a self,
113        cx: &'a HandlerCtx,
114    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = NotifAction> + Send + 'a>> {
115        (**self).handle(cx)
116    }
117}
118
119impl Handler for std::sync::Arc<dyn Handler> {
120    fn handle<'a>(
121        &'a self,
122        cx: &'a HandlerCtx,
123    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = NotifAction> + Send + 'a>> {
124        (**self).handle(cx)
125    }
126}
127
128/// Errors raised when registering user handlers via
129/// [`crate::Sandbox::run_with_handlers`].
130#[derive(Debug, Error, PartialEq, Eq)]
131pub enum HandlerError {
132    #[error("invalid syscall in handler registration: {0}")]
133    InvalidSyscall(#[from] SyscallError),
134
135    #[error(
136        "handler on syscall {syscall_nr} conflicts with the policy syscall blocklist \
137         and would let user code bypass it via SECCOMP_USER_NOTIF_FLAG_CONTINUE"
138    )]
139    OnDenySyscall { syscall_nr: i64 },
140}
141
142/// Reject handler registrations that would weaken sandlock's confinement
143/// guarantees.
144///
145/// The cBPF program emits notif JEQs *before* deny JEQs, so a syscall
146/// present in both lists hits `SECCOMP_RET_USER_NOTIF` first.  A handler
147/// registered on a syscall that is on the blocklist would therefore
148/// convert a kernel-deny into a user-supervised path: a handler returning
149/// `NotifAction::Continue` becomes `SECCOMP_USER_NOTIF_FLAG_CONTINUE` and
150/// the kernel actually runs the syscall — silently bypassing deny.
151///
152/// The blocklist is whatever [`crate::context::blocklist_syscall_numbers`]
153/// resolves from Sandlock's default syscall blocklist plus policy extras.
154///
155/// Takes only the syscall numbers because that's all it needs to check.
156/// Called from the `run_with_handlers` entry points before any
157/// handler is registered against the dispatch table.
158///
159/// Returns the offending syscall number on rejection so the caller can
160/// surface it to the end user.
161pub(crate) fn validate_handler_syscalls_against_policy(
162    syscall_nrs: &[i64],
163    policy: &crate::sandbox::Sandbox,
164) -> Result<(), i64> {
165    let blocklist: std::collections::HashSet<u32> =
166        crate::context::blocklist_syscall_numbers(policy).into_iter().collect();
167    for &nr in syscall_nrs {
168        if blocklist.contains(&(nr as u32)) {
169            return Err(nr);
170        }
171    }
172    Ok(())
173}
174
175
176/// Ordered chain of handlers for a single syscall number.
177struct HandlerChain {
178    handlers: Vec<std::sync::Arc<dyn Handler>>,
179}
180
181/// Maps syscall numbers to handler chains.
182pub struct DispatchTable {
183    chains: HashMap<i64, HandlerChain>,
184}
185
186impl DispatchTable {
187    /// Create an empty dispatch table.
188    pub fn new() -> Self {
189        Self {
190            chains: HashMap::new(),
191        }
192    }
193
194    /// Register a handler for the given syscall number.  Handlers are
195    /// called in registration order; the first non-Continue result wins.
196    ///
197    /// Generic over `H: Handler` — accepts either a struct with explicit
198    /// `impl Handler for ...` or a closure (via blanket impl).
199    pub fn register<H: Handler>(&mut self, syscall_nr: i64, handler: H) {
200        self.register_arc(syscall_nr, std::sync::Arc::new(handler));
201    }
202
203    /// Register a pre-`Arc`'d handler.  Used both by builtin chunks
204    /// that share state via `Arc::clone` (one `ForkHandler` instance
205    /// registers against `SYS_clone`/`SYS_clone3`/`SYS_vfork`) and by
206    /// `run_with_handlers` when each item already arrives as
207    /// `Arc<dyn Handler>`.
208    pub(crate) fn register_arc(
209        &mut self,
210        syscall_nr: i64,
211        handler: std::sync::Arc<dyn Handler>,
212    ) {
213        self.chains
214            .entry(syscall_nr)
215            .or_insert_with(|| HandlerChain { handlers: Vec::new() })
216            .handlers
217            .push(handler);
218    }
219
220    /// Dispatch a notification through the handler chain for its syscall number.
221    pub(crate) async fn dispatch(
222        &self,
223        notif: SeccompNotif,
224        notif_fd: RawFd,
225    ) -> NotifAction {
226        let nr = notif.data.nr as i64;
227        if let Some(chain) = self.chains.get(&nr) {
228            let handler_ctx = HandlerCtx { notif, notif_fd };
229            for handler in &chain.handlers {
230                let action = handler.handle(&handler_ctx).await;
231                if !matches!(action, NotifAction::Continue) {
232                    return action;
233                }
234            }
235        }
236        NotifAction::Continue
237    }
238}
239
240// ============================================================
241// Table builder — mechanical translation of old dispatch()
242// ============================================================
243
244/// Build the dispatch table from a `NotifPolicy`.  Every branch from the old
245/// monolithic `dispatch()` function is translated into a `table.register()` call.
246/// Priority is preserved by registration order.
247///
248/// `pending_handlers` are appended **after** all builtin handlers, so they
249/// observe the post-builtin view (e.g. `chroot`-normalized paths on
250/// `openat`).  Builtins cannot be overridden or removed — this is the
251/// security boundary for downstream crates.
252pub(crate) fn build_dispatch_table(
253    policy: &Arc<NotifPolicy>,
254    resource: &Arc<Mutex<ResourceState>>,
255    ctx: &Arc<SupervisorCtx>,
256    pending_handlers: Vec<(i64, std::sync::Arc<dyn Handler>)>,
257) -> DispatchTable {
258    let mut table = DispatchTable::new();
259
260    // ------------------------------------------------------------------
261    // Fork/clone family (always on)
262    // ------------------------------------------------------------------
263    for &nr in arch::FORK_LIKE_SYSCALLS {
264        let policy_for_fork = Arc::clone(policy);
265        let resource_for_fork = Arc::clone(resource);
266        table.register(nr, move |cx: &HandlerCtx| {
267            let notif = cx.notif;
268            let notif_fd = cx.notif_fd;
269            let policy = Arc::clone(&policy_for_fork);
270            let resource = Arc::clone(&resource_for_fork);
271            async move {
272                crate::resource::handle_fork(&notif, notif_fd, &resource, &policy).await
273            }
274        });
275    }
276
277    // ------------------------------------------------------------------
278    // Wait family (always on)
279    // ------------------------------------------------------------------
280    for &nr in &[libc::SYS_wait4, libc::SYS_waitid] {
281        let resource_for_wait = Arc::clone(resource);
282        table.register(nr, move |cx: &HandlerCtx| {
283            let notif = cx.notif;
284            let resource = Arc::clone(&resource_for_wait);
285            async move {
286                crate::resource::handle_wait(&notif, &resource).await
287            }
288        });
289    }
290
291    // ------------------------------------------------------------------
292    // Memory management (conditional on has_memory_limit)
293    // ------------------------------------------------------------------
294    if policy.has_memory_limit {
295        for &nr in &[
296            libc::SYS_mmap, libc::SYS_munmap, libc::SYS_brk,
297            libc::SYS_mremap, libc::SYS_shmget,
298        ] {
299            let policy_for_mem = Arc::clone(policy);
300            let __sup = Arc::clone(ctx);
301            table.register(nr, move |cx: &HandlerCtx| {
302                let notif = cx.notif;
303                let sup = Arc::clone(&__sup);
304                let policy = Arc::clone(&policy_for_mem);
305                async move {
306                    crate::resource::handle_memory(&notif, &sup, &policy).await
307                }
308            });
309        }
310    }
311
312    // ------------------------------------------------------------------
313    // Network (conditional on has_net_allowlist || has_http_acl)
314    // ------------------------------------------------------------------
315    if policy.has_net_allowlist || policy.has_http_acl {
316        for &nr in &[
317            libc::SYS_connect,
318            libc::SYS_sendto,
319            libc::SYS_sendmsg,
320            libc::SYS_sendmmsg,
321        ] {
322            let __sup = Arc::clone(ctx);
323            table.register(nr, move |cx: &HandlerCtx| {
324                let notif = cx.notif;
325                let sup = Arc::clone(&__sup);
326                let notif_fd = cx.notif_fd;
327                async move {
328                    crate::network::handle_net(&notif, &sup, notif_fd).await
329                }
330            });
331        }
332    }
333
334    // ------------------------------------------------------------------
335    // Deterministic random — getrandom()
336    // ------------------------------------------------------------------
337    if policy.has_random_seed {
338        let __sup = Arc::clone(ctx);
339        table.register(libc::SYS_getrandom, move |cx: &HandlerCtx| {
340            let notif = cx.notif;
341            let sup = Arc::clone(&__sup);
342            let notif_fd = cx.notif_fd;
343            async move {
344                let mut tr = sup.time_random.lock().await;
345                if let Some(ref mut rng) = tr.random_state {
346                    crate::random::handle_getrandom(&notif, rng, notif_fd)
347                } else {
348                    NotifAction::Continue
349                }
350            }
351        });
352    }
353
354    // ------------------------------------------------------------------
355    // Deterministic random — /dev/urandom opens (openat)
356    // ------------------------------------------------------------------
357    if policy.has_random_seed {
358        let __sup = Arc::clone(ctx);
359        table.register(libc::SYS_openat, move |cx: &HandlerCtx| {
360            let notif = cx.notif;
361            let sup = Arc::clone(&__sup);
362            let notif_fd = cx.notif_fd;
363            async move {
364                let mut tr = sup.time_random.lock().await;
365                if let Some(ref mut rng) = tr.random_state {
366                    if let Some(action) = crate::random::handle_random_open(&notif, rng, notif_fd) {
367                        return action;
368                    }
369                }
370                NotifAction::Continue
371            }
372        });
373    }
374
375    // ------------------------------------------------------------------
376    // Timer adjustment (conditional on has_time_start)
377    // ------------------------------------------------------------------
378    if policy.has_time_start {
379        let time_offset = policy.time_offset;
380        for &nr in &[
381            libc::SYS_clock_nanosleep as i64,
382            libc::SYS_timerfd_settime as i64,
383            libc::SYS_timer_settime as i64,
384        ] {
385            table.register(nr, move |cx: &HandlerCtx| {
386                let notif = cx.notif;
387                let notif_fd = cx.notif_fd;
388                async move {
389                    crate::time::handle_timer(&notif, time_offset, notif_fd)
390                }
391            });
392        }
393    }
394
395    // ------------------------------------------------------------------
396    // Chroot path interception (before COW)
397    // ------------------------------------------------------------------
398    if policy.chroot_root.is_some() {
399        register_chroot_handlers(&mut table, policy, ctx);
400    }
401
402    // ------------------------------------------------------------------
403    // COW filesystem interception
404    // ------------------------------------------------------------------
405    if policy.cow_enabled {
406        register_cow_handlers(&mut table, ctx);
407    }
408
409    // ------------------------------------------------------------------
410    // /proc virtualization (always on)
411    // ------------------------------------------------------------------
412    {
413        let policy_for_proc_open = Arc::clone(policy);
414        let resource_for_proc_open = Arc::clone(resource);
415        let __sup = Arc::clone(ctx);
416        table.register(libc::SYS_openat, move |cx: &HandlerCtx| {
417            let notif = cx.notif;
418            let sup = Arc::clone(&__sup);
419            let notif_fd = cx.notif_fd;
420            let policy = Arc::clone(&policy_for_proc_open);
421            let resource = Arc::clone(&resource_for_proc_open);
422            async move {
423                let processes = Arc::clone(&sup.processes);
424                let network = Arc::clone(&sup.network);
425                crate::procfs::handle_proc_open(&notif, &processes, &resource, &network, &policy, notif_fd).await
426            }
427        });
428    }
429    let mut getdents_nrs = vec![libc::SYS_getdents64];
430    if let Some(getdents) = arch::SYS_GETDENTS {
431        getdents_nrs.push(getdents);
432    }
433    for nr in getdents_nrs {
434        let policy_for_getdents = Arc::clone(policy);
435        let __sup = Arc::clone(ctx);
436        table.register(nr, move |cx: &HandlerCtx| {
437            let notif = cx.notif;
438            let sup = Arc::clone(&__sup);
439            let notif_fd = cx.notif_fd;
440            let policy = Arc::clone(&policy_for_getdents);
441            async move {
442                let processes = Arc::clone(&sup.processes);
443                crate::procfs::handle_getdents(&notif, &processes, &policy, notif_fd).await
444            }
445        });
446    }
447
448    // ------------------------------------------------------------------
449    // Virtual CPU count
450    // ------------------------------------------------------------------
451    if let Some(n) = policy.num_cpus {
452        table.register(libc::SYS_sched_getaffinity, move |cx: &HandlerCtx| {
453            let notif = cx.notif;
454            let notif_fd = cx.notif_fd;
455            async move {
456                crate::procfs::handle_sched_getaffinity(&notif, n, notif_fd)
457            }
458        });
459    }
460
461    // ------------------------------------------------------------------
462    // Hostname virtualization
463    // ------------------------------------------------------------------
464    if let Some(ref hostname) = policy.virtual_hostname {
465        let hostname_for_uname = hostname.clone();
466        let hostname_for_open = hostname.clone();
467        table.register(libc::SYS_uname, move |cx: &HandlerCtx| {
468            let notif = cx.notif;
469            let notif_fd = cx.notif_fd;
470            let hostname = hostname_for_uname.clone();
471            async move {
472                crate::procfs::handle_uname(&notif, &hostname, notif_fd)
473            }
474        });
475        table.register(libc::SYS_openat, move |cx: &HandlerCtx| {
476            let notif = cx.notif;
477            let notif_fd = cx.notif_fd;
478            let hostname = hostname_for_open.clone();
479            async move {
480                if let Some(action) = crate::procfs::handle_hostname_open(&notif, &hostname, notif_fd) {
481                    action
482                } else {
483                    NotifAction::Continue
484                }
485            }
486        });
487    }
488
489    // ------------------------------------------------------------------
490    // /etc/hosts virtualization (for concrete-host entries in net_allow)
491    // ------------------------------------------------------------------
492    if let Some(ref etc_hosts) = policy.virtual_etc_hosts {
493        let etc_hosts_for_open = etc_hosts.clone();
494        table.register(libc::SYS_openat, move |cx: &HandlerCtx| {
495            let notif = cx.notif;
496            let notif_fd = cx.notif_fd;
497            let etc_hosts = etc_hosts_for_open.clone();
498            async move {
499                if let Some(action) = crate::procfs::handle_etc_hosts_open(&notif, &etc_hosts, notif_fd) {
500                    action
501                } else {
502                    NotifAction::Continue
503                }
504            }
505        });
506    }
507
508    // ------------------------------------------------------------------
509    // Deterministic directory listing
510    // ------------------------------------------------------------------
511    if policy.deterministic_dirs {
512        let mut getdents_nrs = vec![libc::SYS_getdents64];
513        if let Some(getdents) = arch::SYS_GETDENTS {
514            getdents_nrs.push(getdents);
515        }
516        for nr in getdents_nrs {
517            let __sup = Arc::clone(ctx);
518            table.register(nr, move |cx: &HandlerCtx| {
519                let notif = cx.notif;
520                let sup = Arc::clone(&__sup);
521                let notif_fd = cx.notif_fd;
522                async move {
523                    let processes = Arc::clone(&sup.processes);
524                    crate::procfs::handle_sorted_getdents(&notif, &processes, notif_fd).await
525                }
526            });
527        }
528    }
529
530    // ------------------------------------------------------------------
531    // NETLINK_ROUTE virtualization (always on).
532    //
533    // Send/recv traffic flows through a `socketpair(AF_UNIX,
534    // SOCK_SEQPACKET)` whose supervisor-side end is driven by a tokio
535    // task spawned in `handle_socket`.  Only `socket`, `bind`,
536    // `getsockname`, `recvmsg`/`recvfrom`, and `close` need supervisor
537    // intercepts; send uses the kernel directly.
538    //
539    // Must register before `port_remap` so the netlink `bind` handler
540    // runs first and returns `Continue` for non-cookie fds.
541    // ------------------------------------------------------------------
542    {
543        let __sup = Arc::clone(ctx);
544        table.register(libc::SYS_socket, move |cx: &HandlerCtx| {
545            let notif = cx.notif;
546            let sup = Arc::clone(&__sup);
547            async move {
548                let state = Arc::clone(&sup.netlink);
549                crate::netlink::handlers::handle_socket(&notif, &state).await
550            }
551        });
552        let __sup = Arc::clone(ctx);
553        table.register(libc::SYS_bind, move |cx: &HandlerCtx| {
554            let notif = cx.notif;
555            let sup = Arc::clone(&__sup);
556            async move {
557                let state = Arc::clone(&sup.netlink);
558                crate::netlink::handlers::handle_bind(&notif, &state).await
559            }
560        });
561        let __sup = Arc::clone(ctx);
562        table.register(libc::SYS_getsockname, move |cx: &HandlerCtx| {
563            let notif = cx.notif;
564            let sup = Arc::clone(&__sup);
565            let notif_fd = cx.notif_fd;
566            async move {
567                let state = Arc::clone(&sup.netlink);
568                crate::netlink::handlers::handle_getsockname(&notif, &state, notif_fd).await
569            }
570        });
571        // Zero the msg_name region on recv so glibc sees nl_pid=0
572        // (the kernel only writes sun_family on unix socketpair recvmsg,
573        //  leaving the rest of the buffer as stack garbage otherwise).
574        for &nr in &[libc::SYS_recvfrom, libc::SYS_recvmsg] {
575            let __sup = Arc::clone(ctx);
576            table.register(nr, move |cx: &HandlerCtx| {
577                let notif = cx.notif;
578                let sup = Arc::clone(&__sup);
579                let notif_fd = cx.notif_fd;
580                async move {
581                    let state = Arc::clone(&sup.netlink);
582                    crate::netlink::handlers::handle_netlink_recvmsg(&notif, &state, notif_fd).await
583                }
584            });
585        }
586        // Unregister on close so the (pid, fd) slot isn't left in the
587        // cookie set once the child reuses the fd for something else.
588        let __sup = Arc::clone(ctx);
589        table.register(libc::SYS_close, move |cx: &HandlerCtx| {
590            let notif = cx.notif;
591            let sup = Arc::clone(&__sup);
592            async move {
593                let state = Arc::clone(&sup.netlink);
594                crate::netlink::handlers::handle_close(&notif, &state).await
595            }
596        });
597    }
598
599    // ------------------------------------------------------------------
600    // Bind — on-behalf
601    // ------------------------------------------------------------------
602    if policy.port_remap || policy.has_net_allowlist {
603        let __sup = Arc::clone(ctx);
604        table.register(libc::SYS_bind, move |cx: &HandlerCtx| {
605            let notif = cx.notif;
606            let sup = Arc::clone(&__sup);
607            let notif_fd = cx.notif_fd;
608            async move {
609                crate::port_remap::handle_bind(&notif, &sup.network, notif_fd).await
610            }
611        });
612    }
613
614    // ------------------------------------------------------------------
615    // getsockname — port remap
616    // ------------------------------------------------------------------
617    if policy.port_remap {
618        let __sup = Arc::clone(ctx);
619        table.register(libc::SYS_getsockname, move |cx: &HandlerCtx| {
620            let notif = cx.notif;
621            let sup = Arc::clone(&__sup);
622            let notif_fd = cx.notif_fd;
623            async move {
624                crate::port_remap::handle_getsockname(&notif, &sup.network, notif_fd).await
625            }
626        });
627    }
628
629    // ------------------------------------------------------------------
630    // Pending user handlers — appended after builtins so builtin handlers
631    // keep their security-critical priority (chroot path normalization,
632    // COW writes, resource accounting).
633    // ------------------------------------------------------------------
634    for (nr, h) in pending_handlers {
635        table.register_arc(nr, h);
636    }
637
638    table
639}
640
641// ============================================================
642// Chroot handler registration
643// ============================================================
644
645fn register_chroot_handlers(
646    table: &mut DispatchTable,
647    policy: &Arc<NotifPolicy>,
648    ctx: &Arc<SupervisorCtx>,
649) {
650    use crate::chroot::dispatch::ChrootCtx;
651
652    // Helper macro — produces a closure satisfying Handler via blanket impl.
653    // The closure clones `policy` (Arc) before the async block; inside the
654    // async block it borrows fields of that cloned Arc to build `ChrootCtx`.
655    macro_rules! chroot_handler {
656        ($policy:expr, $handler:expr) => {{
657            let policy = Arc::clone($policy);
658            let chroot_state = Arc::clone(&ctx.chroot);
659            let cow_state = Arc::clone(&ctx.cow);
660            move |cx: &HandlerCtx| {
661                let notif = cx.notif;
662                let chroot_state = Arc::clone(&chroot_state);
663                let cow_state = Arc::clone(&cow_state);
664                let notif_fd = cx.notif_fd;
665                let policy = Arc::clone(&policy);
666                async move {
667                    let chroot_ctx = ChrootCtx {
668                        root: policy.chroot_root.as_ref().unwrap(),
669                        readable: &policy.chroot_readable,
670                        writable: &policy.chroot_writable,
671                        denied: &policy.chroot_denied,
672                        mounts: &policy.chroot_mounts,
673                    };
674                    $handler(&notif, &chroot_state, &cow_state, notif_fd, &chroot_ctx).await
675                }
676            }
677        }};
678    }
679
680    // Same shape for fall-through variants (semantically identical here;
681    // kept separate for symmetry with the old code).
682    macro_rules! chroot_handler_fallthrough {
683        ($policy:expr, $handler:expr) => {{
684            let policy = Arc::clone($policy);
685            let chroot_state = Arc::clone(&ctx.chroot);
686            let cow_state = Arc::clone(&ctx.cow);
687            move |cx: &HandlerCtx| {
688                let notif = cx.notif;
689                let chroot_state = Arc::clone(&chroot_state);
690                let cow_state = Arc::clone(&cow_state);
691                let notif_fd = cx.notif_fd;
692                let policy = Arc::clone(&policy);
693                async move {
694                    let chroot_ctx = ChrootCtx {
695                        root: policy.chroot_root.as_ref().unwrap(),
696                        readable: &policy.chroot_readable,
697                        writable: &policy.chroot_writable,
698                        denied: &policy.chroot_denied,
699                        mounts: &policy.chroot_mounts,
700                    };
701                    $handler(&notif, &chroot_state, &cow_state, notif_fd, &chroot_ctx).await
702                }
703            }
704        }};
705    }
706
707    // openat — fallthrough if Continue
708    table.register(libc::SYS_openat, chroot_handler_fallthrough!(policy,
709        crate::chroot::dispatch::handle_chroot_open));
710
711    // open (legacy) — fallthrough if Continue
712    if let Some(open) = arch::SYS_OPEN {
713        table.register(open, chroot_handler_fallthrough!(policy,
714            crate::chroot::dispatch::handle_chroot_legacy_open));
715    }
716
717    // execve, execveat — unconditional return
718    for &nr in &[libc::SYS_execve, libc::SYS_execveat] {
719        table.register(nr, chroot_handler!(policy,
720            crate::chroot::dispatch::handle_chroot_exec));
721    }
722
723    // Modern write syscalls
724    for &nr in &[
725        libc::SYS_unlinkat, libc::SYS_mkdirat, libc::SYS_renameat2,
726        libc::SYS_symlinkat, libc::SYS_linkat, libc::SYS_fchmodat,
727        libc::SYS_fchownat, libc::SYS_truncate,
728    ] {
729        table.register(nr, chroot_handler!(policy,
730            crate::chroot::dispatch::handle_chroot_write));
731    }
732
733    // Legacy write syscalls
734    if let Some(nr) = arch::SYS_UNLINK {
735        table.register(nr, chroot_handler!(policy,
736            crate::chroot::dispatch::handle_chroot_legacy_unlink));
737    }
738    if let Some(nr) = arch::SYS_RMDIR {
739        table.register(nr, chroot_handler!(policy,
740            crate::chroot::dispatch::handle_chroot_legacy_rmdir));
741    }
742    if let Some(nr) = arch::SYS_MKDIR {
743        table.register(nr, chroot_handler!(policy,
744            crate::chroot::dispatch::handle_chroot_legacy_mkdir));
745    }
746    if let Some(nr) = arch::SYS_RENAME {
747        table.register(nr, chroot_handler!(policy,
748            crate::chroot::dispatch::handle_chroot_legacy_rename));
749    }
750    if let Some(nr) = arch::SYS_SYMLINK {
751        table.register(nr, chroot_handler!(policy,
752            crate::chroot::dispatch::handle_chroot_legacy_symlink));
753    }
754    if let Some(nr) = arch::SYS_LINK {
755        table.register(nr, chroot_handler!(policy,
756            crate::chroot::dispatch::handle_chroot_legacy_link));
757    }
758    if let Some(nr) = arch::SYS_CHMOD {
759        table.register(nr, chroot_handler!(policy,
760            crate::chroot::dispatch::handle_chroot_legacy_chmod));
761    }
762
763    // chown — non-follow
764    if let Some(chown) = arch::SYS_CHOWN {
765        let policy_for_chown = Arc::clone(policy);
766        let __sup = Arc::clone(ctx);
767        table.register(chown, move |cx: &HandlerCtx| {
768            let notif = cx.notif;
769            let sup = Arc::clone(&__sup);
770            let notif_fd = cx.notif_fd;
771            let policy = Arc::clone(&policy_for_chown);
772            async move {
773                let chroot_ctx = ChrootCtx {
774                    root: policy.chroot_root.as_ref().unwrap(),
775                    readable: &policy.chroot_readable,
776                    writable: &policy.chroot_writable,
777                    denied: &policy.chroot_denied,
778                    mounts: &policy.chroot_mounts,
779                };
780                crate::chroot::dispatch::handle_chroot_legacy_chown(&notif, &sup.chroot, &sup.cow, notif_fd, &chroot_ctx, false).await
781            }
782        });
783    }
784
785    // lchown — follow
786    if let Some(lchown) = arch::SYS_LCHOWN {
787        let policy_for_lchown = Arc::clone(policy);
788        let __sup = Arc::clone(ctx);
789        table.register(lchown, move |cx: &HandlerCtx| {
790            let notif = cx.notif;
791            let sup = Arc::clone(&__sup);
792            let notif_fd = cx.notif_fd;
793            let policy = Arc::clone(&policy_for_lchown);
794            async move {
795                let chroot_ctx = ChrootCtx {
796                    root: policy.chroot_root.as_ref().unwrap(),
797                    readable: &policy.chroot_readable,
798                    writable: &policy.chroot_writable,
799                    denied: &policy.chroot_denied,
800                    mounts: &policy.chroot_mounts,
801                };
802                crate::chroot::dispatch::handle_chroot_legacy_chown(&notif, &sup.chroot, &sup.cow, notif_fd, &chroot_ctx, true).await
803            }
804        });
805    }
806
807    // stat family
808    for &nr in &[
809        libc::SYS_newfstatat,
810        libc::SYS_faccessat,
811        crate::chroot::dispatch::SYS_FACCESSAT2,
812    ] {
813        table.register(nr, chroot_handler!(policy,
814            crate::chroot::dispatch::handle_chroot_stat));
815    }
816
817    // Legacy stat
818    if let Some(nr) = arch::SYS_STAT {
819        table.register(nr, chroot_handler!(policy,
820            crate::chroot::dispatch::handle_chroot_legacy_stat));
821    }
822    if let Some(nr) = arch::SYS_LSTAT {
823        table.register(nr, chroot_handler!(policy,
824            crate::chroot::dispatch::handle_chroot_legacy_lstat));
825    }
826    if let Some(nr) = arch::SYS_ACCESS {
827        table.register(nr, chroot_handler!(policy,
828            crate::chroot::dispatch::handle_chroot_legacy_access));
829    }
830
831    // statx
832    table.register(libc::SYS_statx, chroot_handler!(policy,
833        crate::chroot::dispatch::handle_chroot_statx));
834
835    // readlink
836    table.register(libc::SYS_readlinkat, chroot_handler!(policy,
837        crate::chroot::dispatch::handle_chroot_readlink));
838    if let Some(nr) = arch::SYS_READLINK {
839        table.register(nr, chroot_handler!(policy,
840            crate::chroot::dispatch::handle_chroot_legacy_readlink));
841    }
842
843    // getdents
844    let mut getdents_nrs = vec![libc::SYS_getdents64];
845    if let Some(getdents) = arch::SYS_GETDENTS {
846        getdents_nrs.push(getdents);
847    }
848    for nr in getdents_nrs {
849        table.register(nr, chroot_handler!(policy,
850            crate::chroot::dispatch::handle_chroot_getdents));
851    }
852
853    // chdir, getcwd, statfs, utimensat
854    table.register(libc::SYS_chdir as i64, chroot_handler!(policy,
855        crate::chroot::dispatch::handle_chroot_chdir));
856    table.register(libc::SYS_getcwd as i64, chroot_handler!(policy,
857        crate::chroot::dispatch::handle_chroot_getcwd));
858    table.register(libc::SYS_statfs as i64, chroot_handler!(policy,
859        crate::chroot::dispatch::handle_chroot_statfs));
860    table.register(libc::SYS_utimensat as i64, chroot_handler!(policy,
861        crate::chroot::dispatch::handle_chroot_utimensat));
862}
863
864// ============================================================
865// COW handler registration
866// ============================================================
867
868fn register_cow_handlers(table: &mut DispatchTable, ctx: &Arc<SupervisorCtx>) {
869    // Helper that captures `ctx.cow` and `ctx.processes` once at table-build
870    // time, then re-clones the per-handler `Arc`s on each invocation.
871    macro_rules! cow_call {
872        ($handler:expr) => {{
873            let cow_state = Arc::clone(&ctx.cow);
874            let processes_state = Arc::clone(&ctx.processes);
875            move |cx: &HandlerCtx| {
876                let notif = cx.notif;
877                let cow_state = Arc::clone(&cow_state);
878                let processes_state = Arc::clone(&processes_state);
879                let notif_fd = cx.notif_fd;
880                async move {
881                    $handler(&notif, &cow_state, &processes_state, notif_fd).await
882                }
883            }
884        }};
885    }
886
887    // Write syscalls (*at variants + legacy)
888    let mut write_nrs = vec![
889        libc::SYS_unlinkat, libc::SYS_mkdirat, libc::SYS_renameat2,
890        libc::SYS_symlinkat, libc::SYS_linkat, libc::SYS_fchmodat,
891        libc::SYS_fchownat, libc::SYS_truncate,
892    ];
893    write_nrs.extend([
894        arch::SYS_UNLINK, arch::SYS_RMDIR, arch::SYS_MKDIR, arch::SYS_RENAME,
895        arch::SYS_SYMLINK, arch::SYS_LINK, arch::SYS_CHMOD, arch::SYS_CHOWN,
896        arch::SYS_LCHOWN,
897    ].into_iter().flatten());
898    for nr in write_nrs {
899        table.register(nr, cow_call!(crate::cow::dispatch::handle_cow_write));
900    }
901
902    table.register(libc::SYS_utimensat, cow_call!(crate::cow::dispatch::handle_cow_utimensat));
903
904    let mut access_nrs = vec![libc::SYS_faccessat, crate::cow::dispatch::SYS_FACCESSAT2];
905    access_nrs.extend(arch::SYS_ACCESS);
906    for nr in access_nrs {
907        table.register(nr, cow_call!(crate::cow::dispatch::handle_cow_access));
908    }
909
910    let mut open_nrs = vec![libc::SYS_openat];
911    open_nrs.extend(arch::SYS_OPEN);
912    for nr in open_nrs {
913        table.register(nr, cow_call!(crate::cow::dispatch::handle_cow_open));
914    }
915
916    let mut stat_nrs = vec![libc::SYS_newfstatat, libc::SYS_faccessat];
917    stat_nrs.extend([arch::SYS_STAT, arch::SYS_LSTAT, arch::SYS_ACCESS].into_iter().flatten());
918    for nr in stat_nrs {
919        table.register(nr, cow_call!(crate::cow::dispatch::handle_cow_stat));
920    }
921
922    table.register(libc::SYS_statx, cow_call!(crate::cow::dispatch::handle_cow_statx));
923
924    let mut readlink_nrs = vec![libc::SYS_readlinkat];
925    readlink_nrs.extend(arch::SYS_READLINK);
926    for nr in readlink_nrs {
927        table.register(nr, cow_call!(crate::cow::dispatch::handle_cow_readlink));
928    }
929
930    let mut getdents_nrs = vec![libc::SYS_getdents64];
931    getdents_nrs.extend(arch::SYS_GETDENTS);
932    for nr in getdents_nrs {
933        table.register(nr, cow_call!(crate::cow::dispatch::handle_cow_getdents));
934    }
935
936    table.register(libc::SYS_chdir, cow_call!(crate::cow::dispatch::handle_cow_chdir));
937    table.register(libc::SYS_getcwd, cow_call!(crate::cow::dispatch::handle_cow_getcwd));
938
939    for &nr in &[libc::SYS_execve, libc::SYS_execveat] {
940        table.register(nr, cow_call!(crate::cow::dispatch::handle_cow_exec));
941    }
942}
943
944// ============================================================
945// Tests
946// ============================================================
947
948#[cfg(test)]
949mod handler_tests {
950    //! Unit tests for the user-supplied handler extension API.
951    //!
952    //! Drive the actual `DispatchTable::dispatch` walker against a minimal
953    //! `SupervisorCtx` constructed from default-state pieces.  Handler
954    //! closures here ignore the context (no notif fd, no real child), so
955    //! the dispatch invariants under test (registration order, chain
956    //! short-circuit on first non-`Continue`, append-after-builtin
957    //! placement) are exercised end-to-end without needing a live
958    //! Landlock+seccomp sandbox — those scenarios live under
959    //! `crates/sandlock-core/tests/integration/test_handlers.rs`.
960    use super::*;
961    use crate::netlink::NetlinkState;
962    use crate::seccomp::ctx::SupervisorCtx;
963    use crate::seccomp::notif::NotifPolicy;
964    use crate::seccomp::state::{
965        ChrootState, CowState, NetworkState, PolicyFnState, ProcessIndex, ProcfsState,
966        ResourceState, TimeRandomState,
967    };
968    use crate::sys::structs::{SeccompData, SeccompNotif};
969    use std::sync::atomic::{AtomicUsize, Ordering};
970
971    fn fake_notif(nr: i32) -> SeccompNotif {
972        SeccompNotif {
973            id: 0,
974            pid: 1,
975            flags: 0,
976            data: SeccompData {
977                nr,
978                arch: 0,
979                instruction_pointer: 0,
980                args: [0; 6],
981            },
982        }
983    }
984
985    /// Minimal `SupervisorCtx` for unit tests.  Every field is built from
986    /// the corresponding state's `new()`/default constructor — no syscalls,
987    /// no fds, no spawned children.  Handlers in these tests do not
988    /// actually inspect the context, so the values do not need to match
989    /// any real run; they only need to satisfy the type signature so we
990    /// can call `dispatch()`.
991    fn fake_supervisor_ctx() -> Arc<SupervisorCtx> {
992        Arc::new(SupervisorCtx {
993            resource: Arc::new(Mutex::new(ResourceState::new(0, 0))),
994            cow: Arc::new(Mutex::new(CowState::new())),
995            procfs: Arc::new(Mutex::new(ProcfsState::new())),
996            network: Arc::new(Mutex::new(NetworkState::new())),
997            time_random: Arc::new(Mutex::new(TimeRandomState::new(None, None))),
998            policy_fn: Arc::new(Mutex::new(PolicyFnState::new())),
999            chroot: Arc::new(Mutex::new(ChrootState::new())),
1000            netlink: Arc::new(NetlinkState::new()),
1001            processes: Arc::new(ProcessIndex::new()),
1002            policy: Arc::new(NotifPolicy {
1003                max_memory_bytes: 0,
1004                max_processes: 0,
1005                has_memory_limit: false,
1006                has_net_allowlist: false,
1007                has_random_seed: false,
1008                has_time_start: false,
1009                time_offset: 0,
1010                num_cpus: None,
1011                argv_safety_required: false,
1012                port_remap: false,
1013                cow_enabled: false,
1014                chroot_root: None,
1015                chroot_readable: Vec::new(),
1016                chroot_writable: Vec::new(),
1017                chroot_denied: Vec::new(),
1018                chroot_mounts: Vec::new(),
1019                deterministic_dirs: false,
1020                virtual_hostname: None,
1021                has_http_acl: false,
1022                virtual_etc_hosts: None,
1023            }),
1024            child_pidfd: None,
1025            notif_fd: -1,
1026        })
1027    }
1028
1029    /// All registered handlers run, in registration order, when each
1030    /// returns `Continue`.  Verifies that `register` appends to the
1031    /// underlying `Vec` and that `dispatch` walks it front-to-back.
1032    #[tokio::test]
1033    async fn dispatch_walks_chain_in_registration_order() {
1034        let mut table = DispatchTable::new();
1035        let order = Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
1036
1037        for tag in [1u8, 2u8, 3u8] {
1038            let order_clone = Arc::clone(&order);
1039            table.register(
1040                libc::SYS_openat,
1041                move |_cx: &HandlerCtx| {
1042                    let order = Arc::clone(&order_clone);
1043                    async move {
1044                        order.lock().unwrap().push(tag);
1045                        NotifAction::Continue
1046                    }
1047                },
1048            );
1049        }
1050
1051        let _ctx = fake_supervisor_ctx();
1052        let action = table
1053            .dispatch(fake_notif(libc::SYS_openat as i32), -1)
1054            .await;
1055
1056        assert!(matches!(action, NotifAction::Continue));
1057        let recorded = order.lock().unwrap();
1058        assert_eq!(
1059            *recorded,
1060            [1u8, 2u8, 3u8],
1061            "every handler must run, in the order it was registered"
1062        );
1063    }
1064
1065    /// Append-after-builtin contract: when a user handler is registered
1066    /// after a builtin, dispatch invokes the builtin first and the
1067    /// user handler second.  This is the security-load-bearing invariant —
1068    /// a builtin returning a non-`Continue` `NotifAction` must short-circuit
1069    /// before the user handler runs (covered by
1070    /// `dispatch_stops_at_first_non_continue`); when the builtin returns
1071    /// `Continue`, the user handler observes the post-builtin view.
1072    #[tokio::test]
1073    async fn dispatch_runs_builtin_before_extra() {
1074        let mut table = DispatchTable::new();
1075        let order = Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
1076
1077        // Builtin first, tagged 'B'.
1078        let order_builtin = Arc::clone(&order);
1079        table.register(
1080            libc::SYS_openat,
1081            move |_cx: &HandlerCtx| {
1082                let order = Arc::clone(&order_builtin);
1083                async move {
1084                    order.lock().unwrap().push(b'B');
1085                    NotifAction::Continue
1086                }
1087            },
1088        );
1089
1090        // Extra after, tagged 'E'.  Registered after builtin to mirror
1091        // append-after-builtin placement from `build_dispatch_table`.
1092        let order_extra = Arc::clone(&order);
1093        table.register(
1094            libc::SYS_openat,
1095            move |_cx: &HandlerCtx| {
1096                let order = Arc::clone(&order_extra);
1097                async move {
1098                    order.lock().unwrap().push(b'E');
1099                    NotifAction::Continue
1100                }
1101            },
1102        );
1103
1104        let _ctx = fake_supervisor_ctx();
1105        let action = table
1106            .dispatch(fake_notif(libc::SYS_openat as i32), -1)
1107            .await;
1108
1109        assert!(matches!(action, NotifAction::Continue));
1110        let recorded = order.lock().unwrap();
1111        assert_eq!(
1112            *recorded,
1113            [b'B', b'E'],
1114            "builtin must run before extra (insertion order preserved)"
1115        );
1116    }
1117
1118    /// First non-`Continue` wins: a handler returning `Errno` short-circuits
1119    /// the chain, and subsequent handlers must not run.  This is the
1120    /// invariant that prevents a user-supplied extra from being observed
1121    /// (or, in the inverse direction, prevents an extra's `Errno` from
1122    /// being silently overridden by a later handler that happens to also
1123    /// be registered for the same syscall).
1124    #[tokio::test]
1125    async fn dispatch_stops_at_first_non_continue() {
1126        let mut table = DispatchTable::new();
1127        let calls = Arc::new(AtomicUsize::new(0));
1128
1129        // First handler — returns Errno, must terminate the chain.
1130        let calls_first = Arc::clone(&calls);
1131        table.register(
1132            libc::SYS_openat,
1133            move |_cx: &HandlerCtx| {
1134                let calls = Arc::clone(&calls_first);
1135                async move {
1136                    calls.fetch_add(1, Ordering::SeqCst);
1137                    NotifAction::Errno(libc::EACCES)
1138                }
1139            },
1140        );
1141
1142        // Second handler — must NOT be called.
1143        let calls_second = Arc::clone(&calls);
1144        table.register(
1145            libc::SYS_openat,
1146            move |_cx: &HandlerCtx| {
1147                let calls = Arc::clone(&calls_second);
1148                async move {
1149                    calls.fetch_add(1, Ordering::SeqCst);
1150                    NotifAction::Continue
1151                }
1152            },
1153        );
1154
1155        let _ctx = fake_supervisor_ctx();
1156        let action = table
1157            .dispatch(fake_notif(libc::SYS_openat as i32), -1)
1158            .await;
1159
1160        match action {
1161            NotifAction::Errno(e) => assert_eq!(e, libc::EACCES),
1162            other => panic!("expected Errno(EACCES), got {:?}", other),
1163        }
1164        assert_eq!(
1165            calls.load(Ordering::SeqCst),
1166            1,
1167            "second handler must not run after first returned non-Continue"
1168        );
1169    }
1170
1171    /// `validate_handler_syscalls_against_policy` must reject handlers whose
1172    /// syscall is in the policy's user-specified blocklist, with the same
1173    /// rationale as DEFAULT_BLOCKLIST: the BPF program emits notif JEQs before
1174    /// deny JEQs, so a user handler returning `Continue` would translate into
1175    /// `SECCOMP_USER_NOTIF_FLAG_CONTINUE` and silently bypass the kernel-level
1176    /// block.
1177    ///
1178    /// Uses `mremap` because it is in `syscall_name_to_nr` but not in
1179    /// `DEFAULT_BLOCKLIST_SYSCALLS` — putting it into `extra_deny_syscalls` is the only
1180    /// way it ends up on the extra blocklist, so the test isolates the user-supplied
1181    /// path of `blocklist_syscall_numbers` from the default branch covered by
1182    /// `handler_on_default_blocklist_syscall_is_rejected`.
1183    ///
1184    /// Pure-logic counterpart to the integration test of the same name —
1185    /// runs without a live sandbox so the contract is enforced even on
1186    /// hosts where seccomp integration tests are skipped.
1187    #[test]
1188    fn validate_extras_rejects_user_specified_blocklist() {
1189        let policy = crate::sandbox::Sandbox::builder()
1190            .extra_deny_syscalls(vec!["mremap".into()])
1191            .build()
1192            .expect("policy builds");
1193
1194        let result = validate_handler_syscalls_against_policy(&[libc::SYS_mremap], &policy);
1195        assert_eq!(
1196            result,
1197            Err(libc::SYS_mremap),
1198            "handler on user-specified blocklist must be rejected, naming the offending syscall"
1199        );
1200    }
1201
1202    // ---- Handler trait tests --------------------------------------
1203
1204    #[tokio::test]
1205    async fn handler_via_blanket_impl_dispatches_closures() {
1206        use std::sync::atomic::{AtomicU64, Ordering};
1207        let counter = Arc::new(AtomicU64::new(0));
1208        let counter_clone = Arc::clone(&counter);
1209
1210        let h = move |cx: &HandlerCtx| {
1211            let counter = Arc::clone(&counter_clone);
1212            async move {
1213                counter.fetch_add(1, Ordering::SeqCst);
1214                let _ = cx.notif.pid; // touch ctx so it's exercised
1215                NotifAction::Continue
1216            }
1217        };
1218
1219        let _sup = fake_supervisor_ctx();
1220        let notif = fake_notif(libc::SYS_openat as i32);
1221        let cx = HandlerCtx { notif, notif_fd: -1 };
1222
1223        let action = h.handle(&cx).await;
1224        assert!(matches!(action, NotifAction::Continue));
1225        assert_eq!(counter.load(Ordering::SeqCst), 1);
1226    }
1227
1228    /// Struct-based `Handler` registered through `DispatchTable::register`
1229    /// MUST be invoked when `dispatch()` walks the chain — and `&self`
1230    /// state MUST persist across notifications.  Bridges the gap between
1231    /// the trait-shape unit tests above (which call `.handle()` directly)
1232    /// and the dispatch ordering tests (which use closures via blanket
1233    /// impl).  Without this test, a regression where the dispatch walker
1234    /// dropped `Arc<dyn Handler>` calls but kept closures working would
1235    /// not be caught at the unit layer.
1236    #[tokio::test]
1237    async fn dispatch_invokes_struct_handler_with_persistent_self_state() {
1238        use std::sync::atomic::{AtomicU64, Ordering};
1239
1240        struct StructHandler {
1241            calls: AtomicU64,
1242        }
1243
1244        impl Handler for StructHandler {
1245            fn handle<'a>(
1246                &'a self,
1247                _cx: &'a HandlerCtx,
1248            ) -> std::pin::Pin<Box<dyn std::future::Future<Output = NotifAction> + Send + 'a>> {
1249                Box::pin(async move {
1250                    self.calls.fetch_add(1, Ordering::SeqCst);
1251                    NotifAction::Continue
1252                })
1253            }
1254        }
1255
1256        let mut table = DispatchTable::new();
1257        let handler = std::sync::Arc::new(StructHandler {
1258            calls: AtomicU64::new(0),
1259        });
1260        table.register_arc(libc::SYS_openat, handler.clone() as std::sync::Arc<dyn Handler>);
1261
1262        let _sup = fake_supervisor_ctx();
1263        let notif = fake_notif(libc::SYS_openat as i32);
1264
1265        // Three independent dispatches against the same registered handler.
1266        // Walker MUST hit the struct's handle() each time, accumulating
1267        // state on &self.calls.
1268        for _ in 0..3 {
1269            let action = table.dispatch(notif, -1).await;
1270            assert!(matches!(action, NotifAction::Continue));
1271        }
1272
1273        assert_eq!(
1274            handler.calls.load(Ordering::SeqCst),
1275            3,
1276            "dispatch must invoke the struct-based handler on every walk"
1277        );
1278    }
1279}