feat(seccomp): Handler trait + IntoIterator-shaped run_with_extra_handlers (Follow-up A)#36
Conversation
|
Thanks for the thorough PR. The security work is exactly right — 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
What I actually wanted from "builder pattern" was the registration-site cleanup (no more 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 2. No deprecation cycle — hard break. Sandlock doesn't maintain backwards compatibility before 1.0. The whole Concrete asks Keep, source-compatible with
Keep the names, change only the handler parameter shape:
Delete:
Re-export at the crate root in What stays — keep all of it:
Substance is right; just want to land it with a simpler shape. Happy to discuss if any of the above feels off. |
1e4a6fe to
d32b1df
Compare
|
Thanks for the careful review — pushed v2 (force-push, 6 commits replacing the previous 14). Done per the asks:
One ask I'd like to clarify before going further:
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 |
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`).
d32b1df to
7ad4e8d
Compare
|
Rebased onto current One note on the final test result locally: Everything else still holds: 259 lib unit tests pass, the other 13 carry-over+new integration tests pass, clippy clean, zero deprecation warnings. |
7ad4e8d to
43d525e
Compare
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.
43d525e to
ba6b657
Compare
|
Found and fixed the |
|
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
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 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 2.
|
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.
|
Pushed three follow-up commits addressing all three asks; PR title and body rewritten to match what shipped. 1. Box / Arc blanket impls. Added 2. Drop async-trait macro dep. Done — 3. Drop SupervisorCtx from public API. 4. PR body and title rewritten to match the shipped diff — no more references to CI green on both targets, 259 lib unit + 14 integration extra-handler tests pass, clippy clean, zero deprecation warnings. |
Signed-off-by: Cong Wang <cwang@multikernel.io>
Follow-up A from the PR #20 review. Reshapes the user-supplied seccomp-notif extension API around a
Handlertrait +Syscall::checkednewtype, 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 (noasync_traitmacro dep) so the trait stays object-safe; the supervisor stores user handlers asVec<Arc<dyn Handler>>.pub struct HandlerCtx { pub notif: SeccompNotif, pub notif_fd: RawFd }— no<'a>parameter, nosupfield;SupervisorCtxispub(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 asSandlockError::Handler(HandlerError).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>andimpl Handler for Arc<dyn Handler>— type-erasure for callers mixing differently-typed handlers in oneIntoIterator.Reshaped entry points
Sandbox::run_with_extra_handlersandrun_interactive_with_extra_handlerskeep their names; the handler parameter shape changes fromVec<ExtraHandler>toIntoIterator<Item = (S, H)>:Old call:
New call:
Sandbox::runandSandbox::run_interactivekeep their signatures unchanged frommain.Removed (hard break, no deprecation)
pub struct ExtraHandler,ExtraHandler::newpub type HandlerFn(the boxed-closure typedef)async_trait = "0.1"dep fromcrates/sandlock-core/Cargo.tomlPublic utility promotion
pub fn read_child_cstr— TOCTOU-safe NUL-terminated child-memory read (waspub(crate)). Mirrors the272ae0dprecedent forread_child_mem/write_child_mem.Internal changes
DispatchTablebacking storage isVec<Arc<dyn Handler>>.register<H: Handler>and thepub(crate) register_arcaccept any handler / pre-Arc'd handler.cx.sup.<field>now capture the specific sub-Arc (ctx.chroot,ctx.cow,ctx.processes, etc.) at table-build time.validate_extras_against_policyrenamed tovalidate_handler_syscalls_against_policy, now takes&[i64].seccomp::ctxandseccomp::statemodules arepub(crate).DispatchTable::dispatchispub(crate); itsctxparameter is gone.notif::supervisorparameter follows the sameVec<(i64, Arc<dyn Handler>)>shape change.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
[(syscall, handler)]array-literal call site.Syscall::checkedrejections, insertion-order, and default-deny rejection.Syscall::checked.259 lib unit + 14 integration extra-handler tests pass; clippy clean; zero deprecation warnings.
Notes
Pin<Box<dyn Future + Send + '_>>instead of nativeimpl Future + Sendin 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 theasync_traitmacro dep; the Box-future behaviour is the same the macro was generating.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::Beforeaxis — explicitly deferred until a concrete consumer.Handlertrait — Follow-up B, separate PR on top of A.