Skip to content

feat(seccomp): Handler trait + IntoIterator-shaped run_with_extra_handlers (Follow-up A)#36

Merged
congwang-mk merged 9 commits intomultikernel:mainfrom
dzerik:feature/extra-handler-trait-reshape
May 6, 2026
Merged

feat(seccomp): Handler trait + IntoIterator-shaped run_with_extra_handlers (Follow-up A)#36
congwang-mk merged 9 commits intomultikernel:mainfrom
dzerik:feature/extra-handler-trait-reshape

Conversation

@dzerik
Copy link
Copy Markdown
Contributor

@dzerik dzerik commented May 4, 2026

Follow-up A from the PR #20 review. Reshapes the user-supplied seccomp-notif extension API around a Handler trait + Syscall::checked newtype, kept dyn-compatible and minimal in public surface. Hard break — no deprecation cycle.

Public API

New exports at the crate root (sandlock_core::*):

  • pub trait Handler { fn handle<'a>(&'a self, cx: &'a HandlerCtx) -> Pin<Box<dyn Future<Output = NotifAction> + Send + 'a>>; } — manually written (no async_trait macro dep) so the trait stays object-safe; the supervisor stores user handlers as Vec<Arc<dyn Handler>>.
  • pub struct HandlerCtx { pub notif: SeccompNotif, pub notif_fd: RawFd } — no <'a> parameter, no sup field; SupervisorCtx is pub(crate) and not part of the extension contract.
  • pub struct Syscall(i64) + Syscall::checked(nr) -> Result<Self, SyscallError> — non-negative + arch-known validation. Closes the silent-never-fires footgun.
  • pub enum HandlerError { InvalidSyscall(SyscallError), OnDenySyscall { syscall_nr } }, surfaced as SandlockError::Handler(HandlerError).
  • Three blanket impls for ergonomic composition:
    • impl<F, Fut> Handler for F where F: Fn(&HandlerCtx) -> Fut + Send + Sync + 'static, Fut: Future<Output = NotifAction> + Send + 'static — closures.
    • impl Handler for Box<dyn Handler> and impl Handler for Arc<dyn Handler> — type-erasure for callers mixing differently-typed handlers in one IntoIterator.

Reshaped entry points

Sandbox::run_with_extra_handlers and run_interactive_with_extra_handlers keep their names; the handler parameter shape changes from Vec<ExtraHandler> to IntoIterator<Item = (S, H)>:

pub async fn run_with_extra_handlers<I, S, H>(
    policy: &Policy,
    name: Option<&str>,
    cmd: &[&str],
    extra_handlers: I,
) -> Result<RunResult, SandlockError>
where
    I: IntoIterator<Item = (S, H)>,
    S: TryInto<Syscall, Error = SyscallError>,
    H: Handler;

Old call:

Sandbox::run_with_extra_handlers(&p, name, &c, vec![ExtraHandler::new(libc::SYS_openat, h)]).await?;

New call:

Sandbox::run_with_extra_handlers(&p, name, &c, [(libc::SYS_openat, openat_h)]).await?;

Sandbox::run and Sandbox::run_interactive keep their signatures unchanged from main.

Removed (hard break, no deprecation)

  • pub struct ExtraHandler, ExtraHandler::new
  • pub type HandlerFn (the boxed-closure typedef)
  • async_trait = "0.1" dep from crates/sandlock-core/Cargo.toml

Public utility promotion

  • pub fn read_child_cstr — TOCTOU-safe NUL-terminated child-memory read (was pub(crate)). Mirrors the 272ae0d precedent for read_child_mem / write_child_mem.

Internal changes

  • DispatchTable backing storage is Vec<Arc<dyn Handler>>. register<H: Handler> and the pub(crate) register_arc accept any handler / pre-Arc'd handler.
  • All 21+ builtin handler chunks rewritten onto the new shape using the closure-via-blanket-impl form.
  • Builtins that previously dereferenced cx.sup.<field> now capture the specific sub-Arc (ctx.chroot, ctx.cow, ctx.processes, etc.) at table-build time.
  • validate_extras_against_policy renamed to validate_handler_syscalls_against_policy, now takes &[i64].
  • seccomp::ctx and seccomp::state modules are pub(crate).
  • DispatchTable::dispatch is pub(crate); its ctx parameter is gone.
  • notif::supervisor parameter follows the same Vec<(i64, Arc<dyn Handler>)> shape change.
  • The chroot_handler! / chroot_handler_fallthrough! / cow_call! macros clone the specific sub-Arcs they need at expansion site instead of going through the public dispatch context.

