From e2c49f974cdde47ee0f4059a90b59a0370802974 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:56:30 -0700 Subject: [PATCH 1/6] #74 (ABI half): the instance context is a kernel-tagged handle, verified before it names anything BUSBAR-1.6.0.md Part 2 #74 (l.2274): "the handle a plugin holds is kernel-MAC'd ... the kernel verifies it on every ABI call". The mechanism's HostCtx was the address of the instance's leaked InstanceWake, and host_wake, every host service (services.rs served/caller/credential_kinds/ destination), every connector slot (conn_services.rs armed/warn_unverified/ticket_waker) and every I/O slot (io_slots.rs caller) dereferenced whatever pointer the plugin handed back: a forged context was undefined behaviour inside the host, not a refused handle. busbar-contract abi/mechanism/ticket.rs: HostCtx { handle: u64 } (NONE = 0). The same 8 bytes and alignment as the pointer, so no struct that carries it moves: the layout golden changes one field name (MechHostCtx.ptr -> .handle) and the C header renders uint64_t handle. plugin-loader dispatch/handle.rs: handle = tag << 24 | id. id indexes the host's instance table; tag = the high 40 bits of SipHash-2-4(key, [domain, id]) with a 128-bit key from getrandom at the first bind (host memory only, never logged; the #75 channel-2 residual stands). resolve() recomputes the tag and compares it in constant time before the table is read; a handle the host did not mint names nothing, is counted (handle::forged), and the entry answers as it answers no context. Every former pointer dereference goes through resolve(). A bind past 2^24 instances is refused (LoadError::HandlesSpent) rather than a handle reused. RED: host_services_tests::a_forged_context_is_refused_before_it_names_an_instance (tag bit off, id moved, a raw host address: clock.now REFUSED, wake wakes nothing, both counted; control: the minted context serves and wakes). handle_tests: SipHash-2-4 reference vectors; only a minted handle resolves (six forgeries, each counted; NONE is not a forgery). Test doubles hand HostCtx::NONE where they handed a null pointer. --- .../busbar-contract/include/busbar_plugin.h | 6 +- .../src/abi/mechanism/ticket.rs | 26 ++- .../src/abi/sdk/tests/auth_door_tests.rs | 4 +- .../src/abi/sdk/tests/auth_outbound_tests.rs | 4 +- .../src/abi/sdk/tests/conn_tests.rs | 8 +- .../src/abi/sdk/tests/door_tests.rs | 4 +- .../src/abi/sdk/tests/exchange_tests.rs | 4 +- .../src/abi/sdk/tests/hook_tests.rs | 4 +- .../src/abi/sdk/tests/io_tests.rs | 16 +- .../src/abi/sdk/tests/life_tests.rs | 4 +- .../src/abi/sdk/tests/safe_tests.rs | 4 +- .../src/abi/sdk/tests/services_tests.rs | 8 +- crates/busbar-contract/tests/abi_versions.rs | 7 +- .../tests/golden/abi-layout.golden | 2 +- crates/busbar-contract/tests/layout_golden.rs | 2 +- .../busbar-contract/tests/sdk_door_cdylib.rs | 4 +- .../tests/door_harness/mod.rs | 4 +- crates/busbar-plane-mcp/tests/door_clock.rs | 4 +- crates/busbar-plane-mcp/tests/program.rs | 4 +- crates/busbar-plane-mcp/tests/sessions.rs | 4 +- crates/busbar-transport-stdio/tests/door.rs | 4 +- .../tests/fixtures/plane_driver_test_plane.rs | 9 +- .../src/dispatch/conn_services.rs | 24 +-- crates/plugin-loader/src/dispatch/handle.rs | 169 ++++++++++++++++++ crates/plugin-loader/src/dispatch/io_slots.rs | 10 +- crates/plugin-loader/src/dispatch/load.rs | 7 + crates/plugin-loader/src/dispatch/mod.rs | 5 +- crates/plugin-loader/src/dispatch/plugin.rs | 16 +- crates/plugin-loader/src/dispatch/services.rs | 27 +-- crates/plugin-loader/src/dispatch/ticket.rs | 10 +- .../plugin-loader/src/tests/dispatch_tests.rs | 11 +- .../plugin-loader/src/tests/handle_tests.rs | 59 ++++++ .../src/tests/host_services_tests.rs | 71 ++++++-- 33 files changed, 375 insertions(+), 170 deletions(-) create mode 100644 crates/plugin-loader/src/dispatch/handle.rs create mode 100644 crates/plugin-loader/src/tests/handle_tests.rs 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/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-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/tests/fixtures/plane_driver_test_plane.rs b/crates/busbar/tests/fixtures/plane_driver_test_plane.rs index 027df976e0..5d0107c1b5 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); }); } } 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..573eeb7ff7 --- /dev/null +++ b/crates/plugin-loader/src/dispatch/handle.rs @@ -0,0 +1,169 @@ +// 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. +//! +//! 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::{AtomicU64, Ordering}; +use std::sync::{OnceLock, RwLock}; + +use busbar_contract::abi::mechanism::ticket::HostCtx; + +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"); + +/// Every handle the host was handed back and did not mint. +static FORGED: AtomicU64 = AtomicU64::new(0); + +/// 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 host's key: 16 bytes from the OS random source, drawn once. A source that cannot answer +/// falls back to the standard library's own per-process random hasher keys (also OS-seeded), never +/// to a constant. +fn key() -> (u64, u64) { + static KEY: OnceLock<(u64, u64)> = OnceLock::new(); + *KEY.get_or_init(|| { + let mut b = [0u8; 16]; + if getrandom::fill(&mut b).is_err() { + use std::hash::{BuildHasher, Hasher}; + let s = std::collections::hash_map::RandomState::new(); + for (i, chunk) in b.chunks_mut(8).enumerate() { + let mut h = s.build_hasher(); + h.write_usize(i); + chunk.copy_from_slice(&h.finish().to_le_bytes()); + } + } + let (lo, hi) = b.split_at(8); + ( + u64::from_le_bytes(lo.try_into().unwrap_or_default()), + u64::from_le_bytes(hi.try_into().unwrap_or_default()), + ) + }) +} + +/// 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. `None` 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) -> Option { + 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 None; + } + table.push(wake); + Some(HostCtx { + handle: (tag_of(key(), id) << HANDLE_ID_BITS) | id, + }) +} + +/// THE ONE CHECK: the instance `ctx` names, when the host minted it. The tag is recomputed and +/// compared before the table is read; a context with no id is no context (not counted), and one +/// whose tag does not verify, or whose id the host never minted, is counted as forged. +pub(crate) fn resolve(ctx: HostCtx) -> Option<&'static InstanceWake> { + let id = ctx.handle & ID_MASK; + if ctx.handle == 0 { + return None; + } + 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 { + FORGED.fetch_add(1, Ordering::Relaxed); + 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()); + if found.is_none() { + FORGED.fetch_add(1, Ordering::Relaxed); + } + found +} + +/// 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..688c4af8c3 100644 --- a/crates/plugin-loader/src/dispatch/load.rs +++ b/crates/plugin-loader/src/dispatch/load.rs @@ -317,6 +317,9 @@ 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, } /// What every refusal of a plugin built against an older contract tells the operator to do. @@ -392,6 +395,10 @@ 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" + ), } } } 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..8e8ad1f52d 100644 --- a/crates/plugin-loader/src/dispatch/plugin.rs +++ b/crates/plugin-loader/src/dispatch/plugin.rs @@ -329,8 +329,8 @@ 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 {} @@ -520,10 +520,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`]). @@ -1350,6 +1350,8 @@ 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).ok_or(LoadError::HandlesSpent)?; // 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 +1415,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, 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..f5dd36c87c 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)] @@ -109,11 +109,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/tests/dispatch_tests.rs b/crates/plugin-loader/src/tests/dispatch_tests.rs index 52626290dc..9f0ae1b684 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"); 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..9e748379d9 --- /dev/null +++ b/crates/plugin-loader/src/tests/handle_tests.rs @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! The instance handle's own arithmetic and its one check (#74). The forged context refused at +//! the host's entries is `host_services_tests::a_forged_context_is_refused_before_it_names_an_instance`. + +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 + ); +} + +/// 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 wake: &'static InstanceWake = Box::leak(Box::default()); + let ctx = mint(wake).expect("the host mints a handle"); + assert!(std::ptr::eq(resolve(ctx).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" + ); + + let before = forged(); + assert!(resolve(HostCtx::NONE).is_none()); + assert_eq!(forged(), before, "no context is not a forgery"); + + let id = ctx.handle & ID_MASK; + let unminted = (1u64 << HANDLE_ID_BITS) - 2; + 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, + ), + ("an address", std::ptr::from_ref(wake) as usize as u64), + ] { + let at = forged(); + assert!(resolve(HostCtx { handle }).is_none(), "{why}"); + assert!(forged() > at, "{why} was not counted"); + } +} diff --git a/crates/plugin-loader/src/tests/host_services_tests.rs b/crates/plugin-loader/src/tests/host_services_tests.rs index 42805e6fcb..1b404efbce 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,57 @@ 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, the wake wakes nothing, and both are +/// counted ([`crate::dispatch::handle::forged`]); the minted context does both (the control). It +/// used to be an address the host read through, whatever the plugin handed back. +#[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, + }, + HostCtx { + handle: std::ptr::from_ref(&d) as usize as u64, + }, + ] { + let before = crate::dispatch::handle::forged(); + 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:?}" + ); + assert!( + crate::dispatch::handle::forged() >= before + 2, + "a forged context was not counted: {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]); +} From e84c5dc13dca7c7c4814a1b071538504e85975e2 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:00:28 -0700 Subject: [PATCH 2/6] #74: a forged context quarantines the instance making the crossing (N = 1, one guess) The coordinator's bar for the 40-bit tag: a failed verify quarantines the calling instance, so no plugin gets a second guess. Every crossing now states its instance on its thread for the span of the op (plugin.rs CROSSING, set on the caller's thread and on the FFI worker for lifecycle ops, restored for nested crossings). handle::resolve's refusal counts the miss and calls plugin::quarantine_crossing: the crossing instance is faulted exactly as the watchdog faults a hung one (never called again on any worker, every later op FAULT, its connector streams purged) and a WARN names the plugin. A call from a plugin's own thread outside any crossing names no caller and is refused and counted. RED: dispatch_tests::a_forged_context_quarantines_the_instance_that_forged_it: the test plugin's new FORGE tick hands wake a context one tag bit off; the instance is faulted and its next op is FAULT; control: an honest instance on the same dispatcher is never faulted. --- crates/plugin-loader/src/dispatch/handle.rs | 16 +++++-- crates/plugin-loader/src/dispatch/plugin.rs | 46 +++++++++++++++++++ .../plugin-loader/src/tests/dispatch_tests.rs | 38 +++++++++++++++ .../tests/fixtures/dispatch_test_plugin.rs | 10 ++++ 4 files changed, 107 insertions(+), 3 deletions(-) diff --git a/crates/plugin-loader/src/dispatch/handle.rs b/crates/plugin-loader/src/dispatch/handle.rs index 573eeb7ff7..fd288ac322 100644 --- a/crates/plugin-loader/src/dispatch/handle.rs +++ b/crates/plugin-loader/src/dispatch/handle.rs @@ -16,7 +16,9 @@ //! 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. +//! 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. //! //! 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 @@ -143,7 +145,7 @@ pub(crate) fn resolve(ctx: HostCtx) -> Option<&'static InstanceWake> { 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 { - FORGED.fetch_add(1, Ordering::Relaxed); + refused(); return None; } let table = INSTANCES @@ -152,12 +154,20 @@ pub(crate) fn resolve(ctx: HostCtx) -> Option<&'static InstanceWake> { let found = usize::try_from(id - 1) .ok() .and_then(|i| table.get(i).copied()); + drop(table); if found.is_none() { - FORGED.fetch_add(1, Ordering::Relaxed); + refused(); } found } +/// A handle the host did not mint: counted, and the instance crossing on this thread, if any, +/// quarantined ([`super::plugin::quarantine_crossing`]): one guess, never a second. +fn refused() { + FORGED.fetch_add(1, Ordering::Relaxed); + super::plugin::quarantine_crossing(); +} + /// How many handles the host was handed back and did not mint, since the process started. #[must_use] pub fn forged() -> u64 { diff --git a/crates/plugin-loader/src/dispatch/plugin.rs b/crates/plugin-loader/src/dispatch/plugin.rs index 8e8ad1f52d..6a3c17d415 100644 --- a/crates/plugin-loader/src/dispatch/plugin.rs +++ b/crates/plugin-loader/src/dispatch/plugin.rs @@ -334,6 +334,50 @@ struct Tables(Box); unsafe impl Send for Tables {} unsafe impl Sync for Tables {} +thread_local! { + /// The instance whose crossing is on this thread (innermost first), for the one thing a host + /// entry needs it for: the quarantine of a forged context ([`quarantine_crossing`]). + static CROSSING: std::cell::Cell<*const Instance> = const { std::cell::Cell::new(std::ptr::null()) }; +} + +/// The crossing of `inst` on this thread, stated for the span of its op and restored (to the +/// crossing it is nested in, if any) when the op returns. +struct Crossing(*const Instance); + +impl Drop for Crossing { + fn drop(&mut self) { + CROSSING.with(|c| c.set(self.0)); + } +} + +fn crossing(inst: &Instance) -> Crossing { + Crossing(CROSSING.with(|c| c.replace(std::ptr::from_ref(inst)))) +} + +/// 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 for the span of an op of `*p`, set by the guard + // `crossing(&*p)` the crossing holds; the instance outlives its own crossing. + let inst = unsafe { &*p }; + inst.faulted.store(true, Ordering::Release); + 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 +} + /// One family's shape, copied from the Statement: its kind and its label count. #[derive(Debug, Clone, Copy)] struct FamilyShape { @@ -943,6 +987,7 @@ 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); + let _crossing = crossing(self); enter_lifecycle((self.op_name)(s), op, instance, input, output) }); match routed { @@ -952,6 +997,7 @@ impl Instance { Err(_) => return Crossed::host(Outcome::Fault), } } else { + let _crossing = crossing(self); op(instance, input.cast_const().cast(), out.cast()) }; if s == slot::OPEN diff --git a/crates/plugin-loader/src/tests/dispatch_tests.rs b/crates/plugin-loader/src/tests/dispatch_tests.rs index 9f0ae1b684..b951a5c0f6 100644 --- a/crates/plugin-loader/src/tests/dispatch_tests.rs +++ b/crates/plugin-loader/src/tests/dispatch_tests.rs @@ -2499,3 +2499,41 @@ 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): faulted, never called again, every later op +/// FAULT without a crossing. It used to be an address the host read through. 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); + let before = crate::dispatch::handle::forged(); + forger.call(TICK, &mut frame(plug::FORGE)); + assert!( + crate::dispatch::handle::forged() > before, + "the forged context was not counted" + ); + 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" + ); + assert!( + !honest.is_faulted(), + "the quarantine reached another instance" + ); +} 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); From c681d6dc9af03cfbac4f0fe2a169a28b8b1e67f8 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:13:24 -0700 Subject: [PATCH 3/6] #74: security review rulings -- latch, revocation, FAULT of the forging op, key at boot, own-instance check, closure-only crossing mark Rulings on the review of e84c5dc13d, in one change: 1. LATCH: a forgery outside any crossing names no instance to quarantine, so it counts against a process-wide latch (handle::Latch); at LATCH_AT = 16 every resolve made outside a crossing is refused for the rest of the process, with one ERROR line; DispatchStats carries forged_handles and handles_latched. A revoked handle is no forgery (its tag verifies). 2. REVOCATION: InstanceWake.revoked, set by Instance::revoke (quarantine, watchdog fault), on close, and on drop; resolve refuses a revoked entry after the tag check. 3. The forging op itself answers FAULT: after the crossing returns, a faulted instance's answer is neither judged nor ingested. 4. KEY AT BOOT: drawn when the loader boots (Dispatcher::build -> handle::arm) and at the latest by the first bind; a failing OS random source refuses the bind with LoadError::Entropy. The RandomState fallback is deleted. 6. Inside a crossing a context must name the crossing's own instance; another instance's handle is a forgery (the interim bound before the CallId ticket). 7. The crossing mark is set only through with_crossing(inst, || ..) (and without_crossing for the inline HOT-lane plane and transport slots, plane.rs and transport.rs, which null it). REDs: handle_tests -- the raw address of the leaked entry itself, a revoked handle refused and not counted, another instance's handle refused inside a crossing, the latch tripping at K and refusing every off-crossing resolve (a crossing's own still resolves), a failing entropy source refused; dispatch_tests -- the forging tick (the FFI-worker lifecycle path) answers FAULT, the instance is quarantined and its own handle revoked, and a boot whose entropy fails is refused through the test seam; host_services_tests -- the raw-address case is the entry's own address. The HOT lane's HostCtx (abi/hot/host.rs, kernel plane_host) still carries a raw pointer; it goes with abi/hot (row 84). --- crates/plugin-loader/src/dispatch/handle.rs | 189 ++++++++++++++---- crates/plugin-loader/src/dispatch/load.rs | 8 + crates/plugin-loader/src/dispatch/plugin.rs | 76 +++++-- crates/plugin-loader/src/dispatch/ticket.rs | 3 + crates/plugin-loader/src/dispatch/watchdog.rs | 4 +- crates/plugin-loader/src/dispatch/worker.rs | 10 + crates/plugin-loader/src/plane.rs | 5 +- .../plugin-loader/src/tests/dispatch_tests.rs | 40 +++- .../plugin-loader/src/tests/handle_tests.rs | 114 ++++++++++- .../src/tests/host_services_tests.rs | 4 +- crates/plugin-loader/src/transport.rs | 7 +- 11 files changed, 381 insertions(+), 79 deletions(-) diff --git a/crates/plugin-loader/src/dispatch/handle.rs b/crates/plugin-loader/src/dispatch/handle.rs index fd288ac322..ee7d6bf5e6 100644 --- a/crates/plugin-loader/src/dispatch/handle.rs +++ b/crates/plugin-loader/src/dispatch/handle.rs @@ -18,18 +18,23 @@ //! 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. +//! 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::{AtomicU64, Ordering}; +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. @@ -43,35 +48,92 @@ const ID_MASK: u64 = (1 << HANDLE_ID_BITS) - 1; /// 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 host's key: 16 bytes from the OS random source, drawn once. A source that cannot answer -/// falls back to the standard library's own per-process random hasher keys (also OS-seeded), never -/// to a constant. -fn key() -> (u64, u64) { - static KEY: OnceLock<(u64, u64)> = OnceLock::new(); - *KEY.get_or_init(|| { - let mut b = [0u8; 16]; - if getrandom::fill(&mut b).is_err() { - use std::hash::{BuildHasher, Hasher}; - let s = std::collections::hash_map::RandomState::new(); - for (i, chunk) in b.chunks_mut(8).enumerate() { - let mut h = s.build_hasher(); - h.write_usize(i); - chunk.copy_from_slice(&h.finish().to_le_bytes()); - } +/// The off-crossing latch: unattributed forgeries counted; tripped at [`LATCH_AT`]. +pub(crate) struct Latch { + unattributed: AtomicU64, + tripped: AtomicBool, +} + +impl Latch { + pub(crate) const fn new() -> Self { + Self { + unattributed: AtomicU64::new(0), + tripped: AtomicBool::new(false), } - let (lo, hi) = b.split_at(8); - ( - u64::from_le_bytes(lo.try_into().unwrap_or_default()), - u64::from_le_bytes(hi.try_into().unwrap_or_default()), - ) - }) + } + + /// 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" + ); + } + } + + fn tripped(&self) -> bool { + self.tripped.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. @@ -117,35 +179,59 @@ 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. `None` 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) -> Option { +/// 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 None; + return Err(LoadError::HandlesSpent); } table.push(wake); - Some(HostCtx { - handle: (tag_of(key(), id) << HANDLE_ID_BITS) | id, + Ok(HostCtx { + handle: (tag_of(key, id) << HANDLE_ID_BITS) | id, }) } -/// THE ONE CHECK: the instance `ctx` names, when the host minted it. The tag is recomputed and -/// compared before the table is read; a context with no id is no context (not counted), and one -/// whose tag does not verify, or whose id the host never minted, is counted as forged. +/// 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> { - let id = ctx.handle & ID_MASK; + 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; } - let want = tag_of(key(), id); + 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 { - refused(); + count_forgery(latch, crossing); return None; } let table = INSTANCES @@ -155,17 +241,36 @@ pub(crate) fn resolve(ctx: HostCtx) -> Option<&'static InstanceWake> { .ok() .and_then(|i| table.get(i).copied()); drop(table); - if found.is_none() { - refused(); + let Some(entry) = found else { + count_forgery(latch, crossing); + return None; + }; + if entry.revoked.load(Ordering::Acquire) { + return None; + } + if let Some(own) = crossing { + if !std::ptr::eq(own, entry) { + count_forgery(latch, crossing); + return None; + } } - found + Some(entry) } -/// A handle the host did not mint: counted, and the instance crossing on this thread, if any, -/// quarantined ([`super::plugin::quarantine_crossing`]): one guess, never a second. -fn refused() { +/// 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); - super::plugin::quarantine_crossing(); + 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. diff --git a/crates/plugin-loader/src/dispatch/load.rs b/crates/plugin-loader/src/dispatch/load.rs index 688c4af8c3..c4d9a9c000 100644 --- a/crates/plugin-loader/src/dispatch/load.rs +++ b/crates/plugin-loader/src/dispatch/load.rs @@ -320,6 +320,9 @@ pub enum LoadError { /// 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. @@ -399,6 +402,11 @@ impl fmt::Display for LoadError { 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/plugin.rs b/crates/plugin-loader/src/dispatch/plugin.rs index 6a3c17d415..7ee899286d 100644 --- a/crates/plugin-loader/src/dispatch/plugin.rs +++ b/crates/plugin-loader/src/dispatch/plugin.rs @@ -335,23 +335,39 @@ unsafe impl Send for Tables {} unsafe impl Sync for Tables {} thread_local! { - /// The instance whose crossing is on this thread (innermost first), for the one thing a host - /// entry needs it for: the quarantine of a forged context ([`quarantine_crossing`]). + /// 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()) }; } -/// The crossing of `inst` on this thread, stated for the span of its op and restored (to the -/// crossing it is nested in, if any) when the op returns. -struct Crossing(*const Instance); - -impl Drop for Crossing { - fn drop(&mut self) { - CROSSING.with(|c| c.set(self.0)); +/// `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() } -fn crossing(inst: &Instance) -> Crossing { - Crossing(CROSSING.with(|c| c.replace(std::ptr::from_ref(inst)))) +/// `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, @@ -364,10 +380,10 @@ pub(crate) fn quarantine_crossing() -> bool { if p.is_null() { return false; } - // SAFETY: `CROSSING` is non-null only for the span of an op of `*p`, set by the guard - // `crossing(&*p)` the crossing holds; the instance outlives its own crossing. + // SAFETY: `CROSSING` is non-null only inside `with_crossing(Some(&*p), ..)`, which borrows the + // instance for the whole span. let inst = unsafe { &*p }; - inst.faulted.store(true, Ordering::Release); + inst.revoke(); if let Some((id, table)) = inst.wake.conn.get() { super::conn_services::purge(*id, table); } @@ -378,6 +394,16 @@ pub(crate) fn quarantine_crossing() -> bool { true } +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 { @@ -467,6 +493,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); } @@ -835,6 +863,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); } @@ -987,8 +1017,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); - let _crossing = crossing(self); - 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, @@ -997,8 +1028,9 @@ impl Instance { Err(_) => return Crossed::host(Outcome::Fault), } } else { - let _crossing = crossing(self); - 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)) @@ -1008,6 +1040,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); @@ -1397,7 +1435,7 @@ impl Plugin { // 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).ok_or(LoadError::HandlesSpent)?; + let ctx = super::handle::mint(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); diff --git a/crates/plugin-loader/src/dispatch/ticket.rs b/crates/plugin-loader/src/dispatch/ticket.rs index f5dd36c87c..13bc363986 100644 --- a/crates/plugin-loader/src/dispatch/ticket.rs +++ b/crates/plugin-loader/src/dispatch/ticket.rs @@ -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 { 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..b11479ca00 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)] @@ -1426,6 +1431,9 @@ impl Dispatcher { } fn build(config: DispatchConfig, provider: Option>) -> Self { + // THE LOADER'S BOOT draws the handle key now (#74); a source that cannot answer refuses + // every bind with `LoadError::Entropy`, so nothing is minted under a weaker key. + let _ = super::handle::arm(); // The last worker index names the inline tickets (`inline::INLINE_WORKER`), never a worker. let n = config.workers.clamp(1, MAX_WORKERS - 1); let env = Arc::new(Env { @@ -1481,6 +1489,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 b951a5c0f6..d0246b910e 100644 --- a/crates/plugin-loader/src/tests/dispatch_tests.rs +++ b/crates/plugin-loader/src/tests/dispatch_tests.rs @@ -2501,9 +2501,12 @@ fn a_dispatcher_built_and_first_submitted_inside_a_runtime_runs_in_no_runtime_un } /// #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): faulted, never called again, every later op -/// FAULT without a crossing. It used to be an address the host read through. The control: an -/// instance that only hands back its own context is never faulted. +/// 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: it names nothing, and is no forgery. 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()); @@ -2518,7 +2521,11 @@ fn a_forged_context_quarantines_the_instance_that_forged_it() { let forger = linked(Arc::new(Recorder::default())); assert_eq!(open(&d, &forger, 0), Outcome::Ready); let before = crate::dispatch::handle::forged(); - forger.call(TICK, &mut frame(plug::FORGE)); + assert_eq!( + forger.call(TICK, &mut frame(plug::FORGE)).outcome, + Outcome::Fault, + "the forging op answered other than FAULT" + ); assert!( crate::dispatch::handle::forged() > before, "the forged context was not counted" @@ -2532,8 +2539,33 @@ fn a_forged_context_quarantines_the_instance_that_forged_it() { Outcome::Fault, "a quarantined instance was called again" ); + let at = crate::dispatch::handle::forged(); + assert!( + crate::dispatch::handle::resolve(forger.inner.ctx()).is_none(), + "the quarantined instance's own handle still names it" + ); + assert_eq!( + crate::dispatch::handle::forged(), + at, + "a revoked handle was counted as a forgery" + ); assert!( !honest.is_faulted(), "the quarantine reached another instance" ); + assert!(crate::dispatch::handle::resolve(honest.inner.ctx()).is_some()); +} + +/// #74, THE KEY AT BOOT: a loader whose OS random source cannot answer refuses to bind +/// (`LoadError::Entropy`); there is no fallback key. Driven through the test seam on this thread. +#[test] +fn a_boot_whose_entropy_fails_is_refused() { + crate::dispatch::handle::ENTROPY_FAILS.with(|f| f.set(true)); + let refused = load_linked::(&row(), bind(Arc::new(Recorder::default()))); + crate::dispatch::handle::ENTROPY_FAILS.with(|f| f.set(false)); + assert!( + matches!(refused, Err(crate::dispatch::LoadError::Entropy(_))), + "a failing entropy source did not refuse the bind: {:?}", + refused.err() + ); } diff --git a/crates/plugin-loader/src/tests/handle_tests.rs b/crates/plugin-loader/src/tests/handle_tests.rs index 9e748379d9..979303d793 100644 --- a/crates/plugin-loader/src/tests/handle_tests.rs +++ b/crates/plugin-loader/src/tests/handle_tests.rs @@ -1,8 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (C) 2026 Busbar Inc and contributors -//! The instance handle's own arithmetic and its one check (#74). The forged context refused at -//! the host's entries is `host_services_tests::a_forged_context_is_refused_before_it_names_an_instance`. +//! 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::*; @@ -22,13 +24,21 @@ fn siphash24_is_the_reference_function() { ); } +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 wake: &'static InstanceWake = Box::leak(Box::default()); + let latch = Latch::new(); + let wake = leaked(); let ctx = mint(wake).expect("the host mints a handle"); - assert!(std::ptr::eq(resolve(ctx).expect("its own instance"), wake)); + 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, @@ -36,11 +46,12 @@ fn only_a_minted_handle_names_an_instance() { ); let before = forged(); - assert!(resolve(HostCtx::NONE).is_none()); + assert!(resolve_in(HostCtx::NONE, &latch, None).is_none()); assert_eq!(forged(), before, "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)), @@ -48,12 +59,99 @@ fn only_a_minted_handle_names_an_instance() { ("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, + (tag_of(key, unminted) << HANDLE_ID_BITS) | unminted, + ), + ( + "the leaked entry's own address", + std::ptr::from_ref(wake) as usize as u64, ), - ("an address", std::ptr::from_ref(wake) as usize as u64), ] { let at = forged(); - assert!(resolve(HostCtx { handle }).is_none(), "{why}"); + assert!( + resolve_in(HostCtx { handle }, &latch, None).is_none(), + "{why}" + ); assert!(forged() > at, "{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); + let at = forged(); + assert!( + resolve_in(ctx, &latch, None).is_none(), + "a revoked handle resolved" + ); + assert_eq!(forged(), at, "a revoked handle was counted as a forgery"); +} + +/// Inside a crossing a context must name the crossing's own instance: another instance's handle, +/// its tag the host's own, is refused and counted (the cheap bound on a library holding two +/// instances' handles). +#[test] +fn inside_a_crossing_only_its_own_handle_resolves() { + let latch = Latch::new(); + let (mine, other) = (leaked(), leaked()); + let (my_ctx, other_ctx) = (mint(mine).unwrap(), mint(other).unwrap()); + assert!(std::ptr::eq( + resolve_in(my_ctx, &latch, Some(mine)).expect("its own"), + mine + )); + let at = forged(); + assert!(resolve_in(other_ctx, &latch, Some(mine)).is_none()); + assert!(forged() > at, "another instance's handle was not counted"); +} + +/// 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 1b404efbce..a09058bc46 100644 --- a/crates/plugin-loader/src/tests/host_services_tests.rs +++ b/crates/plugin-loader/src/tests/host_services_tests.rs @@ -2205,8 +2205,10 @@ fn a_forged_context_is_refused_before_it_names_an_instance() { 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(&d) as usize as u64, + handle: std::ptr::from_ref(crate::dispatch::handle::resolve(d.ctx).unwrap()) as usize + as u64, }, ] { let before = crate::dispatch::handle::forged(); 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 { From d8dff64686fdf314d06c8a43d910bfaf3b5f5d57 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:31:45 -0700 Subject: [PATCH 4/6] #74: re-review rulings -- revocation REDs, per-test latches, a real boot refusal, readiness on the latch 1. REDs added: a closed, a dropped and a watchdog-faulted instance's handle names nothing and is no forgery (dispatch_tests); a forgery inside an inline HOT-lane slot nested in a crossing blames no one and the outer mark is restored (without_crossing under with_crossing); FORGE through a NON-lifecycle op (a plane's arrive, crossed on the caller's thread, crates/busbar/tests/ plane_forged_context.rs) answers FAULT and quarantines. 2. No test asserts on the process-wide counter any more: Latch carries its own forged/unattributed counts and the tests judge a latch of their own; the in-crossing case runs under a real crossing (with_crossing on a loaded instance) and asserts the quarantine and an unmoved latch. 3. The boot refusal is real: Dispatcher::try_new / try_with_services draw the key first and answer LoadError::Entropy; new / with_services panic on it rather than run keyless; busbar's main draws the key before the dispatcher and exits 1 on a failing source. RED: a boot (try_new) and a bind through the seam, both refused. 4. Readiness: /healthz answers 503 with a reason line while the handle latch is tripped (no new metrics family); the ERROR line stays. 5. Inside a crossing another instance's handle is a forgery even if revoked (own-instance check before the revocation check). 7. A bind refused after mint revokes the minted entry (Unbound guard). 8. The latch is read again after counting, so no off-crossing resolve passes once it trips. 9. sdk/services.rs SAFETY comments describe the u64 handle. --- .../busbar-contract/src/abi/sdk/services.rs | 8 +- crates/busbar-kernel/src/endpoints.rs | 12 ++ crates/busbar/src/main.rs | 6 + crates/busbar/tests/plane_forged_context.rs | 156 +++++++++++++++ crates/plugin-loader/src/dispatch/handle.rs | 33 +++- crates/plugin-loader/src/dispatch/plugin.rs | 15 ++ crates/plugin-loader/src/dispatch/worker.rs | 39 +++- .../plugin-loader/src/tests/dispatch_tests.rs | 179 ++++++++++++++++-- .../plugin-loader/src/tests/handle_tests.rs | 20 +- .../src/tests/host_services_tests.rs | 11 +- 10 files changed, 421 insertions(+), 58 deletions(-) create mode 100644 crates/busbar/tests/plane_forged_context.rs 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-kernel/src/endpoints.rs b/crates/busbar-kernel/src/endpoints.rs index 1675fb5505..77fed5c730 100644 --- a/crates/busbar-kernel/src/endpoints.rs +++ b/crates/busbar-kernel/src/endpoints.rs @@ -281,6 +281,18 @@ fn list_models_dialect( } pub async fn healthz(crate::state::CurrentApp(app): crate::state::CurrentApp) -> Response { + // THE PLUGIN HANDLE LATCH (#74): a plugin handed the host contexts it did not mint from outside + // any crossing until the latch tripped; every host call made outside a crossing is refused from + // then on (a plugin's background wakes included), so the node is not ready and says why. The + // remedy is a restart without the plugin that forged them. + if busbar_plugin_loader::dispatch::handle::latched() { + return ( + StatusCode::SERVICE_UNAVAILABLE, + "plugin handle latch tripped: a plugin forged host contexts outside any crossing; \ + restart without it", + ) + .into_response(); + } 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 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/plane_forged_context.rs b/crates/busbar/tests/plane_forged_context.rs new file mode 100644 index 0000000000..36a2c75fd5 --- /dev/null +++ b/crates/busbar/tests/plane_forged_context.rs @@ -0,0 +1,156 @@ +// 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 door wrapped: `open` keeps the host tables +//! it is handed, and `arrive` first hands the host's `wake` its own context one tag bit off, then +//! answers as the plane does. The plane fixture itself is unchanged. + +#[path = "fixtures/plane_driver_test_plane.rs"] +#[allow(dead_code)] +mod plane; + +use std::ffi::c_void; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, OnceLock}; + +use busbar_contract::abi::mechanism::call::{AbiStr, InHead, Outcome as AbiOutcome, RawOutcome}; +use busbar_contract::abi::mechanism::door::Door; +use busbar_contract::abi::mechanism::lifecycle::{OpenIn, OpsHead}; +use busbar_contract::abi::mechanism::ticket::{HostCtx, HostTables, Ticket}; +use busbar_contract::abi::plane::{ + slot, ArriveIn, ArriveOut, Ops, 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, +}; + +/// A `'static` table of raw pointers, shared read-only. +struct Shared(T); +// SAFETY: written once (`OnceLock`) before any read, never mutated after; the pointers it holds +// are to `'static` plugin data. +unsafe impl Sync for Shared {} +// SAFETY: as above. +unsafe impl Send for Shared {} + +/// The host tables `open` was handed: `'static` host memory, kept for the instance's life. +static TABLES: AtomicUsize = AtomicUsize::new(0); + +fn inner() -> &'static Ops { + static INNER: OnceLock> = OnceLock::new(); + &INNER + .get_or_init(|| { + // SAFETY: the test plane's door is `'static`; a plane door's `ops` is a plane `Ops`. + Shared(unsafe { *(*plane::door()).ops.cast::() }) + }) + .0 +} + +extern "C" fn open(i: *mut c_void, input: *const c_void, out: *mut c_void) -> RawOutcome { + // SAFETY: the host's `open` in, which leads with an `OpenIn`. + let host = unsafe { (*input.cast::()).host }; + TABLES.store(host as usize, Ordering::SeqCst); + (inner().head.open.expect("the plane opens"))(i, input, out) +} + +extern "C" fn arrive(i: *mut c_void, input: *const c_void, out: *mut c_void) -> RawOutcome { + // SAFETY: the tables `open` was handed are the host's, alive for the instance's life; the `in` + // leads with its `InHead`. + unsafe { + let tables = &*(TABLES.load(Ordering::SeqCst) as *const HostTables); + let ctx = (*input.cast::()).host; + let forged = HostCtx { + handle: ctx.handle ^ (1 << 24), + }; + (tables.wake.expect("the host's wake"))(forged, Ticket::NONE); + } + (inner().arrive.expect("the plane arrives"))(i, input, out) +} + +extern "C" fn door() -> *const Door { + static OPS: OnceLock> = OnceLock::new(); + static DOOR: OnceLock> = OnceLock::new(); + let ops = &OPS + .get_or_init(|| { + let mut ops = *inner(); + ops.head.open = Some(open); + ops.arrive = Some(arrive); + Shared(ops) + }) + .0; + &DOOR + .get_or_init(|| { + Shared(Door { + ops: (ops as *const Ops).cast::(), + // SAFETY: the test plane's door is `'static`. + ..unsafe { *plane::door() } + }) + }) + .0 +} + +#[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(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"/stats"), + 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/handle.rs b/crates/plugin-loader/src/dispatch/handle.rs index ee7d6bf5e6..5d3f2d7406 100644 --- a/crates/plugin-loader/src/dispatch/handle.rs +++ b/crates/plugin-loader/src/dispatch/handle.rs @@ -65,6 +65,9 @@ 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, } @@ -72,6 +75,7 @@ pub(crate) struct Latch { impl Latch { pub(crate) const fn new() -> Self { Self { + forged: AtomicU64::new(0), unattributed: AtomicU64::new(0), tripped: AtomicBool::new(false), } @@ -89,9 +93,19 @@ impl Latch { } } - fn tripped(&self) -> bool { + pub(crate) fn tripped(&self) -> bool { self.tripped.load(Ordering::Acquire) } + + /// Forgeries judged against this latch. + pub(crate) fn forged(&self) -> u64 { + self.forged.load(Ordering::Acquire) + } + + /// Unattributed forgeries counted towards the trip. + pub(crate) fn unattributed(&self) -> u64 { + self.unattributed.load(Ordering::Acquire) + } } #[cfg(test)] @@ -245,15 +259,25 @@ pub(crate) fn resolve_in( count_forgery(latch, crossing); return None; }; - if entry.revoked.load(Ordering::Acquire) { - 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) } @@ -262,6 +286,7 @@ pub(crate) fn resolve_in( /// 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(); } diff --git a/crates/plugin-loader/src/dispatch/plugin.rs b/crates/plugin-loader/src/dispatch/plugin.rs index 7ee899286d..d72d040263 100644 --- a/crates/plugin-loader/src/dispatch/plugin.rs +++ b/crates/plugin-loader/src/dispatch/plugin.rs @@ -394,6 +394,17 @@ pub(crate) fn quarantine_crossing() -> bool { 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 @@ -1436,6 +1447,9 @@ impl Plugin { 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); @@ -1570,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/worker.rs b/crates/plugin-loader/src/dispatch/worker.rs index b11479ca00..52b0d93371 100644 --- a/crates/plugin-loader/src/dispatch/worker.rs +++ b/crates/plugin-loader/src/dispatch/worker.rs @@ -1395,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 @@ -1426,14 +1441,28 @@ 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 { - // THE LOADER'S BOOT draws the handle key now (#74); a source that cannot answer refuses - // every bind with `LoadError::Entropy`, so nothing is minted under a weaker key. - let _ = super::handle::arm(); // The last worker index names the inline tickets (`inline::INLINE_WORKER`), never a worker. let n = config.workers.clamp(1, MAX_WORKERS - 1); let env = Arc::new(Env { diff --git a/crates/plugin-loader/src/tests/dispatch_tests.rs b/crates/plugin-loader/src/tests/dispatch_tests.rs index d0246b910e..a3bdcfb436 100644 --- a/crates/plugin-loader/src/tests/dispatch_tests.rs +++ b/crates/plugin-loader/src/tests/dispatch_tests.rs @@ -2505,8 +2505,7 @@ fn a_dispatcher_built_and_first_submitted_inside_a_runtime_runs_in_no_runtime_un /// 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: it names nothing, and is no forgery. The control: an instance that only hands back its -/// own context is never faulted. +/// 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()); @@ -2520,16 +2519,11 @@ fn a_forged_context_quarantines_the_instance_that_forged_it() { let forger = linked(Arc::new(Recorder::default())); assert_eq!(open(&d, &forger, 0), Outcome::Ready); - let before = crate::dispatch::handle::forged(); assert_eq!( forger.call(TICK, &mut frame(plug::FORGE)).outcome, Outcome::Fault, "the forging op answered other than FAULT" ); - assert!( - crate::dispatch::handle::forged() > before, - "the forged context was not counted" - ); assert!( forger.is_faulted(), "the forging instance was not quarantined" @@ -2539,33 +2533,182 @@ fn a_forged_context_quarantines_the_instance_that_forged_it() { Outcome::Fault, "a quarantined instance was called again" ); - let at = crate::dispatch::handle::forged(); + let latch = crate::dispatch::handle::Latch::new(); assert!( - crate::dispatch::handle::resolve(forger.inner.ctx()).is_none(), + crate::dispatch::handle::resolve_in(forger.inner.ctx(), &latch, None).is_none(), "the quarantined instance's own handle still names it" ); assert_eq!( - crate::dispatch::handle::forged(), - at, + 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(honest.inner.ctx()).is_some()); + assert!(crate::dispatch::handle::resolve_in(honest.inner.ctx(), &latch, None).is_some()); } -/// #74, THE KEY AT BOOT: a loader whose OS random source cannot answer refuses to bind -/// (`LoadError::Entropy`); there is no fallback key. Driven through the test seam on this thread. +/// #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, 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 = load_linked::(&row(), bind(Arc::new(Recorder::default()))); + 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(crate::dispatch::LoadError::Entropy(_))), - "a failing entropy source did not refuse the bind: {:?}", - refused.err() + 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 index 979303d793..1c28d69374 100644 --- a/crates/plugin-loader/src/tests/handle_tests.rs +++ b/crates/plugin-loader/src/tests/handle_tests.rs @@ -45,9 +45,8 @@ fn only_a_minted_handle_names_an_instance() { "the handle is not the instance's address" ); - let before = forged(); assert!(resolve_in(HostCtx::NONE, &latch, None).is_none()); - assert_eq!(forged(), before, "no context is not a forgery"); + assert_eq!(latch.forged(), 0, "no context is not a forgery"); let id = ctx.handle & ID_MASK; let unminted = (1u64 << HANDLE_ID_BITS) - 2; @@ -92,23 +91,6 @@ fn a_revoked_handle_names_nothing_and_is_no_forgery() { assert_eq!(forged(), at, "a revoked handle was counted as a forgery"); } -/// Inside a crossing a context must name the crossing's own instance: another instance's handle, -/// its tag the host's own, is refused and counted (the cheap bound on a library holding two -/// instances' handles). -#[test] -fn inside_a_crossing_only_its_own_handle_resolves() { - let latch = Latch::new(); - let (mine, other) = (leaked(), leaked()); - let (my_ctx, other_ctx) = (mint(mine).unwrap(), mint(other).unwrap()); - assert!(std::ptr::eq( - resolve_in(my_ctx, &latch, Some(mine)).expect("its own"), - mine - )); - let at = forged(); - assert!(resolve_in(other_ctx, &latch, Some(mine)).is_none()); - assert!(forged() > at, "another instance's handle was not counted"); -} - /// 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. diff --git a/crates/plugin-loader/src/tests/host_services_tests.rs b/crates/plugin-loader/src/tests/host_services_tests.rs index a09058bc46..2cefaa9131 100644 --- a/crates/plugin-loader/src/tests/host_services_tests.rs +++ b/crates/plugin-loader/src/tests/host_services_tests.rs @@ -2177,9 +2177,9 @@ fn snapshot_read_refuses_an_unknown_scope_or_a_misaligned_buffer() { } /// #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, the wake wakes nothing, and both are -/// counted ([`crate::dispatch::handle::forged`]); the minted context does both (the control). It -/// used to be an address the host read through, whatever the plugin handed back. +/// 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(); @@ -2211,7 +2211,6 @@ fn a_forged_context_is_refused_before_it_names_an_instance() { as u64, }, ] { - let before = crate::dispatch::handle::forged(); 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:?}"); @@ -2220,10 +2219,6 @@ fn a_forged_context_is_refused_before_it_names_an_instance() { d.route.wakes.lock().unwrap().is_empty(), "a forged context woke a ticket: {forged:?}" ); - assert!( - crate::dispatch::handle::forged() >= before + 2, - "a forged context was not counted: {forged:?}" - ); } let mut o = blank(); let ret = HOST_SLOTS.clock_now.unwrap()(d.ctx, std::ptr::from_ref(&i).cast(), &mut o); From a91e2b81157a93e6a33bf6ec09c35ea5d8f60589 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:41:09 -0700 Subject: [PATCH 5/6] #74: second re-review -- per-test counters everywhere, healthz and ticket-less REDs, a neutral readiness line 1. handle_tests judges its own latch (latch.forged()), not the process-wide counter. 3. REDs: /healthz answers 503 while the handle latch is tripped (plugin_dispatch_halted); an instance adopted by the dispatcher whose ticket-less call hangs past its budget is faulted by the watchdog's ticket-less path (watchdog.rs ticketless -> Instance::revoke) and its handle names nothing, no forgery counted. 4. The 503 reason line is neutral ("plugin dispatch halted; restart required"): the route is unauthenticated, and the detail is the latch's ERROR line. --- crates/busbar-kernel/src/endpoints.rs | 42 ++++++++++++++----- .../plugin-loader/src/tests/dispatch_tests.rs | 37 ++++++++++++++++ .../plugin-loader/src/tests/handle_tests.rs | 11 +++-- 3 files changed, 75 insertions(+), 15 deletions(-) diff --git a/crates/busbar-kernel/src/endpoints.rs b/crates/busbar-kernel/src/endpoints.rs index 77fed5c730..96788230e1 100644 --- a/crates/busbar-kernel/src/endpoints.rs +++ b/crates/busbar-kernel/src/endpoints.rs @@ -281,17 +281,9 @@ fn list_models_dialect( } pub async fn healthz(crate::state::CurrentApp(app): crate::state::CurrentApp) -> Response { - // THE PLUGIN HANDLE LATCH (#74): a plugin handed the host contexts it did not mint from outside - // any crossing until the latch tripped; every host call made outside a crossing is refused from - // then on (a plugin's background wakes included), so the node is not ready and says why. The - // remedy is a restart without the plugin that forged them. - if busbar_plugin_loader::dispatch::handle::latched() { - return ( - StatusCode::SERVICE_UNAVAILABLE, - "plugin handle latch tripped: a plugin forged host contexts outside any crossing; \ - restart without it", - ) - .into_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 @@ -318,6 +310,34 @@ 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() + }) +} + +#[cfg(test)] +mod plugin_dispatch_halted_tests { + 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()); + } +} + /// 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 { diff --git a/crates/plugin-loader/src/tests/dispatch_tests.rs b/crates/plugin-loader/src/tests/dispatch_tests.rs index a3bdcfb436..5a8805d3f6 100644 --- a/crates/plugin-loader/src/tests/dispatch_tests.rs +++ b/crates/plugin-loader/src/tests/dispatch_tests.rs @@ -2694,6 +2694,43 @@ fn a_closed_a_dropped_and_a_watchdog_faulted_instance_name_nothing() { ); } +/// #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. diff --git a/crates/plugin-loader/src/tests/handle_tests.rs b/crates/plugin-loader/src/tests/handle_tests.rs index 1c28d69374..7ac6b1bc2b 100644 --- a/crates/plugin-loader/src/tests/handle_tests.rs +++ b/crates/plugin-loader/src/tests/handle_tests.rs @@ -65,12 +65,12 @@ fn only_a_minted_handle_names_an_instance() { std::ptr::from_ref(wake) as usize as u64, ), ] { - let at = forged(); + let at = latch.forged(); assert!( resolve_in(HostCtx { handle }, &latch, None).is_none(), "{why}" ); - assert!(forged() > at, "{why} was not counted"); + assert_eq!(latch.forged(), at + 1, "{why} was not counted"); } } @@ -83,12 +83,15 @@ fn a_revoked_handle_names_nothing_and_is_no_forgery() { let ctx = mint(wake).expect("the host mints a handle"); assert!(resolve_in(ctx, &latch, None).is_some()); wake.revoked.store(true, Ordering::Release); - let at = forged(); assert!( resolve_in(ctx, &latch, None).is_none(), "a revoked handle resolved" ); - assert_eq!(forged(), at, "a revoked handle was counted as a forgery"); + 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 From 40ef836ae8485bf5ac95fa66ce698f1830f89a8e Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 14 Oct 2026 07:44:10 -0700 Subject: [PATCH 6/6] #74: own reds on predev -- no dead latch readers, no new instance names in the root crate, no inline test - Latch::forged/unattributed are the tests' readings: cfg(test), not dead code in a no-test build. - plane_forged_context: the forged context comes from the plane-driver test plane's own /forge-context arrival, so the test declares no extern "C" door of its own. Each "C" was a hit on busbar x instance:secret (the census learns `c` from busbar-secret-c): the cell is back at its base 35, the ceiling untouched. - endpoints: the /healthz latch RED moves to tests/plugin_dispatch_halted_tests.rs. --- crates/busbar-kernel/src/endpoints.rs | 18 +--- .../src/tests/plugin_dispatch_halted_tests.rs | 15 ++++ .../tests/fixtures/plane_driver_test_plane.rs | 11 +++ crates/busbar/tests/plane_forged_context.rs | 85 ++----------------- crates/plugin-loader/src/dispatch/handle.rs | 7 +- 5 files changed, 43 insertions(+), 93 deletions(-) create mode 100644 crates/busbar-kernel/src/tests/plugin_dispatch_halted_tests.rs diff --git a/crates/busbar-kernel/src/endpoints.rs b/crates/busbar-kernel/src/endpoints.rs index 96788230e1..42a5067f18 100644 --- a/crates/busbar-kernel/src/endpoints.rs +++ b/crates/busbar-kernel/src/endpoints.rs @@ -324,20 +324,6 @@ fn plugin_dispatch_halted(latched: bool) -> Option { }) } -#[cfg(test)] -mod plugin_dispatch_halted_tests { - 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()); - } -} - /// 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 { @@ -358,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/tests/fixtures/plane_driver_test_plane.rs b/crates/busbar/tests/fixtures/plane_driver_test_plane.rs index 5d0107c1b5..af067fa1c7 100644 --- a/crates/busbar/tests/fixtures/plane_driver_test_plane.rs +++ b/crates/busbar/tests/fixtures/plane_driver_test_plane.rs @@ -794,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 index 36a2c75fd5..f8922b7f38 100644 --- a/crates/busbar/tests/plane_forged_context.rs +++ b/crates/busbar/tests/plane_forged_context.rs @@ -5,24 +5,19 @@ //! (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 door wrapped: `open` keeps the host tables -//! it is handed, and `arrive` first hands the host's `wake` its own context one tag bit off, then -//! answers as the plane does. The plane fixture itself is unchanged. +//! 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::ffi::c_void; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, OnceLock}; +use std::sync::Arc; -use busbar_contract::abi::mechanism::call::{AbiStr, InHead, Outcome as AbiOutcome, RawOutcome}; -use busbar_contract::abi::mechanism::door::Door; -use busbar_contract::abi::mechanism::lifecycle::{OpenIn, OpsHead}; -use busbar_contract::abi::mechanism::ticket::{HostCtx, HostTables, Ticket}; +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, Ops, PlaneOpenIn, PlaneOpenOut, UnitCount, + slot, ArriveIn, ArriveOut, PlaneOpenIn, PlaneOpenOut, UnitCount, }; use busbar_contract::abi::sdk::door::{blank_in, blank_out}; use busbar_plugin_loader::dispatch::{ @@ -30,70 +25,6 @@ use busbar_plugin_loader::dispatch::{ LinkedRow, NoSink, NO_BLOB, }; -/// A `'static` table of raw pointers, shared read-only. -struct Shared(T); -// SAFETY: written once (`OnceLock`) before any read, never mutated after; the pointers it holds -// are to `'static` plugin data. -unsafe impl Sync for Shared {} -// SAFETY: as above. -unsafe impl Send for Shared {} - -/// The host tables `open` was handed: `'static` host memory, kept for the instance's life. -static TABLES: AtomicUsize = AtomicUsize::new(0); - -fn inner() -> &'static Ops { - static INNER: OnceLock> = OnceLock::new(); - &INNER - .get_or_init(|| { - // SAFETY: the test plane's door is `'static`; a plane door's `ops` is a plane `Ops`. - Shared(unsafe { *(*plane::door()).ops.cast::() }) - }) - .0 -} - -extern "C" fn open(i: *mut c_void, input: *const c_void, out: *mut c_void) -> RawOutcome { - // SAFETY: the host's `open` in, which leads with an `OpenIn`. - let host = unsafe { (*input.cast::()).host }; - TABLES.store(host as usize, Ordering::SeqCst); - (inner().head.open.expect("the plane opens"))(i, input, out) -} - -extern "C" fn arrive(i: *mut c_void, input: *const c_void, out: *mut c_void) -> RawOutcome { - // SAFETY: the tables `open` was handed are the host's, alive for the instance's life; the `in` - // leads with its `InHead`. - unsafe { - let tables = &*(TABLES.load(Ordering::SeqCst) as *const HostTables); - let ctx = (*input.cast::()).host; - let forged = HostCtx { - handle: ctx.handle ^ (1 << 24), - }; - (tables.wake.expect("the host's wake"))(forged, Ticket::NONE); - } - (inner().arrive.expect("the plane arrives"))(i, input, out) -} - -extern "C" fn door() -> *const Door { - static OPS: OnceLock> = OnceLock::new(); - static DOOR: OnceLock> = OnceLock::new(); - let ops = &OPS - .get_or_init(|| { - let mut ops = *inner(); - ops.head.open = Some(open); - ops.arrive = Some(arrive); - Shared(ops) - }) - .0; - &DOOR - .get_or_init(|| { - Shared(Door { - ops: (ops as *const Ops).cast::(), - // SAFETY: the test plane's door is `'static`. - ..unsafe { *plane::door() } - }) - }) - .0 -} - #[test] fn a_forged_context_in_a_non_lifecycle_op_answers_fault_and_quarantines() { let dispatcher = Dispatcher::new(DispatchConfig::default()); @@ -104,7 +35,7 @@ fn a_forged_context_in_a_non_lifecycle_op_answers_fault_and_quarantines() { dispatcher: dispatcher.adopter(), conns: ConnTable::Probe, }; - let row = LinkedRow::of(door).expect("the plane states its Statement"); + 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 { @@ -130,7 +61,7 @@ fn a_forged_context_in_a_non_lifecycle_op_answers_fault_and_quarantines() { ]; let mut arrive_frame = Frame::new( ArriveIn { - target: AbiStr::over(b"/stats"), + target: AbiStr::over(b"/forge-context"), method: AbiStr::over(b"POST"), body: NO_BLOB, units_buf: units.as_mut_ptr(), diff --git a/crates/plugin-loader/src/dispatch/handle.rs b/crates/plugin-loader/src/dispatch/handle.rs index 5d3f2d7406..ffe4fe846b 100644 --- a/crates/plugin-loader/src/dispatch/handle.rs +++ b/crates/plugin-loader/src/dispatch/handle.rs @@ -97,12 +97,15 @@ impl Latch { self.tripped.load(Ordering::Acquire) } - /// Forgeries judged against this latch. + /// 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. + /// Unattributed forgeries counted towards the trip: the tests' reading. + #[cfg(test)] pub(crate) fn unattributed(&self) -> u64 { self.unattributed.load(Ordering::Acquire) }