diff --git a/crates/busbar-contract/include/busbar_plugin.h b/crates/busbar-contract/include/busbar_plugin.h index 0f431b1b54..2c35ae2fe0 100644 --- a/crates/busbar-contract/include/busbar_plugin.h +++ b/crates/busbar-contract/include/busbar_plugin.h @@ -1149,9 +1149,9 @@ struct bb_mech_Blob { uint32_t flags; }; -/* The host's context for one plugin instance: opaque to the plugin, handed back on `wake`. */ +/* The host's context for one plugin instance: an opaque handle the host verifies on every call. */ struct bb_mech_HostCtx { - void *ptr; + uint64_t handle; }; /* A ticket: `(slot, generation)`. UNIQUE PER INSTANCE across all workers, so a plugin may key its */ @@ -4189,7 +4189,7 @@ BB_ASSERT(offsetof(bb_mech_CompletionHandle, seq) == 8, "bb_mech_CompletionHandl BB_ASSERT(offsetof(bb_mech_CompletionHandle, _reserved) == 12, "bb_mech_CompletionHandle._reserved: offset"); BB_ASSERT(sizeof(bb_mech_HostCtx) == 8, "bb_mech_HostCtx: size"); BB_ASSERT(BB_ALIGNOF(bb_mech_HostCtx) == 8, "bb_mech_HostCtx: alignment"); -BB_ASSERT(offsetof(bb_mech_HostCtx, ptr) == 0, "bb_mech_HostCtx.ptr: offset"); +BB_ASSERT(offsetof(bb_mech_HostCtx, handle) == 0, "bb_mech_HostCtx.handle: offset"); BB_ASSERT(sizeof(bb_mech_HostTables) == 48, "bb_mech_HostTables: size"); BB_ASSERT(BB_ALIGNOF(bb_mech_HostTables) == 8, "bb_mech_HostTables: alignment"); BB_ASSERT(offsetof(bb_mech_HostTables, size) == 0, "bb_mech_HostTables.size: offset"); diff --git a/crates/busbar-contract/src/abi/mechanism/ticket.rs b/crates/busbar-contract/src/abi/mechanism/ticket.rs index b51e48534d..f6f5af8246 100644 --- a/crates/busbar-contract/src/abi/mechanism/ticket.rs +++ b/crates/busbar-contract/src/abi/mechanism/ticket.rs @@ -14,8 +14,6 @@ //! * A host service that can pend answers with a [`CompletionHandle`] `(ticket, seq)`. On resume the //! plugin re-issues the same handle and receives the stored result: the host never runs it twice. -use std::os::raw::c_void; - use crate::abi::host::conn::connector::ConnectorSlots; use crate::abi::host::io::IoSlots; use crate::abi::host::service::HostSlots; @@ -58,12 +56,28 @@ pub struct CompletionHandle { pub _reserved: u32, } -/// The host's context for one plugin instance: opaque to the plugin, handed back on `wake`. +/// The host's context for one plugin instance: an opaque handle the host verifies on every call. +/// +/// BUSBAR-1.6.0.md Part 2 #74: no host address crosses the ABI. The handle is the host's own +/// index for the instance beside a keyed tag over it; the host recomputes the tag on every `wake`, +/// service, connector and I/O call and refuses a handle it did not mint before anything is read +/// through it. A plugin copies it and hands it back, and never interprets it. `0` is no context. #[repr(C)] -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct HostCtx { - /// The host's per-instance state; never dereferenced by a plugin. - pub ptr: *mut c_void, + /// The host's handle for the instance; never interpreted by a plugin. + pub handle: u64, +} + +impl HostCtx { + /// No context: what a host hands before it binds one, and what a test double hands. + pub const NONE: HostCtx = HostCtx { handle: 0 }; + + /// Whether this is [`HostCtx::NONE`]. + #[must_use] + pub const fn is_none(self) -> bool { + self.handle == 0 + } } /// `host.wake(ctx, ticket)`: callable from any thread, never blocks, never fails. A wake for a diff --git a/crates/busbar-contract/src/abi/sdk/services.rs b/crates/busbar-contract/src/abi/sdk/services.rs index 5954ad2e4e..99a1b30ce7 100644 --- a/crates/busbar-contract/src/abi/sdk/services.rs +++ b/crates/busbar-contract/src/abi/sdk/services.rs @@ -223,8 +223,8 @@ pub struct Wake { wake: WakeFn, } -// SAFETY: the context is the host's per-instance state and the wake a plain code address; the -// mechanism states the wake callable from any thread for the instance's life. +// SAFETY: the context is an opaque `u64` handle (#74: no host address crosses) and the wake a plain +// code address; the mechanism states the wake callable from any thread for the instance's life. unsafe impl Send for Wake {} // SAFETY: as above. unsafe impl Sync for Wake {} @@ -252,8 +252,8 @@ pub struct Services { table: *const HostSlots, } -// SAFETY: the table is the host's static and the context the host's leaked per-instance state; the -// host states both are callable from any thread for the instance's life. +// SAFETY: the table is the host's static and the context an opaque `u64` handle (#74); the host +// states both are usable from any thread for the instance's life. unsafe impl Send for Services {} // SAFETY: as above. unsafe impl Sync for Services {} diff --git a/crates/busbar-contract/src/abi/sdk/tests/auth_door_tests.rs b/crates/busbar-contract/src/abi/sdk/tests/auth_door_tests.rs index 1bd7fddcc8..4a186ce0ed 100644 --- a/crates/busbar-contract/src/abi/sdk/tests/auth_door_tests.rs +++ b/crates/busbar-contract/src/abi/sdk/tests/auth_door_tests.rs @@ -163,9 +163,7 @@ fn head(op: u32) -> InHead { flags: 0, deadline_class: 0, _reserved: [0; 3], - host: HostCtx { - ptr: ptr::null_mut(), - }, + host: HostCtx::NONE, ticket: Ticket::NONE, deadline_ns: 0, trace_id: [0; 16], diff --git a/crates/busbar-contract/src/abi/sdk/tests/auth_outbound_tests.rs b/crates/busbar-contract/src/abi/sdk/tests/auth_outbound_tests.rs index c6b1d7835a..01d6f7d905 100644 --- a/crates/busbar-contract/src/abi/sdk/tests/auth_outbound_tests.rs +++ b/crates/busbar-contract/src/abi/sdk/tests/auth_outbound_tests.rs @@ -168,9 +168,7 @@ fn head(op: u32) -> InHead { flags: 0, deadline_class: 0, _reserved: [0; 3], - host: HostCtx { - ptr: ptr::null_mut(), - }, + host: HostCtx::NONE, ticket: Ticket::NONE, deadline_ns: 0, trace_id: [0; 16], diff --git a/crates/busbar-contract/src/abi/sdk/tests/conn_tests.rs b/crates/busbar-contract/src/abi/sdk/tests/conn_tests.rs index f6b2e9add0..2d8c933a72 100644 --- a/crates/busbar-contract/src/abi/sdk/tests/conn_tests.rs +++ b/crates/busbar-contract/src/abi/sdk/tests/conn_tests.rs @@ -125,9 +125,7 @@ fn host(conns: *const ConnectorSlots) -> Host { Host::of(&HostTables { size: std::mem::size_of::() as u32, _reserved: 0, - ctx: HostCtx { - ptr: std::ptr::null_mut(), - }, + ctx: HostCtx::NONE, wake: None, conns, services: services(), @@ -242,9 +240,7 @@ fn open_learns_the_hosts_verdict_on_a_need() { let bare = Host::of(&HostTables { size: std::mem::size_of::() as u32, _reserved: 0, - ctx: HostCtx { - ptr: std::ptr::null_mut(), - }, + ctx: HostCtx::NONE, wake: None, conns: &SLOTS, services: std::ptr::null(), diff --git a/crates/busbar-contract/src/abi/sdk/tests/door_tests.rs b/crates/busbar-contract/src/abi/sdk/tests/door_tests.rs index e297de3302..61180b3669 100644 --- a/crates/busbar-contract/src/abi/sdk/tests/door_tests.rs +++ b/crates/busbar-contract/src/abi/sdk/tests/door_tests.rs @@ -178,9 +178,7 @@ fn in_head(op: u32, ticket: Ticket) -> InHead { flags: 0, deadline_class: 0, _reserved: [0; 3], - host: HostCtx { - ptr: ptr::null_mut(), - }, + host: HostCtx::NONE, ticket, deadline_ns: 0, trace_id: [0; 16], diff --git a/crates/busbar-contract/src/abi/sdk/tests/exchange_tests.rs b/crates/busbar-contract/src/abi/sdk/tests/exchange_tests.rs index e7ff7fb9af..af5d42015f 100644 --- a/crates/busbar-contract/src/abi/sdk/tests/exchange_tests.rs +++ b/crates/busbar-contract/src/abi/sdk/tests/exchange_tests.rs @@ -265,9 +265,7 @@ fn host() -> Host { Host::of(&HostTables { size: std::mem::size_of::() as u32, _reserved: 0, - ctx: HostCtx { - ptr: std::ptr::null_mut(), - }, + ctx: HostCtx::NONE, wake: None, conns: &SLOTS, services: std::ptr::null(), diff --git a/crates/busbar-contract/src/abi/sdk/tests/hook_tests.rs b/crates/busbar-contract/src/abi/sdk/tests/hook_tests.rs index b853fc671d..825b1e5243 100644 --- a/crates/busbar-contract/src/abi/sdk/tests/hook_tests.rs +++ b/crates/busbar-contract/src/abi/sdk/tests/hook_tests.rs @@ -729,9 +729,7 @@ fn red_a_hook_waiting_on_its_exchange_pends_and_resumes_on_the_wake() { let tables = HostTables { size: std::mem::size_of::() as u32, _reserved: 0, - ctx: HostCtx { - ptr: std::ptr::null_mut(), - }, + ctx: HostCtx::NONE, wake: None, conns: &PENDING_SLOTS, services: std::ptr::null(), diff --git a/crates/busbar-contract/src/abi/sdk/tests/io_tests.rs b/crates/busbar-contract/src/abi/sdk/tests/io_tests.rs index 9aa6adfdde..5d435a4976 100644 --- a/crates/busbar-contract/src/abi/sdk/tests/io_tests.rs +++ b/crates/busbar-contract/src/abi/sdk/tests/io_tests.rs @@ -93,13 +93,7 @@ static TABLE: IoSlots = IoSlots { }; fn io(ticket: Ticket) -> Io<'static> { - Io::new( - HostCtx { - ptr: std::ptr::null_mut(), - }, - &TABLE, - ticket, - ) + Io::new(HostCtx::NONE, &TABLE, ticket) } #[test] @@ -136,13 +130,7 @@ fn a_slot_that_may_pend_is_not_made_on_no_ticket_and_an_absent_slot_is_unarmed() io(TICKET).write(2, b"x"), Poll::Ready(Err(IoFailure::Unarmed)) ); - let bare = Io::new( - HostCtx { - ptr: std::ptr::null_mut(), - }, - std::ptr::null(), - TICKET, - ); + let bare = Io::new(HostCtx::NONE, std::ptr::null(), TICKET); assert!(!bare.armed()); } diff --git a/crates/busbar-contract/src/abi/sdk/tests/life_tests.rs b/crates/busbar-contract/src/abi/sdk/tests/life_tests.rs index e43ec487d3..a82e04a2bf 100644 --- a/crates/busbar-contract/src/abi/sdk/tests/life_tests.rs +++ b/crates/busbar-contract/src/abi/sdk/tests/life_tests.rs @@ -172,9 +172,7 @@ fn in_head(op: u32) -> InHead { flags: 0, deadline_class: 0, _reserved: [0; 3], - host: HostCtx { - ptr: ptr::null_mut(), - }, + host: HostCtx::NONE, ticket: Ticket::NONE, deadline_ns: 0, trace_id: [0; 16], diff --git a/crates/busbar-contract/src/abi/sdk/tests/safe_tests.rs b/crates/busbar-contract/src/abi/sdk/tests/safe_tests.rs index 578229b175..d67f170881 100644 --- a/crates/busbar-contract/src/abi/sdk/tests/safe_tests.rs +++ b/crates/busbar-contract/src/abi/sdk/tests/safe_tests.rs @@ -201,9 +201,7 @@ fn in_head(op: u32) -> InHead { flags: 0, deadline_class: 0, _reserved: [0; 3], - host: HostCtx { - ptr: ptr::null_mut(), - }, + host: HostCtx::NONE, ticket: Ticket::NONE, deadline_ns: 0, trace_id: [0; 16], diff --git a/crates/busbar-contract/src/abi/sdk/tests/services_tests.rs b/crates/busbar-contract/src/abi/sdk/tests/services_tests.rs index e218c553fe..3ab2afcca8 100644 --- a/crates/busbar-contract/src/abi/sdk/tests/services_tests.rs +++ b/crates/busbar-contract/src/abi/sdk/tests/services_tests.rs @@ -126,9 +126,7 @@ fn services(t: &HostSlots) -> Services { let tables = HostTables { size: size_of::() as u32, _reserved: 0, - ctx: HostCtx { - ptr: std::ptr::null_mut(), - }, + ctx: HostCtx::NONE, wake: None, conns: std::ptr::null(), services: t, @@ -171,9 +169,7 @@ fn no_table_is_no_services() { let tables = HostTables { size: size_of::() as u32, _reserved: 0, - ctx: HostCtx { - ptr: std::ptr::null_mut(), - }, + ctx: HostCtx::NONE, wake: None, conns: std::ptr::null(), services: std::ptr::null(), diff --git a/crates/busbar-contract/tests/abi_versions.rs b/crates/busbar-contract/tests/abi_versions.rs index 1b705c183a..6468c705fe 100644 --- a/crates/busbar-contract/tests/abi_versions.rs +++ b/crates/busbar-contract/tests/abi_versions.rs @@ -121,10 +121,5 @@ fn every_slot_type_is_extern_c() { Outcome::Ready ); assert!(door().is_null()); - wake( - HostCtx { - ptr: std::ptr::null_mut(), - }, - Ticket::NONE, - ); + wake(HostCtx::NONE, Ticket::NONE); } diff --git a/crates/busbar-contract/tests/golden/abi-layout.golden b/crates/busbar-contract/tests/golden/abi-layout.golden index d613b94692..be7b92ae8a 100644 --- a/crates/busbar-contract/tests/golden/abi-layout.golden +++ b/crates/busbar-contract/tests/golden/abi-layout.golden @@ -863,7 +863,7 @@ MechCompletionHandle.seq=8 MechCompletionHandle._reserved=12 MechCompletionHandle.__size=16 MechCompletionHandle.__align=4 -MechHostCtx.ptr=0 +MechHostCtx.handle=0 MechHostCtx.__size=8 MechHostCtx.__align=8 MechHostTables.size=0 diff --git a/crates/busbar-contract/tests/layout_golden.rs b/crates/busbar-contract/tests/layout_golden.rs index 3a86dc8e1c..0209025854 100644 --- a/crates/busbar-contract/tests/layout_golden.rs +++ b/crates/busbar-contract/tests/layout_golden.rs @@ -1010,7 +1010,7 @@ fn compute_layout() -> String { record!(s, MechKindTailHead, [size, _reserved]); record!(s, MechTicket, [slot, generation]); record!(s, MechCompletionHandle, [ticket, seq, _reserved]); - record!(s, MechHostCtx, [ptr]); + record!(s, MechHostCtx, [handle]); record!( s, MechHostTables, diff --git a/crates/busbar-contract/tests/sdk_door_cdylib.rs b/crates/busbar-contract/tests/sdk_door_cdylib.rs index 477d4dde42..2d220368b0 100644 --- a/crates/busbar-contract/tests/sdk_door_cdylib.rs +++ b/crates/busbar-contract/tests/sdk_door_cdylib.rs @@ -121,9 +121,7 @@ fn in_head(size: usize, op: u32) -> InHead { flags: 0, deadline_class: 0, _reserved: [0; 3], - host: HostCtx { - ptr: ptr::null_mut(), - }, + host: HostCtx::NONE, ticket: Ticket::NONE, deadline_ns: 0, trace_id: [0; 16], diff --git a/crates/busbar-kernel/src/endpoints.rs b/crates/busbar-kernel/src/endpoints.rs index 1675fb5505..42a5067f18 100644 --- a/crates/busbar-kernel/src/endpoints.rs +++ b/crates/busbar-kernel/src/endpoints.rs @@ -281,6 +281,10 @@ fn list_models_dialect( } pub async fn healthz(crate::state::CurrentApp(app): crate::state::CurrentApp) -> Response { + if let Some(halted) = plugin_dispatch_halted(busbar_plugin_loader::dispatch::handle::latched()) + { + return halted; + } let t = now(); // Side-effect-FREE readiness check: `/healthz` is unauthenticated and high-frequency (k8s // liveness, load balancers), so it must NOT transition expired-Open lanes to HalfOpen or steal @@ -306,6 +310,20 @@ pub async fn healthz(crate::state::CurrentApp(app): crate::state::CurrentApp) -> } } +/// THE PLUGIN HANDLE LATCH (#74): once plugins handed the host enough contexts it did not mint from +/// outside any crossing, every host call made outside a crossing is refused for the rest of the +/// process (a plugin's background wakes included), so the node is not ready. The reason line is +/// neutral (this route is unauthenticated); the detail is the ERROR line the latch logged. +fn plugin_dispatch_halted(latched: bool) -> Option { + latched.then(|| { + ( + StatusCode::SERVICE_UNAVAILABLE, + "plugin dispatch halted; restart required", + ) + .into_response() + }) +} + /// Whether any configured non-model plane's generation opened: its runtime slot was built for this /// generation and, for a door plane, its open answered ([`crate::plane::door::opened`]). fn a_plane_opened(app: &crate::state::App) -> bool { @@ -326,3 +344,7 @@ fn a_plane_opened(app: &crate::state::App) -> bool { #[cfg(test)] #[path = "tests/endpoints_doc_tests.rs"] mod endpoints_doc_tests; + +#[cfg(test)] +#[path = "tests/plugin_dispatch_halted_tests.rs"] +mod plugin_dispatch_halted_tests; diff --git a/crates/busbar-kernel/src/tests/plugin_dispatch_halted_tests.rs b/crates/busbar-kernel/src/tests/plugin_dispatch_halted_tests.rs new file mode 100644 index 0000000000..c50431685d --- /dev/null +++ b/crates/busbar-kernel/src/tests/plugin_dispatch_halted_tests.rs @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE PLUGIN HANDLE LATCH ON `/healthz` (#74). + +use super::*; + +/// RED: while the handle latch is tripped `/healthz` answers 503 with the neutral reason, and with +/// it untripped the readiness check goes on to the lanes. +#[test] +fn healthz_answers_503_while_the_handle_latch_is_tripped() { + let halted = plugin_dispatch_halted(true).expect("a tripped latch halts readiness"); + assert_eq!(halted.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!(plugin_dispatch_halted(false).is_none()); +} diff --git a/crates/busbar-plane-decisions/tests/door_harness/mod.rs b/crates/busbar-plane-decisions/tests/door_harness/mod.rs index a99d20c939..f59860eb4e 100644 --- a/crates/busbar-plane-decisions/tests/door_harness/mod.rs +++ b/crates/busbar-plane-decisions/tests/door_harness/mod.rs @@ -85,9 +85,7 @@ unsafe impl Sync for Tables {} static TABLES: Tables = Tables(HostTables { size: std::mem::size_of::() as u32, _reserved: 0, - ctx: HostCtx { - ptr: std::ptr::null_mut(), - }, + ctx: HostCtx::NONE, wake: None, conns: std::ptr::null(), io: std::ptr::null(), diff --git a/crates/busbar-plane-mcp/tests/door_clock.rs b/crates/busbar-plane-mcp/tests/door_clock.rs index e7f59d5da1..b7d92fb8f7 100644 --- a/crates/busbar-plane-mcp/tests/door_clock.rs +++ b/crates/busbar-plane-mcp/tests/door_clock.rs @@ -138,9 +138,7 @@ fn with_seal( let tables = HostTables { size: std::mem::size_of::() as u32, _reserved: 0, - ctx: HostCtx { - ptr: std::ptr::null_mut(), - }, + ctx: HostCtx::NONE, wake: None, conns: std::ptr::null(), io: std::ptr::null(), diff --git a/crates/busbar-plane-mcp/tests/program.rs b/crates/busbar-plane-mcp/tests/program.rs index bad5bad49a..a4f8e30be6 100644 --- a/crates/busbar-plane-mcp/tests/program.rs +++ b/crates/busbar-plane-mcp/tests/program.rs @@ -192,9 +192,7 @@ fn host() -> Host { Host::of(&HostTables { size: std::mem::size_of::() as u32, _reserved: 0, - ctx: HostCtx { - ptr: std::ptr::null_mut(), - }, + ctx: HostCtx::NONE, wake: None, conns: &SLOTS, services: std::ptr::null(), diff --git a/crates/busbar-plane-mcp/tests/sessions.rs b/crates/busbar-plane-mcp/tests/sessions.rs index 76ea0fa215..45f29e28f6 100644 --- a/crates/busbar-plane-mcp/tests/sessions.rs +++ b/crates/busbar-plane-mcp/tests/sessions.rs @@ -415,9 +415,7 @@ unsafe impl Sync for Tables {} static TABLES: Tables = Tables(HostTables { size: std::mem::size_of::() as u32, _reserved: 0, - ctx: HostCtx { - ptr: std::ptr::null_mut(), - }, + ctx: HostCtx::NONE, wake: None, conns: &CONN_SLOTS, io: std::ptr::null(), diff --git a/crates/busbar-transport-stdio/tests/door.rs b/crates/busbar-transport-stdio/tests/door.rs index 27efbcf23e..4d3e899f36 100644 --- a/crates/busbar-transport-stdio/tests/door.rs +++ b/crates/busbar-transport-stdio/tests/door.rs @@ -177,9 +177,7 @@ impl Host { let tables = Box::new(HostTables { size: size_of::() as u32, _reserved: 0, - ctx: HostCtx { - ptr: std::ptr::null_mut(), - }, + ctx: HostCtx::NONE, wake: None, conns: std::ptr::null(), services: std::ptr::null(), diff --git a/crates/busbar/src/main.rs b/crates/busbar/src/main.rs index 0385d60dac..76ad20dd67 100644 --- a/crates/busbar/src/main.rs +++ b/crates/busbar/src/main.rs @@ -356,6 +356,12 @@ fn main() { // It serves the kernel's host services through `LateServices`, installed once the config is // composed (`root::serve::compose`, in `run()`). let late_services = root::serve::LateServices::new(); + // THE HANDLE KEY (#74), drawn before the dispatcher exists: a process whose OS random source + // cannot answer refuses to boot rather than mint plugin handles under a weaker key. + if let Err(e) = root::loader::dispatch::handle::arm() { + eprintln!("busbar: {e}"); + std::process::exit(1); + } let dispatcher = root::dispatch::boot(WORKERS.0, late_services.clone()); root::doors::install_dispatcher(dispatcher); // PROTOCOL REGISTRATION FIRST — before the CLI flags, because `--validate` reads the protocol diff --git a/crates/busbar/tests/fixtures/plane_driver_test_plane.rs b/crates/busbar/tests/fixtures/plane_driver_test_plane.rs index 027df976e0..af067fa1c7 100644 --- a/crates/busbar/tests/fixtures/plane_driver_test_plane.rs +++ b/crates/busbar/tests/fixtures/plane_driver_test_plane.rs @@ -516,18 +516,13 @@ impl Inst { self.stats[s as usize].fetch_add(1, Ordering::SeqCst); } fn wake_later(&'static self, t: Ticket, ms: u64) { - let (wake, ctx) = (self.wake as usize, self.ctx.ptr as usize); + let (wake, ctx) = (self.wake as usize, self.ctx); self.count(Stat::HostCalls); std::thread::spawn(move || { std::thread::sleep(Duration::from_millis(ms)); // SAFETY: the host's wake, with the context it handed `open`. let wake: WakeFn = unsafe { std::mem::transmute::(wake) }; - wake( - HostCtx { - ptr: ctx as *mut c_void, - }, - t, - ); + wake(ctx, t); }); } } @@ -799,6 +794,17 @@ extern "C" fn arrive(instance: *mut c_void, input: *const c_void, out: *mut c_vo (me.wake)(me.ctx, ticket); vec![estimate(0, 0)] } + b"/forge-context" => { + // #74: hand the host's `wake` this instance's own context one tag bit off, a + // context the host never minted, from inside a non-lifecycle op. The host + // quarantines the instance, and this arrive itself answers FAULT. + me.count(Stat::HostCalls); + let forged = HostCtx { + handle: me.ctx.handle ^ (1 << 24), + }; + (me.wake)(forged, Ticket::NONE); + vec![estimate(0, 0)] + } b"/clock" => { // The host's own clock, through its service table: the reading, as two amounts. A // clock the host would not read (no table, no slot, not READY) refuses the arrival, diff --git a/crates/busbar/tests/plane_forged_context.rs b/crates/busbar/tests/plane_forged_context.rs new file mode 100644 index 0000000000..f8922b7f38 --- /dev/null +++ b/crates/busbar/tests/plane_forged_context.rs @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! #74 ON THE CALLER'S THREAD: a forged context handed to the host from inside a NON-lifecycle op +//! (a plane's `arrive`, crossed on the calling thread, not on the FFI worker) quarantines the +//! instance, and the forging op itself answers FAULT. +//! +//! The plane is the plane-driver test plane, LINKED: its `/forge-context` arrival hands the host's +//! `wake` its own context one tag bit off, then answers as the plane does. + +#[path = "fixtures/plane_driver_test_plane.rs"] +#[allow(dead_code)] +mod plane; + +use std::sync::Arc; + +use busbar_contract::abi::mechanism::call::{AbiStr, Outcome as AbiOutcome}; +use busbar_contract::abi::mechanism::lifecycle::OpenIn; +use busbar_contract::abi::plane::{ + slot, ArriveIn, ArriveOut, PlaneOpenIn, PlaneOpenOut, UnitCount, +}; +use busbar_contract::abi::sdk::door::{blank_in, blank_out}; +use busbar_plugin_loader::dispatch::{ + kinds::plane::Plane, load_linked, Bind, ConnTable, DispatchConfig, Dispatcher, Frame, + LinkedRow, NoSink, NO_BLOB, +}; + +#[test] +fn a_forged_context_in_a_non_lifecycle_op_answers_fault_and_quarantines() { + let dispatcher = Dispatcher::new(DispatchConfig::default()); + let bind = Bind { + instance: Arc::from("the-instance"), + max_inflight_cap: 64, + sink: Arc::new(NoSink), + dispatcher: dispatcher.adopter(), + conns: ConnTable::Probe, + }; + let row = LinkedRow::of(plane::door).expect("the plane states its Statement"); + let plugin = load_linked::(&row, bind).expect("the linked door loads"); + let mut open_frame = Frame::new( + PlaneOpenIn { + open: OpenIn { + settings: NO_BLOB, + generation: 1, + ..blank_in() + }, + owned: NO_BLOB, + ..blank_in() + }, + blank_out::(), + ); + assert_eq!(plugin.open(&mut open_frame).0.outcome, AbiOutcome::Ready); + + let mut units = vec![ + UnitCount { + class: 0, + source: 0, + amount: 0 + }; + 16 + ]; + let mut arrive_frame = Frame::new( + ArriveIn { + target: AbiStr::over(b"/forge-context"), + method: AbiStr::over(b"POST"), + body: NO_BLOB, + units_buf: units.as_mut_ptr(), + units_cap: units.len(), + ..blank_in() + }, + blank_out::(), + ); + assert_eq!( + plugin.call(slot::ARRIVE, &mut arrive_frame).outcome, + AbiOutcome::Fault, + "the forging arrive answered other than FAULT" + ); + assert!( + plugin.is_faulted(), + "the forging instance was not quarantined" + ); + assert_eq!( + plugin.call(slot::ARRIVE, &mut arrive_frame).outcome, + AbiOutcome::Fault, + "a quarantined instance was called again" + ); +} diff --git a/crates/plugin-loader/src/dispatch/conn_services.rs b/crates/plugin-loader/src/dispatch/conn_services.rs index d422a055c7..b52c1fc633 100644 --- a/crates/plugin-loader/src/dispatch/conn_services.rs +++ b/crates/plugin-loader/src/dispatch/conn_services.rs @@ -70,8 +70,6 @@ use busbar_contract::conn::{ }; use std::future::Future as _; -use super::ticket::InstanceWake; - /// The table an instance with a declared need is handed. pub static CONN_SLOTS: ConnectorSlots = ConnectorSlots { size: size_of::() as u32, @@ -199,12 +197,7 @@ fn interned(text: &str) -> &'static str { /// The instance a context names, and its connection table. pub(crate) fn armed(ctx: HostCtx) -> Option<&'static (InstanceId, Arc)> { - if ctx.ptr.is_null() { - return None; - } - // SAFETY: every `HostCtx` the host hands out points to a leaked `InstanceWake`. - let wake: &'static InstanceWake = unsafe { &*ctx.ptr.cast_const().cast::() }; - wake.conn.get() + super::handle::resolve(ctx)?.conn.get() } /// ONE SLOT'S FRAME: the `in` and `out` are there and the head is this service's and covers its @@ -543,20 +536,18 @@ extern "C" fn upgrade_secure( /// The first unverified handshake an instance's connection ran (the operator's opt-in, ARCHITECT /// ruling 2026-10-03 on Q-L16-4) is logged at WARN, naming the instance: once per instance. fn warn_unverified(ctx: HostCtx) { - static WARNED: OnceLock>> = OnceLock::new(); - if ctx.ptr.is_null() { + static WARNED: OnceLock>> = OnceLock::new(); + let Some(wake) = super::handle::resolve(ctx) else { return; - } + }; let first = WARNED .get_or_init(Mutex::default) .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert(ctx.ptr as usize); + .insert(ctx.handle); if !first { return; } - // SAFETY: every `HostCtx` the host hands out points to a leaked `InstanceWake`. - let wake: &InstanceWake = unsafe { &*ctx.ptr.cast_const().cast::() }; let instance = wake .caller .get() @@ -1186,11 +1177,10 @@ fn ticket_waker(ctx: HostCtx, ticket: Ticket) -> Option { } } } - if ctx.ptr.is_null() || ticket.is_none() { + if ticket.is_none() { return None; } - // SAFETY: every `HostCtx` the host hands out points to a leaked `InstanceWake`. - let wake: &'static InstanceWake = unsafe { &*ctx.ptr.cast_const().cast::() }; + let wake = super::handle::resolve(ctx)?; let route = wake.route.get()?.clone(); Some(std::task::Waker::from(Arc::new(TicketWaker { route, diff --git a/crates/plugin-loader/src/dispatch/handle.rs b/crates/plugin-loader/src/dispatch/handle.rs new file mode 100644 index 0000000000..ffe4fe846b --- /dev/null +++ b/crates/plugin-loader/src/dispatch/handle.rs @@ -0,0 +1,312 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE INSTANCE HANDLE (BUSBAR-1.6.0.md Part 2 #74, the untrusted-ABI half: "the handle a plugin +//! holds is kernel-MAC'd ... the kernel verifies it on every ABI call, so a plugin cannot forge, +//! reuse across flows, or replay a stale handle"). +//! +//! A plugin is handed one [`HostCtx`] per instance and hands it back on every host call: `wake`, +//! every host service, every connector and I/O slot. It used to be the address of the instance's +//! leaked [`InstanceWake`], and every one of those entries dereferenced whatever pointer came back. +//! A plugin that passed another value got the host to read through it: not a refused handle, +//! undefined behaviour inside the host. +//! +//! Now no host address crosses. The handle is `tag << 24 | id`: `id` indexes the host's table of +//! instances ([`HANDLE_ID_BITS`] bits, `0` = none) and `tag` is the high [`HANDLE_TAG_BITS`] bits of +//! a SipHash-2-4 over `(domain, id)` under a 128-bit key drawn from the OS random source at the +//! first bind, held in host memory only and never written anywhere. [`resolve`] recomputes the tag +//! and compares it in constant time BEFORE the table is read; a handle the host did not mint names +//! no instance, is counted ([`forged`]), and the entry answers as it answers no context. Made inside +//! a crossing, it QUARANTINES the instance making that crossing (N = 1): faulted, never called +//! again, so no plugin gets a second guess at a tag; inside a crossing a context must name the +//! crossing's own instance. Made outside any crossing it names no one to quarantine, so it counts +//! against a process-wide LATCH: at [`LATCH_AT`] such forgeries nothing resolves outside a +//! crossing again. A handle whose instance was quarantined, faulted, closed or let go is REVOKED: +//! its tag still verifies, and it names nothing. +//! +//! Width: the handle is the 8 bytes the pointer was, so no struct that carries it moves (the layout +//! golden changes one field name). A guess at a 40-bit tag is right once in about 10^12 tries, each +//! one counted. The key is readable to a native plugin that ignores the ABI and reads process +//! memory; that is the channel-2 residual BUSBAR-1.6.0.md #75 accepts, not a hole this closes. + +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{OnceLock, RwLock}; + +use busbar_contract::abi::mechanism::ticket::HostCtx; + +use super::load::LoadError; +use super::ticket::InstanceWake; + +/// The bits of a handle that index the instance table. +pub const HANDLE_ID_BITS: u32 = 24; +/// The bits of a handle that carry the tag. +pub const HANDLE_TAG_BITS: u32 = 64 - HANDLE_ID_BITS; + +const ID_MASK: u64 = (1 << HANDLE_ID_BITS) - 1; + +/// Domain separation: a tag minted for an instance handle is never a tag for anything else the +/// key signs. +const DOMAIN_INSTANCE: u64 = u64::from_le_bytes(*b"bb.inst1"); + +/// THE LATCH (#74): unattributed forgeries (made outside any crossing, so no instance can be +/// quarantined for them) the process tolerates. At this many, every resolve made outside a crossing +/// is refused for the rest of the process: off-crossing guessing is bounded at K tries in all. +pub const LATCH_AT: u64 = 16; + +/// Every handle the host was handed back and did not mint. +static FORGED: AtomicU64 = AtomicU64::new(0); + +/// The process's off-crossing latch ([`LATCH_AT`]). +static LATCH: Latch = Latch::new(); + +/// Every instance the host has minted a handle for, by `id - 1`. Instances are leaked, so an entry +/// never dangles. +static INSTANCES: RwLock> = RwLock::new(Vec::new()); + +/// The off-crossing latch: unattributed forgeries counted; tripped at [`LATCH_AT`]. +pub(crate) struct Latch { + /// Every forgery judged against this latch, attributed or not: what a test asserts on, so no + /// test reads the process-wide [`forged`] another test moves. + forged: AtomicU64, + unattributed: AtomicU64, + tripped: AtomicBool, +} + +impl Latch { + pub(crate) const fn new() -> Self { + Self { + forged: AtomicU64::new(0), + unattributed: AtomicU64::new(0), + tripped: AtomicBool::new(false), + } + } + + /// One unattributed forgery; trips the latch at [`LATCH_AT`], with ONE error line. + fn count(&self) { + let n = self.unattributed.fetch_add(1, Ordering::AcqRel) + 1; + if n >= LATCH_AT && !self.tripped.swap(true, Ordering::AcqRel) { + tracing::error!( + forgeries = n, + "plugins handed the host {n} contexts it did not mint from outside any crossing; \ + every host call made outside a crossing is refused for the rest of the process" + ); + } + } + + pub(crate) fn tripped(&self) -> bool { + self.tripped.load(Ordering::Acquire) + } + + /// Forgeries judged against this latch: the tests' reading (no production path reads a + /// latch's own count; the process's figure is [`forged`]). + #[cfg(test)] + pub(crate) fn forged(&self) -> u64 { + self.forged.load(Ordering::Acquire) + } + + /// Unattributed forgeries counted towards the trip: the tests' reading. + #[cfg(test)] + pub(crate) fn unattributed(&self) -> u64 { + self.unattributed.load(Ordering::Acquire) + } +} + +#[cfg(test)] +thread_local! { + /// THE TEST SEAM: this thread's next key draw finds the OS random source failing. + pub(crate) static ENTROPY_FAILS: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// 16 bytes from `source` as the key; a source that cannot answer is a refusal, never a weaker key. +fn draw(source: impl FnOnce(&mut [u8]) -> Result<(), String>) -> Result<(u64, u64), LoadError> { + let mut b = [0u8; 16]; + source(&mut b).map_err(LoadError::Entropy)?; + let (lo, hi) = b.split_at(8); + Ok(( + u64::from_le_bytes(lo.try_into().unwrap_or_default()), + u64::from_le_bytes(hi.try_into().unwrap_or_default()), + )) +} + +/// The host's key: 16 bytes from the OS random source, drawn once, at the loader's boot +/// ([`arm`]) and at the latest by the first bind. A source that cannot answer refuses the boot +/// ([`LoadError::Entropy`]): there is no fallback key. +fn key() -> Result<(u64, u64), LoadError> { + static KEY: OnceLock<(u64, u64)> = OnceLock::new(); + #[cfg(test)] + if ENTROPY_FAILS.with(std::cell::Cell::get) { + return draw(|_| Err("the test seam's failing entropy source".to_string())); + } + if let Some(k) = KEY.get() { + return Ok(*k); + } + let k = draw(|b| getrandom::fill(b).map_err(|e| e.to_string()))?; + Ok(*KEY.get_or_init(|| k)) +} + +/// THE LOADER'S BOOT: draw the key now, before any plugin binds. A failure is the boot's refusal. +/// +/// # Errors +/// +/// [`LoadError::Entropy`] when the OS random source cannot answer. +pub fn arm() -> Result<(), LoadError> { + key().map(|_| ()) +} + +/// SipHash-2-4 over `words`, each eight little-endian message bytes. +fn siphash24((k0, k1): (u64, u64), words: &[u64]) -> u64 { + let mut v = [ + k0 ^ 0x736f_6d65_7073_6575, + k1 ^ 0x646f_7261_6e64_6f6d, + k0 ^ 0x6c79_6765_6e65_7261, + k1 ^ 0x7465_6462_7974_6573, + ]; + let round = |v: &mut [u64; 4]| { + v[0] = v[0].wrapping_add(v[1]); + v[1] = v[1].rotate_left(13) ^ v[0]; + v[0] = v[0].rotate_left(32); + v[2] = v[2].wrapping_add(v[3]); + v[3] = v[3].rotate_left(16) ^ v[2]; + v[0] = v[0].wrapping_add(v[3]); + v[3] = v[3].rotate_left(21) ^ v[0]; + v[2] = v[2].wrapping_add(v[1]); + v[1] = v[1].rotate_left(17) ^ v[2]; + v[2] = v[2].rotate_left(32); + }; + for &m in words { + v[3] ^= m; + round(&mut v); + round(&mut v); + v[0] ^= m; + } + let last = ((words.len() as u64).wrapping_mul(8) & 0xff) << 56; + v[3] ^= last; + round(&mut v); + round(&mut v); + v[0] ^= last; + v[2] ^= 0xff; + for _ in 0..4 { + round(&mut v); + } + v[0] ^ v[1] ^ v[2] ^ v[3] +} + +/// The tag `id`'s handle carries under `key`. +fn tag_of(key: (u64, u64), id: u64) -> u64 { + siphash24(key, &[DOMAIN_INSTANCE, id]) >> HANDLE_ID_BITS +} + +/// Mint the handle for `wake`: the next id, and its tag. +/// +/// # Errors +/// +/// [`LoadError::Entropy`] when the key cannot be drawn; [`LoadError::HandlesSpent`] once the id +/// space is spent (16.7M instances in one process): the bind is refused rather than a handle +/// reused. +pub(crate) fn mint(wake: &'static InstanceWake) -> Result { + let key = key()?; + let mut table = INSTANCES + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let id = table.len() as u64 + 1; + if id > ID_MASK { + return Err(LoadError::HandlesSpent); + } + table.push(wake); + Ok(HostCtx { + handle: (tag_of(key, id) << HANDLE_ID_BITS) | id, + }) +} + +/// THE ONE CHECK: the instance `ctx` names, when the host minted it, it is live, and (inside a +/// crossing) it is the crossing's own. +pub(crate) fn resolve(ctx: HostCtx) -> Option<&'static InstanceWake> { + resolve_in(ctx, &LATCH, super::plugin::crossing_wake()) +} + +/// [`resolve`] against `latch`, with `crossing` the entry of the instance crossing on this thread. +/// +/// In order: no context is no context (not counted); past a tripped latch, nothing resolves outside +/// a crossing; the tag is recomputed and compared in constant time BEFORE the table is read, and a +/// tag that does not verify, or an id the host never minted, is a FORGERY; a revoked entry (its +/// instance quarantined, faulted, closed or let go) names nothing and is NOT a forgery, its tag +/// being the host's own; and inside a crossing a context naming another instance is a forgery. +pub(crate) fn resolve_in( + ctx: HostCtx, + latch: &Latch, + crossing: Option<&'static InstanceWake>, +) -> Option<&'static InstanceWake> { + if ctx.handle == 0 { + return None; + } + if crossing.is_none() && latch.tripped() { + return None; + } + let key = key().ok()?; + let id = ctx.handle & ID_MASK; + let want = tag_of(key, id); + let got = ctx.handle >> HANDLE_ID_BITS; + // Constant time: the whole difference is folded before it is judged. + if id == 0 || std::hint::black_box(want ^ got) != 0 { + count_forgery(latch, crossing); + return None; + } + let table = INSTANCES + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let found = usize::try_from(id - 1) + .ok() + .and_then(|i| table.get(i).copied()); + drop(table); + let Some(entry) = found else { + count_forgery(latch, crossing); + return None; + }; + // Inside a crossing another instance's handle is a forgery, revoked or not: judged BEFORE the + // revocation, so a stale handle of another instance cannot pass as merely revoked. + if let Some(own) = crossing { + if !std::ptr::eq(own, entry) { + count_forgery(latch, crossing); + return None; + } + } + if entry.revoked.load(Ordering::Acquire) { + return None; + } + // The latch read again after any counting this or another thread did: once it trips, not one + // off-crossing resolve passes, so the bound is exactly `LATCH_AT`. + if crossing.is_none() && latch.tripped() { + return None; + } + // THE REVOKE/USE RACE, memory-safe by construction: an entry revoked by another thread after + // this load is still a leaked, valid `InstanceWake`; the call it serves finishes against an + // instance that refuses its next op. + Some(entry) +} + +/// A handle the host did not mint, or another instance's inside a crossing: counted; the instance +/// crossing on this thread, if any, quarantined ([`super::plugin::quarantine_crossing`], one guess, +/// never a second); and with no crossing to blame, counted against `latch`. +fn count_forgery(latch: &Latch, crossing: Option<&'static InstanceWake>) { + FORGED.fetch_add(1, Ordering::Relaxed); + latch.forged.fetch_add(1, Ordering::AcqRel); + if crossing.is_none() || !super::plugin::quarantine_crossing() { + latch.count(); + } +} + +/// Whether the process's off-crossing latch has tripped ([`LATCH_AT`]). +#[must_use] +pub fn latched() -> bool { + LATCH.tripped() +} + +/// How many handles the host was handed back and did not mint, since the process started. +#[must_use] +pub fn forged() -> u64 { + FORGED.load(Ordering::Relaxed) +} + +#[cfg(test)] +#[path = "../tests/handle_tests.rs"] +mod handle_tests; diff --git a/crates/plugin-loader/src/dispatch/io_slots.rs b/crates/plugin-loader/src/dispatch/io_slots.rs index e58ee319e8..01b5a5e27a 100644 --- a/crates/plugin-loader/src/dispatch/io_slots.rs +++ b/crates/plugin-loader/src/dispatch/io_slots.rs @@ -31,7 +31,7 @@ use busbar_contract::abi::mechanism::call::{AbiStr, Field, Outcome, RawOutcome}; use busbar_contract::abi::mechanism::ticket::{HostCtx, Ticket}; use busbar_contract::io_host::{IoHost, IoRefusal, Spawn}; -use super::ticket::{InstanceWake, WakeRoute}; +use super::ticket::WakeRoute; /// The table every instance is handed. pub static IO_SLOTS: IoSlots = IoSlots { @@ -134,15 +134,11 @@ impl Wake for Routed { } fn caller(ctx: HostCtx) -> Option { - if ctx.ptr.is_null() { - return None; - } - // SAFETY: every `HostCtx` the host hands out points to a leaked `InstanceWake`. - let wake = unsafe { &*ctx.ptr.cast_const().cast::() }; + let wake = super::handle::resolve(ctx)?; let route = wake.route.get()?.clone(); let io = route.upgrade()?.io()?; Some(Caller { - owner: ctx.ptr as usize as u64, + owner: ctx.handle, route, io, }) diff --git a/crates/plugin-loader/src/dispatch/load.rs b/crates/plugin-loader/src/dispatch/load.rs index 4e489dff53..c4d9a9c000 100644 --- a/crates/plugin-loader/src/dispatch/load.rs +++ b/crates/plugin-loader/src/dispatch/load.rs @@ -317,6 +317,12 @@ pub enum LoadError { /// How many needs it declares. needs: usize, }, + /// The host has minted every instance handle its handle can index (#74, `dispatch::handle`): + /// a bind is refused rather than a handle reused. + HandlesSpent, + /// The OS random source could not answer the host's key draw (#74): the loader refuses to boot + /// rather than mint handles under a weaker key. + Entropy(String), } /// What every refusal of a plugin built against an older contract tells the operator to do. @@ -392,6 +398,15 @@ impl fmt::Display for LoadError { f, "plugin '{plugin}' declares {needs} connection need(s) and was bound with no connection table" ), + Self::HandlesSpent => write!( + f, + "the host has minted every instance handle it can index; restart the process" + ), + Self::Entropy(why) => write!( + f, + "the OS random source could not answer the host's handle key ({why}); the loader \ + refuses to boot" + ), } } } diff --git a/crates/plugin-loader/src/dispatch/mod.rs b/crates/plugin-loader/src/dispatch/mod.rs index 2096b9af19..f13bc30d3c 100644 --- a/crates/plugin-loader/src/dispatch/mod.rs +++ b/crates/plugin-loader/src/dispatch/mod.rs @@ -47,6 +47,7 @@ pub mod answer; pub mod auth_outbound; pub mod auth_ticks; pub mod conn_services; +pub mod handle; pub mod inline; pub mod io_slots; pub mod kinds; @@ -353,9 +354,7 @@ pub fn in_head() -> InHead { flags: 0, deadline_class: 0, _reserved: [0; 3], - host: HostCtx { - ptr: std::ptr::null_mut(), - }, + host: HostCtx::NONE, ticket: Ticket::NONE, deadline_ns: 0, trace_id: [0; 16], diff --git a/crates/plugin-loader/src/dispatch/plugin.rs b/crates/plugin-loader/src/dispatch/plugin.rs index b5070e1560..d72d040263 100644 --- a/crates/plugin-loader/src/dispatch/plugin.rs +++ b/crates/plugin-loader/src/dispatch/plugin.rs @@ -329,11 +329,92 @@ impl Crossed { /// The host tables `open` is handed; immutable after construction. struct Tables(Box); -// SAFETY: the tables are written once at bind and only read after; `ctx` points to a leaked, -// `Sync` [`InstanceWake`]. +// SAFETY: the tables are written once at bind and only read after; `ctx` is a plain handle (#74) +// and every pointer in them is to `'static` host tables. unsafe impl Send for Tables {} unsafe impl Sync for Tables {} +thread_local! { + /// The instance whose crossing is on this thread (innermost first), for what a host entry needs + /// it for (#74): the instance a context must name, and the quarantine of a forged one. + static CROSSING: std::cell::Cell<*const Instance> = const { std::cell::Cell::new(std::ptr::null()) }; +} + +/// `f` run as the crossing of `inst` on this thread (`None`: as no crossing), the mark restored to +/// the crossing it is nested in, if any, when `f` returns or unwinds. The ONE way the mark is set: +/// a call into plugin code is either a crossing of an instance or explicitly none. +pub(crate) fn with_crossing(inst: Option<&Instance>, f: impl FnOnce() -> R) -> R { + struct Restore(*const Instance); + impl Drop for Restore { + fn drop(&mut self) { + CROSSING.with(|c| c.set(self.0)); + } + } + let mark = inst.map_or(std::ptr::null(), std::ptr::from_ref); + let _restore = Restore(CROSSING.with(|c| c.replace(mark))); + f() +} + +/// `f`, a call into plugin code that is no mechanism crossing (the inline HOT-lane plane and +/// transport slots), run with the mark nulled, so nothing it hands the host is taken for the +/// crossing it happens to be nested in. +pub(crate) fn without_crossing(f: impl FnOnce() -> R) -> R { + with_crossing(None, f) +} + +/// The handle table entry of the instance crossing on this thread; `None` outside a crossing. +pub(crate) fn crossing_wake() -> Option<&'static InstanceWake> { + let p = CROSSING.with(std::cell::Cell::get); + // SAFETY: `CROSSING` is non-null only inside `with_crossing(Some(&*p), ..)`, which borrows the + // instance for the whole span; its `wake` entry is leaked (`'static`). + (!p.is_null()).then(|| unsafe { (*p).wake }) +} + +/// THE ONE GUESS (#74): a host entry handed a context the host did not mint, inside a crossing, +/// quarantines the instance making that crossing, as the watchdog does a hung one: faulted, never +/// called again on any worker, its connections gone. No plugin gets a second guess. Answers whether +/// there was a crossing to quarantine (a call from a plugin's own thread, outside any crossing, +/// names no caller; it is refused and counted all the same). +pub(crate) fn quarantine_crossing() -> bool { + let p = CROSSING.with(std::cell::Cell::get); + if p.is_null() { + return false; + } + // SAFETY: `CROSSING` is non-null only inside `with_crossing(Some(&*p), ..)`, which borrows the + // instance for the whole span. + let inst = unsafe { &*p }; + inst.revoke(); + if let Some((id, table)) = inst.wake.conn.get() { + super::conn_services::purge(*id, table); + } + tracing::warn!( + plugin = %inst.name, + "a plugin handed the host a context the host did not mint; the instance is quarantined" + ); + true +} + +/// A minted handle whose bind has not completed: revoked when dropped still holding it (#74). +struct Unbound(Option<&'static InstanceWake>); + +impl Drop for Unbound { + fn drop(&mut self) { + if let Some(wake) = self.0 { + wake.revoked.store(true, Ordering::Release); + } + } +} + +impl Instance { + /// FAULT AND REVOKE (#74): the instance is never called again on any worker, and its handle + /// names nothing any more. The watchdog's fault and the forged-context quarantine both land + /// here. + pub(crate) fn revoke(&self) { + self.faulted.store(true, Ordering::Release); + self.wake.revoked.store(true, Ordering::Release); + } +} + /// One family's shape, copied from the Statement: its kind and its label count. #[derive(Debug, Clone, Copy)] struct FamilyShape { @@ -423,6 +504,8 @@ impl Drop for Instance { /// The instance is gone (closed and let go, or replaced by a reload): what it held on the /// connector's process-wide maps goes with it. fn drop(&mut self) { + // Let go: its handle names nothing any more (#74). + self.wake.revoked.store(true, Ordering::Release); if let Some((id, table)) = self.wake.conn.get() { super::conn_services::purge(*id, table); } @@ -520,10 +603,10 @@ impl Instance { self.tables.0.conns } + /// The instance's handle (#74): what the plugin is handed and hands back, verified by the host + /// on every call ([`super::handle::resolve`]). pub(crate) fn ctx(&self) -> HostCtx { - HostCtx { - ptr: std::ptr::from_ref(self.wake).cast_mut().cast(), - } + self.tables.0.ctx } /// Whether the door states `ready` ([`slot::READY`]). @@ -791,6 +874,8 @@ impl Instance { shut = was_open || crossed.outcome == Outcome::Fault; if shut { self.closed.store(true, Ordering::Release); + // A closed instance's handle names nothing any more (#74). + self.wake.revoked.store(true, Ordering::Release); } self.ptr.store(std::ptr::null_mut(), Ordering::Release); } @@ -943,7 +1028,9 @@ impl Instance { let _runtime = runtime.as_ref().map(tokio::runtime::Handle::enter); let _acting = super::worker::acting_for(acting); let _unit = super::services::serving(unit); - enter_lifecycle((self.op_name)(s), op, instance, input, output) + with_crossing(Some(self), || { + enter_lifecycle((self.op_name)(s), op, instance, input, output) + }) }); match routed { Ok(raw) => raw, @@ -952,7 +1039,9 @@ impl Instance { Err(_) => return Crossed::host(Outcome::Fault), } } else { - op(instance, input.cast_const().cast(), out.cast()) + with_crossing(Some(self), || { + op(instance, input.cast_const().cast(), out.cast()) + }) }; if s == slot::OPEN && (raw == RawOutcome::of(Outcome::Ready) || raw == RawOutcome::of(Outcome::Pending)) @@ -962,6 +1051,12 @@ impl Instance { // SAFETY: `refuse` checked the frame holds an `OpenOut`. open_ended.live = unsafe { (*out.cast::()).instance }; } + // FAULTED WHILE IT CROSSED (#74): an instance quarantined inside this very crossing (a + // forged context), or by the watchdog meanwhile, does not have its answer judged or its + // envelope read: the op itself answers FAULT. + if self.faulted.load(Ordering::Acquire) { + return Crossed::host(Outcome::Fault); + } // SAFETY: the plugin wrote at most the host's `out`; read it back. let head = unsafe { *out }; let outcome = judge(raw, &head, ticket, out_size); @@ -1350,6 +1445,11 @@ impl Plugin { // One small leak per instance: a plugin may call `wake` with this context at any time, even // late, so it must never dangle. let wake: &'static InstanceWake = Box::leak(Box::default()); + // ITS HANDLE (#74): the context the plugin is handed is a tagged index, never this address. + let ctx = super::handle::mint(wake)?; + // A bind refused after this point leaves no live handle behind: the entry is revoked unless + // the bind completes (#74). + let mut unbound = Unbound(Some(wake)); // THE CONNECTION TABLE: minted an identity and declared on the host's one table, need by // need under its Statement index, when the Statement declares a need; otherwise none. let instance = mint_instance(&NEXT_INSTANCE); @@ -1413,9 +1513,7 @@ impl Plugin { let tables = Tables(Box::new(HostTables { size: size_of::() as u32, _reserved: 0, - ctx: HostCtx { - ptr: std::ptr::from_ref(wake).cast_mut().cast(), - }, + ctx, wake: Some(host_wake), conns, services: &super::services::HOST_SLOTS, @@ -1486,6 +1584,7 @@ impl Plugin { _k: PhantomData, }; bind.dispatcher.adopt(&plugin.inner); + unbound.0 = None; Ok(plugin) } diff --git a/crates/plugin-loader/src/dispatch/services.rs b/crates/plugin-loader/src/dispatch/services.rs index c790548521..2e326f0399 100644 --- a/crates/plugin-loader/src/dispatch/services.rs +++ b/crates/plugin-loader/src/dispatch/services.rs @@ -58,7 +58,7 @@ pub use busbar_contract::services::{ Stored, }; -use super::ticket::{decode, InstanceWake, WakeRoute}; +use super::ticket::{decode, WakeRoute}; /// The error text of a caller-scoped service called from an instance the host states no caller for. pub const NO_CALLER: &str = "no caller is bound to this instance"; @@ -398,11 +398,7 @@ pub static HOST_SLOTS: HostSlots = HostSlots { /// The dispatcher an instance's context routes to, and what it serves. fn served(ctx: HostCtx) -> Option<(Served, Weak)> { - if ctx.ptr.is_null() { - return None; - } - // SAFETY: every `HostCtx` the host hands out points to a leaked `InstanceWake`. - let wake = unsafe { &*ctx.ptr.cast_const().cast::() }; + let wake = super::handle::resolve(ctx)?; let route = wake.route.get()?.clone(); let served = route.upgrade()?.services()?; Some((served, route)) @@ -410,12 +406,7 @@ fn served(ctx: HostCtx) -> Option<(Served, Weak)> { /// The caller an instance's context names; `None` before bind stated one. fn caller(ctx: HostCtx) -> Option { - if ctx.ptr.is_null() { - return None; - } - // SAFETY: every `HostCtx` the host hands out points to a leaked `InstanceWake`. - let wake = unsafe { &*ctx.ptr.cast_const().cast::() }; - wake.caller.get().cloned() + super::handle::resolve(ctx)?.caller.get().cloned() } /// A checked range of the caller's, copied: `None` for NULL with a length. @@ -665,11 +656,9 @@ extern "C" fn records_get(ctx: HostCtx, input: *const c_void, out: *mut ServiceO /// The credential kinds an instance's context declares it reads; none before bind stated them. fn credential_kinds(ctx: HostCtx) -> &'static [String] { - if ctx.ptr.is_null() { + let Some(wake) = super::handle::resolve(ctx) else { return &[]; - } - // SAFETY: every `HostCtx` the host hands out points to a leaked (`'static`) `InstanceWake`. - let wake: &'static InstanceWake = unsafe { &*ctx.ptr.cast_const().cast::() }; + }; wake.credential_kinds.get().map_or(&[], Vec::as_slice) } @@ -716,11 +705,7 @@ extern "C" fn records_secret( /// The destination the calling instance's `key` is bound to; `None` when it was granted no such /// key, or its settings leave it unset. fn destination(ctx: HostCtx, key: &str) -> Option { - if ctx.ptr.is_null() { - return None; - } - // SAFETY: every `HostCtx` the host hands out points to a leaked (`'static`) `InstanceWake`. - let wake: &'static InstanceWake = unsafe { &*ctx.ptr.cast_const().cast::() }; + let wake = super::handle::resolve(ctx)?; if !wake.destinations.get()?.iter().any(|k| k == key) { return None; } diff --git a/crates/plugin-loader/src/dispatch/ticket.rs b/crates/plugin-loader/src/dispatch/ticket.rs index 6588021d7c..13bc363986 100644 --- a/crates/plugin-loader/src/dispatch/ticket.rs +++ b/crates/plugin-loader/src/dispatch/ticket.rs @@ -66,7 +66,7 @@ pub(crate) trait WakeRoute: Send + Sync { } } -/// What an instance's `HostCtx` points to: the dispatcher its tickets live in, and who the instance +/// What an instance's `HostCtx` names (through [`super::handle`], never by address): the dispatcher its tickets live in, and who the instance /// is to the host services. Leaked per instance, so a late wake never dangles; a wake before any /// dispatcher is bound is dropped. #[derive(Default)] @@ -89,6 +89,9 @@ pub(crate) struct InstanceWake { /// Each granted key bound to the file the instance's settings give it, re-bound by every /// `open` and `refresh` (`disk.append` maps a key through it). pub(crate) bound: std::sync::RwLock>, + /// The instance's handle names nothing any more (#74): set when it is quarantined, faulted by + /// the watchdog, closed or dropped. Checked by `handle::resolve` after the tag verifies. + pub(crate) revoked: std::sync::atomic::AtomicBool, } impl std::fmt::Debug for InstanceWake { @@ -109,11 +112,11 @@ impl std::fmt::Debug for InstanceWake { pub(crate) extern "C" fn host_wake(ctx: HostCtx, ticket: Ticket) { // A generation-0 ticket (NONE) is a stale wake like any other: routed, and dropped and // counted by the worker, never silently lost here. - if ctx.ptr.is_null() { + // THE HANDLE IS VERIFIED BEFORE IT NAMES ANYTHING (#74): a context the host did not mint wakes + // nothing and is counted. + let Some(target) = super::handle::resolve(ctx) else { return; - } - // SAFETY: every `HostCtx` the host hands out points to a leaked `InstanceWake`. - let target = unsafe { &*ctx.ptr.cast_const().cast::() }; + }; if let Some(route) = target.route.get().and_then(Weak::upgrade) { route.wake(ticket); } diff --git a/crates/plugin-loader/src/dispatch/watchdog.rs b/crates/plugin-loader/src/dispatch/watchdog.rs index a0acd4c18a..ac49e48cd2 100644 --- a/crates/plugin-loader/src/dispatch/watchdog.rs +++ b/crates/plugin-loader/src/dispatch/watchdog.rs @@ -75,7 +75,7 @@ fn ticketless(pool: &Pool) { started.elapsed() > pool.env.budgets.of(s, DeadlineClass::Call) }); if hung { - inst.faulted.store(true, Ordering::Release); + inst.revoke(); // A faulted instance never crosses again: its connector streams go now. if let Some((id, table)) = inst.wake.conn.get() { super::conn_services::purge(*id, table); @@ -96,7 +96,7 @@ fn replace(pool: &Pool, i: usize, old: &Arc, started: Instant) { let Some(record) = crossing.as_ref().filter(|c| c.started == started) else { return; }; - record.instance.faulted.store(true, Ordering::Release); + record.instance.revoke(); let faulted = Arc::clone(&record.instance); drop(crossing); st.dead = true; diff --git a/crates/plugin-loader/src/dispatch/worker.rs b/crates/plugin-loader/src/dispatch/worker.rs index 308a336178..52b0d93371 100644 --- a/crates/plugin-loader/src/dispatch/worker.rs +++ b/crates/plugin-loader/src/dispatch/worker.rs @@ -135,6 +135,11 @@ pub struct DispatchStats { /// cancelled, a WriteBehind op's caller released), never woken before it. A door whose every /// wait is woken keeps it 0; every conformance script requires 0. pub deadline_without_wake: u64, + /// Contexts plugins handed the host that it did not mint, process-wide (#74, + /// `dispatch::handle`): each inside a crossing quarantined its instance. + pub forged_handles: u64, + /// The off-crossing latch tripped (#74): nothing resolves outside a crossing any more. + pub handles_latched: bool, } #[derive(Debug, Default)] @@ -1390,8 +1395,23 @@ impl std::fmt::Debug for Dispatcher { impl Dispatcher { /// Workers and a watchdog, per `config`, with no host services bound: every service answers /// REFUSED. + /// + /// # Panics + /// + /// When the OS random source cannot answer the handle key (#74): a boot with no key is refused, + /// never run under a weaker one. [`Dispatcher::try_new`] answers the refusal instead. pub fn new(config: DispatchConfig) -> Self { - Self::build(config, None) + Self::try_new(config).unwrap_or_else(|e| panic!("{e}")) + } + + /// THE LOADER'S BOOT (#74): the handle key drawn first, then the dispatcher. + /// + /// # Errors + /// + /// [`LoadError::Entropy`](super::LoadError::Entropy) when the OS random source cannot answer. + pub fn try_new(config: DispatchConfig) -> Result { + super::handle::arm()?; + Ok(Self::build(config, None)) } /// The connection table's wake: its wake number ([`super::conn_services::conn_ticket`]) routed to @@ -1421,8 +1441,25 @@ impl Dispatcher { } /// Workers and a watchdog, per `config`, serving the host services the kernel implements. + /// + /// # Panics + /// + /// As [`Dispatcher::new`]; [`Dispatcher::try_with_services`] answers the refusal instead. pub fn with_services(config: DispatchConfig, provider: Arc) -> Self { - Self::build(config, Some(provider)) + Self::try_with_services(config, provider).unwrap_or_else(|e| panic!("{e}")) + } + + /// [`Dispatcher::try_new`], serving `provider`. + /// + /// # Errors + /// + /// [`LoadError::Entropy`](super::LoadError::Entropy) when the OS random source cannot answer. + pub fn try_with_services( + config: DispatchConfig, + provider: Arc, + ) -> Result { + super::handle::arm()?; + Ok(Self::build(config, Some(provider))) } fn build(config: DispatchConfig, provider: Option>) -> Self { @@ -1481,6 +1518,8 @@ impl Dispatcher { live_reapers: super::load::live_reapers(), driver_kept_high: s.driver_kept_high.load(Ordering::Relaxed), deadline_without_wake: s.deadline_without_wake.load(Ordering::Relaxed), + forged_handles: super::handle::forged(), + handles_latched: super::handle::latched(), } } diff --git a/crates/plugin-loader/src/plane.rs b/crates/plugin-loader/src/plane.rs index d88b0772c7..46c4e6f901 100644 --- a/crates/plugin-loader/src/plane.rs +++ b/crates/plugin-loader/src/plane.rs @@ -436,7 +436,10 @@ impl DynPlane { return StatusClass::Unsupported; }; let work_ptr: *const WorkItem = work; - match crate::ffi_guard(&self.path, "plane_dispatch", || f(state, work_ptr)) { + // No mechanism crossing: the mark is nulled for it (#74). + match crate::dispatch::plugin::without_crossing(|| { + crate::ffi_guard(&self.path, "plane_dispatch", || f(state, work_ptr)) + }) { Ok(status) => status.class(), Err(_) => StatusClass::Fault, } diff --git a/crates/plugin-loader/src/tests/dispatch_tests.rs b/crates/plugin-loader/src/tests/dispatch_tests.rs index 52626290dc..5a8805d3f6 100644 --- a/crates/plugin-loader/src/tests/dispatch_tests.rs +++ b/crates/plugin-loader/src/tests/dispatch_tests.rs @@ -714,7 +714,7 @@ mod open_faults_on_its_resume { /// Crossings that were handed the freed box. static STALE: AtomicU32 = AtomicU32::new(0); /// The pended `open`'s wake, its context and its ticket. - static WAKE: Mutex> = Mutex::new(None); + static WAKE: Mutex> = Mutex::new(None); fn real() -> OpsHead { REAL.lock().unwrap().expect("the door was restated") @@ -749,7 +749,7 @@ mod open_faults_on_its_resume { let ticket = (*input.cast::()).ticket; let tables = &*(*input.cast::()).host; let wake = tables.wake.expect("the host hands a wake"); - *WAKE.lock().unwrap() = Some((wake, tables.ctx.ptr as usize, ticket)); + *WAKE.lock().unwrap() = Some((wake, tables.ctx, ticket)); (*out.cast::()).outcome = RawOutcome::of(Outcome::Pending); } @@ -824,12 +824,7 @@ mod open_faults_on_its_resume { .outcome; let (wake, ctx, ticket) = WAKE.lock().unwrap().take().expect("the open pended"); - wake( - HostCtx { - ptr: ctx as *mut c_void, - }, - ticket, - ); + wake(ctx, ticket); let opened = reply.wait(WAIT).expect("the open answers"); assert_eq!(opened.outcome, Outcome::Fault); assert_ne!(FREED.load(Ordering::SeqCst), 0, "the resume freed the box"); @@ -2504,3 +2499,253 @@ fn a_dispatcher_built_and_first_submitted_inside_a_runtime_runs_in_no_runtime_un ); assert!(d.installed_runtime().is_some()); } + +/// #74, ONE GUESS: an instance that hands the host's `wake` a context the host did not mint, from +/// inside its own crossing, is quarantined (N = 1). The crossing is a `tick`, a lifecycle op, so +/// this is the FFI-worker path: the mark travels with the crossing onto the plugin thread. The +/// forging op ITSELF answers FAULT (its answer is not judged and its envelope not read), the +/// instance is faulted and its every later op is FAULT without a crossing, and its own handle is +/// REVOKED. The control: an instance that only hands back its own context is never faulted. +#[test] +fn a_forged_context_quarantines_the_instance_that_forged_it() { + let d = Dispatcher::new(config()); + let honest = linked(Arc::new(Recorder::default())); + assert_eq!(open(&d, &honest, 0), Outcome::Ready); + assert_eq!( + honest.call(TICK, &mut frame(plug::COUNT)).outcome, + Outcome::Ready + ); + assert!(!honest.is_faulted(), "an honest instance was faulted"); + + let forger = linked(Arc::new(Recorder::default())); + assert_eq!(open(&d, &forger, 0), Outcome::Ready); + assert_eq!( + forger.call(TICK, &mut frame(plug::FORGE)).outcome, + Outcome::Fault, + "the forging op answered other than FAULT" + ); + assert!( + forger.is_faulted(), + "the forging instance was not quarantined" + ); + assert_eq!( + forger.call(TICK, &mut frame(plug::COUNT)).outcome, + Outcome::Fault, + "a quarantined instance was called again" + ); + let latch = crate::dispatch::handle::Latch::new(); + assert!( + crate::dispatch::handle::resolve_in(forger.inner.ctx(), &latch, None).is_none(), + "the quarantined instance's own handle still names it" + ); + assert_eq!( + latch.forged(), + 0, + "a revoked handle was counted as a forgery" + ); + assert!( + !honest.is_faulted(), + "the quarantine reached another instance" + ); + assert!(crate::dispatch::handle::resolve_in(honest.inner.ctx(), &latch, None).is_some()); +} + +/// #74, IN A REAL CROSSING: another instance's handle is a forgery, revoked or not, and quarantines +/// the instance crossing; the latch does not move (the forgery had someone to blame). Driven under +/// `with_crossing` with a real instance, as a crossing states it. +#[test] +fn inside_a_crossing_another_instances_handle_quarantines_the_crossing() { + let d = Dispatcher::new(config()); + let crossing = linked(Arc::new(Recorder::default())); + assert_eq!(open(&d, &crossing, 0), Outcome::Ready); + let other = linked(Arc::new(Recorder::default())); + let (own_ctx, other_ctx) = (crossing.inner.ctx(), other.inner.ctx()); + // The other instance revoked first: a revoked handle of ANOTHER instance is still a forgery. + other.inner.revoke(); + let latch = crate::dispatch::handle::Latch::new(); + let (own, theirs) = crate::dispatch::plugin::with_crossing(Some(&*crossing.inner), || { + let own = crate::dispatch::handle::resolve_in( + own_ctx, + &latch, + crate::dispatch::plugin::crossing_wake(), + ) + .is_some(); + let theirs = crate::dispatch::handle::resolve_in( + other_ctx, + &latch, + crate::dispatch::plugin::crossing_wake(), + ) + .is_some(); + (own, theirs) + }); + assert!(own, "the crossing's own handle was refused"); + assert!( + !theirs, + "another instance's handle resolved inside a crossing" + ); + assert_eq!(latch.forged(), 1, "the forgery was not counted"); + assert_eq!( + latch.unattributed(), + 0, + "an attributed forgery moved the latch" + ); + assert!( + crossing.is_faulted(), + "the crossing instance was not quarantined" + ); +} + +/// #74, THE INLINE HOT-LANE SLOT: a call into plugin code that is no mechanism crossing, nested in +/// one, runs with the mark nulled. A forgery made inside it blames no one (the outer instance stays +/// unquarantined, the latch counts it) and the outer crossing's mark is restored after it. +#[test] +fn a_forgery_inside_an_inline_slot_leaves_the_outer_crossing_alone() { + let d = Dispatcher::new(config()); + let outer = linked(Arc::new(Recorder::default())); + assert_eq!(open(&d, &outer, 0), Outcome::Ready); + let forged = busbar_contract::abi::mechanism::ticket::HostCtx { + handle: outer.inner.ctx().handle ^ (1 << crate::dispatch::handle::HANDLE_ID_BITS), + }; + let latch = crate::dispatch::handle::Latch::new(); + let restored = crate::dispatch::plugin::with_crossing(Some(&*outer.inner), || { + crate::dispatch::plugin::without_crossing(|| { + assert!(crate::dispatch::plugin::crossing_wake().is_none()); + assert!(crate::dispatch::handle::resolve_in( + forged, + &latch, + crate::dispatch::plugin::crossing_wake(), + ) + .is_none()); + }); + crate::dispatch::plugin::crossing_wake().is_some_and(|w| std::ptr::eq(w, outer.inner.wake)) + }); + assert!(restored, "the outer crossing's mark was not restored"); + assert!( + !outer.is_faulted(), + "the outer instance was quarantined for an inline forgery" + ); + assert_eq!( + latch.unattributed(), + 1, + "the unattributed forgery was not counted" + ); +} + +/// #74, REVOCATION: a closed instance's handle, a dropped one's and a watchdog-faulted one's name +/// nothing, and none of the three is counted as a forgery (their tags are the host's own). +#[test] +fn a_closed_a_dropped_and_a_watchdog_faulted_instance_name_nothing() { + let d = Dispatcher::new(config()); + let latch = crate::dispatch::handle::Latch::new(); + let resolves = |ctx| crate::dispatch::handle::resolve_in(ctx, &latch, None).is_some(); + + let closed = linked(Arc::new(Recorder::default())); + assert_eq!(open(&d, &closed, 0), Outcome::Ready); + let ctx = closed.inner.ctx(); + assert!(resolves(ctx)); + let t = d.mint(0).expect("a ticket"); + let _ = d + .submit( + &closed, + t, + slot::CLOSE, + Frame::new(in_head(), out_head()), + DeadlineClass::Call, + 0, + ) + .wait(WAIT); + d.recycle(t); + assert!(!resolves(ctx), "a closed instance's handle still names it"); + + let dropped = linked(Arc::new(Recorder::default())); + let ctx = dropped.inner.ctx(); + assert!(resolves(ctx)); + drop(dropped); + assert!(!resolves(ctx), "a dropped instance's handle still names it"); + + let hung = linked(Arc::new(Recorder::default())); + assert_eq!(open(&d, &hung, 1), Outcome::Ready); + let ctx = hung.inner.ctx(); + let t = d.mint(1).expect("a ticket"); + let reply = d.submit( + &hung, + t, + TICK, + frame(b"hang:revoked"), + DeadlineClass::Call, + 0, + ); + until("the watchdog faults the hung instance", || { + hung.is_faulted() + }); + assert!( + !resolves(ctx), + "a watchdog-faulted instance's handle still names it" + ); + let releaser = linked(Arc::new(Recorder::default())); + assert_eq!(open(&d, &releaser, 0), Outcome::Ready); + let _ = releaser.call(TICK, &mut frame(b"unhang:revoked")); + let _ = reply.wait(WAIT); + + assert_eq!( + latch.forged(), + 0, + "a revoked handle was counted as a forgery" + ); +} + +/// #74, REVOCATION ON THE WATCHDOG'S TICKET-LESS PATH: an instance adopted by the dispatcher whose +/// ticket-less call hangs past its budget is faulted by the watchdog (`ticketless`), and its handle +/// names nothing from then on, with no forgery counted. +#[test] +fn a_ticketless_hang_faulted_by_the_watchdog_revokes_the_handle() { + let d = Dispatcher::new(config()); + let bind_adopted = || Bind { + dispatcher: d.adopter(), + ..bind(Arc::new(Recorder::default())) + }; + let hung = load_linked::(&row(), bind_adopted()).expect("the linked door loads"); + assert_eq!(open(&d, &hung, 0), Outcome::Ready); + let ctx = hung.inner.ctx(); + let latch = crate::dispatch::handle::Latch::new(); + assert!(crate::dispatch::handle::resolve_in(ctx, &latch, None).is_some()); + std::thread::scope(|scope| { + let caller = scope.spawn(|| hung.call(TICK, &mut frame(b"hang:ticketless")).outcome); + until("the watchdog faults the ticket-less hang", || { + hung.is_faulted() + }); + assert!( + crate::dispatch::handle::resolve_in(ctx, &latch, None).is_none(), + "a watchdog-faulted instance's handle still names it" + ); + let releaser = + load_linked::(&row(), bind_adopted()).expect("the linked door loads"); + assert_eq!(open(&d, &releaser, 0), Outcome::Ready); + let _ = releaser.call(TICK, &mut frame(b"unhang:ticketless")); + let _ = caller.join(); + }); + assert_eq!( + latch.forged(), + 0, + "a revoked handle was counted as a forgery" + ); +} + +/// #74, THE KEY AT BOOT: a loader boot whose OS random source cannot answer is refused +/// (`LoadError::Entropy`), the dispatcher never built; there is no fallback key. Driven through the +/// test seam, which makes this thread's draw a fresh one against a failing source. +#[test] +fn a_boot_whose_entropy_fails_is_refused() { + crate::dispatch::handle::ENTROPY_FAILS.with(|f| f.set(true)); + let refused = Dispatcher::try_new(config()); + let bound = load_linked::(&row(), bind(Arc::new(Recorder::default()))); + crate::dispatch::handle::ENTROPY_FAILS.with(|f| f.set(false)); + assert!( + matches!(refused, Err(LoadError::Entropy(_))), + "a failing entropy source did not refuse the boot" + ); + assert!( + matches!(bound, Err(LoadError::Entropy(_))), + "a failing entropy source did not refuse the bind" + ); +} diff --git a/crates/plugin-loader/src/tests/handle_tests.rs b/crates/plugin-loader/src/tests/handle_tests.rs new file mode 100644 index 0000000000..7ac6b1bc2b --- /dev/null +++ b/crates/plugin-loader/src/tests/handle_tests.rs @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! The instance handle's own arithmetic and its one check (#74), each judged against a latch of the +//! test's own so no case here moves the process's. The forged context refused at the host's +//! entries is `host_services_tests::a_forged_context_is_refused_before_it_names_an_instance`; the +//! quarantine, the FAULT of the forging op and the entropy refusal at boot are `dispatch_tests`'. + +use super::*; + +/// The SipHash-2-4 reference vectors (Aumasson and Bernstein, `vectors.h`): key `00..0f`, message +/// `00..len-1`, for the empty, 8-byte and 16-byte messages. +#[test] +fn siphash24_is_the_reference_function() { + let k = (0x0706_0504_0302_0100, 0x0f0e_0d0c_0b0a_0908); + assert_eq!(siphash24(k, &[]), 0x726f_db47_dd0e_0e31); + assert_eq!( + siphash24(k, &[0x0706_0504_0302_0100]), + 0x93f5_f579_9a93_2462 + ); + assert_eq!( + siphash24(k, &[0x0706_0504_0302_0100, 0x0f0e_0d0c_0b0a_0908]), + 0x3f2a_cc7f_57c2_9bdb + ); +} + +fn leaked() -> &'static InstanceWake { + Box::leak(Box::default()) +} + +/// A minted handle names its instance and no address; every handle the host did not mint names +/// none and is counted; no context is no context, and is not counted. +#[test] +fn only_a_minted_handle_names_an_instance() { + let latch = Latch::new(); + let wake = leaked(); + let ctx = mint(wake).expect("the host mints a handle"); + assert!(std::ptr::eq( + resolve_in(ctx, &latch, None).expect("its own instance"), + wake + )); + assert_ne!( + ctx.handle, + std::ptr::from_ref(wake) as usize as u64, + "the handle is not the instance's address" + ); + + assert!(resolve_in(HostCtx::NONE, &latch, None).is_none()); + assert_eq!(latch.forged(), 0, "no context is not a forgery"); + + let id = ctx.handle & ID_MASK; + let unminted = (1u64 << HANDLE_ID_BITS) - 2; + let key = key().expect("the key"); + for (why, handle) in [ + ("one tag bit off", ctx.handle ^ (1 << HANDLE_ID_BITS)), + ("the top tag bit off", ctx.handle ^ (1 << 63)), + ("an id with no tag", id), + ("a tag with no id", ctx.handle & !ID_MASK), + ( + "a correctly tagged id the host never minted", + (tag_of(key, unminted) << HANDLE_ID_BITS) | unminted, + ), + ( + "the leaked entry's own address", + std::ptr::from_ref(wake) as usize as u64, + ), + ] { + let at = latch.forged(); + assert!( + resolve_in(HostCtx { handle }, &latch, None).is_none(), + "{why}" + ); + assert_eq!(latch.forged(), at + 1, "{why} was not counted"); + } +} + +/// A REVOKED handle (its instance quarantined, faulted, closed or let go) names nothing, and is not +/// a forgery: its tag is the host's own. +#[test] +fn a_revoked_handle_names_nothing_and_is_no_forgery() { + let latch = Latch::new(); + let wake = leaked(); + let ctx = mint(wake).expect("the host mints a handle"); + assert!(resolve_in(ctx, &latch, None).is_some()); + wake.revoked.store(true, Ordering::Release); + assert!( + resolve_in(ctx, &latch, None).is_none(), + "a revoked handle resolved" + ); + assert_eq!( + latch.forged(), + 0, + "a revoked handle was counted as a forgery" + ); +} + +/// THE LATCH: off-crossing forgeries name no instance to quarantine, so they count; at +/// `LATCH_AT` every resolve outside a crossing is refused, the honest one included, while a +/// crossing's own still resolves. +#[test] +fn the_latch_trips_at_k_and_refuses_every_off_crossing_resolve() { + let latch = Latch::new(); + let wake = leaked(); + let ctx = mint(wake).unwrap(); + let forged_ctx = HostCtx { + handle: ctx.handle ^ (1 << HANDLE_ID_BITS), + }; + for _ in 0..LATCH_AT - 1 { + assert!(resolve_in(forged_ctx, &latch, None).is_none()); + } + assert!(!latch.tripped(), "tripped before K"); + assert!( + resolve_in(ctx, &latch, None).is_some(), + "below K the honest handle resolves" + ); + assert!(resolve_in(forged_ctx, &latch, None).is_none()); + assert!(latch.tripped(), "K forgeries did not trip the latch"); + assert!( + resolve_in(ctx, &latch, None).is_none(), + "a tripped latch let an off-crossing resolve through" + ); + assert!( + resolve_in(ctx, &latch, Some(wake)).is_some(), + "a crossing's own handle is refused by the latch" + ); +} + +/// The key comes from the OS random source or not at all: a source that cannot answer is a +/// refusal ([`LoadError::Entropy`]), never a fallback key. +#[test] +fn a_failing_entropy_source_is_a_refusal_not_a_weaker_key() { + let refused = draw(|_| Err("no entropy".to_string())); + assert!(matches!(refused, Err(LoadError::Entropy(why)) if why == "no entropy")); + let drawn = draw(|b| { + b.copy_from_slice(&[1; 16]); + Ok(()) + }); + assert_eq!( + drawn.ok(), + Some((0x0101_0101_0101_0101, 0x0101_0101_0101_0101)) + ); +} diff --git a/crates/plugin-loader/src/tests/host_services_tests.rs b/crates/plugin-loader/src/tests/host_services_tests.rs index 42805e6fcb..2cefaa9131 100644 --- a/crates/plugin-loader/src/tests/host_services_tests.rs +++ b/crates/plugin-loader/src/tests/host_services_tests.rs @@ -19,6 +19,7 @@ use busbar_contract::abi::mechanism::check::Filled; use busbar_contract::services::DiskDest; use super::*; +use crate::dispatch::ticket::InstanceWake; const TICKET: Ticket = Ticket { slot: 3, @@ -425,9 +426,7 @@ fn double() -> Double { .is_ok()); Double { route, - ctx: HostCtx { - ptr: std::ptr::from_ref(wake).cast_mut().cast(), - }, + ctx: crate::dispatch::handle::mint(wake).expect("the host mints a handle"), } } @@ -1230,9 +1229,7 @@ fn a_caller_scoped_service_with_no_caller_is_refused() { let wake: &'static InstanceWake = Box::leak(Box::default()); let dyn_route: Arc = d.route.clone(); assert!(wake.route.set(Arc::downgrade(&dyn_route)).is_ok()); - let anon = HostCtx { - ptr: std::ptr::from_ref(wake).cast_mut().cast(), - }; + let anon = crate::dispatch::handle::mint(wake).expect("the host mints a handle"); let i = claim_in(1); let mut o = blank(); let ret = HOST_SLOTS.records_claim.unwrap()(anon, std::ptr::from_ref(&i).cast(), &mut o); @@ -1639,9 +1636,7 @@ fn records_secret_serves_only_a_declared_kind() { let dyn_route: Arc = d.route.clone(); assert!(wake.route.set(Arc::downgrade(&dyn_route)).is_ok()); assert!(wake.credential_kinds.set(vec!["sigv4".to_string()]).is_ok()); - let reader = HostCtx { - ptr: std::ptr::from_ref(wake).cast_mut().cast(), - }; + let reader = crate::dispatch::handle::mint(wake).expect("the host mints a handle"); let other = secret_in("bearer", "AKID", &mut buf, &mut spans); let (ret, o) = call_secret(reader, &other); assert_eq!(ret.outcome(), Outcome::Refused, "an undeclared kind"); @@ -1860,9 +1855,7 @@ fn disk_append_writes_only_to_a_granted_bound_destination() { let dyn_route: Arc = d.route.clone(); assert!(wake.route.set(Arc::downgrade(&dyn_route)).is_ok()); assert!(wake.destinations.set(vec!["path".to_string()]).is_ok()); - let ctx = HostCtx { - ptr: std::ptr::from_ref(wake).cast_mut().cast(), - }; + let ctx = crate::dispatch::handle::mint(wake).expect("the host mints a handle"); // Granted, not yet bound (its settings set no path): refused. let (ret, o) = call_disk(ctx, &disk_in("path", b"line\n", 0, &mut w)); assert_eq!(ret.outcome(), Outcome::Refused); @@ -2182,3 +2175,54 @@ fn snapshot_read_refuses_an_unknown_scope_or_a_misaligned_buffer() { "only the well-formed call reached the kernel" ); } + +/// #74, THE UNTRUSTED-ABI HALF: a context the host did not mint is refused before it names an +/// instance. The service answers as it answers no context and the wake wakes nothing; the minted +/// context does both (the control). It used to be an address the host read through, whatever the +/// plugin handed back. (The counting is `handle_tests`', on a latch of its own.) +#[test] +fn a_forged_context_is_refused_before_it_names_an_instance() { + let d = double(); + let mut reading = ClockReading { + size: 0, + _reserved: 0, + wall_ns: 0, + mono_ns: 0, + }; + let i = ClockNowIn { + head: head(op::CLOCK_NOW, Ticket::NONE, 0, size_of::()), + reading: &mut reading, + }; + let woken = Ticket { + slot: 3, + generation: 1, + }; + let tag_bit = 1u64 << crate::dispatch::handle::HANDLE_ID_BITS; + for forged in [ + HostCtx { + handle: d.ctx.handle ^ tag_bit, + }, + HostCtx { + handle: d.ctx.handle ^ 1, + }, + // The raw address of the very entry the handle names: the old shape of a context. + HostCtx { + handle: std::ptr::from_ref(crate::dispatch::handle::resolve(d.ctx).unwrap()) as usize + as u64, + }, + ] { + let mut o = blank(); + let ret = HOST_SLOTS.clock_now.unwrap()(forged, std::ptr::from_ref(&i).cast(), &mut o); + assert_eq!(ret.outcome(), Outcome::Refused, "{forged:?}"); + crate::dispatch::ticket::host_wake(forged, woken); + assert!( + d.route.wakes.lock().unwrap().is_empty(), + "a forged context woke a ticket: {forged:?}" + ); + } + let mut o = blank(); + let ret = HOST_SLOTS.clock_now.unwrap()(d.ctx, std::ptr::from_ref(&i).cast(), &mut o); + assert_eq!(ret.outcome(), Outcome::Ready); + crate::dispatch::ticket::host_wake(d.ctx, woken); + assert_eq!(*d.route.wakes.lock().unwrap(), vec![woken]); +} diff --git a/crates/plugin-loader/src/transport.rs b/crates/plugin-loader/src/transport.rs index a1a7e7f580..1c05ec060e 100644 --- a/crates/plugin-loader/src/transport.rs +++ b/crates/plugin-loader/src/transport.rs @@ -362,8 +362,11 @@ impl Image { /// One guarded crossing into slot `what`. fn call(&self, what: &str, f: impl FnOnce(*mut c_void) -> RawWireOutcome) -> WireOutcome { let ptr = self.state.ptr; - crate::ffi_guard(&self.wire.path, what, || f(ptr)) - .map_or(WireOutcome::Fault, RawWireOutcome::outcome) + // No mechanism crossing: the mark is nulled for it (#74). + crate::dispatch::plugin::without_crossing(|| { + crate::ffi_guard(&self.wire.path, what, || f(ptr)) + }) + .map_or(WireOutcome::Fault, RawWireOutcome::outcome) } fn key(&self) -> &'static str { diff --git a/crates/plugin-loader/tests/fixtures/dispatch_test_plugin.rs b/crates/plugin-loader/tests/fixtures/dispatch_test_plugin.rs index 266650d966..7c502a9928 100644 --- a/crates/plugin-loader/tests/fixtures/dispatch_test_plugin.rs +++ b/crates/plugin-loader/tests/fixtures/dispatch_test_plugin.rs @@ -100,6 +100,9 @@ pub const KIND_REJECTS: u64 = 0xDEAD; pub const SHORT: u64 = 0x5807; /// `tick` mode: panic. `tick` has no guard, so the process aborts. pub const PANIC: &[u8] = b"panic"; +/// `tick`: hand the host's `wake` a context one tag bit off the one this crossing was handed (#74: +/// the host refuses it and quarantines this instance), then answer READY. +pub const FORGE: &[u8] = b"forge"; /// Family index of the `invocations` gauge. pub const GAUGE_INVOCATIONS: u32 = 2; @@ -517,6 +520,13 @@ extern "C" fn tick(instance: *mut c_void, input: *const c_void, out: *mut c_void hang_until(key); return say(out, Outcome::Ready); } + if mode == FORGE { + let forged = HostCtx { + handle: tin.head.host.handle ^ (1 << 24), + }; + (me.wake)(forged, tin.head.ticket); + return say(out, Outcome::Ready); + } if mode == DRIVE_HANGS { me.drive_hangs.store(true, Ordering::SeqCst); return say(out, Outcome::Ready);