Tests

  • 8 carry-over integration tests from PR feat(seccomp): ExtraHandler — user-supplied syscall handlers #20, rewritten to use the new closure-via-blanket-impl shape and the [(syscall, handler)] array-literal call site.
  • 6 new integration tests: blanket-impl-dispatches, struct-state-persists, two Syscall::checked rejections, insertion-order, and default-deny rejection.
  • 1 new dispatch-level unit test plus 4 unit tests for Syscall::checked.

259 lib unit + 14 integration extra-handler tests pass; clippy clean; zero deprecation warnings.

Notes

  • Pin<Box<dyn Future + Send + '_>> instead of native impl Future + Send in trait — RPITIT is not object-safe; the native shape would require a non-erased dispatch chain incompatible with arbitrary user handler types. This still drops the async_trait macro dep; the Box-future behaviour is the same the macro was generating.
  • Concrete Box<dyn Handler> / Arc<dyn Handler> impls (not blanket <H: Handler + ?Sized>) to avoid coherence overlap with the closure blanket above.

Out of scope (deferred per PR #20 review)

  • HandlerPriority::Before axis — explicitly deferred until a concrete consumer.
  • FFI / Python parity for Handler trait — Follow-up B, separate PR on top of A.

@congwang-mk
Copy link
Copy Markdown
Contributor

congwang-mk commented May 4, 2026

Thanks for the thorough PR. The security work is exactly right — Syscall::checked, per-syscall deny validation, the Handler trait + blanket impl, read_child_cstr promotion, dogfooding all 21 builtins onto the new shape, and the 6 new integration tests are all solid.

I want to walk back two things from my PR #20 review notes before this lands:

1. Drop the builder; keep the existing four-method shape.

I sketched Sandbox::add_handler builder in the PR #20 review, but on reading this PR I don't think it's the right shape for sandlock right now. The builder forces:

  • The runexecute rename (only because the deprecated static run blocks the name)
  • A two-phase configure/execute model where run takes &mut self, leaking the mem::take on pending_handlers into the type signature
  • Two extra tokens (Sandbox::new(&p)?.run(&c) vs Sandbox::run(&p, &c)) on every common-case invocation, forever

What I actually wanted from "builder pattern" was the registration-site cleanup (no more Box::new(move |notif, ctx, fd| Box::pin(async move {...}))), per-syscall validation, and dropping the : HandlerFn ascription. All three come along by simply changing the handler parameter type on the existing run_with_extra_handlers to use your new Handler trait + Syscall::checked:

pub async fn run_with_extra_handlers<I, S, H>(
    policy: &Policy,
    cmd: &[&str],
    extra_handlers: I,
) -> Result<RunResult, SandlockError>
where
    I: IntoIterator<Item = (S, H)>,
    S: TryInto<Syscall, Error = SyscallError>,
    H: Handler,

Old call:

Sandbox::run_with_extra_handlers(&p, &c, vec![ExtraHandler::new(libc::SYS_openat, h)]).await?;

New call:

Sandbox::run_with_extra_handlers(&p, &c, [(libc::SYS_openat, openat_h)]).await?;

Same Handler trait, same Syscall::checked, same security boundary, no naming collision, no two-phase model. If we later expose more public Sandbox configuration (init_fn, on_bind, stdout_pipe, etc.) and a builder is warranted on its own merits, we can add Sandbox::builder() additively next to the existing run family then.

2. No deprecation cycle — hard break.

Sandlock doesn't maintain backwards compatibility before 1.0. The whole #[deprecated] apparatus — HandlerFnAdapter, the deprecated marks, the HandlerFn typedef, the ExtraHandler struct, the regression test bridging old↔new — should come out. Hard rename, mention the migration in the changelog.

Concrete asks

Keep, source-compatible with main:

  • Sandbox::run(policy, cmd) — unchanged
  • Sandbox::run_interactive(policy, cmd) — unchanged

Keep the names, change only the handler parameter shape:

  • Sandbox::run_with_extra_handlers(policy, cmd, handlers)handlers becomes IntoIterator<Item = (S, H)> instead of Vec<ExtraHandler>
  • Sandbox::run_interactive_with_extra_handlers(policy, cmd, handlers) — same

Delete:

  • The builder layer added by this PR: Sandbox::add_handler, Sandbox::execute, Sandbox::execute_interactive
  • Sandbox::new from the public API surface — drop to pub(crate); users no longer need to construct a Sandbox directly
  • pub type HandlerFn, pub struct ExtraHandler, ExtraHandler::new, HandlerFnAdapter
  • BuilderError — fold validation errors into SandlockError (or a thin HandlerError if you prefer; no strong opinion)
  • All #[deprecated] markers and the module-level #![allow(deprecated)] on tests/integration/test_extra_handlers.rs
  • The deprecated_run_with_extras_observable_behavior_matches_add_handler regression test (bridging old↔new is no longer a thing once the old shape is gone)

Re-export at the crate root in lib.rs: Handler, HandlerCtx, Syscall, SyscallError. Right now users have to spell out sandlock_core::seccomp::dispatch::Handler etc.

What stays — keep all of it:

  • Syscall(i64) newtype + Syscall::checked + TryFrom<i64> + SyscallError
  • Handler trait, HandlerCtx<'a>, blanket impl<F, Fut> Handler for F
  • validate_handler_syscalls_against_policy (the per-syscall variant)
  • read_child_cstr promotion + the generic-phrasing fix on read_child_mem / write_child_mem doc comments
  • All 21 builtin handler closures rewritten onto the new shape
  • The 6 new integration tests (blanket-impl dispatch, struct state persistence, negative/arch-unknown rejection, insertion order, default-deny rejection) and the dispatch-level struct-handler unit test
  • MAX_SYSCALL_NR + is_known_syscall in arch.rs
  • Cargo.toml async-trait dep
  • The docs/extension-handlers.md rewrite (will need a pass to unwind the builder examples and the Backwards-Compatibility / Migration sections, but the structure and the new content on interactive mode + child-mem reads + state patterns is keep-as-is)

Substance is right; just want to land it with a simpler shape. Happy to discuss if any of the above feels off.

@dzerik
Copy link
Copy Markdown
Contributor Author

dzerik commented May 5, 2026

Thanks for the careful review — pushed v2 (force-push, 6 commits replacing the previous 14).

Done per the asks:

  • Builder layer (Sandbox::add_handler / execute / execute_interactive) and BuilderError removed. Validation errors fold into a thin HandlerError enum surfaced as SandlockError::Handler(HandlerError).
  • run_with_extra_handlers and run_interactive_with_extra_handlers now take I: IntoIterator<Item = (S, H)> where S: TryInto<Syscall, Error = SyscallError>, H: Handler. No rename, no two-phase model, no source-incompatible change to the existing run / run_interactive.
  • pub struct ExtraHandler, pub type HandlerFn, HandlerFnAdapter, all #[deprecated] marks, the module-level #![allow(deprecated)], and the deprecated-path adapter-regression test all removed.
  • Handler, HandlerCtx, HandlerError, Syscall, SyscallError re-exported at the crate root.
  • Doc unwound: builder examples replaced with run_with_extra_handlers examples, Backwards-Compatibility and Migration-from-ExtraHandler sections deleted.
  • During self-review I also dropped a small impl Handler for Arc<dyn Handler> blanket that an earlier draft had — it wasn't part of your asks, and the chain-ordering integration tests now use a homogeneous struct handler (Counter / OrderTracker with id + configurable action) instead of trait-object erasure, which is cleaner and removes that public-surface footnote.

One ask I'd like to clarify before going further:

  • Sandbox::new left as pub. Trying to drop it to pub(crate) surfaced that it isn't only used by the kill/pause/resume lifecycle tests — it's the entry point for an entire alternative public API surface: spawn / spawn_captured / spawn_with_io / spawn_with_gather_io / wait / pause / resume / kill / commit / abort_branch / fork / reduce / checkpoint, all of which take &mut self on a Sandbox first constructed via new. This pattern is what supports long-running supervised processes (and what pipeline.rs uses internally too). Did you intend to retire that whole alternative API in favour of run_with_extra_handlers only, or just remove the add_handler builder layer that this PR added? Happy to follow whichever direction — pub(crate)-ing new plus the spawn/lifecycle suite is a meaningfully larger surface change and seemed worth a check before doing it.

Tests / CI: 235 lib unit + 14 integration extra-handler tests pass; workspace builds with zero deprecation warnings and zero clippy errors. Branch rebased onto current main.

dzerik added 5 commits May 5, 2026 11:00
Closes the footgun where ExtraHandler::new(-5, h) or future add_handler
patterns would compile but silently never fire because the cBPF filter
cannot emit a JEQ for arch-unknown nr.

Syscall(i64) + Syscall::checked(nr) -> Result<Self, SyscallError>
(Negative / UnknownForArch variants) + TryFrom<i64> via checked.

Adds:
- crates/sandlock-core/src/seccomp/syscall.rs (78 lines, 4 unit tests)
- crates/sandlock-core/src/arch.rs: MAX_SYSCALL_NR per arch + pub fn
  is_known_syscall(nr) for the checked() validator
- async-trait dependency for the Handler trait that the next commit
  introduces
…dlers

Replaces the closure-typedef + struct-wrapper extension API with a
proper trait, and migrates all 21 builtin handlers + the public entry
points onto it.  No deprecation cycle: the old `ExtraHandler` /
`HandlerFn` types are removed outright.

API surface
-----------

New public types in `seccomp::dispatch`:

- `pub trait Handler { async fn handle(&self, &HandlerCtx<'_>) -> NotifAction; }`
  (via `async_trait` to match the maintainer's PR multikernel#20 review sketch).
- `pub struct HandlerCtx<'a>` — borrowed `notif`, `&Arc<SupervisorCtx>`,
  `notif_fd`.
- Blanket `impl<F, Fut> Handler for F where F: Fn(&HandlerCtx<'_>) -> Fut`
  so closure-shaped handlers compose with the trait without ceremony.
- `impl Handler for Arc<dyn Handler>` so callers can erase concrete
  closure types when collecting differently-shaped handlers in one
  iterator.
- `pub enum HandlerError { InvalidSyscall(SyscallError), OnDenySyscall { syscall_nr } }`
  added as a `#[from]` variant on `SandlockError::Handler`.

Public entry points reshaped (no rename, no deprecation):

- `Sandbox::run_with_extra_handlers<I, S, H>(policy, cmd, extra_handlers)`
  where `I: IntoIterator<Item = (S, H)>`,
  `S: TryInto<Syscall, Error = SyscallError>`,
  `H: Handler`.  Old:
      run_with_extra_handlers(p, c, vec![ExtraHandler::new(nr, fn)])
  New:
      run_with_extra_handlers(p, c, [(libc::SYS_openat, openat_h)])
- `Sandbox::run_interactive_with_extra_handlers<I, S, H>(...)` — same
  shape, interactive stdio.
- `Sandbox::run` and `Sandbox::run_interactive` keep their pre-existing
  signatures and are now self-contained (no longer route through the
  extras path with an empty Vec).

Removed
-------

- `pub struct ExtraHandler` and `ExtraHandler::new(nr, fn)`.
- `pub type HandlerFn` (the `Box<dyn Fn(SeccompNotif, Arc<...>, RawFd)
  -> Pin<Box<dyn Future>>>` typedef).
- The internal `HandlerFnAdapter` shim that wrapped `HandlerFn` into
  the new trait during the proposed deprecation cycle.

Internal
--------

- `DispatchTable` backing storage migrated to `Vec<Arc<dyn Handler>>`.
- `DispatchTable::register<H: Handler>` accepts any handler;
  `register_arc` for pre-`Arc`'d handlers shared across syscalls.
- All 21 builtin handler closures (fork/clone, wait, memory, network,
  random, time, openat virtualisation, etc.) rewritten onto the new
  shape using closures via blanket impl.  No behaviour change.
- `validate_extras_against_policy` renamed to
  `validate_handler_syscalls_against_policy` and now takes `&[i64]`.
- `Sandbox.extra_handlers` field type changed from `Vec<ExtraHandler>`
  to `Vec<(i64, Arc<dyn Handler>)>`.
- `notif::supervisor` parameter follows the same shape change.

Tests
-----

- 8 carry-over integration tests from PR multikernel#20 rewritten to use the new
  closure-via-blanket-impl shape and the `[(syscall, handler)]` array
  literal at the call site.  Coverage unchanged: deny-list rejection,
  ordering boundary, builtin short-circuit, Continue passthrough.
- 6 new integration tests:
  * `handler_via_blanket_impl_dispatches_in_sandbox` (closure shape)
  * `struct_handler_state_persists_across_sandbox_calls` (struct +
    `impl Handler` with `Arc<AtomicUsize>` self state observed across
    multiple `uname` notifications)
  * `run_with_extra_handlers_rejects_negative_syscall` /
    `..._rejects_arch_unknown_syscall` — surface
    `HandlerError::InvalidSyscall(SyscallError::*)`.
  * `run_with_extra_handlers_preserves_insertion_order_in_sandbox_chain`
    — `Arc<dyn Handler>` erasure pattern observed end-to-end.
  * `run_with_extra_handlers_rejects_handler_on_default_deny_syscall`
    — surfaces `HandlerError::OnDenySyscall`.
- 2 dispatch-level unit tests (closure-via-blanket and struct-impl)
  exercising the new trait through `DispatchTable::register` +
  `dispatch()`.

234 lib unit tests + 14 integration extra-handler tests pass.
Promotes pub(crate) fn read_child_cstr to pub.  Downstream Handler
implementations reading path arguments (openat / unlinkat / statx /
newfstatat / etc.) need TOCTOU-safe NUL-terminated string reads;
without public access, every consumer crate duplicates the page-aware
read loop.

Also tightens the doc-comments of read_child_mem and write_child_mem
to refer generically to "downstream Handler implementations" rather
than naming any specific consumer crate — a forward-compatible
phrasing as more crates start using these helpers.

Precedent: the sibling read_child_mem and write_child_mem were
promoted to pub in 272ae0d (the same commit chain that introduced
the original ExtraHandler API).  Same rationale here.

Adds 1 unit test covering the null-addr / zero-max_len short-circuits.
235 lib unit tests pass.
Adds re-exports in `lib.rs` so consumers can write
`sandlock_core::Handler` instead of `sandlock_core::seccomp::dispatch::Handler`,
mirroring the existing pattern for `Sandbox`, `Policy`,
`SandlockError`, etc.

Re-exported:
- `Handler`, `HandlerCtx`, `HandlerError` (from `seccomp::dispatch`)
- `Syscall`, `SyscallError` (from `seccomp::syscall`)

Updates the integration test imports to use the re-exports as a
documentation-by-example of the recommended path.
Restructured under: API / Semantics / Security boundary / Panics /
Use cases / Limitations / Downstream usage.

Lead example uses
`Sandbox::run_with_extra_handlers(&policy, &cmd, [(SYS_x, h)])`.
Closure via blanket impl shown alongside struct-based handler with
state on `&self`.  `Syscall::checked` newtype documented with
`TryInto<Syscall>` ergonomics.

Three additional sections cover practical patterns:

- Interactive mode: `run_interactive_with_extra_handlers` for
  shells/REPLs.
- Reading syscall arguments: `read_child_cstr` / `read_child_mem` /
  `write_child_mem` table + worked example using `read_child_cstr`
  for an `openat` extension handler.
- State patterns: `AtomicU64` / `Mutex` / `RwLock` / `DashMap`
  comparison plus a `CallStats` handler shared across multiple
  syscalls via `Arc<dyn Handler>` erasure.

The earlier draft of this rewrite included a Backwards-Compatibility
section and a Migration-from-`ExtraHandler` walk-through; both are
removed because the trait reshape is a hard break (no deprecation
cycle), as agreed in the upstream PR review.

Use cases re-anchored to generic archetypes (VFS engine, audit
pipeline, synthetic file content via `InjectFdSendTracked`).
@dzerik dzerik force-pushed the feature/extra-handler-trait-reshape branch from d32b1df to 7ad4e8d Compare May 5, 2026 08:17
@dzerik
Copy link
Copy Markdown
Contributor Author

dzerik commented May 5, 2026

Rebased onto current main (your name runtime parameter, arch::FORK_LIKE_SYSCALLS, policy.virtual_hostname, argv_safety_required, c218e28's /bin/pwd+SYS_getcwd test fix all picked up). PR is mergeable again; same 6-commit shape over the new base.

One note on the final test result locally: builtin_non_continue_blocks_extra (the cat /proc/1/cmdline; cat /etc/hostname carry-over from PR #20) fails for me on this rebase even though it passes on the v2-pre-rebase tip and presumably passes on your CI. The /etc/hostname path doesn't appear in the observed-paths list — only the dynamic-linker probes do — which suggests cat no longer reaches the second argument in my environment. I haven't isolated whether it's a SysV-IPC default-deny interaction (3816e8a), an argv-safety effect (3a87632), or a local-environment thing. I'd appreciate eyes on this from your CI run — the behaviour change isn't from anything in this PR's scope (the test body is unchanged from carry-over, only the IntoIterator call-site was rewritten), but it'll be more useful with confirmation either way.

Everything else still holds: 259 lib unit tests pass, the other 13 carry-over+new integration tests pass, clippy clean, zero deprecation warnings.

@dzerik dzerik force-pushed the feature/extra-handler-trait-reshape branch from 7ad4e8d to 43d525e Compare May 5, 2026 16:46
Self-review pass before publishing v2 of the PR.  Two related changes:

1. **Remove `impl Handler for Arc<dyn Handler>`** added in the previous
   commit.  Maintainer's review didn't ask for it — it's extra public
   surface area that obscures the message of the PR.  The two
   chain-ordering integration tests
   (`chain_of_extras_runs_in_insertion_order`,
   `run_with_extra_handlers_preserves_insertion_order_in_sandbox_chain`)
   are rewritten with a `Counter`/`OrderTracker` struct so the iterator's
   `H` parameter stays homogeneous through real `impl Handler` instances
   instead of trait-object erasure.

   `docs/extension-handlers.md` updated accordingly: the entry-points
   section, the VFS engine example, and the downstream-usage example
   now show an enum-adapter pattern for the rare case where downstream
   needs to mix differently-typed handler structs in one iterator —
   adapters are user-side concerns, not part of `sandlock-core`'s API.

2. **Stale doc-references** to the now-removed `ExtraHandler` /
   `HandlerFn` in `context.rs`, `sandbox.rs`, and the integration-test
   module header are reworded to refer to `Handler` /
   `Arc<dyn Handler>` generically.

No behaviour change.  235 lib unit tests + 14 integration extra-handler
tests pass; clippy clean; zero deprecation warnings workspace-wide.
@dzerik dzerik force-pushed the feature/extra-handler-trait-reshape branch from 43d525e to ba6b657 Compare May 5, 2026 16:50
@dzerik
Copy link
Copy Markdown
Contributor Author

dzerik commented May 5, 2026

Found and fixed the builtin_non_continue_blocks_extra failure I flagged earlier — it was a stale path in the carry-over test (/etc/hostname from PR #20) that you had already updated to /etc/passwd on main. Picked up your version of the path; CI is now fully green (8/8 checks).

@congwang-mk
Copy link
Copy Markdown
Contributor

Thanks for the rework. The hard-break direction is right and the body of the change is in good shape — three things I'd like resolved before merge.

1. Homogeneous-iterator constraint regresses multi-handler registration

run_with_extra_handlers<I, S, H> constrains every entry to a single concrete H. The old Vec<ExtraHandler> was already type-erased via Box<dyn Fn>, so callers could mix handler shapes freely; the new shape forces every downstream that registers more than one handler kind to write a wrapper enum (the S3Handler / DownstreamHandler pattern from the doc, repeated per crate). That's a real ergonomics regression vs. PR #20.

The fix is small and lives upstream:

#[async_trait]
impl<H: Handler + ?Sized> Handler for Box<H> {
    async fn handle(&self, cx: &HandlerCtx<'_>) -> NotifAction {
        (**self).handle(cx).await
    }
}

Then downstream can write Vec<(i64, Box<dyn Handler>)> with Box::new(h_open) / Box::new(h_close) of any concrete types — H resolves to one type, the per-crate adapter enum disappears, and &cmd, [...] array-literal call-sites still work.

The doc note ("sandlock-core does not ship a built-in erasure to keep the public surface minimal") is the wrong tradeoff: 8 lines upstream vs. 12 lines × every downstream forever. Please add the blanket impl (and an Arc<H> mirror) and drop the wrapper-enum guidance from extension-handlers.md.

2. #[async_trait] vs. native AFIT

Adds a proc-macro dep, boxes a future on every dispatch, and forces #[async_trait::async_trait] on every downstream impl block. Native async-fn-in-trait has been stable since Rust 1.75 (Dec 2023); the workspace is on edition 2021 with no rust-version pin. Unless there's a concrete MSRV blocker, the trait should be:

pub trait Handler: Send + Sync + 'static {
    fn handle<'a>(
        &'a self,
        cx: &'a HandlerCtx<'_>,
    ) -> impl Future<Output = NotifAction> + Send + 'a;
}

Drops the dep, drops the per-call Box::pin, and downstream impl blocks lose the macro. If you've considered AFIT and ruled it out, please document the reason in the PR description; otherwise let's switch.

3. Don't expose SupervisorCtx at all

pub sup: &'a Arc<SupervisorCtx> makes every public field of SupervisorCtx (processes, network, chroot, cow, netlink, time_random, …) part of the downstream extension contract. The dispatch builtins already reach into sup.processes / sup.cow / etc., so any internal restructure of SupervisorCtx becomes a breaking API change downstream. Please drop it from the public surface.

Concretely:

pub struct HandlerCtx {
    pub notif: SeccompNotif,
    pub notif_fd: RawFd,
}

No sup field, no 'a parameter. Public Handler impls become fn handle(&self, cx: &HandlerCtx) — no lifetime threading.

Builtins keep working by capturing the sub-Arcs they need at table-build time instead of going through cx.sup. build_dispatch_table takes &Arc<SupervisorCtx> (still pub(crate)), and each builtin closure clones the specific field it needs once at registration:

let network = Arc::clone(&ctx.network);
table.register(libc::SYS_connect, move |cx: &HandlerCtx| {
    let network = Arc::clone(&network);
    async move {
        crate::network::handle_net(&cx.notif, &network, cx.notif_fd).await
    }
});

Same pattern for processes / cow / chroot / netlink / time_random; the chroot_handler! and cow_call! macros take ctx: and clone the relevant fields at expansion. Mechanical change — each of the ~30 builtin closures already does Arc::clone(cx.sup) followed by Arc::clone(&sup.foo), so this is one line per closure plus a captured-binding above.

Benefits:

  • SupervisorCtx itself can drop from pub to pub(crate) (please verify nothing else re-exports it).
  • Adding a field to SupervisorCtx becomes a non-breaking change forever.
  • No HandlerCtx<'a> lifetime parameter in the public API.
  • The "future task wants Arc<SupervisorCtx> to spawn helpers" justification still works for builtins — they capture Arc::clone(ctx) at table-build time and stash it in the closure. None of it leaks into HandlerCtx.

Separately: the PR body still describes the original add_handler / Sandbox::execute / #[deprecated] / 14-commits sketch, none of which is in the diff. Please rewrite it before merge so the merge commit message matches what shipped.

dzerik added 3 commits May 6, 2026 00:08
Lets callers mix differently-typed handlers in a single
`run_with_extra_handlers` invocation by erasing them behind a
`Box<dyn Handler>` (or `Arc<dyn Handler>`):

    Sandbox::run_with_extra_handlers(
        &policy,
        None,
        &cmd,
        [
            (libc::SYS_openat, Box::new(open_h)  as Box<dyn Handler>),
            (libc::SYS_close,  Box::new(close_h) as Box<dyn Handler>),
        ],
    ).await?;

Without these impls every downstream that registers more than one
handler kind has to write a per-crate wrapper enum.  The blanket
form `<H: Handler + ?Sized>` would overlap with the existing
`impl<F, Fut> Handler for F where F: Fn(&HandlerCtx<'_>) -> Fut`, so
these are concrete `Box<dyn Handler>` / `Arc<dyn Handler>` impls.

Updates `docs/extension-handlers.md` to use this pattern in the
"Entry points", "VFS engine", and "Downstream usage" examples,
replacing the previous wrapper-enum guidance.
Replaces `#[async_trait::async_trait]` on the `Handler` trait + impls
with a manually-written `fn handle(&self, …) -> Pin<Box<dyn Future + Send + '_>>`
signature.

Drops the proc-macro dependency, removes the `async fn` macro layer
from every downstream `impl Handler` block, and keeps the trait
dyn-compatible (the supervisor stores user handlers as
`Vec<Arc<dyn Handler>>`).

Native return-position-impl-Trait (`fn handle(...) -> impl Future + Send`)
would be more efficient but is not object-safe today, and changing the
storage to a non-erased shape would force a generic dispatch chain
incompatible with arbitrary user handler types — so the trait keeps
the `Pin<Box<dyn Future>>` shape that `async_trait` was generating.

Trait shape:

    pub trait Handler: Send + Sync + 'static {
        fn handle<'a>(
            &'a self,
            cx: &'a HandlerCtx<'_>,
        ) -> Pin<Box<dyn Future<Output = NotifAction> + Send + 'a>>;
    }

The `impl<F, Fut> Handler for F where F: Fn(&HandlerCtx<'_>) -> Fut`
blanket and the `Box<dyn Handler>` / `Arc<dyn Handler>` impls are
updated to match.  Three struct-based test handlers (`Counter`,
`UnameCounter`, `OrderTracker`, plus the `StructHandler` dispatch unit
test) wrap their async body in `Box::pin(async move { ... })`.

`async-trait = "0.1"` removed from `crates/sandlock-core/Cargo.toml`.
Removes the `pub sup: &Arc<SupervisorCtx>` field from `HandlerCtx`,
along with the `<'a>` lifetime parameter on the type.  `SupervisorCtx`
itself drops to `pub(crate)`, as does its sibling `state` module.
Builtins keep working by capturing the sub-Arcs they need at
table-build time instead of going through `cx.sup`.

Public surface change:

    // before
    pub struct HandlerCtx<'a> {
        pub notif: SeccompNotif,
        pub sup: &'a Arc<SupervisorCtx>,
        pub notif_fd: RawFd,
    }

    // after
    pub struct HandlerCtx {
        pub notif: SeccompNotif,
        pub notif_fd: RawFd,
    }

`SupervisorCtx` had public fields (`processes`, `network`, `chroot`,
`cow`, `netlink`, `time_random`, …); exposing it through the dispatch
context made every internal restructure of those fields a breaking
downstream change.  After this commit, `HandlerCtx` exposes only the
kernel notification and the supervisor's seccomp listener fd, so
adding fields to `SupervisorCtx` becomes a non-breaking change forever.

Internal mechanics:

- `build_dispatch_table` gains a `ctx: &Arc<SupervisorCtx>` parameter
  and is now `pub(crate)`.
- `register_chroot_handlers` and `register_cow_handlers` likewise take
  `ctx: &Arc<SupervisorCtx>`.
- The `chroot_handler!` / `chroot_handler_fallthrough!` / `cow_call!`
  macros clone the specific sub-Arcs they need (`ctx.chroot`, `ctx.cow`,
  `ctx.processes`) once at expansion time, then `Arc::clone` them per
  closure invocation — matching the per-handler pattern the rest of
  `build_dispatch_table` already uses.
- ~25 builtin closures rewritten to capture `Arc::clone(ctx)` (or a
  specific sub-Arc) at registration time instead of dereferencing
  `cx.sup` inside the async block.  All `move` keywords audited.
- `DispatchTable::dispatch` drops its `ctx` parameter (no longer needed
  to populate the public field) and is `pub(crate)`.

Continue-site safety doc rewritten: `cx.sup` no longer exists, so the
contract simplifies to "do not hold a handler-owned
`tokio::sync::Mutex` guard across `.await`".  Sandlock's internal locks
are unreachable from user handlers and therefore not part of the
contract.

259 lib unit tests + 14 integration extra-handler tests pass.
@dzerik dzerik changed the title feat(seccomp): Handler trait + Sandbox::add_handler builder (Follow-up A) feat(seccomp): Handler trait + IntoIterator-shaped run_with_extra_handlers (Follow-up A) May 5, 2026
@dzerik
Copy link
Copy Markdown
Contributor Author

dzerik commented May 5, 2026

Pushed three follow-up commits addressing all three asks; PR title and body rewritten to match what shipped.

1. Box / Arc blanket impls. Added impl Handler for Box<dyn Handler> and impl Handler for Arc<dyn Handler> so callers mixing different handler types in one iterator just Box::new(...) per entry — wrapper-enum guidance dropped from the doc. Note: these are concrete Box<dyn Handler> / Arc<dyn Handler> impls rather than the <H: Handler + ?Sized> blanket form you sketched, because the latter has a coherence overlap with the existing impl<F, Fut> Handler for F where F: Fn(...) -> Fut blanket — the compiler rejects it. Concrete impls cover the same use case (any Box<dyn Handler> / Arc<dyn Handler>).

2. Drop async-trait macro dep. Done — async_trait = "0.1" removed from Cargo.toml, #[async_trait] attributes removed from the trait + every impl. One nuance worth flagging: I tried your exact fn handle<'a>(&'a self, cx: &'a HandlerCtx) -> impl Future<Output = NotifAction> + Send + 'a shape first; it doesn't compile because RPITIT in trait position is not object-safe today, and the supervisor stores user handlers as Vec<Arc<dyn Handler>>. The trait now uses Pin<Box<dyn Future<Output = NotifAction> + Send + 'a>> explicitly — same Box::pin per dispatch the macro was generating, but with the proc-macro dep gone and the trait still dyn-compatible. If you'd prefer to change the storage shape away from dyn Handler to allow native AFIT, that's a meaningfully larger restructure and probably a separate PR; happy to do it if you want.

3. Drop SupervisorCtx from public API. HandlerCtx is now { pub notif, pub notif_fd } — no <'a> parameter, no sup field. seccomp::ctx and seccomp::state are pub(crate). Builtins capture the specific sub-Arcs (ctx.chroot, ctx.cow, ctx.processes, etc.) at table-build time inside the chroot_handler! / chroot_handler_fallthrough! / cow_call! macros and the per-syscall closures — cx.sup no longer exists internally either. DispatchTable::dispatch is pub(crate) and dropped its ctx parameter. Continue-site safety doc rewritten to reflect the simpler contract (handler-owned mutexes only).

4. PR body and title rewritten to match the shipped diff — no more references to Sandbox::add_handler / execute / #[deprecated] / 14-commits.

CI green on both targets, 259 lib unit + 14 integration extra-handler tests pass, clippy clean, zero deprecation warnings.

@congwang-mk congwang-mk merged commit 36cda37 into multikernel:main May 6, 2026
8 checks passed
congwang-mk added a commit that referenced this pull request May 6, 2026
Signed-off-by: Cong Wang <cwang@multikernel.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants