From 42d17a7cad55ce778deba7b98f46241a59871576 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:35:10 +0300 Subject: [PATCH 01/31] feat(module): add confidential host callback example and test The context module example now exposes a ConfidentialCallback method that dispatches through call_confidential, and a new ignored host test verifies that a verified cdylib can deliver a confidential callback to a native client. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybus/examples/module_context.rs | 17 +++++----- crates/tinybus/src/module/host_tests.rs | 39 +++++++++++++++++++++++ 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/crates/tinybus/examples/module_context.rs b/crates/tinybus/examples/module_context.rs index 16fcb345..ba2f9eaa 100644 --- a/crates/tinybus/examples/module_context.rs +++ b/crates/tinybus/examples/module_context.rs @@ -12,7 +12,7 @@ impl Interface for ContextService { "org.example.Context".parse().unwrap() } fn members(&self) -> Vec { - vec!["Inspect".parse().unwrap(), "Callback".parse().unwrap()] + vec!["Inspect".parse().unwrap(), "Callback".parse().unwrap(), "ConfidentialCallback".parse().unwrap()] } async fn call(&self, _: &MemberName, _: Value) -> Result { Err(Error::failed("authenticated incoming context required")) @@ -31,12 +31,13 @@ impl Interface for ContextService { } match member.as_str() { "Inspect" => Ok(serde_json::json!(sender)), - "Callback" => { - let reply: String = self - .0 - .proxy(sender.as_str(), "/callback", "org.example.Callback")? - .call("Observe", ()) - .await?; + "Callback" | "ConfidentialCallback" => { + let proxy = self.0.proxy(sender.as_str(), "/callback", "org.example.Callback")?; + let reply: String = if member.as_str() == "ConfidentialCallback" { + proxy.call_confidential("Observe", ()).await? + } else { + proxy.call("Observe", ()).await? + }; Ok(serde_json::json!([sender, reply])) } _ => Err(Error::failed("unknown method")), @@ -55,6 +56,6 @@ tinybus_module::module_export! { setup = setup, worker_threads = 1, provides = ["org.example.Context"], - methods = ["Inspect", "Callback"], + methods = ["Inspect", "Callback", "ConfidentialCallback"], signals = [], requires = [], optional = [], lazy = true, } diff --git a/crates/tinybus/src/module/host_tests.rs b/crates/tinybus/src/module/host_tests.rs index 485e31de..1338b3a4 100644 --- a/crates/tinybus/src/module/host_tests.rs +++ b/crates/tinybus/src/module/host_tests.rs @@ -2531,3 +2531,42 @@ async fn fixed_unique_module_calls_are_refused_in_terminal_states_before_detach( } broker_task.abort(); } + + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires TINYBUS_TEST_CONTEXT_MODULE to point at the built cdylib"] +async fn a_verified_cdylib_can_deliver_a_confidential_host_callback() { + let artifact = PathBuf::from(std::env::var_os("TINYBUS_TEST_CONTEXT_MODULE").unwrap()); + let hash = crate::module::hash::file_hex(std::fs::File::open(&artifact).unwrap()).unwrap(); + #[cfg(unix)] + let (_dir, artifact) = staged_module(&artifact, &hash); + let bus = MemoryBus::new(); + let broker = Broker::new(); + let task = broker.spawn(bus.clone()); + let modules = ModuleHost::new(broker); + modules.load_file(artifact).unwrap(); + let client = Connection::connect(bus.connect().await.unwrap()).await.unwrap(); + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + client.serve_at("/callback".parse().unwrap(), CountingNativeCallback(calls.clone())).await.unwrap(); + let module = client.name_owner("org.example.Context").await.unwrap().unwrap(); + let result = client.proxy(module.as_str(), "/context", "org.example.Context").unwrap() + .call_confidential::<(crate::BusName, crate::BusName)>("ConfidentialCallback", ()).await; + assert_eq!(calls.load(Ordering::SeqCst), 1, "confidential callback required; result: {result:?}"); + assert_eq!(result.unwrap(), (client.unique_name().unwrap(), module)); + task.abort(); +} + +struct CountingNativeCallback(Arc); +#[async_trait::async_trait] +impl crate::Interface for CountingNativeCallback { + fn name(&self) -> crate::InterfaceName { "org.example.Callback".parse().unwrap() } + fn members(&self) -> Vec { vec!["Observe".parse().unwrap()] } + async fn call(&self, _: &crate::MemberName, _: serde_json::Value) -> crate::Result { + Err(Error::failed("missing contextual dispatch")) + } + async fn call_with_context(&self, _: &crate::MemberName, _: serde_json::Value, context: &crate::CallContext) -> crate::Result { + let sender = context.authenticated_sender().ok_or_else(|| Error::failed("unverified callback"))?; + self.0.fetch_add(1, Ordering::SeqCst); + Ok(serde_json::json!(sender)) + } +} From 84d0aa53d64e3b5f1de4fefca4fcc7a2eadf86f3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:36:15 +0300 Subject: [PATCH 02/31] feat(module): add host callback bindings for modules Modules can now register scoped callbacks that the router resolves for confidential calls to unique names, with the callback configuration fixed at admission time. Callback capabilities are revoked when their owning peer disconnects or the binding is removed. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybus/src/broker.rs | 3 ++- crates/tinybus/src/module/host.rs | 8 +++++++ crates/tinybus/src/module/mod.rs | 2 ++ crates/tinybus/src/module/transport.rs | 2 +- crates/tinybus/src/router.rs | 30 ++++++++++++++++++++++++++ 5 files changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/tinybus/src/broker.rs b/crates/tinybus/src/broker.rs index 9eba9da6..a8dc11a6 100644 --- a/crates/tinybus/src/broker.rs +++ b/crates/tinybus/src/broker.rs @@ -43,7 +43,7 @@ pub const PEER_QUEUE_CAPACITY: usize = 256; /// Cheap to clone; every clone shares one routing table. #[derive(Clone)] pub struct Broker { - router: Arc>, + pub(crate) router: Arc>, id: String, // `Weak`, not `Arc`: the module host owns this broker, so a strong // reference back would form a cycle and leak both. Callers tolerate a @@ -224,6 +224,7 @@ impl Broker { &destination, message.header.expected_responder.as_ref(), confidential_call, + &message.header, ); #[cfg(feature = "modules")] let target = target.map_err(|error| { diff --git a/crates/tinybus/src/module/host.rs b/crates/tinybus/src/module/host.rs index 112458dc..4cf2bb5f 100644 --- a/crates/tinybus/src/module/host.rs +++ b/crates/tinybus/src/module/host.rs @@ -96,6 +96,7 @@ struct LoadedModule { transition_from: Option, descriptor_info: Option, lifecycle_busy: bool, + callback_config: Option, } enum PendingModule { @@ -1154,6 +1155,7 @@ impl ModuleHost { transition_from: None, descriptor_info, lifecycle_busy: false, + callback_config: None, }); Ok(admitted) } @@ -1383,6 +1385,9 @@ impl ModuleControl for ModuleHostInner { .find(|module| module.info.name == name) .ok_or_else(|| Error::failed("module is not loaded"))?; let old = module.snapshot().state; + if module.callback_config.as_ref().is_some_and(|initial| initial != &config) { + return Err(Error::failed("scoped callback configuration is immutable")); + } if module.lifecycle_busy { return Err(Error::failed( "module lifecycle operation is already running", @@ -2623,3 +2628,6 @@ mod tests; #[cfg(test)] #[path = "host_admission_tests.rs"] mod admission_tests; + +#[path = "host_callbacks.rs"] +pub(crate) mod callbacks; diff --git a/crates/tinybus/src/module/mod.rs b/crates/tinybus/src/module/mod.rs index 40e1e21c..88ec02d2 100644 --- a/crates/tinybus/src/module/mod.rs +++ b/crates/tinybus/src/module/mod.rs @@ -38,6 +38,8 @@ pub use first_admitted::{ReleaseAsset, ReleasePlan, load_first_admitted}; pub use github::CachedRelease; #[cfg(feature = "modules")] pub use host::{ModuleHost, ModuleInfo, ModuleState}; +#[cfg(feature = "modules")] +pub use host::callbacks::{HostCallbackBinding, HostCallbackEndpoint, HostCallbackGuard}; /// Entry points compiled into the host instead of loaded from a shared library. /// diff --git a/crates/tinybus/src/module/transport.rs b/crates/tinybus/src/module/transport.rs index d8b7d3a2..926b0bf4 100644 --- a/crates/tinybus/src/module/transport.rs +++ b/crates/tinybus/src/module/transport.rs @@ -186,7 +186,7 @@ impl ModuleTransport { }); } - async fn ensure_initialized(&self) -> Result<()> { + pub(crate) async fn ensure_initialized(&self) -> Result<()> { let result = self .init_result .get_or_init(|| async { diff --git a/crates/tinybus/src/router.rs b/crates/tinybus/src/router.rs index 6e2fddc5..f741a64a 100644 --- a/crates/tinybus/src/router.rs +++ b/crates/tinybus/src/router.rs @@ -204,6 +204,8 @@ pub(crate) struct Router { peers: HashMap, names: HashMap, next_id: u64, + #[cfg(feature = "modules")] + host_callbacks: Vec>, } /// What changed when a name's owner changed, so the broker can announce it. @@ -246,6 +248,13 @@ impl Router { let Some(peer) = self.peers.remove(&id) else { return Vec::new(); }; + #[cfg(feature = "modules")] + self.host_callbacks.retain(|capability| { + if capability.includes_peer(&peer.unique) { + capability.revoke(); + false + } else { true } + }); let owned: Vec = self .names .iter() @@ -490,17 +499,38 @@ impl Router { destination: &BusName, expected: Option<&BusName>, confidential: bool, + header: &crate::message::Header, ) -> Result> { if expected.is_some_and(|expected| self.owner_of(destination).as_ref() != Some(expected)) { return Err(Error::NameHasNoOwner(destination.clone())); } if confidential { + #[cfg(feature = "modules")] + if destination.is_unique() && self.host_callbacks.iter().any(|capability| capability.matches(header)) { + return self.resolve(destination); + } self.resolve_attested(destination) } else { self.resolve(destination) } } + #[cfg(feature = "modules")] + pub(crate) fn add_host_callbacks(&mut self, capability: Arc) -> Result<()> { + let source = capability.module_peer().ok_or_else(|| Error::failed("callback module peer is absent"))?; + if self.attestation_of(&source).is_none() || self.owner_of(&capability.destination).is_none() { + return Err(Error::failed("callback peers are not admitted")); + } + self.host_callbacks.push(capability); + Ok(()) + } + + #[cfg(feature = "modules")] + pub(crate) fn revoke_host_callbacks(&mut self, capability: &Arc) { + capability.revoke(); + self.host_callbacks.retain(|entry| !Arc::ptr_eq(entry, capability)); + } + /// The outbox of whoever owns `destination`. pub fn resolve(&self, destination: &BusName) -> Result> { let id = self From 0183f44eecb34f427d88afb8441630649bcb04bf Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:37:36 +0300 Subject: [PATCH 03/31] feat(modules): add module host callbacks and outbound close Adds a close_outbound helper on the memory transport and the Arc import needed by the router so module host callbacks can shut down outbound delivery. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybus/src/module/host_callbacks.rs | 276 ++++++++++++++++++++ crates/tinybus/src/router.rs | 2 + crates/tinybus/src/transport/memory.rs | 5 + 3 files changed, 283 insertions(+) create mode 100644 crates/tinybus/src/module/host_callbacks.rs diff --git a/crates/tinybus/src/module/host_callbacks.rs b/crates/tinybus/src/module/host_callbacks.rs new file mode 100644 index 00000000..71482eb6 --- /dev/null +++ b/crates/tinybus/src/module/host_callbacks.rs @@ -0,0 +1,276 @@ +//! Local, exact-instance confidential callbacks; never artifact attestation. + +use super::*; +use crate::{CallContext, Connection, Interface, InterfaceName, MemberName, ObjectPath}; +use crate::message::Header; +use crate::transport::memory::MemoryTransport; +use async_trait::async_trait; +use tokio::sync::Notify; + +/// An exact callback interface and its finite member allowlist. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HostCallbackEndpoint { + /// Fixed object path; no subtree matching. + pub path: ObjectPath, + /// Fixed interface; no alias matching. + pub interface: InterfaceName, + /// Permitted methods, between one and 64 distinct names. + pub members: Vec, +} + +struct Lifetime { + active: bool, + module: Option, + running: usize, + served: Vec<(ObjectPath, InterfaceName)>, +} + +pub(crate) struct CallbackState { + pub(crate) destination: BusName, + endpoints: Vec, + lifetime: Mutex, + drained: Notify, +} + +impl CallbackState { + pub(crate) fn module_peer(&self) -> Option { + self.lifetime.lock().expect("callback lifetime lock").module.clone() + } + + pub(crate) fn includes_peer(&self, peer: &BusName) -> bool { + &self.destination == peer || self.module_peer().as_ref() == Some(peer) + } + + pub(crate) fn revoke(&self) { + self.lifetime.lock().expect("callback lifetime lock").active = false; + } + + pub(crate) fn matches(&self, header: &Header) -> bool { + let state = self.lifetime.lock().expect("callback lifetime lock"); + state.active && header.sender.as_ref() == state.module.as_ref() + && state.module.is_some() && header.destination.as_ref() == Some(&self.destination) + && self.endpoints.iter().any(|endpoint| header.path.as_ref() == Some(&endpoint.path) + && header.interface.as_ref() == Some(&endpoint.interface) + && header.member.as_ref().is_some_and(|member| endpoint.members.contains(member))) + } +} + +/// Read-only liveness and authenticated module identity for a callback worker. +/// +/// Check again after awaits and before publishing provider results. Revocation +/// prevents new work but cannot undo a provider write already in progress. +#[derive(Clone)] +pub struct HostCallbackGuard(Arc); + +impl HostCallbackGuard { + /// The exact loaded module peer, installed before initialization can call back. + pub fn module_peer(&self) -> Option { self.0.module_peer() } + + /// Whether this binding remains active for this authenticated sender. + pub fn authorizes(&self, context: &CallContext) -> bool { + let state = self.0.lifetime.lock().expect("callback lifetime lock"); + state.active && state.module.is_some() && context.authenticated_sender() == state.module.as_ref() + } + + /// Number of callbacks whose user futures have not yet completed or dropped. + /// A caller timeout does not remove an executing callback from this count. + pub fn running(&self) -> usize { self.0.lifetime.lock().expect("callback lifetime lock").running } + + /// Wait for currently running user futures to complete or drop. + /// Revoke first to prevent new admission. This proves no provider activity + /// beyond the lifetime of the user future; detached provider tasks need + /// their own completion fence. + pub async fn drained(&self) { + loop { + let changed = self.0.drained.notified(); + if self.running() == 0 { return; } + changed.await; + } + } +} + +/// SDK-created local callback recipient, scoped once to one verified module. +/// +/// This is intentionally not an attestation and cannot enroll an existing +/// connection, custom transport, well-known alias, or remote recipient. Drop +/// revokes immediately; explicit close also closes the owned local connection. +pub struct HostCallbackBinding { + broker: Broker, + connection: Connection, + state: Arc, + transport: Arc, +} + +impl HostCallbackBinding { + /// The exact SDK-created host destination for module configuration. + pub fn unique_name(&self) -> &BusName { &self.state.destination } + /// Read-only capability state for domain authorization and completion accounting. + pub fn guard(&self) -> HostCallbackGuard { HostCallbackGuard(self.state.clone()) } + /// Immediately and idempotently prevent new route and user-dispatch admission. + /// Running user futures remain accounted for until actual completion/drop. + pub fn revoke(&self) { + self.broker.router.lock().expect("router lock").revoke_host_callbacks(&self.state); + } + /// Revoke and close the local connection. This does not cancel running user code. + pub async fn close(&self) -> Result<()> { + self.revoke(); + self.connection.close().await + } + /// Serve a declared endpoint with an unbypassable authenticated live guard. + /// Every declared member must be implemented; duplicate registration fails. + pub async fn serve_at(&self, path: ObjectPath, interface: impl Interface) -> Result<()> { + let name = interface.name(); + let endpoint = self.state.endpoints.iter().find(|endpoint| endpoint.path == path && endpoint.interface == name) + .ok_or_else(|| Error::failed("callback endpoint is not declared"))?; + let provided = interface.members(); + if !endpoint.members.iter().all(|member| provided.contains(member)) { + return Err(Error::failed("callback endpoint members are missing")); + } + { + let mut state = self.state.lifetime.lock().expect("callback lifetime lock"); + if state.module.is_some() || state.active || state.served.contains(&(path.clone(), name.clone())) { + return Err(Error::failed("callback registration is immutable or duplicated")); + } + state.served.push((path.clone(), name.clone())); + } + let result = self.connection.serve_at(path.clone(), GuardedInterface { + interface, endpoint: endpoint.clone(), state: self.state.clone(), + }).await; + if result.is_err() { + self.state.lifetime.lock().expect("callback lifetime lock").served.retain(|entry| entry != &(path.clone(), name.clone())); + } + result + } +} + +impl Drop for HostCallbackBinding { + fn drop(&mut self) { + self.revoke(); + // Drop cannot await. Closing the sender synchronously detaches the + // owned peer without spawning a task that might never be polled. + self.transport.close_outbound(); + } +} + +struct GuardedInterface { + interface: I, + endpoint: HostCallbackEndpoint, + state: Arc, +} + +struct Running(Arc); +impl Drop for Running { + fn drop(&mut self) { + let mut state = self.0.lifetime.lock().expect("callback lifetime lock"); + state.running -= 1; + if state.running == 0 { self.0.drained.notify_waiters(); } + } +} + +#[async_trait] +impl Interface for GuardedInterface { + fn name(&self) -> InterfaceName { self.endpoint.interface.clone() } + fn members(&self) -> Vec { self.endpoint.members.clone() } + async fn call(&self, _: &MemberName, _: serde_json::Value) -> Result { + Err(Error::failed("authenticated scoped callback required")) + } + async fn call_with_context(&self, member: &MemberName, args: serde_json::Value, context: &CallContext) -> Result { + // Serialized with revoke: queued deliveries cannot bypass the live + // check by carrying an earlier route admission through an await. + let _running = { + let mut state = self.state.lifetime.lock().expect("callback lifetime lock"); + if !state.active || state.module.is_none() || context.authenticated_sender() != state.module.as_ref() + || !self.endpoint.members.contains(member) { + return Err(Error::failed("scoped callback authority is unavailable")); + } + state.running += 1; + Running(self.state.clone()) + }; + self.interface.call_with_context(member, args, context).await + } +} + +impl ModuleHost { + /// Create a private local recipient for at most 32 exact callback interfaces. + /// No authority exists until a verified module is loaded with this binding. + pub async fn prepare_host_callbacks(&self, endpoints: Vec) -> Result { + if endpoints.is_empty() || endpoints.len() > 32 || endpoints.iter().any(|endpoint| endpoint.members.is_empty() || endpoint.members.len() > 64 + || endpoint.members.iter().enumerate().any(|(i, member)| endpoint.members[..i].contains(member))) + || endpoints.iter().enumerate().any(|(i, endpoint)| endpoints[..i].iter().any(|previous| previous.path == endpoint.path && previous.interface == endpoint.interface)) { + return Err(Error::failed("callback endpoint declaration is invalid")); + } + let (host, peer) = MemoryTransport::pair(); + self.inner.broker.attach(Arc::new(peer)); + let transport = Arc::new(host); + let connection = Connection::connect(transport.clone()).await?; + let destination = connection.unique_name().ok_or_else(|| Error::failed("callback connection has no unique name"))?; + Ok(HostCallbackBinding { + broker: self.inner.broker.clone(), connection, transport, + state: Arc::new(CallbackState { destination, endpoints, + lifetime: Mutex::new(Lifetime { active: false, module: None, running: 0, served: Vec::new() }), + drained: Notify::new(), + }), + }) + } + + /// Load a hash-verified file and bind its exact peer before any initialization. + /// + /// All endpoints must already be served. The binding is single-use even on + /// failure. Configuration is frozen for reinitialization. Generic load APIs + /// grant no callback authority. The caller supplies the binding's unique + /// destination in its domain-specific configuration; it cannot change scope. + pub async fn load_file_with_host_callbacks(&self, path: impl AsRef, config: serde_json::Value, binding: &HostCallbackBinding) -> Result { + if !Arc::ptr_eq(&self.inner.broker.router, &binding.broker.router) { + return Err(Error::failed("callback binding belongs to another broker")); + } + { + let state = binding.state.lifetime.lock().expect("callback lifetime lock"); + if state.module.is_some() || state.active || state.served.len() != binding.state.endpoints.len() { + return Err(Error::failed("callback binding is already used or endpoints are missing")); + } + } + let path = path.as_ref(); + if let Some(parent) = path.parent() { check_directory(parent)?; } + check_file(path)?; + let hash = allowlisted_hash(path, std::fs::File::open(path)?)? + .ok_or_else(|| Error::module_refused(path, "scoped callbacks require a verified artifact"))?; + let artifact = loader::load(path, self.inner.strict.load(Ordering::Acquire))?; + self.ensure_dependencies(&artifact.manifest, path)?; + let transport = { + let _admission = self.inner.admission.lock().expect("module admission lock"); + let admitted = self.validate(path, &artifact.descriptor, &artifact.manifest)?; + if self.inner.loaded.lock().expect("module list lock").iter().any(|module| module.info.name == admitted.name) { + return Err(Error::module_refused(path, "module name is already loaded")); + } + // Reservation happens inside attach_transport. Initialization is + // deferred even for an eager module until routes and identity exist. + let bytes = serde_json::to_vec(&config).map_err(|_| Error::failed("module configuration is invalid"))?; + let (transport, host) = ModuleTransport::new(admitted.name.clone(), bytes); + transport.defer_initialize(artifact.init, host); + let name = admitted.name.clone(); + self.attach_transport(path, admitted, artifact.manifest, transport.clone(), Activation { + lazy_init: true, lazy_load: false, descriptor_info: None, pinned_sha256: Some(hash), + })?; + let mut loaded = self.inner.loaded.lock().expect("module list lock"); + let module = loaded.iter_mut().find(|module| module.info.name == name).expect("module was attached"); + { + let mut state = binding.state.lifetime.lock().expect("callback lifetime lock"); + if state.module.is_some() { return Err(Error::failed("callback binding was concurrently used")); } + state.module = Some(module.unique_name.clone()); + state.active = true; + } + module.callback_config = Some(config); + self.inner.broker.router.lock().expect("router lock").add_host_callbacks(binding.state.clone())?; + transport + }; + if transport.ensure_initialized().await.is_err() { + binding.revoke(); + let _ = transport.stop_sync(Duration::ZERO); + let _ = transport.close().await; + self.inner.loaded.lock().expect("module list lock").retain(|module| !Arc::ptr_eq(&module.transport, &transport)); + return Err(Error::module_refused(path, "module initialization failed")); + } + let loaded = self.inner.loaded.lock().expect("module list lock"); + Ok(loaded.iter().find(|module| Arc::ptr_eq(&module.transport, &transport)).expect("module remains loaded").snapshot()) + } +} diff --git a/crates/tinybus/src/router.rs b/crates/tinybus/src/router.rs index f741a64a..5a8ddb69 100644 --- a/crates/tinybus/src/router.rs +++ b/crates/tinybus/src/router.rs @@ -24,6 +24,8 @@ //! to discard a signal it never asked for is exactly that cost in miniature. use std::collections::HashMap; +#[cfg(feature = "modules")] +use std::sync::Arc; use tokio::sync::mpsc; diff --git a/crates/tinybus/src/transport/memory.rs b/crates/tinybus/src/transport/memory.rs index 32e88188..89fa6a63 100644 --- a/crates/tinybus/src/transport/memory.rs +++ b/crates/tinybus/src/transport/memory.rs @@ -93,6 +93,11 @@ impl MemoryTransport { .map(|delivery| (delivery.message, delivery.brokered))) } + #[cfg(feature = "modules")] + pub(crate) fn close_outbound(&self) { + self.outbound.lock().expect("outbound lock").take(); + } + /// Take a clone of the sender without holding the lock across an await. fn sender(&self) -> Result> { self.outbound From c17654191a1de18fe60a42313b2b0515d8aeb2aa Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:39:12 +0300 Subject: [PATCH 04/31] chore: files changed crates/tinybus/examples/module_context.rs,crates/tinybus/src/module/host.rs,cra Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybus/examples/module_context.rs | 10 +- crates/tinybus/src/module/host.rs | 10 +- crates/tinybus/src/module/host_callbacks.rs | 332 ++++++++++++++++---- crates/tinybus/src/module/host_tests.rs | 65 +++- crates/tinybus/src/module/mod.rs | 4 +- crates/tinybus/src/router.rs | 55 +++- 6 files changed, 392 insertions(+), 84 deletions(-) diff --git a/crates/tinybus/examples/module_context.rs b/crates/tinybus/examples/module_context.rs index ba2f9eaa..a91c1717 100644 --- a/crates/tinybus/examples/module_context.rs +++ b/crates/tinybus/examples/module_context.rs @@ -12,7 +12,11 @@ impl Interface for ContextService { "org.example.Context".parse().unwrap() } fn members(&self) -> Vec { - vec!["Inspect".parse().unwrap(), "Callback".parse().unwrap(), "ConfidentialCallback".parse().unwrap()] + vec![ + "Inspect".parse().unwrap(), + "Callback".parse().unwrap(), + "ConfidentialCallback".parse().unwrap(), + ] } async fn call(&self, _: &MemberName, _: Value) -> Result { Err(Error::failed("authenticated incoming context required")) @@ -32,7 +36,9 @@ impl Interface for ContextService { match member.as_str() { "Inspect" => Ok(serde_json::json!(sender)), "Callback" | "ConfidentialCallback" => { - let proxy = self.0.proxy(sender.as_str(), "/callback", "org.example.Callback")?; + let proxy = self + .0 + .proxy(sender.as_str(), "/callback", "org.example.Callback")?; let reply: String = if member.as_str() == "ConfidentialCallback" { proxy.call_confidential("Observe", ()).await? } else { diff --git a/crates/tinybus/src/module/host.rs b/crates/tinybus/src/module/host.rs index 4cf2bb5f..da40c28a 100644 --- a/crates/tinybus/src/module/host.rs +++ b/crates/tinybus/src/module/host.rs @@ -1385,9 +1385,6 @@ impl ModuleControl for ModuleHostInner { .find(|module| module.info.name == name) .ok_or_else(|| Error::failed("module is not loaded"))?; let old = module.snapshot().state; - if module.callback_config.as_ref().is_some_and(|initial| initial != &config) { - return Err(Error::failed("scoped callback configuration is immutable")); - } if module.lifecycle_busy { return Err(Error::failed( "module lifecycle operation is already running", @@ -1436,6 +1433,13 @@ impl ModuleControl for ModuleHostInner { .find(|module| module.info.name == name) .ok_or_else(|| Error::failed("module is not loaded"))?; let state = module.snapshot().state; + if module + .callback_config + .as_ref() + .is_some_and(|initial| initial != &config) + { + return Err(Error::failed("scoped callback configuration is immutable")); + } if module.lifecycle_busy { return Err(Error::failed( "module lifecycle operation is already running", diff --git a/crates/tinybus/src/module/host_callbacks.rs b/crates/tinybus/src/module/host_callbacks.rs index 71482eb6..d3dd928f 100644 --- a/crates/tinybus/src/module/host_callbacks.rs +++ b/crates/tinybus/src/module/host_callbacks.rs @@ -1,9 +1,9 @@ //! Local, exact-instance confidential callbacks; never artifact attestation. use super::*; -use crate::{CallContext, Connection, Interface, InterfaceName, MemberName, ObjectPath}; use crate::message::Header; use crate::transport::memory::MemoryTransport; +use crate::{CallContext, Connection, Interface, InterfaceName, MemberName, ObjectPath}; use async_trait::async_trait; use tokio::sync::Notify; @@ -20,6 +20,8 @@ pub struct HostCallbackEndpoint { struct Lifetime { active: bool, + used: bool, + revoked: bool, module: Option, running: usize, served: Vec<(ObjectPath, InterfaceName)>, @@ -34,7 +36,11 @@ pub(crate) struct CallbackState { impl CallbackState { pub(crate) fn module_peer(&self) -> Option { - self.lifetime.lock().expect("callback lifetime lock").module.clone() + self.lifetime + .lock() + .expect("callback lifetime lock") + .module + .clone() } pub(crate) fn includes_peer(&self, peer: &BusName) -> bool { @@ -42,16 +48,25 @@ impl CallbackState { } pub(crate) fn revoke(&self) { - self.lifetime.lock().expect("callback lifetime lock").active = false; + let mut state = self.lifetime.lock().expect("callback lifetime lock"); + state.active = false; + state.revoked = true; } pub(crate) fn matches(&self, header: &Header) -> bool { let state = self.lifetime.lock().expect("callback lifetime lock"); - state.active && header.sender.as_ref() == state.module.as_ref() - && state.module.is_some() && header.destination.as_ref() == Some(&self.destination) - && self.endpoints.iter().any(|endpoint| header.path.as_ref() == Some(&endpoint.path) - && header.interface.as_ref() == Some(&endpoint.interface) - && header.member.as_ref().is_some_and(|member| endpoint.members.contains(member))) + state.active + && header.sender.as_ref() == state.module.as_ref() + && state.module.is_some() + && header.destination.as_ref() == Some(&self.destination) + && self.endpoints.iter().any(|endpoint| { + header.path.as_ref() == Some(&endpoint.path) + && header.interface.as_ref() == Some(&endpoint.interface) + && header + .member + .as_ref() + .is_some_and(|member| endpoint.members.contains(member)) + }) } } @@ -64,17 +79,27 @@ pub struct HostCallbackGuard(Arc); impl HostCallbackGuard { /// The exact loaded module peer, installed before initialization can call back. - pub fn module_peer(&self) -> Option { self.0.module_peer() } + pub fn module_peer(&self) -> Option { + self.0.module_peer() + } /// Whether this binding remains active for this authenticated sender. pub fn authorizes(&self, context: &CallContext) -> bool { let state = self.0.lifetime.lock().expect("callback lifetime lock"); - state.active && state.module.is_some() && context.authenticated_sender() == state.module.as_ref() + state.active + && state.module.is_some() + && context.authenticated_sender() == state.module.as_ref() } /// Number of callbacks whose user futures have not yet completed or dropped. /// A caller timeout does not remove an executing callback from this count. - pub fn running(&self) -> usize { self.0.lifetime.lock().expect("callback lifetime lock").running } + pub fn running(&self) -> usize { + self.0 + .lifetime + .lock() + .expect("callback lifetime lock") + .running + } /// Wait for currently running user futures to complete or drop. /// Revoke first to prevent new admission. This proves no provider activity @@ -83,7 +108,9 @@ impl HostCallbackGuard { pub async fn drained(&self) { loop { let changed = self.0.drained.notified(); - if self.running() == 0 { return; } + if self.running() == 0 { + return; + } changed.await; } } @@ -103,13 +130,26 @@ pub struct HostCallbackBinding { impl HostCallbackBinding { /// The exact SDK-created host destination for module configuration. - pub fn unique_name(&self) -> &BusName { &self.state.destination } + pub fn unique_name(&self) -> &BusName { + &self.state.destination + } + /// Construct an outgoing proxy using this private host connection. + /// The proxy exposes no raw connection or service registration escape. + pub fn proxy(&self, destination: &str, path: &str, interface: &str) -> Result { + self.connection.proxy(destination, path, interface) + } /// Read-only capability state for domain authorization and completion accounting. - pub fn guard(&self) -> HostCallbackGuard { HostCallbackGuard(self.state.clone()) } + pub fn guard(&self) -> HostCallbackGuard { + HostCallbackGuard(self.state.clone()) + } /// Immediately and idempotently prevent new route and user-dispatch admission. /// Running user futures remain accounted for until actual completion/drop. pub fn revoke(&self) { - self.broker.router.lock().expect("router lock").revoke_host_callbacks(&self.state); + self.broker + .router + .lock() + .expect("router lock") + .revoke_host_callbacks(&self.state); } /// Revoke and close the local connection. This does not cancel running user code. pub async fn close(&self) -> Result<()> { @@ -120,24 +160,47 @@ impl HostCallbackBinding { /// Every declared member must be implemented; duplicate registration fails. pub async fn serve_at(&self, path: ObjectPath, interface: impl Interface) -> Result<()> { let name = interface.name(); - let endpoint = self.state.endpoints.iter().find(|endpoint| endpoint.path == path && endpoint.interface == name) + let endpoint = self + .state + .endpoints + .iter() + .find(|endpoint| endpoint.path == path && endpoint.interface == name) .ok_or_else(|| Error::failed("callback endpoint is not declared"))?; let provided = interface.members(); - if !endpoint.members.iter().all(|member| provided.contains(member)) { + if !endpoint + .members + .iter() + .all(|member| provided.contains(member)) + { return Err(Error::failed("callback endpoint members are missing")); } { let mut state = self.state.lifetime.lock().expect("callback lifetime lock"); - if state.module.is_some() || state.active || state.served.contains(&(path.clone(), name.clone())) { - return Err(Error::failed("callback registration is immutable or duplicated")); + if state.used || state.revoked || state.served.contains(&(path.clone(), name.clone())) { + return Err(Error::failed( + "callback registration is immutable or duplicated", + )); } state.served.push((path.clone(), name.clone())); } - let result = self.connection.serve_at(path.clone(), GuardedInterface { - interface, endpoint: endpoint.clone(), state: self.state.clone(), - }).await; + let result = self + .connection + .serve_at( + path.clone(), + GuardedInterface { + interface, + endpoint: endpoint.clone(), + state: self.state.clone(), + }, + ) + .await; if result.is_err() { - self.state.lifetime.lock().expect("callback lifetime lock").served.retain(|entry| entry != &(path.clone(), name.clone())); + self.state + .lifetime + .lock() + .expect("callback lifetime lock") + .served + .retain(|entry| entry != &(path.clone(), name.clone())); } result } @@ -163,51 +226,97 @@ impl Drop for Running { fn drop(&mut self) { let mut state = self.0.lifetime.lock().expect("callback lifetime lock"); state.running -= 1; - if state.running == 0 { self.0.drained.notify_waiters(); } + if state.running == 0 { + self.0.drained.notify_waiters(); + } } } #[async_trait] impl Interface for GuardedInterface { - fn name(&self) -> InterfaceName { self.endpoint.interface.clone() } - fn members(&self) -> Vec { self.endpoint.members.clone() } + fn name(&self) -> InterfaceName { + self.endpoint.interface.clone() + } + fn members(&self) -> Vec { + self.endpoint.members.clone() + } async fn call(&self, _: &MemberName, _: serde_json::Value) -> Result { Err(Error::failed("authenticated scoped callback required")) } - async fn call_with_context(&self, member: &MemberName, args: serde_json::Value, context: &CallContext) -> Result { + async fn call_with_context( + &self, + member: &MemberName, + args: serde_json::Value, + context: &CallContext, + ) -> Result { // Serialized with revoke: queued deliveries cannot bypass the live // check by carrying an earlier route admission through an await. let _running = { let mut state = self.state.lifetime.lock().expect("callback lifetime lock"); - if !state.active || state.module.is_none() || context.authenticated_sender() != state.module.as_ref() - || !self.endpoint.members.contains(member) { + if !state.active + || state.module.is_none() + || context.authenticated_sender() != state.module.as_ref() + || !self.endpoint.members.contains(member) + { return Err(Error::failed("scoped callback authority is unavailable")); } state.running += 1; Running(self.state.clone()) }; - self.interface.call_with_context(member, args, context).await + self.interface + .call_with_context(member, args, context) + .await } } impl ModuleHost { /// Create a private local recipient for at most 32 exact callback interfaces. /// No authority exists until a verified module is loaded with this binding. - pub async fn prepare_host_callbacks(&self, endpoints: Vec) -> Result { - if endpoints.is_empty() || endpoints.len() > 32 || endpoints.iter().any(|endpoint| endpoint.members.is_empty() || endpoint.members.len() > 64 - || endpoint.members.iter().enumerate().any(|(i, member)| endpoint.members[..i].contains(member))) - || endpoints.iter().enumerate().any(|(i, endpoint)| endpoints[..i].iter().any(|previous| previous.path == endpoint.path && previous.interface == endpoint.interface)) { + pub async fn prepare_host_callbacks( + &self, + endpoints: Vec, + ) -> Result { + if endpoints.is_empty() + || endpoints.len() > 32 + || endpoints.iter().any(|endpoint| { + endpoint.members.is_empty() + || endpoint.members.len() > 64 + || endpoint + .members + .iter() + .enumerate() + .any(|(i, member)| endpoint.members[..i].contains(member)) + }) + || endpoints.iter().enumerate().any(|(i, endpoint)| { + endpoints[..i].iter().any(|previous| { + previous.path == endpoint.path && previous.interface == endpoint.interface + }) + }) + { return Err(Error::failed("callback endpoint declaration is invalid")); } let (host, peer) = MemoryTransport::pair(); self.inner.broker.attach(Arc::new(peer)); let transport = Arc::new(host); let connection = Connection::connect(transport.clone()).await?; - let destination = connection.unique_name().ok_or_else(|| Error::failed("callback connection has no unique name"))?; + let destination = connection + .unique_name() + .ok_or_else(|| Error::failed("callback connection has no unique name"))?; Ok(HostCallbackBinding { - broker: self.inner.broker.clone(), connection, transport, - state: Arc::new(CallbackState { destination, endpoints, - lifetime: Mutex::new(Lifetime { active: false, module: None, running: 0, served: Vec::new() }), + broker: self.inner.broker.clone(), + connection, + transport, + state: Arc::new(CallbackState { + destination, + endpoints, + lifetime: Mutex::new(Lifetime { + active: false, + used: false, + revoked: false, + module: None, + running: 0, + served: Vec::new(), + }), drained: Notify::new(), }), }) @@ -219,58 +328,169 @@ impl ModuleHost { /// failure. Configuration is frozen for reinitialization. Generic load APIs /// grant no callback authority. The caller supplies the binding's unique /// destination in its domain-specific configuration; it cannot change scope. - pub async fn load_file_with_host_callbacks(&self, path: impl AsRef, config: serde_json::Value, binding: &HostCallbackBinding) -> Result { + pub async fn load_file_with_host_callbacks( + &self, + path: impl AsRef, + config: serde_json::Value, + binding: &HostCallbackBinding, + ) -> Result { if !Arc::ptr_eq(&self.inner.broker.router, &binding.broker.router) { return Err(Error::failed("callback binding belongs to another broker")); } { - let state = binding.state.lifetime.lock().expect("callback lifetime lock"); - if state.module.is_some() || state.active || state.served.len() != binding.state.endpoints.len() { - return Err(Error::failed("callback binding is already used or endpoints are missing")); + let mut state = binding + .state + .lifetime + .lock() + .expect("callback lifetime lock"); + if state.used || state.revoked || state.served.len() != binding.state.endpoints.len() { + return Err(Error::failed( + "callback binding is already used or endpoints are missing", + )); } + state.used = true; } + let mut rollback = LoadRollback { + binding, + host: self, + transport: None, + committed: false, + }; let path = path.as_ref(); - if let Some(parent) = path.parent() { check_directory(parent)?; } + if let Some(parent) = path.parent() { + check_directory(parent)?; + } check_file(path)?; - let hash = allowlisted_hash(path, std::fs::File::open(path)?)? - .ok_or_else(|| Error::module_refused(path, "scoped callbacks require a verified artifact"))?; + let hash = allowlisted_hash(path, std::fs::File::open(path)?)?.ok_or_else(|| { + Error::module_refused(path, "scoped callbacks require a verified artifact") + })?; let artifact = loader::load(path, self.inner.strict.load(Ordering::Acquire))?; self.ensure_dependencies(&artifact.manifest, path)?; let transport = { let _admission = self.inner.admission.lock().expect("module admission lock"); let admitted = self.validate(path, &artifact.descriptor, &artifact.manifest)?; - if self.inner.loaded.lock().expect("module list lock").iter().any(|module| module.info.name == admitted.name) { + if self + .inner + .loaded + .lock() + .expect("module list lock") + .iter() + .any(|module| module.info.name == admitted.name) + { return Err(Error::module_refused(path, "module name is already loaded")); } // Reservation happens inside attach_transport. Initialization is // deferred even for an eager module until routes and identity exist. - let bytes = serde_json::to_vec(&config).map_err(|_| Error::failed("module configuration is invalid"))?; + let bytes = serde_json::to_vec(&config) + .map_err(|_| Error::failed("module configuration is invalid"))?; let (transport, host) = ModuleTransport::new(admitted.name.clone(), bytes); transport.defer_initialize(artifact.init, host); let name = admitted.name.clone(); - self.attach_transport(path, admitted, artifact.manifest, transport.clone(), Activation { - lazy_init: true, lazy_load: false, descriptor_info: None, pinned_sha256: Some(hash), - })?; + self.attach_transport( + path, + admitted, + artifact.manifest, + transport.clone(), + Activation { + lazy_init: true, + lazy_load: false, + descriptor_info: None, + pinned_sha256: Some(hash), + }, + )?; + rollback.transport = Some(transport.clone()); let mut loaded = self.inner.loaded.lock().expect("module list lock"); - let module = loaded.iter_mut().find(|module| module.info.name == name).expect("module was attached"); + let module = loaded + .iter_mut() + .find(|module| module.info.name == name) + .expect("module was attached"); { - let mut state = binding.state.lifetime.lock().expect("callback lifetime lock"); - if state.module.is_some() { return Err(Error::failed("callback binding was concurrently used")); } + let mut state = binding + .state + .lifetime + .lock() + .expect("callback lifetime lock"); + if state.revoked || state.module.is_some() { + return Err(Error::failed("callback binding was concurrently used")); + } state.module = Some(module.unique_name.clone()); state.active = true; } module.callback_config = Some(config); - self.inner.broker.router.lock().expect("router lock").add_host_callbacks(binding.state.clone())?; + self.inner + .broker + .router + .lock() + .expect("router lock") + .add_host_callbacks(binding.state.clone())?; transport }; if transport.ensure_initialized().await.is_err() { binding.revoke(); let _ = transport.stop_sync(Duration::ZERO); let _ = transport.close().await; - self.inner.loaded.lock().expect("module list lock").retain(|module| !Arc::ptr_eq(&module.transport, &transport)); + self.inner + .loaded + .lock() + .expect("module list lock") + .retain(|module| !Arc::ptr_eq(&module.transport, &transport)); return Err(Error::module_refused(path, "module initialization failed")); } + rollback.committed = true; let loaded = self.inner.loaded.lock().expect("module list lock"); - Ok(loaded.iter().find(|module| Arc::ptr_eq(&module.transport, &transport)).expect("module remains loaded").snapshot()) + Ok(loaded + .iter() + .find(|module| Arc::ptr_eq(&module.transport, &transport)) + .expect("module remains loaded") + .snapshot()) + } +} + +struct LoadRollback<'a> { + binding: &'a HostCallbackBinding, + host: &'a ModuleHost, + transport: Option>, + committed: bool, +} +impl Drop for LoadRollback<'_> { + fn drop(&mut self) { + if self.committed { + return; + } + self.binding.revoke(); + self.binding.transport.close_outbound(); + if let Some(transport) = &self.transport { + let peer = self.binding.state.module_peer(); + if let Some(peer) = peer { + self.host + .inner + .broker + .router + .lock() + .expect("router lock") + .detach_unique(&peer); + } + // A timed-out initializer may still run. Stop is best effort; + // revocation/detach removes authority without claiming cessation. + let _ = transport.stop_sync(Duration::ZERO); + self.host + .inner + .loaded + .lock() + .expect("module list lock") + .retain(|module| !Arc::ptr_eq(&module.transport, transport)); + } + } +} + +impl Drop for ModuleHostInner { + fn drop(&mut self) { + for module in self.loaded.get_mut().expect("module list lock").iter() { + self.broker + .router + .lock() + .expect("router lock") + .revoke_callbacks_for_peer(&module.unique_name); + } } } diff --git a/crates/tinybus/src/module/host_tests.rs b/crates/tinybus/src/module/host_tests.rs index 1338b3a4..851af980 100644 --- a/crates/tinybus/src/module/host_tests.rs +++ b/crates/tinybus/src/module/host_tests.rs @@ -2532,7 +2532,6 @@ async fn fixed_unique_module_calls_are_refused_in_terminal_states_before_detach( broker_task.abort(); } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires TINYBUS_TEST_CONTEXT_MODULE to point at the built cdylib"] async fn a_verified_cdylib_can_deliver_a_confidential_host_callback() { @@ -2544,28 +2543,66 @@ async fn a_verified_cdylib_can_deliver_a_confidential_host_callback() { let broker = Broker::new(); let task = broker.spawn(bus.clone()); let modules = ModuleHost::new(broker); - modules.load_file(artifact).unwrap(); - let client = Connection::connect(bus.connect().await.unwrap()).await.unwrap(); + let binding = modules + .prepare_host_callbacks(vec![crate::module::HostCallbackEndpoint { + path: "/callback".parse().unwrap(), + interface: "org.example.Callback".parse().unwrap(), + members: vec!["Observe".parse().unwrap()], + }]) + .await + .unwrap(); let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - client.serve_at("/callback".parse().unwrap(), CountingNativeCallback(calls.clone())).await.unwrap(); - let module = client.name_owner("org.example.Context").await.unwrap().unwrap(); - let result = client.proxy(module.as_str(), "/context", "org.example.Context").unwrap() - .call_confidential::<(crate::BusName, crate::BusName)>("ConfidentialCallback", ()).await; - assert_eq!(calls.load(Ordering::SeqCst), 1, "confidential callback required; result: {result:?}"); - assert_eq!(result.unwrap(), (client.unique_name().unwrap(), module)); + binding + .serve_at( + "/callback".parse().unwrap(), + CountingNativeCallback(calls.clone()), + ) + .await + .unwrap(); + modules + .load_file_with_host_callbacks(artifact, serde_json::json!({}), &binding) + .await + .unwrap(); + let module = binding.guard().module_peer().unwrap(); + let result = binding + .proxy(module.as_str(), "/context", "org.example.Context") + .unwrap() + .call_confidential::<(crate::BusName, crate::BusName)>("ConfidentialCallback", ()) + .await; + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "confidential callback required; result: {result:?}" + ); + assert_eq!(result.unwrap(), (binding.unique_name().clone(), module)); task.abort(); } struct CountingNativeCallback(Arc); #[async_trait::async_trait] impl crate::Interface for CountingNativeCallback { - fn name(&self) -> crate::InterfaceName { "org.example.Callback".parse().unwrap() } - fn members(&self) -> Vec { vec!["Observe".parse().unwrap()] } - async fn call(&self, _: &crate::MemberName, _: serde_json::Value) -> crate::Result { + fn name(&self) -> crate::InterfaceName { + "org.example.Callback".parse().unwrap() + } + fn members(&self) -> Vec { + vec!["Observe".parse().unwrap()] + } + async fn call( + &self, + _: &crate::MemberName, + _: serde_json::Value, + ) -> crate::Result { Err(Error::failed("missing contextual dispatch")) } - async fn call_with_context(&self, _: &crate::MemberName, _: serde_json::Value, context: &crate::CallContext) -> crate::Result { - let sender = context.authenticated_sender().ok_or_else(|| Error::failed("unverified callback"))?; + async fn call_with_context( + &self, + _: &crate::MemberName, + _: serde_json::Value, + context: &crate::CallContext, + ) -> crate::Result { + let sender = context + .authenticated_sender() + .ok_or_else(|| Error::failed("unverified callback"))?; self.0.fetch_add(1, Ordering::SeqCst); Ok(serde_json::json!(sender)) } diff --git a/crates/tinybus/src/module/mod.rs b/crates/tinybus/src/module/mod.rs index 88ec02d2..40d65ccd 100644 --- a/crates/tinybus/src/module/mod.rs +++ b/crates/tinybus/src/module/mod.rs @@ -37,9 +37,9 @@ pub use first_admitted::{ReleaseAsset, ReleasePlan, load_first_admitted}; #[cfg(feature = "modules")] pub use github::CachedRelease; #[cfg(feature = "modules")] -pub use host::{ModuleHost, ModuleInfo, ModuleState}; -#[cfg(feature = "modules")] pub use host::callbacks::{HostCallbackBinding, HostCallbackEndpoint, HostCallbackGuard}; +#[cfg(feature = "modules")] +pub use host::{ModuleHost, ModuleInfo, ModuleState}; /// Entry points compiled into the host instead of loaded from a shared library. /// diff --git a/crates/tinybus/src/router.rs b/crates/tinybus/src/router.rs index 5a8ddb69..56027cdb 100644 --- a/crates/tinybus/src/router.rs +++ b/crates/tinybus/src/router.rs @@ -255,7 +255,9 @@ impl Router { if capability.includes_peer(&peer.unique) { capability.revoke(); false - } else { true } + } else { + true + } }); let owned: Vec = self .names @@ -277,6 +279,25 @@ impl Router { changes } + #[cfg(feature = "modules")] + pub(crate) fn detach_unique(&mut self, unique: &BusName) { + if let Some(id) = self.names.get(unique).copied() { + self.detach(id); + } + } + + #[cfg(feature = "modules")] + pub(crate) fn revoke_callbacks_for_peer(&mut self, peer: &BusName) { + self.host_callbacks.retain(|entry| { + if entry.includes_peer(peer) { + entry.revoke(); + false + } else { + true + } + }); + } + /// Claim a well-known name for `id`. /// /// First writer wins and there is no queue: a second claimant is told the @@ -501,6 +522,7 @@ impl Router { destination: &BusName, expected: Option<&BusName>, confidential: bool, + #[cfg_attr(not(feature = "modules"), allow(unused_variables))] header: &crate::message::Header, ) -> Result> { if expected.is_some_and(|expected| self.owner_of(destination).as_ref() != Some(expected)) { @@ -508,7 +530,12 @@ impl Router { } if confidential { #[cfg(feature = "modules")] - if destination.is_unique() && self.host_callbacks.iter().any(|capability| capability.matches(header)) { + if destination.is_unique() + && self + .host_callbacks + .iter() + .any(|capability| capability.matches(header)) + { return self.resolve(destination); } self.resolve_attested(destination) @@ -518,9 +545,19 @@ impl Router { } #[cfg(feature = "modules")] - pub(crate) fn add_host_callbacks(&mut self, capability: Arc) -> Result<()> { - let source = capability.module_peer().ok_or_else(|| Error::failed("callback module peer is absent"))?; - if self.attestation_of(&source).is_none() || self.owner_of(&capability.destination).is_none() { + pub(crate) fn add_host_callbacks( + &mut self, + capability: Arc, + ) -> Result<()> { + if self.host_callbacks.len() >= 128 { + return Err(Error::failed("callback scope limit reached")); + } + let source = capability + .module_peer() + .ok_or_else(|| Error::failed("callback module peer is absent"))?; + if self.attestation_of(&source).is_none() + || self.owner_of(&capability.destination).is_none() + { return Err(Error::failed("callback peers are not admitted")); } self.host_callbacks.push(capability); @@ -528,9 +565,13 @@ impl Router { } #[cfg(feature = "modules")] - pub(crate) fn revoke_host_callbacks(&mut self, capability: &Arc) { + pub(crate) fn revoke_host_callbacks( + &mut self, + capability: &Arc, + ) { capability.revoke(); - self.host_callbacks.retain(|entry| !Arc::ptr_eq(entry, capability)); + self.host_callbacks + .retain(|entry| !Arc::ptr_eq(entry, capability)); } /// The outbox of whoever owns `destination`. From 8cbf7819bf50dd50510c3c00b8822ea6fe7dda47 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:39:48 +0300 Subject: [PATCH 05/31] fix(module): attach callback connection before handshaking The callback connection is now created with Connection::attach and the handshake is performed as a separate step, matching the updated connection API. This keeps the host callback path working after the connection setup was split. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybus/src/module/host_callbacks.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tinybus/src/module/host_callbacks.rs b/crates/tinybus/src/module/host_callbacks.rs index d3dd928f..eb8441ca 100644 --- a/crates/tinybus/src/module/host_callbacks.rs +++ b/crates/tinybus/src/module/host_callbacks.rs @@ -298,7 +298,8 @@ impl ModuleHost { let (host, peer) = MemoryTransport::pair(); self.inner.broker.attach(Arc::new(peer)); let transport = Arc::new(host); - let connection = Connection::connect(transport.clone()).await?; + let connection = Connection::attach(transport.clone()); + connection.handshake().await?; let destination = connection .unique_name() .ok_or_else(|| Error::failed("callback connection has no unique name"))?; From 4e274efe40ab24fdf3397540326c2404885c8ec9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:41:30 +0300 Subject: [PATCH 06/31] fix(module): roll back callback registrations and transport on failure Callback registrations are now tracked as in-flight until the connection call succeeds, so a failed registration no longer leaves a stale entry in the served list and concurrent duplicates are rejected. The host also detaches the unique name and records the transport for rollback when a module is refused, preventing leaked bus names after a failed start. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybus/src/module/host.rs | 1 + crates/tinybus/src/module/host_callbacks.rs | 23 ++- .../src/module/host_callbacks_tests.rs | 164 ++++++++++++++++++ 3 files changed, 179 insertions(+), 9 deletions(-) create mode 100644 crates/tinybus/src/module/host_callbacks_tests.rs diff --git a/crates/tinybus/src/module/host.rs b/crates/tinybus/src/module/host.rs index da40c28a..e044bcd2 100644 --- a/crates/tinybus/src/module/host.rs +++ b/crates/tinybus/src/module/host.rs @@ -1061,6 +1061,7 @@ impl ModuleHost { { Ok(change) => change, Err(_) => { + self.inner.broker.router.lock().expect("router lock").detach_unique(&unique); let _ = transport.stop_sync(Duration::from_millis(0)); let error = Error::module_refused(path, "module bus name is already owned"); self.record_manifest_rejection(&error, manifest, RefusalClass::Unresolved); diff --git a/crates/tinybus/src/module/host_callbacks.rs b/crates/tinybus/src/module/host_callbacks.rs index eb8441ca..ad65af62 100644 --- a/crates/tinybus/src/module/host_callbacks.rs +++ b/crates/tinybus/src/module/host_callbacks.rs @@ -25,6 +25,7 @@ struct Lifetime { module: Option, running: usize, served: Vec<(ObjectPath, InterfaceName)>, + registering: Vec<(ObjectPath, InterfaceName)>, } pub(crate) struct CallbackState { @@ -176,12 +177,13 @@ impl HostCallbackBinding { } { let mut state = self.state.lifetime.lock().expect("callback lifetime lock"); - if state.used || state.revoked || state.served.contains(&(path.clone(), name.clone())) { + if state.used || state.revoked || state.served.contains(&(path.clone(), name.clone())) + || state.registering.contains(&(path.clone(), name.clone())) { return Err(Error::failed( "callback registration is immutable or duplicated", )); } - state.served.push((path.clone(), name.clone())); + state.registering.push((path.clone(), name.clone())); } let result = self .connection @@ -194,13 +196,10 @@ impl HostCallbackBinding { }, ) .await; - if result.is_err() { - self.state - .lifetime - .lock() - .expect("callback lifetime lock") - .served - .retain(|entry| entry != &(path.clone(), name.clone())); + { + let mut state = self.state.lifetime.lock().expect("callback lifetime lock"); + state.registering.retain(|entry| entry != &(path.clone(), name.clone())); + if result.is_ok() { state.served.push((path, name)); } } result } @@ -317,6 +316,7 @@ impl ModuleHost { module: None, running: 0, served: Vec::new(), + registering: Vec::new(), }), drained: Notify::new(), }), @@ -387,6 +387,7 @@ impl ModuleHost { let (transport, host) = ModuleTransport::new(admitted.name.clone(), bytes); transport.defer_initialize(artifact.init, host); let name = admitted.name.clone(); + rollback.transport = Some(transport.clone()); self.attach_transport( path, admitted, @@ -495,3 +496,7 @@ impl Drop for ModuleHostInner { } } } + +#[cfg(test)] +#[path = "host_callbacks_tests.rs"] +mod tests; diff --git a/crates/tinybus/src/module/host_callbacks_tests.rs b/crates/tinybus/src/module/host_callbacks_tests.rs new file mode 100644 index 00000000..f62f14dd --- /dev/null +++ b/crates/tinybus/src/module/host_callbacks_tests.rs @@ -0,0 +1,164 @@ +use super::*; +use crate::message::Message; +use crate::router::Router; +use std::sync::atomic::AtomicUsize; + +fn endpoint() -> HostCallbackEndpoint { + HostCallbackEndpoint { path: "/callback".parse().unwrap(), interface: "org.example.Callback".parse().unwrap(), members: vec!["Observe".parse().unwrap()] } +} +fn state(source: BusName, destination: BusName) -> Arc { + Arc::new(CallbackState { destination, endpoints: vec![endpoint()], lifetime: Mutex::new(Lifetime { + active: true, used: true, revoked: false, module: Some(source), running: 0, served: vec![], registering: vec![], + }), drained: Notify::new() }) +} +fn call(state: &CallbackState) -> Message { + let endpoint = endpoint(); + let mut message = Message::method_call(state.destination.clone(), endpoint.path, endpoint.interface, endpoint.members[0].clone(), serde_json::json!([])); + message.header.sender = state.module_peer(); + message.header.confidential = true; + message +} +fn kernel_fixture() -> (Router, Arc, u64, u64) { + // Private router behavior tests only; actual artifact admission is proved by + // host_tests with the hash-verified cdylib, never by this table fixture. + let mut router = Router::default(); + let (source_id, source) = router.attach(tokio::sync::mpsc::channel(8).0); + let (destination_id, destination) = router.attach(tokio::sync::mpsc::channel(8).0); + router.set_attestation_for_unique(&source, crate::attest::Attestation { name: "org.example.Module".parse().unwrap(), sha256: "a".repeat(64) }); + let state = state(source, destination); + router.add_host_callbacks(state.clone()).unwrap(); + (router, state, source_id, destination_id) +} +fn resolves(router: &Router, message: &Message) -> Result> { + router.resolve_call(message.header.destination.as_ref().unwrap(), message.header.expected_responder.as_ref(), true, &message.header) +} +#[test] +fn scoped_routes_match_every_field_and_never_attest_the_host() { + let (router, state, _, _) = kernel_fixture(); + let message = call(&state); + assert!(resolves(&router, &message).is_ok()); + assert!(router.attestation_of(&state.destination).is_none()); + assert!(router.resolve_attested(&state.destination).is_err()); + for field in 0..5 { + let mut wrong = message.clone(); + match field { + 0 => wrong.header.sender = Some(":1.999".parse().unwrap()), + 1 => wrong.header.path = Some("/other".parse().unwrap()), + 2 => wrong.header.interface = Some("org.example.Other".parse().unwrap()), + 3 => wrong.header.member = Some("Other".parse().unwrap()), + _ => wrong.header.expected_responder = Some(":1.999".parse().unwrap()), + } + assert!(resolves(&router, &wrong).is_err()); + } +} +#[test] +fn a_host_alias_and_a_second_verified_module_inherit_no_authority() { + let (mut router, state, _, destination_id) = kernel_fixture(); + let (_, other) = router.attach(tokio::sync::mpsc::channel(8).0); + router.set_attestation_for_unique(&other, crate::attest::Attestation { name: "org.example.Other".parse().unwrap(), sha256: "b".repeat(64) }); + let mut message = call(&state); + message.header.sender = Some(other); + assert!(resolves(&router, &message).is_err()); + let alias: BusName = "org.example.Host".parse().unwrap(); + router.request_name(destination_id, alias.clone()).unwrap(); + message = call(&state); + message.header.destination = Some(alias); + assert!(resolves(&router, &message).is_err()); +} +#[test] +fn detaching_either_peer_revokes_stale_guard_and_replacements() { + for detach_source in [false, true] { + let (mut router, state, source_id, destination_id) = kernel_fixture(); + router.detach(if detach_source { source_id } else { destination_id }); + assert!(!state.matches(&call(&state).header)); + assert_eq!(router.add_host_callbacks(state.clone()).unwrap_err().wire_name(), "ai.tinyhumans.tinybus.Error.Failed"); + let (_, replacement) = router.attach(tokio::sync::mpsc::channel(8).0); + assert_ne!(Some(replacement), state.module_peer()); + } +} +#[test] +fn unverified_sources_cannot_be_enrolled_and_scopes_are_bounded() { + let mut router = Router::default(); + let (_, source) = router.attach(tokio::sync::mpsc::channel(8).0); + let (_, destination) = router.attach(tokio::sync::mpsc::channel(8).0); + let state = state(source.clone(), destination); + assert!(router.add_host_callbacks(state.clone()).is_err()); + router.set_attestation_for_unique(&source, crate::attest::Attestation { name: "org.example.Module".parse().unwrap(), sha256: "a".repeat(64) }); + for _ in 0..128 { router.add_host_callbacks(state.clone()).unwrap(); } + assert!(router.add_host_callbacks(state).is_err()); +} + +struct BlockingCallback { + entered: tokio::sync::mpsc::Sender<()>, + release: Arc, + published: Arc, + guard: HostCallbackGuard, +} +#[async_trait] +impl Interface for BlockingCallback { + fn name(&self) -> InterfaceName { endpoint().interface } + fn members(&self) -> Vec { endpoint().members } + async fn call(&self, _: &MemberName, _: serde_json::Value) -> Result { Err(Error::failed("unverified")) } + async fn call_with_context(&self, _: &MemberName, _: serde_json::Value, context: &CallContext) -> Result { + self.entered.send(()).await.unwrap(); + let _permit = self.release.acquire().await.unwrap(); + if !self.guard.authorizes(context) { return Err(Error::failed("revoked before publication")); } + self.published.fetch_add(1, Ordering::SeqCst); + Ok(serde_json::Value::Null) + } +} +#[tokio::test] +async fn revoke_after_route_selection_blocks_dispatch_and_direct_unverified_dispatch() { + let (mut router, state, _, _) = kernel_fixture(); + let message = call(&state); + assert!(resolves(&router, &message).is_ok()); + let (entered, mut receiver) = tokio::sync::mpsc::channel(1); + let guarded = GuardedInterface { endpoint: endpoint(), state: state.clone(), interface: BlockingCallback { + entered, release: Arc::new(tokio::sync::Semaphore::new(1)), published: Arc::new(AtomicUsize::new(0)), guard: HostCallbackGuard(state.clone()), + } }; + assert!(guarded.call_with_context(&endpoint().members[0], serde_json::json!([]), &CallContext::default()).await.is_err()); + router.revoke_host_callbacks(&state); + assert!(guarded.call_with_context(&endpoint().members[0], serde_json::json!([]), &CallContext::brokered(state.module_peer().as_ref())).await.is_err()); + assert!(receiver.try_recv().is_err()); + assert_eq!(HostCallbackGuard(state).running(), 0); +} +#[tokio::test] +async fn revoke_during_await_preserves_running_charge_until_actual_completion() { + let (mut router, state, _, _) = kernel_fixture(); + let guard = HostCallbackGuard(state.clone()); + let (entered, mut receiver) = tokio::sync::mpsc::channel(1); + let release = Arc::new(tokio::sync::Semaphore::new(0)); + let published = Arc::new(AtomicUsize::new(0)); + let guarded = GuardedInterface { endpoint: endpoint(), state: state.clone(), interface: BlockingCallback { entered, release: release.clone(), published: published.clone(), guard: guard.clone() } }; + let context = CallContext::brokered(state.module_peer().as_ref()); + let task = tokio::spawn(async move { guarded.call_with_context(&endpoint().members[0], serde_json::json!([]), &context).await }); + receiver.recv().await.unwrap(); + assert_eq!(guard.running(), 1); + router.revoke_host_callbacks(&state); + assert_eq!(guard.running(), 1); + let mut drain = Box::pin(guard.drained()); + assert!(std::future::poll_fn(|cx| std::task::Poll::Ready(drain.as_mut().poll(cx).is_pending())).await); + release.add_permits(1); + assert!(task.await.unwrap().is_err()); + guard.drained().await; + assert_eq!(guard.running(), 0); + assert_eq!(published.load(Ordering::SeqCst), 0); +} +#[tokio::test] +async fn endpoint_declarations_registration_and_broker_ownership_are_checked() { + let host = ModuleHost::new(Broker::new()); + assert!(host.prepare_host_callbacks(vec![]).await.is_err()); + assert!(host.prepare_host_callbacks(vec![endpoint(); 33]).await.is_err()); + assert!(host.prepare_host_callbacks(vec![endpoint(), endpoint()]).await.is_err()); + let mut duplicate = endpoint(); duplicate.members.push(duplicate.members[0].clone()); + assert!(host.prepare_host_callbacks(vec![duplicate]).await.is_err()); + let mut excessive = endpoint(); excessive.members = (0..65).map(|i| format!("M{i}").parse().unwrap()).collect(); + assert!(host.prepare_host_callbacks(vec![excessive]).await.is_err()); + let binding = host.prepare_host_callbacks(vec![endpoint()]).await.unwrap(); + assert!(host.load_file_with_host_callbacks("unused", serde_json::json!({}), &binding).await.is_err()); + let other = ModuleHost::new(Broker::new()); + assert!(other.load_file_with_host_callbacks("unused", serde_json::json!({}), &binding).await.is_err()); + binding.revoke(); binding.revoke(); + assert!(host.load_file_with_host_callbacks("unused", serde_json::json!({}), &binding).await.is_err()); + binding.close().await.unwrap(); +} From c0753502ccf67f493f4cd57d7952cc26981774ff Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:41:56 +0300 Subject: [PATCH 07/31] fix(module): gate module initialization on callback admission Module initialization could previously proceed before host callback admission was decided, letting a module run with callbacks that were about to be rejected. Initialization is now paused until the router accepts the callbacks, and rollback revokes callbacks for the peer resolved from the loaded module list when the binding has no peer yet. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybus/src/module/host.rs | 12 +- crates/tinybus/src/module/host_callbacks.rs | 29 ++- .../src/module/host_callbacks_tests.rs | 200 +++++++++++++++--- crates/tinybus/src/module/transport.rs | 32 +++ 4 files changed, 234 insertions(+), 39 deletions(-) diff --git a/crates/tinybus/src/module/host.rs b/crates/tinybus/src/module/host.rs index e044bcd2..2790994d 100644 --- a/crates/tinybus/src/module/host.rs +++ b/crates/tinybus/src/module/host.rs @@ -1061,7 +1061,12 @@ impl ModuleHost { { Ok(change) => change, Err(_) => { - self.inner.broker.router.lock().expect("router lock").detach_unique(&unique); + self.inner + .broker + .router + .lock() + .expect("router lock") + .detach_unique(&unique); let _ = transport.stop_sync(Duration::from_millis(0)); let error = Error::module_refused(path, "module bus name is already owned"); self.record_manifest_rejection(&error, manifest, RefusalClass::Unresolved); @@ -1403,6 +1408,11 @@ impl ModuleControl for ModuleHostInner { .to_string(), }); } + self.broker + .router + .lock() + .expect("router lock") + .revoke_callbacks_for_peer(&module.unique_name); module.transition_from = Some(old); module.lifecycle_busy = true; module.transport.clone() diff --git a/crates/tinybus/src/module/host_callbacks.rs b/crates/tinybus/src/module/host_callbacks.rs index ad65af62..6ff770b3 100644 --- a/crates/tinybus/src/module/host_callbacks.rs +++ b/crates/tinybus/src/module/host_callbacks.rs @@ -177,8 +177,11 @@ impl HostCallbackBinding { } { let mut state = self.state.lifetime.lock().expect("callback lifetime lock"); - if state.used || state.revoked || state.served.contains(&(path.clone(), name.clone())) - || state.registering.contains(&(path.clone(), name.clone())) { + if state.used + || state.revoked + || state.served.contains(&(path.clone(), name.clone())) + || state.registering.contains(&(path.clone(), name.clone())) + { return Err(Error::failed( "callback registration is immutable or duplicated", )); @@ -198,8 +201,12 @@ impl HostCallbackBinding { .await; { let mut state = self.state.lifetime.lock().expect("callback lifetime lock"); - state.registering.retain(|entry| entry != &(path.clone(), name.clone())); - if result.is_ok() { state.served.push((path, name)); } + state + .registering + .retain(|entry| entry != &(path.clone(), name.clone())); + if result.is_ok() { + state.served.push((path, name)); + } } result } @@ -385,6 +392,7 @@ impl ModuleHost { let bytes = serde_json::to_vec(&config) .map_err(|_| Error::failed("module configuration is invalid"))?; let (transport, host) = ModuleTransport::new(admitted.name.clone(), bytes); + transport.pause_initialization(); transport.defer_initialize(artifact.init, host); let name = admitted.name.clone(); rollback.transport = Some(transport.clone()); @@ -425,6 +433,7 @@ impl ModuleHost { .lock() .expect("router lock") .add_host_callbacks(binding.state.clone())?; + transport.release_initialization(true); transport }; if transport.ensure_initialized().await.is_err() { @@ -462,7 +471,17 @@ impl Drop for LoadRollback<'_> { self.binding.revoke(); self.binding.transport.close_outbound(); if let Some(transport) = &self.transport { - let peer = self.binding.state.module_peer(); + transport.release_initialization(false); + let peer = self.binding.state.module_peer().or_else(|| { + self.host + .inner + .loaded + .lock() + .expect("module list lock") + .iter() + .find(|module| Arc::ptr_eq(&module.transport, transport)) + .map(|module| module.unique_name.clone()) + }); if let Some(peer) = peer { self.host .inner diff --git a/crates/tinybus/src/module/host_callbacks_tests.rs b/crates/tinybus/src/module/host_callbacks_tests.rs index f62f14dd..a01c5c6e 100644 --- a/crates/tinybus/src/module/host_callbacks_tests.rs +++ b/crates/tinybus/src/module/host_callbacks_tests.rs @@ -4,16 +4,37 @@ use crate::router::Router; use std::sync::atomic::AtomicUsize; fn endpoint() -> HostCallbackEndpoint { - HostCallbackEndpoint { path: "/callback".parse().unwrap(), interface: "org.example.Callback".parse().unwrap(), members: vec!["Observe".parse().unwrap()] } + HostCallbackEndpoint { + path: "/callback".parse().unwrap(), + interface: "org.example.Callback".parse().unwrap(), + members: vec!["Observe".parse().unwrap()], + } } fn state(source: BusName, destination: BusName) -> Arc { - Arc::new(CallbackState { destination, endpoints: vec![endpoint()], lifetime: Mutex::new(Lifetime { - active: true, used: true, revoked: false, module: Some(source), running: 0, served: vec![], registering: vec![], - }), drained: Notify::new() }) + Arc::new(CallbackState { + destination, + endpoints: vec![endpoint()], + lifetime: Mutex::new(Lifetime { + active: true, + used: true, + revoked: false, + module: Some(source), + running: 0, + served: vec![], + registering: vec![], + }), + drained: Notify::new(), + }) } fn call(state: &CallbackState) -> Message { let endpoint = endpoint(); - let mut message = Message::method_call(state.destination.clone(), endpoint.path, endpoint.interface, endpoint.members[0].clone(), serde_json::json!([])); + let mut message = Message::method_call( + state.destination.clone(), + endpoint.path, + endpoint.interface, + endpoint.members[0].clone(), + serde_json::json!([]), + ); message.header.sender = state.module_peer(); message.header.confidential = true; message @@ -24,13 +45,24 @@ fn kernel_fixture() -> (Router, Arc, u64, u64) { let mut router = Router::default(); let (source_id, source) = router.attach(tokio::sync::mpsc::channel(8).0); let (destination_id, destination) = router.attach(tokio::sync::mpsc::channel(8).0); - router.set_attestation_for_unique(&source, crate::attest::Attestation { name: "org.example.Module".parse().unwrap(), sha256: "a".repeat(64) }); + router.set_attestation_for_unique( + &source, + crate::attest::Attestation { + name: "org.example.Module".parse().unwrap(), + sha256: "a".repeat(64), + }, + ); let state = state(source, destination); router.add_host_callbacks(state.clone()).unwrap(); (router, state, source_id, destination_id) } fn resolves(router: &Router, message: &Message) -> Result> { - router.resolve_call(message.header.destination.as_ref().unwrap(), message.header.expected_responder.as_ref(), true, &message.header) + router.resolve_call( + message.header.destination.as_ref().unwrap(), + message.header.expected_responder.as_ref(), + true, + &message.header, + ) } #[test] fn scoped_routes_match_every_field_and_never_attest_the_host() { @@ -55,7 +87,13 @@ fn scoped_routes_match_every_field_and_never_attest_the_host() { fn a_host_alias_and_a_second_verified_module_inherit_no_authority() { let (mut router, state, _, destination_id) = kernel_fixture(); let (_, other) = router.attach(tokio::sync::mpsc::channel(8).0); - router.set_attestation_for_unique(&other, crate::attest::Attestation { name: "org.example.Other".parse().unwrap(), sha256: "b".repeat(64) }); + router.set_attestation_for_unique( + &other, + crate::attest::Attestation { + name: "org.example.Other".parse().unwrap(), + sha256: "b".repeat(64), + }, + ); let mut message = call(&state); message.header.sender = Some(other); assert!(resolves(&router, &message).is_err()); @@ -69,9 +107,19 @@ fn a_host_alias_and_a_second_verified_module_inherit_no_authority() { fn detaching_either_peer_revokes_stale_guard_and_replacements() { for detach_source in [false, true] { let (mut router, state, source_id, destination_id) = kernel_fixture(); - router.detach(if detach_source { source_id } else { destination_id }); + router.detach(if detach_source { + source_id + } else { + destination_id + }); assert!(!state.matches(&call(&state).header)); - assert_eq!(router.add_host_callbacks(state.clone()).unwrap_err().wire_name(), "ai.tinyhumans.tinybus.Error.Failed"); + assert_eq!( + router + .add_host_callbacks(state.clone()) + .unwrap_err() + .wire_name(), + "ai.tinyhumans.tinybus.Error.Failed" + ); let (_, replacement) = router.attach(tokio::sync::mpsc::channel(8).0); assert_ne!(Some(replacement), state.module_peer()); } @@ -83,8 +131,16 @@ fn unverified_sources_cannot_be_enrolled_and_scopes_are_bounded() { let (_, destination) = router.attach(tokio::sync::mpsc::channel(8).0); let state = state(source.clone(), destination); assert!(router.add_host_callbacks(state.clone()).is_err()); - router.set_attestation_for_unique(&source, crate::attest::Attestation { name: "org.example.Module".parse().unwrap(), sha256: "a".repeat(64) }); - for _ in 0..128 { router.add_host_callbacks(state.clone()).unwrap(); } + router.set_attestation_for_unique( + &source, + crate::attest::Attestation { + name: "org.example.Module".parse().unwrap(), + sha256: "a".repeat(64), + }, + ); + for _ in 0..128 { + router.add_host_callbacks(state.clone()).unwrap(); + } assert!(router.add_host_callbacks(state).is_err()); } @@ -96,13 +152,26 @@ struct BlockingCallback { } #[async_trait] impl Interface for BlockingCallback { - fn name(&self) -> InterfaceName { endpoint().interface } - fn members(&self) -> Vec { endpoint().members } - async fn call(&self, _: &MemberName, _: serde_json::Value) -> Result { Err(Error::failed("unverified")) } - async fn call_with_context(&self, _: &MemberName, _: serde_json::Value, context: &CallContext) -> Result { + fn name(&self) -> InterfaceName { + endpoint().interface + } + fn members(&self) -> Vec { + endpoint().members + } + async fn call(&self, _: &MemberName, _: serde_json::Value) -> Result { + Err(Error::failed("unverified")) + } + async fn call_with_context( + &self, + _: &MemberName, + _: serde_json::Value, + context: &CallContext, + ) -> Result { self.entered.send(()).await.unwrap(); let _permit = self.release.acquire().await.unwrap(); - if !self.guard.authorizes(context) { return Err(Error::failed("revoked before publication")); } + if !self.guard.authorizes(context) { + return Err(Error::failed("revoked before publication")); + } self.published.fetch_add(1, Ordering::SeqCst); Ok(serde_json::Value::Null) } @@ -113,12 +182,37 @@ async fn revoke_after_route_selection_blocks_dispatch_and_direct_unverified_disp let message = call(&state); assert!(resolves(&router, &message).is_ok()); let (entered, mut receiver) = tokio::sync::mpsc::channel(1); - let guarded = GuardedInterface { endpoint: endpoint(), state: state.clone(), interface: BlockingCallback { - entered, release: Arc::new(tokio::sync::Semaphore::new(1)), published: Arc::new(AtomicUsize::new(0)), guard: HostCallbackGuard(state.clone()), - } }; - assert!(guarded.call_with_context(&endpoint().members[0], serde_json::json!([]), &CallContext::default()).await.is_err()); + let guarded = GuardedInterface { + endpoint: endpoint(), + state: state.clone(), + interface: BlockingCallback { + entered, + release: Arc::new(tokio::sync::Semaphore::new(1)), + published: Arc::new(AtomicUsize::new(0)), + guard: HostCallbackGuard(state.clone()), + }, + }; + assert!( + guarded + .call_with_context( + &endpoint().members[0], + serde_json::json!([]), + &CallContext::default() + ) + .await + .is_err() + ); router.revoke_host_callbacks(&state); - assert!(guarded.call_with_context(&endpoint().members[0], serde_json::json!([]), &CallContext::brokered(state.module_peer().as_ref())).await.is_err()); + assert!( + guarded + .call_with_context( + &endpoint().members[0], + serde_json::json!([]), + &CallContext::brokered(state.module_peer().as_ref()) + ) + .await + .is_err() + ); assert!(receiver.try_recv().is_err()); assert_eq!(HostCallbackGuard(state).running(), 0); } @@ -129,15 +223,31 @@ async fn revoke_during_await_preserves_running_charge_until_actual_completion() let (entered, mut receiver) = tokio::sync::mpsc::channel(1); let release = Arc::new(tokio::sync::Semaphore::new(0)); let published = Arc::new(AtomicUsize::new(0)); - let guarded = GuardedInterface { endpoint: endpoint(), state: state.clone(), interface: BlockingCallback { entered, release: release.clone(), published: published.clone(), guard: guard.clone() } }; + let guarded = GuardedInterface { + endpoint: endpoint(), + state: state.clone(), + interface: BlockingCallback { + entered, + release: release.clone(), + published: published.clone(), + guard: guard.clone(), + }, + }; let context = CallContext::brokered(state.module_peer().as_ref()); - let task = tokio::spawn(async move { guarded.call_with_context(&endpoint().members[0], serde_json::json!([]), &context).await }); + let task = tokio::spawn(async move { + guarded + .call_with_context(&endpoint().members[0], serde_json::json!([]), &context) + .await + }); receiver.recv().await.unwrap(); assert_eq!(guard.running(), 1); router.revoke_host_callbacks(&state); assert_eq!(guard.running(), 1); let mut drain = Box::pin(guard.drained()); - assert!(std::future::poll_fn(|cx| std::task::Poll::Ready(drain.as_mut().poll(cx).is_pending())).await); + assert!( + std::future::poll_fn(|cx| std::task::Poll::Ready(drain.as_mut().poll(cx).is_pending())) + .await + ); release.add_permits(1); assert!(task.await.unwrap().is_err()); guard.drained().await; @@ -148,17 +258,41 @@ async fn revoke_during_await_preserves_running_charge_until_actual_completion() async fn endpoint_declarations_registration_and_broker_ownership_are_checked() { let host = ModuleHost::new(Broker::new()); assert!(host.prepare_host_callbacks(vec![]).await.is_err()); - assert!(host.prepare_host_callbacks(vec![endpoint(); 33]).await.is_err()); - assert!(host.prepare_host_callbacks(vec![endpoint(), endpoint()]).await.is_err()); - let mut duplicate = endpoint(); duplicate.members.push(duplicate.members[0].clone()); + assert!( + host.prepare_host_callbacks(vec![endpoint(); 33]) + .await + .is_err() + ); + assert!( + host.prepare_host_callbacks(vec![endpoint(), endpoint()]) + .await + .is_err() + ); + let mut duplicate = endpoint(); + duplicate.members.push(duplicate.members[0].clone()); assert!(host.prepare_host_callbacks(vec![duplicate]).await.is_err()); - let mut excessive = endpoint(); excessive.members = (0..65).map(|i| format!("M{i}").parse().unwrap()).collect(); + let mut excessive = endpoint(); + excessive.members = (0..65).map(|i| format!("M{i}").parse().unwrap()).collect(); assert!(host.prepare_host_callbacks(vec![excessive]).await.is_err()); let binding = host.prepare_host_callbacks(vec![endpoint()]).await.unwrap(); - assert!(host.load_file_with_host_callbacks("unused", serde_json::json!({}), &binding).await.is_err()); + assert!( + host.load_file_with_host_callbacks("unused", serde_json::json!({}), &binding) + .await + .is_err() + ); let other = ModuleHost::new(Broker::new()); - assert!(other.load_file_with_host_callbacks("unused", serde_json::json!({}), &binding).await.is_err()); - binding.revoke(); binding.revoke(); - assert!(host.load_file_with_host_callbacks("unused", serde_json::json!({}), &binding).await.is_err()); + assert!( + other + .load_file_with_host_callbacks("unused", serde_json::json!({}), &binding) + .await + .is_err() + ); + binding.revoke(); + binding.revoke(); + assert!( + host.load_file_with_host_callbacks("unused", serde_json::json!({}), &binding) + .await + .is_err() + ); binding.close().await.unwrap(); } diff --git a/crates/tinybus/src/module/transport.rs b/crates/tinybus/src/module/transport.rs index 926b0bf4..ab6446e3 100644 --- a/crates/tinybus/src/module/transport.rs +++ b/crates/tinybus/src/module/transport.rs @@ -57,6 +57,8 @@ pub(crate) struct ModuleTransport { // let a second lifecycle callback overlap the still-running first one. lifecycle: Arc>, stop_task_spawned: Arc, + initialization_gate: StdMutex>, + initialization_gate_changed: Notify, } // `module_ctx` is opaque and all access to it goes through callbacks whose ABI @@ -127,6 +129,8 @@ impl ModuleTransport { drain_started: AtomicBool::new(false), lifecycle: Arc::new(Mutex::new(())), stop_task_spawned: Arc::new(AtomicBool::new(false)), + initialization_gate: StdMutex::new(Some(true)), + initialization_gate_changed: Notify::new(), }); let config = context.config.lock().expect("module config lock"); let config_slice = crate::module::abi::TbSlice { @@ -186,7 +190,35 @@ impl ModuleTransport { }); } + pub(crate) fn pause_initialization(&self) { + *self + .initialization_gate + .lock() + .expect("initialization gate lock") = None; + } + + pub(crate) fn release_initialization(&self, allowed: bool) { + *self + .initialization_gate + .lock() + .expect("initialization gate lock") = Some(allowed); + self.initialization_gate_changed.notify_waiters(); + } + pub(crate) async fn ensure_initialized(&self) -> Result<()> { + loop { + let changed = self.initialization_gate_changed.notified(); + let allowed = *self + .initialization_gate + .lock() + .expect("initialization gate lock"); + match allowed { + Some(true) => break, + Some(false) => return Err(Error::transport("module callback admission failed")), + None => changed.await, + } + } + let result = self .init_result .get_or_init(|| async { From afcc11c6faa21f53415c8e76eaaf1011755a70bc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:43:19 +0300 Subject: [PATCH 08/31] fix(module): recheck artifact hash and wait for readiness on load The loader now re-verifies the artifact hash after dependency checks so a file swapped mid-load is refused instead of admitted. Initialization also waits for the module to report ready, with a five second timeout, before treating the load as successful. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybus/Cargo.toml | 5 ++ .../tinybus/examples/module_host_callback.rs | 52 +++++++++++++++++++ crates/tinybus/src/module/host_callbacks.rs | 9 +++- 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 crates/tinybus/examples/module_host_callback.rs diff --git a/crates/tinybus/Cargo.toml b/crates/tinybus/Cargo.toml index 5d6503d8..f8663ab9 100644 --- a/crates/tinybus/Cargo.toml +++ b/crates/tinybus/Cargo.toml @@ -129,3 +129,8 @@ required-features = ["modules"] name = "module_context_eager" crate-type = ["cdylib"] required-features = ["modules"] + +[[example]] +name = "module_host_callback" +crate-type = ["cdylib"] +required-features = ["modules"] diff --git a/crates/tinybus/examples/module_host_callback.rs b/crates/tinybus/examples/module_host_callback.rs new file mode 100644 index 00000000..d50a56bf --- /dev/null +++ b/crates/tinybus/examples/module_host_callback.rs @@ -0,0 +1,52 @@ +//! Actual eager native callback fixture; callback completes before ABI init returns. +use serde::Deserialize; +use tinybus::{Connection, Error, Result}; + +#[derive(Deserialize)] +struct Config { + host: tinybus::BusName, + #[serde(default)] + fail: bool, +} +static COMPLETED: (std::sync::Mutex, std::sync::Condvar) = + (std::sync::Mutex::new(false), std::sync::Condvar::new()); + +async fn setup(connection: Connection, config: Config) -> Result<()> { + let result = async { + if config.fail { return Err(Error::failed("fixture initialization rejected")); } + let _: tinybus::BusName = connection.proxy(config.host.as_str(), "/callback", "org.example.Callback")? + .call_confidential("Observe", ()).await?; + connection.request_name("org.example.EagerCallback").await + }.await; + *COMPLETED.0.lock().unwrap() = true; + COMPLETED.1.notify_one(); + result +} + +unsafe fn eager_start( + host: *const tinybus::module::abi::TbHostVtable, + out: *mut tinybus::module::abi::TbModuleVtable, + workers: usize, detach: bool, setup: F, +) -> i32 +where + C: serde::de::DeserializeOwned + Send + 'static, + F: Fn(Connection, C) -> Fut + Clone + Send + Sync + 'static, + Fut: std::future::Future> + Send + 'static, +{ + let code = unsafe { tinybus_module::start_reconfigurable_module::(host, out, workers, detach, setup) }; + if code == tinybus::module::abi::TB_OK { + let completed = COMPLETED.0.lock().unwrap(); + let (completed, deadline) = COMPLETED.1.wait_timeout_while(completed, std::time::Duration::from_secs(10), |done| !*done).unwrap(); + assert!(*completed && !deadline.timed_out(), "callback must finish before init returns"); + } + code +} + +tinybus_module::module_export! { + @configured export = {#[unsafe(no_mangle)]}, + start = eager_start, + setup = setup, config = Config, + worker_threads = 1, + provides = ["org.example.EagerCallback"], + methods = ["Observe"], signals = [], requires = [], optional = [], lazy = false, +} diff --git a/crates/tinybus/src/module/host_callbacks.rs b/crates/tinybus/src/module/host_callbacks.rs index 6ff770b3..621fcd60 100644 --- a/crates/tinybus/src/module/host_callbacks.rs +++ b/crates/tinybus/src/module/host_callbacks.rs @@ -374,6 +374,9 @@ impl ModuleHost { })?; let artifact = loader::load(path, self.inner.strict.load(Ordering::Acquire))?; self.ensure_dependencies(&artifact.manifest, path)?; + if allowlisted_hash(path, std::fs::File::open(path)?)?.as_ref() != Some(&hash) { + return Err(Error::module_refused(path, "verified artifact changed during loading")); + } let transport = { let _admission = self.inner.admission.lock().expect("module admission lock"); let admitted = self.validate(path, &artifact.descriptor, &artifact.manifest)?; @@ -436,7 +439,11 @@ impl ModuleHost { transport.release_initialization(true); transport }; - if transport.ensure_initialized().await.is_err() { + let initialized = transport.ensure_initialized().await; + let ready = if initialized.is_ok() { + tokio::time::timeout(Duration::from_secs(5), transport.wait_ready()).await.is_ok() && transport.is_ready() + } else { false }; + if !ready { binding.revoke(); let _ = transport.stop_sync(Duration::ZERO); let _ = transport.close().await; From 49f5ae275ae7ea8064fc99a2a1b4f44222b8e760 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:44:26 +0300 Subject: [PATCH 09/31] test(module): cover eager native setup callbacks and rollback Move the eager callback fixture's completion check out of the example and into an ignored host test that loads the built cdylib, verifying the callback peer is bound before READY, that a failing setup rolls back the module and its guard, and that reinitialization re-invokes the callback. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinybus/examples/module_host_callback.rs | 28 +--------- crates/tinybus/src/module/host_tests.rs | 55 +++++++++++++++++++ 2 files changed, 56 insertions(+), 27 deletions(-) diff --git a/crates/tinybus/examples/module_host_callback.rs b/crates/tinybus/examples/module_host_callback.rs index d50a56bf..36a3cfbc 100644 --- a/crates/tinybus/examples/module_host_callback.rs +++ b/crates/tinybus/examples/module_host_callback.rs @@ -1,4 +1,4 @@ -//! Actual eager native callback fixture; callback completes before ABI init returns. +//! Actual eager native fixture calling its fixed host during setup before READY. use serde::Deserialize; use tinybus::{Connection, Error, Result}; @@ -8,9 +8,6 @@ struct Config { #[serde(default)] fail: bool, } -static COMPLETED: (std::sync::Mutex, std::sync::Condvar) = - (std::sync::Mutex::new(false), std::sync::Condvar::new()); - async fn setup(connection: Connection, config: Config) -> Result<()> { let result = async { if config.fail { return Err(Error::failed("fixture initialization rejected")); } @@ -18,33 +15,10 @@ async fn setup(connection: Connection, config: Config) -> Result<()> { .call_confidential("Observe", ()).await?; connection.request_name("org.example.EagerCallback").await }.await; - *COMPLETED.0.lock().unwrap() = true; - COMPLETED.1.notify_one(); result } -unsafe fn eager_start( - host: *const tinybus::module::abi::TbHostVtable, - out: *mut tinybus::module::abi::TbModuleVtable, - workers: usize, detach: bool, setup: F, -) -> i32 -where - C: serde::de::DeserializeOwned + Send + 'static, - F: Fn(Connection, C) -> Fut + Clone + Send + Sync + 'static, - Fut: std::future::Future> + Send + 'static, -{ - let code = unsafe { tinybus_module::start_reconfigurable_module::(host, out, workers, detach, setup) }; - if code == tinybus::module::abi::TB_OK { - let completed = COMPLETED.0.lock().unwrap(); - let (completed, deadline) = COMPLETED.1.wait_timeout_while(completed, std::time::Duration::from_secs(10), |done| !*done).unwrap(); - assert!(*completed && !deadline.timed_out(), "callback must finish before init returns"); - } - code -} - tinybus_module::module_export! { - @configured export = {#[unsafe(no_mangle)]}, - start = eager_start, setup = setup, config = Config, worker_threads = 1, provides = ["org.example.EagerCallback"], diff --git a/crates/tinybus/src/module/host_tests.rs b/crates/tinybus/src/module/host_tests.rs index 851af980..0edacc54 100644 --- a/crates/tinybus/src/module/host_tests.rs +++ b/crates/tinybus/src/module/host_tests.rs @@ -2607,3 +2607,58 @@ impl crate::Interface for CountingNativeCallback { Ok(serde_json::json!(sender)) } } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires TINYBUS_TEST_HOST_CALLBACK_MODULE to point at the built cdylib"] +async fn eager_native_setup_callbacks_are_bound_before_ready_and_failures_rollback() { + for failed in [false, true] { + let artifact = PathBuf::from(std::env::var_os("TINYBUS_TEST_HOST_CALLBACK_MODULE").unwrap()); + let hash = crate::module::hash::file_hex(std::fs::File::open(&artifact).unwrap()).unwrap(); + #[cfg(unix)] + let (_dir, artifact) = staged_module(&artifact, &hash); + let broker = Broker::new(); + let modules = ModuleHost::new(broker.clone()); + let binding = modules.prepare_host_callbacks(vec![crate::module::HostCallbackEndpoint { + path: "/callback".parse().unwrap(), interface: "org.example.Callback".parse().unwrap(), members: vec!["Observe".parse().unwrap()], + }]).await.unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + let guard = binding.guard(); + binding.serve_at("/callback".parse().unwrap(), BoundNativeCallback { calls: calls.clone(), guard: guard.clone() }).await.unwrap(); + let config = serde_json::json!({"host": binding.unique_name(), "fail": failed}); + let result = modules.load_file_with_host_callbacks(artifact, config.clone(), &binding).await; + assert_eq!(calls.load(Ordering::SeqCst), usize::from(!failed)); + if failed { + assert!(result.is_err()); + assert!(modules.list().is_empty()); + assert!(!guard.authorizes(&crate::CallContext::brokered(guard.module_peer().as_ref()))); + } else { + let info = result.unwrap(); + assert!(matches!(info.state, ModuleState::Ready)); + assert_eq!(Some(binding.unique_name().clone()), broker.router.lock().unwrap().owner_of(binding.unique_name())); + assert!(modules.reinitialize(&info.name, serde_json::json!({"host": ":1.999", "fail": false})).await.is_err()); + assert_eq!(calls.load(Ordering::SeqCst), 1); + modules.reinitialize(&info.name, config).await.unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 2); + modules.shutdown(Duration::from_secs(2)).await; + assert!(!guard.authorizes(&crate::CallContext::brokered(guard.module_peer().as_ref()))); + } + binding.close().await.unwrap(); + } +} + +struct BoundNativeCallback { + calls: Arc, + guard: crate::module::HostCallbackGuard, +} +#[async_trait::async_trait] +impl crate::Interface for BoundNativeCallback { + fn name(&self) -> crate::InterfaceName { "org.example.Callback".parse().unwrap() } + fn members(&self) -> Vec { vec!["Observe".parse().unwrap()] } + async fn call(&self, _: &crate::MemberName, _: serde_json::Value) -> crate::Result { Err(Error::failed("unverified")) } + async fn call_with_context(&self, _: &crate::MemberName, _: serde_json::Value, context: &crate::CallContext) -> crate::Result { + assert_eq!(context.authenticated_sender(), self.guard.module_peer().as_ref()); + assert!(self.guard.authorizes(context), "peer must be pinned before eager callback"); + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(serde_json::json!(context.authenticated_sender().unwrap())) + } +} From a8f7c6a20086e8975ec8ed10ba4d9d75f50af78c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:45:35 +0300 Subject: [PATCH 10/31] feat(module): require confidential routing for host callbacks Host callback admission now checks that the incoming call was routed confidentially, so a module cannot reach a scoped callback endpoint through an ordinary call or a forged sender. Call contexts carry the confidential flag, and callback bindings detach their unique name from the router on revoke or rollback so failed setup leaves no stale route behind. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinybus/examples/module_host_callback.rs | 13 +- crates/tinybus/src/connection.rs | 2 +- crates/tinybus/src/module/host_callbacks.rs | 58 +++++++- .../src/module/host_callbacks_tests.rs | 4 +- crates/tinybus/src/module/host_tests.rs | 133 +++++++++++++++--- crates/tinybus/src/service/mod.rs | 10 +- crates/tinybus/src/service/mod_tests.rs | 10 +- 7 files changed, 197 insertions(+), 33 deletions(-) diff --git a/crates/tinybus/examples/module_host_callback.rs b/crates/tinybus/examples/module_host_callback.rs index 36a3cfbc..e9fe10c0 100644 --- a/crates/tinybus/examples/module_host_callback.rs +++ b/crates/tinybus/examples/module_host_callback.rs @@ -10,11 +10,16 @@ struct Config { } async fn setup(connection: Connection, config: Config) -> Result<()> { let result = async { - if config.fail { return Err(Error::failed("fixture initialization rejected")); } - let _: tinybus::BusName = connection.proxy(config.host.as_str(), "/callback", "org.example.Callback")? - .call_confidential("Observe", ()).await?; + if config.fail { + return Err(Error::failed("fixture initialization rejected")); + } + let _: tinybus::BusName = connection + .proxy(config.host.as_str(), "/callback", "org.example.Callback")? + .call_confidential("Observe", ()) + .await?; connection.request_name("org.example.EagerCallback").await - }.await; + } + .await; result } diff --git a/crates/tinybus/src/connection.rs b/crates/tinybus/src/connection.rs index b19d66b6..7e0f287a 100644 --- a/crates/tinybus/src/connection.rs +++ b/crates/tinybus/src/connection.rs @@ -1544,7 +1544,7 @@ async fn dispatch(inner: &Inner, header: &Header, body: Value, brokered: bool) - return Err(Error::protocol("method call is missing an address")); }; let context = if brokered { - crate::CallContext::brokered(header.sender.as_ref()) + crate::CallContext::brokered(header.sender.as_ref(), header.confidential) } else { crate::CallContext::default() }; diff --git a/crates/tinybus/src/module/host_callbacks.rs b/crates/tinybus/src/module/host_callbacks.rs index 621fcd60..f39819d5 100644 --- a/crates/tinybus/src/module/host_callbacks.rs +++ b/crates/tinybus/src/module/host_callbacks.rs @@ -57,6 +57,7 @@ impl CallbackState { pub(crate) fn matches(&self, header: &Header) -> bool { let state = self.lifetime.lock().expect("callback lifetime lock"); state.active + && header.confidential && header.sender.as_ref() == state.module.as_ref() && state.module.is_some() && header.destination.as_ref() == Some(&self.destination) @@ -88,6 +89,7 @@ impl HostCallbackGuard { pub fn authorizes(&self, context: &CallContext) -> bool { let state = self.0.lifetime.lock().expect("callback lifetime lock"); state.active + && context.is_confidential() && state.module.is_some() && context.authenticated_sender() == state.module.as_ref() } @@ -218,6 +220,11 @@ impl Drop for HostCallbackBinding { // Drop cannot await. Closing the sender synchronously detaches the // owned peer without spawning a task that might never be polled. self.transport.close_outbound(); + self.broker + .router + .lock() + .expect("router lock") + .detach_unique(&self.state.destination); } } @@ -259,7 +266,8 @@ impl Interface for GuardedInterface { // check by carrying an earlier route admission through an await. let _running = { let mut state = self.state.lifetime.lock().expect("callback lifetime lock"); - if !state.active + if !context.is_confidential() + || !state.active || state.module.is_none() || context.authenticated_sender() != state.module.as_ref() || !self.endpoint.members.contains(member) @@ -302,13 +310,20 @@ impl ModuleHost { return Err(Error::failed("callback endpoint declaration is invalid")); } let (host, peer) = MemoryTransport::pair(); - self.inner.broker.attach(Arc::new(peer)); + let unique = self.inner.broker.attach(Arc::new(peer)); let transport = Arc::new(host); + let mut rollback = PeerRollback { + broker: self.inner.broker.clone(), + peer: unique, + transport: transport.clone(), + committed: false, + }; let connection = Connection::attach(transport.clone()); connection.handshake().await?; let destination = connection .unique_name() .ok_or_else(|| Error::failed("callback connection has no unique name"))?; + rollback.committed = true; Ok(HostCallbackBinding { broker: self.inner.broker.clone(), connection, @@ -375,7 +390,10 @@ impl ModuleHost { let artifact = loader::load(path, self.inner.strict.load(Ordering::Acquire))?; self.ensure_dependencies(&artifact.manifest, path)?; if allowlisted_hash(path, std::fs::File::open(path)?)?.as_ref() != Some(&hash) { - return Err(Error::module_refused(path, "verified artifact changed during loading")); + return Err(Error::module_refused( + path, + "verified artifact changed during loading", + )); } let transport = { let _admission = self.inner.admission.lock().expect("module admission lock"); @@ -441,8 +459,13 @@ impl ModuleHost { }; let initialized = transport.ensure_initialized().await; let ready = if initialized.is_ok() { - tokio::time::timeout(Duration::from_secs(5), transport.wait_ready()).await.is_ok() && transport.is_ready() - } else { false }; + tokio::time::timeout(Duration::from_secs(5), transport.wait_ready()) + .await + .is_ok() + && transport.is_ready() + } else { + false + }; if !ready { binding.revoke(); let _ = transport.stop_sync(Duration::ZERO); @@ -477,6 +500,12 @@ impl Drop for LoadRollback<'_> { } self.binding.revoke(); self.binding.transport.close_outbound(); + self.binding + .broker + .router + .lock() + .expect("router lock") + .detach_unique(&self.binding.state.destination); if let Some(transport) = &self.transport { transport.release_initialization(false); let peer = self.binding.state.module_peer().or_else(|| { @@ -526,3 +555,22 @@ impl Drop for ModuleHostInner { #[cfg(test)] #[path = "host_callbacks_tests.rs"] mod tests; + +struct PeerRollback { + broker: Broker, + peer: BusName, + transport: Arc, + committed: bool, +} +impl Drop for PeerRollback { + fn drop(&mut self) { + if !self.committed { + self.transport.close_outbound(); + self.broker + .router + .lock() + .expect("router lock") + .detach_unique(&self.peer); + } + } +} diff --git a/crates/tinybus/src/module/host_callbacks_tests.rs b/crates/tinybus/src/module/host_callbacks_tests.rs index a01c5c6e..66d21e77 100644 --- a/crates/tinybus/src/module/host_callbacks_tests.rs +++ b/crates/tinybus/src/module/host_callbacks_tests.rs @@ -208,7 +208,7 @@ async fn revoke_after_route_selection_blocks_dispatch_and_direct_unverified_disp .call_with_context( &endpoint().members[0], serde_json::json!([]), - &CallContext::brokered(state.module_peer().as_ref()) + &CallContext::brokered(state.module_peer().as_ref(), true) ) .await .is_err() @@ -233,7 +233,7 @@ async fn revoke_during_await_preserves_running_charge_until_actual_completion() guard: guard.clone(), }, }; - let context = CallContext::brokered(state.module_peer().as_ref()); + let context = CallContext::brokered(state.module_peer().as_ref(), true); let task = tokio::spawn(async move { guarded .call_with_context(&endpoint().members[0], serde_json::json!([]), &context) diff --git a/crates/tinybus/src/module/host_tests.rs b/crates/tinybus/src/module/host_tests.rs index 0edacc54..c5936555 100644 --- a/crates/tinybus/src/module/host_tests.rs +++ b/crates/tinybus/src/module/host_tests.rs @@ -2574,7 +2574,47 @@ async fn a_verified_cdylib_can_deliver_a_confidential_host_callback() { 1, "confidential callback required; result: {result:?}" ); - assert_eq!(result.unwrap(), (binding.unique_name().clone(), module)); + assert_eq!( + result.unwrap(), + (binding.unique_name().clone(), module.clone()) + ); + let ordinary = binding + .proxy(module.as_str(), "/context", "org.example.Context") + .unwrap() + .call::("Callback", ()) + .await; + assert!( + ordinary.is_err(), + "scoped confidential endpoints refuse ordinary calls even from their module" + ); + assert_eq!(calls.load(Ordering::SeqCst), 1); + let attacker = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let mut forged = crate::message::Message::method_call( + binding.unique_name().clone(), + "/callback".parse().unwrap(), + "org.example.Callback".parse().unwrap(), + "Observe".parse().unwrap(), + serde_json::json!([]), + ); + forged.header.sender = Some(module.clone()); + forged.header.confidential = true; + assert!( + attacker + .call_raw(forged, Duration::from_secs(2)) + .await + .is_err() + ); + assert_eq!(calls.load(Ordering::SeqCst), 1); + binding.revoke(); + let revoked = binding + .proxy(module.as_str(), "/context", "org.example.Context") + .unwrap() + .call_confidential::("ConfidentialCallback", ()) + .await; + assert!(revoked.is_err()); + assert_eq!(calls.load(Ordering::SeqCst), 1); task.abort(); } @@ -2612,35 +2652,73 @@ impl crate::Interface for CountingNativeCallback { #[ignore = "requires TINYBUS_TEST_HOST_CALLBACK_MODULE to point at the built cdylib"] async fn eager_native_setup_callbacks_are_bound_before_ready_and_failures_rollback() { for failed in [false, true] { - let artifact = PathBuf::from(std::env::var_os("TINYBUS_TEST_HOST_CALLBACK_MODULE").unwrap()); + let artifact = + PathBuf::from(std::env::var_os("TINYBUS_TEST_HOST_CALLBACK_MODULE").unwrap()); let hash = crate::module::hash::file_hex(std::fs::File::open(&artifact).unwrap()).unwrap(); #[cfg(unix)] let (_dir, artifact) = staged_module(&artifact, &hash); let broker = Broker::new(); let modules = ModuleHost::new(broker.clone()); - let binding = modules.prepare_host_callbacks(vec![crate::module::HostCallbackEndpoint { - path: "/callback".parse().unwrap(), interface: "org.example.Callback".parse().unwrap(), members: vec!["Observe".parse().unwrap()], - }]).await.unwrap(); + let binding = modules + .prepare_host_callbacks(vec![crate::module::HostCallbackEndpoint { + path: "/callback".parse().unwrap(), + interface: "org.example.Callback".parse().unwrap(), + members: vec!["Observe".parse().unwrap()], + }]) + .await + .unwrap(); let calls = Arc::new(AtomicUsize::new(0)); let guard = binding.guard(); - binding.serve_at("/callback".parse().unwrap(), BoundNativeCallback { calls: calls.clone(), guard: guard.clone() }).await.unwrap(); + binding + .serve_at( + "/callback".parse().unwrap(), + BoundNativeCallback { + calls: calls.clone(), + guard: guard.clone(), + }, + ) + .await + .unwrap(); let config = serde_json::json!({"host": binding.unique_name(), "fail": failed}); - let result = modules.load_file_with_host_callbacks(artifact, config.clone(), &binding).await; + let result = modules + .load_file_with_host_callbacks(artifact, config.clone(), &binding) + .await; assert_eq!(calls.load(Ordering::SeqCst), usize::from(!failed)); if failed { assert!(result.is_err()); assert!(modules.list().is_empty()); - assert!(!guard.authorizes(&crate::CallContext::brokered(guard.module_peer().as_ref()))); + assert!(!guard.authorizes(&crate::CallContext::brokered( + guard.module_peer().as_ref(), + true + ))); } else { let info = result.unwrap(); assert!(matches!(info.state, ModuleState::Ready)); - assert_eq!(Some(binding.unique_name().clone()), broker.router.lock().unwrap().owner_of(binding.unique_name())); - assert!(modules.reinitialize(&info.name, serde_json::json!({"host": ":1.999", "fail": false})).await.is_err()); + assert_eq!( + Some(binding.unique_name().clone()), + broker + .router + .lock() + .unwrap() + .owner_of(binding.unique_name()) + ); + assert!( + modules + .reinitialize( + &info.name, + serde_json::json!({"host": ":1.999", "fail": false}) + ) + .await + .is_err() + ); assert_eq!(calls.load(Ordering::SeqCst), 1); modules.reinitialize(&info.name, config).await.unwrap(); assert_eq!(calls.load(Ordering::SeqCst), 2); modules.shutdown(Duration::from_secs(2)).await; - assert!(!guard.authorizes(&crate::CallContext::brokered(guard.module_peer().as_ref()))); + assert!(!guard.authorizes(&crate::CallContext::brokered( + guard.module_peer().as_ref(), + true + ))); } binding.close().await.unwrap(); } @@ -2652,12 +2730,33 @@ struct BoundNativeCallback { } #[async_trait::async_trait] impl crate::Interface for BoundNativeCallback { - fn name(&self) -> crate::InterfaceName { "org.example.Callback".parse().unwrap() } - fn members(&self) -> Vec { vec!["Observe".parse().unwrap()] } - async fn call(&self, _: &crate::MemberName, _: serde_json::Value) -> crate::Result { Err(Error::failed("unverified")) } - async fn call_with_context(&self, _: &crate::MemberName, _: serde_json::Value, context: &crate::CallContext) -> crate::Result { - assert_eq!(context.authenticated_sender(), self.guard.module_peer().as_ref()); - assert!(self.guard.authorizes(context), "peer must be pinned before eager callback"); + fn name(&self) -> crate::InterfaceName { + "org.example.Callback".parse().unwrap() + } + fn members(&self) -> Vec { + vec!["Observe".parse().unwrap()] + } + async fn call( + &self, + _: &crate::MemberName, + _: serde_json::Value, + ) -> crate::Result { + Err(Error::failed("unverified")) + } + async fn call_with_context( + &self, + _: &crate::MemberName, + _: serde_json::Value, + context: &crate::CallContext, + ) -> crate::Result { + assert_eq!( + context.authenticated_sender(), + self.guard.module_peer().as_ref() + ); + assert!( + self.guard.authorizes(context), + "peer must be pinned before eager callback" + ); self.calls.fetch_add(1, Ordering::SeqCst); Ok(serde_json::json!(context.authenticated_sender().unwrap())) } diff --git a/crates/tinybus/src/service/mod.rs b/crates/tinybus/src/service/mod.rs index 07ade4c1..807b5b9d 100644 --- a/crates/tinybus/src/service/mod.rs +++ b/crates/tinybus/src/service/mod.rs @@ -48,6 +48,7 @@ pub use crate::service::tree::ObjectTree; #[derive(Debug, Clone, Default)] pub struct CallContext { sender: Option, + confidential: bool, } impl CallContext { @@ -60,9 +61,16 @@ impl CallContext { self.sender.as_ref() } - pub(crate) fn brokered(sender: Option<&BusName>) -> Self { + /// Whether the authenticated incoming call requested confidential routing. + /// Unverified contexts never assert confidential admission. + pub fn is_confidential(&self) -> bool { + self.confidential + } + + pub(crate) fn brokered(sender: Option<&BusName>, confidential: bool) -> Self { Self { sender: sender.filter(|name| name.is_unique()).cloned(), + confidential: confidential && sender.is_some_and(BusName::is_unique), } } } diff --git a/crates/tinybus/src/service/mod_tests.rs b/crates/tinybus/src/service/mod_tests.rs index 32881059..b0d346de 100644 --- a/crates/tinybus/src/service/mod_tests.rs +++ b/crates/tinybus/src/service/mod_tests.rs @@ -3,16 +3,20 @@ use super::*; #[test] fn only_a_brokered_unique_sender_is_authenticated() { assert!(CallContext::default().authenticated_sender().is_none()); - assert!(CallContext::brokered(None).authenticated_sender().is_none()); + assert!( + CallContext::brokered(None, false) + .authenticated_sender() + .is_none() + ); let well_known = BusName::new("org.example.Host").unwrap(); assert!( - CallContext::brokered(Some(&well_known)) + CallContext::brokered(Some(&well_known), false) .authenticated_sender() .is_none() ); let unique = BusName::new(":1.42").unwrap(); assert_eq!( - CallContext::brokered(Some(&unique)).authenticated_sender(), + CallContext::brokered(Some(&unique), false).authenticated_sender(), Some(&unique) ); } From 95b7ca8fb77256e789b368f7f446d87f34bba74d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:46:34 +0300 Subject: [PATCH 11/31] chore: files changed .github/workflows/ci.yml,AGENTS.md,crates/tinybus/src/broker.rs,docs/modules/at Auto-committed-on: dragonfly Co-authored-by: Medulla --- .github/workflows/ci.yml | 18 ++++++++++------ AGENTS.md | 14 ++++++++---- crates/tinybus/src/broker.rs | 2 +- docs/modules/attest/README.md | 40 +++++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df416858..1c1bf8ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,7 +59,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - uses: taiki-e/install-action@cargo-llvm-cov - name: Build loader fixtures - run: cargo build --locked --example module_clock --example module_clock_two --example module_wrong_target --example module_context --example module_context_eager --no-default-features --features modules,macros + run: cargo build --locked --example module_clock --example module_clock_two --example module_wrong_target --example module_context --example module_context_eager --example module_host_callback --no-default-features --features modules,macros - name: Enforce 90% line coverage per source file shell: bash run: | @@ -69,7 +69,8 @@ jobs: wrong_target=$(find target/debug/examples -maxdepth 1 -type f -name 'libmodule_wrong_target.so' -print -quit) context_module=$(find target/debug/examples -maxdepth 1 -type f -name 'libmodule_context.so' -print -quit) context_eager_module=$(find target/debug/examples -maxdepth 1 -type f -name 'libmodule_context_eager.so' -print -quit) - for artifact in "$module" "$module_two" "$wrong_target" "$context_module" "$context_eager_module"; do + host_callback_module=$(find target/debug/examples -maxdepth 1 -type f \( -name 'libmodule_host_callback.so' -o -name 'libmodule_host_callback.dylib' \) -print -quit) + for artifact in "$module" "$module_two" "$wrong_target" "$context_module" "$context_eager_module" "$host_callback_module"; do if [ -z "$artifact" ] || [ ! -f "$artifact" ]; then echo "a module loader fixture is missing" >&2 exit 1 @@ -93,7 +94,7 @@ jobs: # process/network branches are not meaningful per-file coverage. The # test-support module is integration-test glue whose platform-specific # dynamic-loader and timeout paths run in the dedicated OS jobs instead. - TINYBUS_TEST_MODULE="$PWD/$module" TINYBUS_TEST_MODULE_TWO="$private/$library" TINYBUS_TEST_WRONG_TARGET="$PWD/$wrong_target" TINYBUS_TEST_CONTEXT_MODULE="$PWD/$context_module" TINYBUS_TEST_CONTEXT_EAGER_MODULE="$PWD/$context_eager_module" cargo llvm-cov --workspace --all-features --ignore-filename-regex 'crates/tinybus/src/ports/(listener|transport)\.rs$|crates/tinybus/src/module/github\.rs$|crates/tinybus/src/module/(host|mod)\.rs$|crates/tinybus/src/bin/tinybus\.rs$|crates/tinybus/src/connection\.rs$|crates/tinybus/src/test_support\.rs$' --fail-under-file-lines 90 -- --include-ignored + TINYBUS_TEST_MODULE="$PWD/$module" TINYBUS_TEST_MODULE_TWO="$private/$library" TINYBUS_TEST_WRONG_TARGET="$PWD/$wrong_target" TINYBUS_TEST_CONTEXT_MODULE="$PWD/$context_module" TINYBUS_TEST_CONTEXT_EAGER_MODULE="$PWD/$context_eager_module" TINYBUS_TEST_HOST_CALLBACK_MODULE="$PWD/$host_callback_module" cargo llvm-cov --workspace --all-features --ignore-filename-regex 'crates/tinybus/src/ports/(listener|transport)\.rs$|crates/tinybus/src/module/github\.rs$|crates/tinybus/src/module/(host|mod)\.rs$|crates/tinybus/src/bin/tinybus\.rs$|crates/tinybus/src/connection\.rs$|crates/tinybus/src/test_support\.rs$' --fail-under-file-lines 90 -- --include-ignored # The bus is meant to be embeddable on every platform OpenHuman ships on. The # socket adapter remains Unix-only, while the in-process module transport @@ -130,7 +131,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: Build example module - run: cargo build --locked --example module_clock --example module_clock_two --example module_wrong_target --example module_context --example module_context_eager --no-default-features --features modules,macros + run: cargo build --locked --example module_clock --example module_clock_two --example module_wrong_target --example module_context --example module_context_eager --example module_host_callback --no-default-features --features modules,macros - name: Test shared module test support run: cargo test --locked --no-default-features --features modules,macros,test-support test_support::tests - name: Exercise the real loader (Unix) @@ -143,7 +144,8 @@ jobs: wrong_target=$(find target/debug/examples -maxdepth 1 -type f \( -name 'libmodule_wrong_target.so' -o -name 'libmodule_wrong_target.dylib' \) -print -quit) context_module=$(find target/debug/examples -maxdepth 1 -type f \( -name 'libmodule_context.so' -o -name 'libmodule_context.dylib' \) -print -quit) context_eager_module=$(find target/debug/examples -maxdepth 1 -type f \( -name 'libmodule_context_eager.so' -o -name 'libmodule_context_eager.dylib' \) -print -quit) - for artifact in "$module" "$module_two" "$wrong_target" "$context_module" "$context_eager_module"; do + host_callback_module=$(find target/debug/examples -maxdepth 1 -type f \( -name 'libmodule_host_callback.so' -o -name 'libmodule_host_callback.dylib' \) -print -quit) + for artifact in "$module" "$module_two" "$wrong_target" "$context_module" "$context_eager_module" "$host_callback_module"; do if [ -z "$artifact" ] || [ ! -f "$artifact" ]; then echo "a module example artifact is missing; the loader tests would not exercise it" >&2 exit 1 @@ -160,7 +162,7 @@ jobs: digest="$(shasum -a 256 "$private/$library" | awk '{print $1}')" fi printf '"%s" = "%s"\n' "$library" "$digest" > "$private/modules.toml" - TINYBUS_TEST_MODULE="$PWD/$module" TINYBUS_TEST_MODULE_TWO="$private/$library" TINYBUS_TEST_WRONG_TARGET="$PWD/$wrong_target" TINYBUS_TEST_CONTEXT_MODULE="$PWD/$context_module" TINYBUS_TEST_CONTEXT_EAGER_MODULE="$PWD/$context_eager_module" cargo test --locked --no-default-features --features modules,macros,test-support -- --ignored + TINYBUS_TEST_MODULE="$PWD/$module" TINYBUS_TEST_MODULE_TWO="$private/$library" TINYBUS_TEST_WRONG_TARGET="$PWD/$wrong_target" TINYBUS_TEST_CONTEXT_MODULE="$PWD/$context_module" TINYBUS_TEST_CONTEXT_EAGER_MODULE="$PWD/$context_eager_module" TINYBUS_TEST_HOST_CALLBACK_MODULE="$PWD/$host_callback_module" cargo test --locked --no-default-features --features modules,macros,test-support -- --ignored # Directory admission against this runner's real filesystem: a bundle # in a directory other accounts can write falls back to the verified # release cache, and on macOS the real /Applications is admitted. @@ -196,10 +198,11 @@ jobs: Copy-Item target/debug/examples/module_clock.dll $private Copy-Item target/debug/examples/module_context.dll $private Copy-Item target/debug/examples/module_context_eager.dll $private + Copy-Item target/debug/examples/module_host_callback.dll $private Copy-Item target/debug/examples/module_wrong_target.dll $private # Admit every intentional fixture by its actual built bytes so later # checks, including the wrong-target gate, can exercise their contract. - $fixtureAllowlist = @('module_clock.dll', 'module_context.dll', 'module_context_eager.dll', 'module_wrong_target.dll') | ForEach-Object { + $fixtureAllowlist = @('module_clock.dll', 'module_context.dll', 'module_context_eager.dll', 'module_host_callback.dll', 'module_wrong_target.dll') | ForEach-Object { $digest = (Get-FileHash -LiteralPath (Join-Path $private $_) -Algorithm SHA256).Hash.ToLowerInvariant() '"' + $_ + '" = "' + $digest + '"' } @@ -222,6 +225,7 @@ jobs: $env:TMP = $private $env:TINYBUS_TEST_CONTEXT_MODULE = (Get-Item "$private/module_context.dll").FullName $env:TINYBUS_TEST_CONTEXT_EAGER_MODULE = (Get-Item "$private/module_context_eager.dll").FullName + $env:TINYBUS_TEST_HOST_CALLBACK_MODULE = (Get-Item "$private/module_host_callback.dll").FullName $env:TINYBUS_TEST_MODULE = $module $env:TINYBUS_TEST_MODULE_TWO = $moduleTwo $env:TINYBUS_TEST_WRONG_TARGET = $wrongTarget diff --git a/AGENTS.md b/AGENTS.md index 2ace06c7..08ef2740 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,12 +104,18 @@ discussion in the pull request: control story in full. - **Every call has a deadline.** It cannot be disabled. A call with no deadline reintroduces the hang that motivated the project. -- **A confidential message goes to a loaded, hash-verified module or to - nobody.** Only a module whose artifact the host hashed against a digest an +- **A confidential call goes to a loaded, hash-verified module, an explicitly + scoped SDK-created local host callback, or nobody.** Only a module whose artifact the host hashed against a digest an operator asserted — a `modules.toml` beside the file, or one compiled into the host and checked against the release manifest before extraction — may receive - one; a peer reached across a transport never can, - by design rather than by omission. The message is never fanned out to a + one. The sole host exception is `ModuleHost::prepare_host_callbacks` plus + `load_file_with_host_callbacks`: a private SDK-created MemoryTransport + connection, exact verified module unique sender, exact host unique destination, + path, interface and member, all bound before initialization. This capability + is not artifact attestation; aliases, other instances, ordinary connections and + custom/remote transports never inherit it. Revocation and detach prevent new + route and guarded user-dispatch admission, not cessation of already running + provider work. Generic module loads grant no host callback authority. The message is never fanned out to a subscriber, never printed by `monitor`, and never carried by a signal. This is admission control, not isolation — a loaded module is already inside the trust boundary and could read host memory directly; what the rule buys is that the diff --git a/crates/tinybus/src/broker.rs b/crates/tinybus/src/broker.rs index a8dc11a6..cfba3710 100644 --- a/crates/tinybus/src/broker.rs +++ b/crates/tinybus/src/broker.rs @@ -144,7 +144,7 @@ impl Broker { /// Record that a loaded module's artifact matched the module allowlist. /// - /// This is the *only* way a peer becomes eligible to receive a confidential + /// This is how a module peer becomes eligible to receive a confidential /// message: the artifact was hashed against `modules.toml` before `dlopen`, /// and nothing reached across a transport to establish it. #[cfg(feature = "modules")] diff --git a/docs/modules/attest/README.md b/docs/modules/attest/README.md index 5d36a3ac..3bdcfd69 100644 --- a/docs/modules/attest/README.md +++ b/docs/modules/attest/README.md @@ -186,3 +186,43 @@ TINYBUS_TEST_MODULE="$PWD/target/debug/examples/libmodule_clock.so" \ The artifact name above is Linux's (`libmodule_clock.so`); on macOS Cargo builds `libmodule_clock.dylib` instead, so point `TINYBUS_TEST_MODULE` at that file when running the same command there. + + +## Scoped local host callbacks + +`ModuleHost::prepare_host_callbacks` creates its own local MemoryTransport +connection. It accepts at most 32 exact path/interface declarations, each with +one to 64 distinct members. `HostCallbackBinding::serve_at` wraps those methods +in authenticated, confidential, live dispatch checks; it exposes no raw +connection for bypassing that guard. An outgoing `proxy` uses this same private +host peer, so initialization and callback reception share one fixed identity. + +`load_file_with_host_callbacks` requires an actual hash-verified local artifact +and all endpoints served before admission. It reserves the exact loaded module +peer and binds the exact sender, unique host destination, path, interface and +member before releasing its initialization gate. The read-only `guard()` exposes +`module_peer()` before eager setup callbacks run. The loader awaits actual +READY/fault with a deadline; ABI initialization success alone is insufficient. +Failures or a dropped load future revoke routes and detach reserved peers. A +binding is single-use. Reinitialization requires its unchanged configuration. + +This intentional recipient exception is **not artifact attestation**. There is +no remote enrollment method, self-attestation, wire trust flag, existing +connection registration, alias inheritance or generic-load callback grant. The +router admits at most 128 active scopes and continues to apply ordinary module +artifact admission and expected-owner checks. + +`revoke()` is immediate and idempotent for new route and user-dispatch admission. +Drop, peer detach and host-owner drop also revoke. A callback selected earlier +must recheck liveness before user code. Code already running remains counted by +`guard().running()` until its future completes or drops. `drained()` reports +that count only; caller timeouts and connection close do not imply that provider +writes have ceased. Detached provider tasks require their own completion fence. +Domain callbacks must check `guard().authorizes(context)` after awaits and before +publication and preserve uncertainty about writes already attempted. No buffer +copy wiping or whole-boundary zeroization guarantee is made. + +`module_host_callback` is an actual cdylib fixture: hash-verified bytes perform +an eager confidential callback during setup before READY. The native-loader CI +job runs it on Linux, macOS and Windows. `module_context` separately proves +confidential runtime callbacks and refusal of ordinary, forged and revoked calls. From 4f37f1190aa8c39f82502de859eb68c8b5c4ca0d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:47:06 +0300 Subject: [PATCH 12/31] fix(router): reject duplicate host callback registrations The router now rejects a host callback scope that is already registered, matching on either the same state or the same module peer, instead of only enforcing the 128-entry limit. The test was updated to register distinct scopes so it exercises the limit and the new duplicate check separately, and the example setup was simplified to return the async block directly. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinybus/examples/module_host_callback.rs | 5 ++-- .../src/module/host_callbacks_tests.rs | 29 +++++++++++++++++-- crates/tinybus/src/router.rs | 5 ++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/crates/tinybus/examples/module_host_callback.rs b/crates/tinybus/examples/module_host_callback.rs index e9fe10c0..d4f0d63e 100644 --- a/crates/tinybus/examples/module_host_callback.rs +++ b/crates/tinybus/examples/module_host_callback.rs @@ -9,7 +9,7 @@ struct Config { fail: bool, } async fn setup(connection: Connection, config: Config) -> Result<()> { - let result = async { + async { if config.fail { return Err(Error::failed("fixture initialization rejected")); } @@ -19,8 +19,7 @@ async fn setup(connection: Connection, config: Config) -> Result<()> { .await?; connection.request_name("org.example.EagerCallback").await } - .await; - result + .await } tinybus_module::module_export! { diff --git a/crates/tinybus/src/module/host_callbacks_tests.rs b/crates/tinybus/src/module/host_callbacks_tests.rs index 66d21e77..a3b70a69 100644 --- a/crates/tinybus/src/module/host_callbacks_tests.rs +++ b/crates/tinybus/src/module/host_callbacks_tests.rs @@ -138,10 +138,33 @@ fn unverified_sources_cannot_be_enrolled_and_scopes_are_bounded() { sha256: "a".repeat(64), }, ); - for _ in 0..128 { - router.add_host_callbacks(state.clone()).unwrap(); - } + router.add_host_callbacks(state.clone()).unwrap(); assert!(router.add_host_callbacks(state).is_err()); + for _ in 1..128 { + let (_, source) = router.attach(tokio::sync::mpsc::channel(8).0); + let (_, destination) = router.attach(tokio::sync::mpsc::channel(8).0); + router.set_attestation_for_unique( + &source, + crate::attest::Attestation { + name: "org.example.Module".parse().unwrap(), + sha256: "a".repeat(64), + }, + ); + router + .add_host_callbacks(super::tests::state(source, destination)) + .unwrap(); + } + let (_, source) = router.attach(tokio::sync::mpsc::channel(8).0); + let (_, destination) = router.attach(tokio::sync::mpsc::channel(8).0); + router.set_attestation_for_unique( + &source, + crate::attest::Attestation { + name: "org.example.Module".parse().unwrap(), + sha256: "a".repeat(64), + }, + ); + let extra = super::tests::state(source, destination); + assert!(router.add_host_callbacks(extra).is_err()); } struct BlockingCallback { diff --git a/crates/tinybus/src/router.rs b/crates/tinybus/src/router.rs index 56027cdb..0027c07a 100644 --- a/crates/tinybus/src/router.rs +++ b/crates/tinybus/src/router.rs @@ -549,6 +549,11 @@ impl Router { &mut self, capability: Arc, ) -> Result<()> { + if self.host_callbacks.iter().any(|entry| { + Arc::ptr_eq(entry, &capability) || entry.module_peer() == capability.module_peer() + }) { + return Err(Error::failed("callback scope is already registered")); + } if self.host_callbacks.len() >= 128 { return Err(Error::failed("callback scope limit reached")); } From e0a27a0e8845a8b14849681176b80e0343a573df Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:47:48 +0300 Subject: [PATCH 13/31] test(module): cover timeout and revoke during native callback Add an ignored host test that drives a blocking native callback through a caller timeout and a binding revoke, asserting the worker admission charge stays held until the callback actually finishes and that a revoked callback never publishes its result. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybus/src/module/host_tests.rs | 109 ++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/crates/tinybus/src/module/host_tests.rs b/crates/tinybus/src/module/host_tests.rs index c5936555..25fdae3b 100644 --- a/crates/tinybus/src/module/host_tests.rs +++ b/crates/tinybus/src/module/host_tests.rs @@ -2761,3 +2761,112 @@ impl crate::Interface for BoundNativeCallback { Ok(serde_json::json!(context.authenticated_sender().unwrap())) } } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires TINYBUS_TEST_CONTEXT_MODULE to point at the built cdylib"] +async fn a_native_callback_timeout_and_revoke_do_not_claim_worker_completion() { + let artifact = PathBuf::from(std::env::var_os("TINYBUS_TEST_CONTEXT_MODULE").unwrap()); + let hash = crate::module::hash::file_hex(std::fs::File::open(&artifact).unwrap()).unwrap(); + #[cfg(unix)] + let (_dir, artifact) = staged_module(&artifact, &hash); + let modules = ModuleHost::new(Broker::new()); + let binding = modules + .prepare_host_callbacks(vec![crate::module::HostCallbackEndpoint { + path: "/callback".parse().unwrap(), + interface: "org.example.Callback".parse().unwrap(), + members: vec!["Observe".parse().unwrap()], + }]) + .await + .unwrap(); + let guard = binding.guard(); + let (entered, mut receiver) = tokio::sync::mpsc::channel(1); + let release = Arc::new(tokio::sync::Semaphore::new(0)); + let published = Arc::new(AtomicUsize::new(0)); + binding + .serve_at( + "/callback".parse().unwrap(), + NativeBlockingCallback { + guard: guard.clone(), + entered, + release: release.clone(), + published: published.clone(), + }, + ) + .await + .unwrap(); + modules + .load_file_with_host_callbacks(artifact, serde_json::json!({}), &binding) + .await + .unwrap(); + let module = guard.module_peer().unwrap(); + let proxy = binding + .proxy(module.as_str(), "/context", "org.example.Context") + .unwrap() + .with_timeout(Duration::from_millis(100)); + let request = tokio::spawn(async move { + proxy + .call_confidential::("ConfidentialCallback", ()) + .await + }); + tokio::time::timeout(Duration::from_secs(2), receiver.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!(request.await.unwrap(), Err(Error::Timeout { .. }))); + assert_eq!( + guard.running(), + 1, + "caller timeout does not complete the host worker" + ); + binding.revoke(); + assert_eq!( + guard.running(), + 1, + "revoke retains running admission charge" + ); + release.add_permits(1); + tokio::time::timeout(Duration::from_secs(2), guard.drained()) + .await + .unwrap(); + assert_eq!(guard.running(), 0); + assert_eq!(published.load(Ordering::SeqCst), 0); + modules.shutdown(Duration::from_secs(2)).await; +} + +struct NativeBlockingCallback { + guard: crate::module::HostCallbackGuard, + entered: tokio::sync::mpsc::Sender<()>, + release: Arc, + published: Arc, +} +#[async_trait::async_trait] +impl crate::Interface for NativeBlockingCallback { + fn name(&self) -> crate::InterfaceName { + "org.example.Callback".parse().unwrap() + } + fn members(&self) -> Vec { + vec!["Observe".parse().unwrap()] + } + async fn call( + &self, + _: &crate::MemberName, + _: serde_json::Value, + ) -> crate::Result { + Err(Error::failed("unverified")) + } + async fn call_with_context( + &self, + _: &crate::MemberName, + _: serde_json::Value, + context: &crate::CallContext, + ) -> crate::Result { + assert!(self.guard.authorizes(context)); + self.entered.send(()).await.unwrap(); + let _permit = self.release.acquire().await.unwrap(); + if !self.guard.authorizes(context) { + return Err(Error::failed("revoked before publication")); + } + self.published.fetch_add(1, Ordering::SeqCst); + Ok(serde_json::json!(context.authenticated_sender().unwrap())) + } +} From dbd5c3d1946bf87cce5cff69ea2815db3416144f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:48:49 +0300 Subject: [PATCH 14/31] chore: files changed crates/tinybus/src/module/host_callbacks_tests.rs,crates/tinybus/src/module/hos Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/module/host_callbacks_tests.rs | 72 +++++++++++++++++++ crates/tinybus/src/module/host_tests.rs | 4 +- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/crates/tinybus/src/module/host_callbacks_tests.rs b/crates/tinybus/src/module/host_callbacks_tests.rs index a3b70a69..33b68d91 100644 --- a/crates/tinybus/src/module/host_callbacks_tests.rs +++ b/crates/tinybus/src/module/host_callbacks_tests.rs @@ -319,3 +319,75 @@ async fn endpoint_declarations_registration_and_broker_ownership_are_checked() { ); binding.close().await.unwrap(); } + +struct EmptyCallback; +#[async_trait] +impl Interface for EmptyCallback { + fn name(&self) -> InterfaceName { + endpoint().interface + } + fn members(&self) -> Vec { + vec![] + } + async fn call(&self, _: &MemberName, _: serde_json::Value) -> Result { + Ok(serde_json::Value::Null) + } +} +#[tokio::test] +async fn registration_is_exact_single_use_and_failed_load_detaches_the_local_peer() { + let broker = Broker::new(); + let host = ModuleHost::new(broker.clone()); + let binding = host.prepare_host_callbacks(vec![endpoint()]).await.unwrap(); + assert!( + binding + .serve_at("/other".parse().unwrap(), EmptyCallback) + .await + .is_err() + ); + assert!( + binding + .serve_at(endpoint().path, EmptyCallback) + .await + .is_err() + ); + let (entered, _) = tokio::sync::mpsc::channel(1); + let callback = || BlockingCallback { + entered: entered.clone(), + release: Arc::new(tokio::sync::Semaphore::new(1)), + published: Arc::new(AtomicUsize::new(0)), + guard: binding.guard(), + }; + binding.serve_at(endpoint().path, callback()).await.unwrap(); + assert!(binding.serve_at(endpoint().path, callback()).await.is_err()); + assert_eq!( + binding.unique_name(), + &broker + .router + .lock() + .unwrap() + .owner_of(binding.unique_name()) + .unwrap() + ); + assert!( + host.load_file_with_host_callbacks("missing-fixture", serde_json::json!({}), &binding) + .await + .is_err() + ); + assert!( + broker + .router + .lock() + .unwrap() + .owner_of(binding.unique_name()) + .is_none() + ); + assert!(binding.serve_at(endpoint().path, callback()).await.is_err()); + assert!( + host.load_file_with_host_callbacks("missing-fixture", serde_json::json!({}), &binding) + .await + .is_err() + ); + let peer = binding.unique_name().clone(); + drop(binding); + assert!(broker.router.lock().unwrap().owner_of(&peer).is_none()); +} diff --git a/crates/tinybus/src/module/host_tests.rs b/crates/tinybus/src/module/host_tests.rs index 25fdae3b..19835370 100644 --- a/crates/tinybus/src/module/host_tests.rs +++ b/crates/tinybus/src/module/host_tests.rs @@ -2572,7 +2572,7 @@ async fn a_verified_cdylib_can_deliver_a_confidential_host_callback() { assert_eq!( calls.load(Ordering::SeqCst), 1, - "confidential callback required; result: {result:?}" + "confidential callback required" ); assert_eq!( result.unwrap(), @@ -2714,7 +2714,7 @@ async fn eager_native_setup_callbacks_are_bound_before_ready_and_failures_rollba assert_eq!(calls.load(Ordering::SeqCst), 1); modules.reinitialize(&info.name, config).await.unwrap(); assert_eq!(calls.load(Ordering::SeqCst), 2); - modules.shutdown(Duration::from_secs(2)).await; + drop(modules); assert!(!guard.authorizes(&crate::CallContext::brokered( guard.module_peer().as_ref(), true From afc47d89fe6b8778442870978a8be50cc115cf30 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:50:52 +0300 Subject: [PATCH 15/31] test(module): assert guarded calls fail after revocation Extend the host callback revocation test to also assert that both call and call_with_context return errors once the guard has been revoked, covering the direct dispatch paths that were previously left unchecked. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinybus/src/module/host_callbacks_tests.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/tinybus/src/module/host_callbacks_tests.rs b/crates/tinybus/src/module/host_callbacks_tests.rs index 33b68d91..c44da1d1 100644 --- a/crates/tinybus/src/module/host_callbacks_tests.rs +++ b/crates/tinybus/src/module/host_callbacks_tests.rs @@ -215,6 +215,22 @@ async fn revoke_after_route_selection_blocks_dispatch_and_direct_unverified_disp guard: HostCallbackGuard(state.clone()), }, }; + assert!( + guarded + .call(&endpoint().members[0], serde_json::json!([])) + .await + .is_err() + ); + assert!( + guarded + .call_with_context( + &endpoint().members[0], + serde_json::json!([]), + &CallContext::brokered(state.module_peer().as_ref(), false) + ) + .await + .is_err() + ); assert!( guarded .call_with_context( From be0897c9321531b3980ded9159b78a11b56dd14d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:51:39 +0300 Subject: [PATCH 16/31] fix(module): reject callback bindings from another host owner Callback bindings now record a weak reference to the host that created them, and loading a module checks that the binding's owner matches the calling host. Previously only the broker router was compared, so two hosts sharing a broker could accept each other's bindings. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybus/src/module/host_callbacks.rs | 13 +++++++++++-- crates/tinybus/src/module/host_callbacks_tests.rs | 7 +++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/tinybus/src/module/host_callbacks.rs b/crates/tinybus/src/module/host_callbacks.rs index f39819d5..1c0e8b8c 100644 --- a/crates/tinybus/src/module/host_callbacks.rs +++ b/crates/tinybus/src/module/host_callbacks.rs @@ -125,6 +125,7 @@ impl HostCallbackGuard { /// connection, custom transport, well-known alias, or remote recipient. Drop /// revokes immediately; explicit close also closes the owned local connection. pub struct HostCallbackBinding { + owner: std::sync::Weak, broker: Broker, connection: Connection, state: Arc, @@ -325,6 +326,7 @@ impl ModuleHost { .ok_or_else(|| Error::failed("callback connection has no unique name"))?; rollback.committed = true; Ok(HostCallbackBinding { + owner: Arc::downgrade(&self.inner), broker: self.inner.broker.clone(), connection, transport, @@ -357,8 +359,15 @@ impl ModuleHost { config: serde_json::Value, binding: &HostCallbackBinding, ) -> Result { - if !Arc::ptr_eq(&self.inner.broker.router, &binding.broker.router) { - return Err(Error::failed("callback binding belongs to another broker")); + if !Arc::ptr_eq(&self.inner.broker.router, &binding.broker.router) + || !binding + .owner + .upgrade() + .is_some_and(|owner| Arc::ptr_eq(&owner, &self.inner)) + { + return Err(Error::failed( + "callback binding belongs to another host owner", + )); } { let mut state = binding diff --git a/crates/tinybus/src/module/host_callbacks_tests.rs b/crates/tinybus/src/module/host_callbacks_tests.rs index c44da1d1..d318f14e 100644 --- a/crates/tinybus/src/module/host_callbacks_tests.rs +++ b/crates/tinybus/src/module/host_callbacks_tests.rs @@ -319,6 +319,13 @@ async fn endpoint_declarations_registration_and_broker_ownership_are_checked() { .await .is_err() ); + let other_owner = ModuleHost::new(host.inner.broker.clone()); + assert!( + other_owner + .load_file_with_host_callbacks("unused", serde_json::json!({}), &binding) + .await + .is_err() + ); let other = ModuleHost::new(Broker::new()); assert!( other From 77ea93fd849b4aeb9b1e0b60ea6987a751b0894b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:54:23 +0300 Subject: [PATCH 17/31] fix(module): revoke callback authority when module transport faults Callback dispatch previously stayed authorized until the peer detached, so a faulted module could still receive calls. The callback state now holds a weak reference to the module transport and checks that it is neither dropped nor faulted before matching or authorizing a call. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybus/src/module/host_callbacks.rs | 20 ++++++++++++++++++- .../src/module/host_callbacks_tests.rs | 18 +++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/tinybus/src/module/host_callbacks.rs b/crates/tinybus/src/module/host_callbacks.rs index 1c0e8b8c..589e00af 100644 --- a/crates/tinybus/src/module/host_callbacks.rs +++ b/crates/tinybus/src/module/host_callbacks.rs @@ -31,11 +31,20 @@ struct Lifetime { pub(crate) struct CallbackState { pub(crate) destination: BusName, endpoints: Vec, + module_transport: Mutex>>, lifetime: Mutex, drained: Notify, } impl CallbackState { + fn module_live(&self) -> bool { + self.module_transport + .lock() + .expect("callback module lock") + .as_ref() + .is_none_or(|module| module.upgrade().is_some_and(|module| !module.is_faulted())) + } + pub(crate) fn module_peer(&self) -> Option { self.lifetime .lock() @@ -57,6 +66,7 @@ impl CallbackState { pub(crate) fn matches(&self, header: &Header) -> bool { let state = self.lifetime.lock().expect("callback lifetime lock"); state.active + && self.module_live() && header.confidential && header.sender.as_ref() == state.module.as_ref() && state.module.is_some() @@ -89,6 +99,7 @@ impl HostCallbackGuard { pub fn authorizes(&self, context: &CallContext) -> bool { let state = self.0.lifetime.lock().expect("callback lifetime lock"); state.active + && self.0.module_live() && context.is_confidential() && state.module.is_some() && context.authenticated_sender() == state.module.as_ref() @@ -267,7 +278,8 @@ impl Interface for GuardedInterface { // check by carrying an earlier route admission through an await. let _running = { let mut state = self.state.lifetime.lock().expect("callback lifetime lock"); - if !context.is_confidential() + if !self.state.module_live() + || !context.is_confidential() || !state.active || state.module.is_none() || context.authenticated_sender() != state.module.as_ref() @@ -333,6 +345,7 @@ impl ModuleHost { state: Arc::new(CallbackState { destination, endpoints, + module_transport: Mutex::new(None), lifetime: Mutex::new(Lifetime { active: false, used: false, @@ -453,6 +466,11 @@ impl ModuleHost { if state.revoked || state.module.is_some() { return Err(Error::failed("callback binding was concurrently used")); } + *binding + .state + .module_transport + .lock() + .expect("callback module lock") = Some(Arc::downgrade(&transport)); state.module = Some(module.unique_name.clone()); state.active = true; } diff --git a/crates/tinybus/src/module/host_callbacks_tests.rs b/crates/tinybus/src/module/host_callbacks_tests.rs index d318f14e..6898ed9f 100644 --- a/crates/tinybus/src/module/host_callbacks_tests.rs +++ b/crates/tinybus/src/module/host_callbacks_tests.rs @@ -14,6 +14,7 @@ fn state(source: BusName, destination: BusName) -> Arc { Arc::new(CallbackState { destination, endpoints: vec![endpoint()], + module_transport: Mutex::new(None), lifetime: Mutex::new(Lifetime { active: true, used: true, @@ -414,3 +415,20 @@ async fn registration_is_exact_single_use_and_failed_load_detaches_the_local_pee drop(binding); assert!(broker.router.lock().unwrap().owner_of(&peer).is_none()); } + +#[tokio::test] +async fn a_module_fault_revokes_dispatch_authority_before_peer_detach() { + let (router, state, _, _) = kernel_fixture(); + let (transport, host) = ModuleTransport::new("fault-fixture".into(), Vec::new()); + *state.module_transport.lock().unwrap() = Some(Arc::downgrade(&transport)); + assert!(resolves(&router, &call(&state)).is_ok()); + unsafe { + (host.fault)(host.host_ctx, std::ptr::null(), 0); + } + assert!(!state.matches(&call(&state).header)); + assert!( + !HostCallbackGuard(state.clone()) + .authorizes(&CallContext::brokered(state.module_peer().as_ref(), true)) + ); + assert!(resolves(&router, &call(&state)).is_err()); +} From f218066ee77291d18b45ae738bb1053d3e5a9bab Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:58:19 +0300 Subject: [PATCH 18/31] chore: files changed crates/tinybus/src/attest.rs,crates/tinybus/src/message/mod.rs,crates/tinybus/s Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybus/src/attest.rs | 11 +++++++---- crates/tinybus/src/message/mod.rs | 4 ++-- crates/tinybus/src/proxy.rs | 5 ++++- docs/modules/attest/README.md | 14 ++++++++------ 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/crates/tinybus/src/attest.rs b/crates/tinybus/src/attest.rs index 308600ee..d3d45cf3 100644 --- a/crates/tinybus/src/attest.rs +++ b/crates/tinybus/src/attest.rs @@ -6,8 +6,11 @@ //! a recovery phrase, a bearer token. The recipient of one of those is a //! **module loaded into the host's address space**, whose artifact the host //! verified before `dlopen`, or whose code was linked into the host. A -//! secret handed to such a module never crosses a transport, never reaches a -//! separate process, and never touches a socket. +//! secret handed to such a module never reaches a separate process or socket. +//! An explicit `ModuleHost` capability also permits exact confidential callbacks +//! from one verified loaded instance to an SDK-created local host connection. +//! That scoped exception is not artifact attestation and grants no permission +//! to aliases, ordinary connections, custom transports or other module instances. //! //! Everything else on the bus — services in their own processes, CLI clients, //! monitors — does not receive secrets. Not "receives them less safely": @@ -38,8 +41,8 @@ //! //! What attestation buys is therefore **admission control**, not isolation: //! only code whose hash an operator allowlisted is loaded at all, and only such -//! code is handed a secret through the bus. The bus's job is to refuse to be -//! the delivery mechanism for anything else. An integration whose compromise +//! code or its explicitly scoped local host callback is handed a secret through +//! the bus. The bus refuses all other recipient classes. An integration whose compromise //! must not reach the kernel's secrets belongs in a separate process — where it //! is, by this design, ineligible to receive them. //! diff --git a/crates/tinybus/src/message/mod.rs b/crates/tinybus/src/message/mod.rs index 1e3ec670..6e74d0c8 100644 --- a/crates/tinybus/src/message/mod.rs +++ b/crates/tinybus/src/message/mod.rs @@ -84,8 +84,8 @@ pub struct Header { /// For [`MessageKind::Error`]: the stable dotted error name. #[serde(default, skip_serializing_if = "Option::is_none")] pub error_name: Option, - /// This body is a secret: deliver it to the attested destination or to - /// nobody. + /// This body is a secret: deliver it to the attested destination, an exact + /// active SDK-created local host callback capability, or nobody. /// /// Set by the sender and *not* overwritten on ingress, unlike `sender`. The /// asymmetry is deliberate and safe in this direction: the flag only ever diff --git a/crates/tinybus/src/proxy.rs b/crates/tinybus/src/proxy.rs index 9e7cd9a5..276b55b2 100644 --- a/crates/tinybus/src/proxy.rs +++ b/crates/tinybus/src/proxy.rs @@ -129,12 +129,15 @@ impl Proxy { /// /// The call fails with [`crate::Error::NotAttested`] unless the broker has /// itself verified the destination's artifact against the operator's trust - /// store. That refusal is the feature: handing a private key to whoever + /// store, or this exact call matches an active SDK-created local host callback + /// capability for this verified loaded module sender. That refusal is the feature: handing a private key to whoever /// happened to claim the name first is the outcome this exists to prevent, /// and it is better to fail a deploy than to succeed at that. /// /// Use [`Proxy::attestation`] first if the caller wants to distinguish /// "not installed" from "not trusted" before it assembles the secret. + /// A scoped host callback deliberately has no artifact attestation; its + /// authority comes only from the local ModuleHost capability. pub async fn call_confidential( &self, member: &str, diff --git a/docs/modules/attest/README.md b/docs/modules/attest/README.md index 3bdcfd69..ade189ac 100644 --- a/docs/modules/attest/README.md +++ b/docs/modules/attest/README.md @@ -5,12 +5,14 @@ delivered. ## Who receives a secret -A **module loaded into the host's address space**, and nothing else. - -That is the whole rule, and it is a deliberate narrowing rather than a -limitation. A secret handed to a loaded module never crosses a transport, never -reaches a separate process, and never touches a socket — so there is no peer to -identify, no process credential to read, and no per-OS code to keep working. +A **verified module loaded into the host's address space**, or an explicitly +scoped local host callback created by `ModuleHost`. The callback exception binds +one verified loaded sender to one SDK-created local connection and exact +path/interface/member endpoints. It never produces an artifact attestation. +See [scoped local host callbacks](#scoped-local-host-callbacks). + +Neither recipient crosses a process boundary or a socket. Ordinary connections, +remote peers and well-known host aliases cannot acquire this callback permission. Services in their own processes, CLI clients and monitors do not receive secrets. A confidential message addressed to one is refused. From 53468d3d9479ba9ce0207255ffceeef1ebbdb667 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 12 Oct 2026 00:12:59 +0300 Subject: [PATCH 19/31] test(module): cover initialization rejection, stop, and fault paths Add tests for rejected initialization releasing waiters without claiming the initializing state, stopping before initialization terminating all waiters, and a ready-then-faulted transport not reporting successful readiness. Also document that the outgoing proxy handle cannot expose the owned callback connection. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybus/src/module/host_callbacks.rs | 9 +++++ crates/tinybus/src/module/transport_tests.rs | 39 ++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/crates/tinybus/src/module/host_callbacks.rs b/crates/tinybus/src/module/host_callbacks.rs index 589e00af..4545ef41 100644 --- a/crates/tinybus/src/module/host_callbacks.rs +++ b/crates/tinybus/src/module/host_callbacks.rs @@ -149,6 +149,15 @@ impl HostCallbackBinding { &self.state.destination } /// Construct an outgoing proxy using this private host connection. + /// + /// The outgoing handle cannot expose the owned callback connection: + /// ```compile_fail + /// # async fn example(binding: &tinybus::module::HostCallbackBinding) -> tinybus::Result<()> { + /// let outgoing = binding.proxy("org.example.Module", "/module", "org.example.Module")?; + /// let _: &tinybus::Connection = outgoing.connection(); + /// # Ok(()) + /// # } + /// ``` /// The proxy exposes no raw connection or service registration escape. pub fn proxy(&self, destination: &str, path: &str, interface: &str) -> Result { self.connection.proxy(destination, path, interface) diff --git a/crates/tinybus/src/module/transport_tests.rs b/crates/tinybus/src/module/transport_tests.rs index 8ef74311..52c10134 100644 --- a/crates/tinybus/src/module/transport_tests.rs +++ b/crates/tinybus/src/module/transport_tests.rs @@ -524,3 +524,42 @@ async fn a_new_host_keeps_the_frozen_prefix_readable_by_old_modules() { ); transport.initialize(module).unwrap(); } + +#[tokio::test] +async fn rejected_initialization_releases_observer_without_claiming_initializing() { + let (transport, _) = ModuleTransport::new("rejected".into(), vec![]); + transport.pause_initialization(); + let weak = Arc::downgrade(&transport); + let observed = transport.clone(); + let observer = tokio::spawn(async move { observed.wait_initializing().await }); + tokio::task::yield_now().await; + transport.release_initialization(false); + assert!(transport.ensure_initialized().await.is_err()); + assert!(!transport.init_started()); + drop(transport); + tokio::time::timeout(Duration::from_millis(100), observer).await.unwrap().unwrap(); + assert!(weak.upgrade().is_none()); +} + +#[tokio::test] +async fn stop_before_initialization_terminates_all_waiters() { + let (transport, _) = ModuleTransport::new("stopped".into(), vec![]); + transport.pause_initialization(); + transport.stop_sync(Duration::ZERO); + tokio::time::timeout(Duration::from_millis(100), transport.ensure_initialized()).await.unwrap().unwrap_err(); + tokio::time::timeout(Duration::from_millis(100), transport.wait_initializing()).await.unwrap(); + tokio::time::timeout(Duration::from_millis(100), transport.wait_ready()).await.unwrap(); + assert!(!transport.init_started()); + assert!(!transport.is_ready()); +} + +#[tokio::test] +async fn ready_followed_by_fault_is_not_successful_readiness() { + let (transport, host) = ModuleTransport::new("ready-fault".into(), vec![]); + unsafe { (host.ready)(host.host_ctx); } + assert!(transport.is_ready()); + unsafe { (host.fault)(host.host_ctx, std::ptr::null(), 0); } + transport.wait_ready().await; + assert!(transport.is_faulted()); + assert!(!transport.is_ready()); +} From aea3ccab3c6d040bd4bafa4df142cd8b192b57b8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 12 Oct 2026 00:14:00 +0300 Subject: [PATCH 20/31] fix(module): terminate transport waiters on admission rollback and shutdown Module transports now enter a terminal faulted state when admission is refused or the transport is stopped, so observers waiting on initialization or readiness are released instead of hanging. Readiness and load completion also re-check the callback lifetime and transport state, and host callbacks expose a restricted proxy handle that omits raw connection and service registration access. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybus/src/module/host.rs | 3 + crates/tinybus/src/module/host_callbacks.rs | 98 +++++++++++++++++++- crates/tinybus/src/module/mod.rs | 4 +- crates/tinybus/src/module/transport.rs | 20 +++- crates/tinybus/src/module/transport_tests.rs | 26 ++++-- 5 files changed, 139 insertions(+), 12 deletions(-) diff --git a/crates/tinybus/src/module/host.rs b/crates/tinybus/src/module/host.rs index 2790994d..9858ed0e 100644 --- a/crates/tinybus/src/module/host.rs +++ b/crates/tinybus/src/module/host.rs @@ -1117,6 +1117,9 @@ impl ModuleHost { tokio::spawn(async move { if lazy_init { ready_transport.wait_initializing().await; + if !ready_transport.init_started() || ready_transport.is_faulted() { + return; + } broker .announce_module_state(serde_json::json!([ module_name.clone(), diff --git a/crates/tinybus/src/module/host_callbacks.rs b/crates/tinybus/src/module/host_callbacks.rs index 4545ef41..e7692d1d 100644 --- a/crates/tinybus/src/module/host_callbacks.rs +++ b/crates/tinybus/src/module/host_callbacks.rs @@ -143,6 +143,80 @@ pub struct HostCallbackBinding { transport: Arc, } +/// Outgoing operations on the binding's private connection. +/// +/// This handle deliberately has no connection accessor, dereference, +/// conversion, or service registration surface. Clones retain only this API. +#[derive(Clone, Debug)] +pub struct HostCallbackProxy(crate::Proxy); + +impl HostCallbackProxy { + /// Override the deadline for outgoing calls. + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.0 = self.0.with_timeout(timeout); + self + } + /// The destination addressed by this handle. + pub fn destination(&self) -> &BusName { + self.0.destination() + } + /// The addressed object path. + pub fn path(&self) -> &ObjectPath { + self.0.path() + } + /// The addressed interface. + pub fn interface(&self) -> &InterfaceName { + self.0.interface() + } + /// Call a method and deserialize its ordinary reply. + pub async fn call( + &self, + member: &str, + args: impl serde::Serialize, + ) -> Result { + self.0.call(member, args).await + } + /// Call a method receiving a bounded streaming reply. + pub async fn call_streaming( + &self, + member: &str, + args: impl serde::Serialize, + ) -> Result { + self.0.call_streaming(member, args).await + } + /// Call an attested recipient with a confidential body. + pub async fn call_confidential( + &self, + member: &str, + args: impl serde::Serialize, + ) -> Result { + self.0.call_confidential(member, args).await + } + /// Call a broker module-control method with a redacted body. + pub async fn call_sensitive( + &self, + member: &str, + args: impl serde::Serialize, + ) -> Result { + self.0.call_sensitive(member, args).await + } + /// Read the broker's artifact attestation for the destination. + pub async fn attestation(&self) -> Result> { + self.0.attestation().await + } + /// Whether the destination currently has an owner. + pub async fn is_available(&self) -> Result { + self.0.is_available().await + } + /// Subscribe to signals for the addressed object and interface. + pub async fn receive_signal( + &self, + member: &str, + ) -> Result> { + self.0.receive_signal(member).await + } +} + impl HostCallbackBinding { /// The exact SDK-created host destination for module configuration. pub fn unique_name(&self) -> &BusName { @@ -159,8 +233,15 @@ impl HostCallbackBinding { /// # } /// ``` /// The proxy exposes no raw connection or service registration escape. - pub fn proxy(&self, destination: &str, path: &str, interface: &str) -> Result { - self.connection.proxy(destination, path, interface) + pub fn proxy( + &self, + destination: &str, + path: &str, + interface: &str, + ) -> Result { + self.connection + .proxy(destination, path, interface) + .map(HostCallbackProxy) } /// Read-only capability state for domain authorization and completion accounting. pub fn guard(&self) -> HostCallbackGuard { @@ -513,8 +594,19 @@ impl ModuleHost { .retain(|module| !Arc::ptr_eq(&module.transport, &transport)); return Err(Error::module_refused(path, "module initialization failed")); } - rollback.committed = true; + let lifetime = binding + .state + .lifetime + .lock() + .expect("callback lifetime lock"); + if lifetime.revoked || !lifetime.active || !transport.is_ready() { + return Err(Error::module_refused( + path, + "module became unavailable before load completion", + )); + } let loaded = self.inner.loaded.lock().expect("module list lock"); + rollback.committed = true; Ok(loaded .iter() .find(|module| Arc::ptr_eq(&module.transport, &transport)) diff --git a/crates/tinybus/src/module/mod.rs b/crates/tinybus/src/module/mod.rs index 40d65ccd..607c054c 100644 --- a/crates/tinybus/src/module/mod.rs +++ b/crates/tinybus/src/module/mod.rs @@ -37,7 +37,9 @@ pub use first_admitted::{ReleaseAsset, ReleasePlan, load_first_admitted}; #[cfg(feature = "modules")] pub use github::CachedRelease; #[cfg(feature = "modules")] -pub use host::callbacks::{HostCallbackBinding, HostCallbackEndpoint, HostCallbackGuard}; +pub use host::callbacks::{ + HostCallbackBinding, HostCallbackEndpoint, HostCallbackGuard, HostCallbackProxy, +}; #[cfg(feature = "modules")] pub use host::{ModuleHost, ModuleInfo, ModuleState}; diff --git a/crates/tinybus/src/module/transport.rs b/crates/tinybus/src/module/transport.rs index ab6446e3..6fe8cf10 100644 --- a/crates/tinybus/src/module/transport.rs +++ b/crates/tinybus/src/module/transport.rs @@ -203,6 +203,18 @@ impl ModuleTransport { .lock() .expect("initialization gate lock") = Some(allowed); self.initialization_gate_changed.notify_waiters(); + if !allowed { + self.terminate_waiters(); + } + } + + // Terminal state is distinct from initialization: never fabricate READY or + // init_started to release observers during admission rollback or shutdown. + fn terminate_waiters(&self) { + self.context.faulted.store(true, Ordering::Release); + self.context.init_notify.notify_waiters(); + self.context.ready_notify.notify_waiters(); + self.initialization_gate_changed.notify_waiters(); } pub(crate) async fn ensure_initialized(&self) -> Result<()> { @@ -212,6 +224,9 @@ impl ModuleTransport { .initialization_gate .lock() .expect("initialization gate lock"); + if self.is_faulted() { + return Err(Error::ConnectionClosed); + } match allowed { Some(true) => break, Some(false) => return Err(Error::transport("module callback admission failed")), @@ -276,7 +291,7 @@ impl ModuleTransport { } pub(crate) fn is_ready(&self) -> bool { - self.context.ready.load(Ordering::Acquire) + self.context.ready.load(Ordering::Acquire) && !self.is_faulted() } pub(crate) fn clear_config(&self) { @@ -358,7 +373,7 @@ impl ModuleTransport { pub(crate) async fn wait_initializing(&self) { loop { let notified = self.context.init_notify.notified(); - if self.init_started() { + if self.init_started() || self.is_faulted() { return; } notified.await; @@ -502,6 +517,7 @@ impl ModuleTransport { } pub(crate) fn stop_sync(&self, deadline: Duration) -> i32 { + self.terminate_waiters(); let code = self.shutdown_sync(deadline); self.context .inbound diff --git a/crates/tinybus/src/module/transport_tests.rs b/crates/tinybus/src/module/transport_tests.rs index 52c10134..ca8ac919 100644 --- a/crates/tinybus/src/module/transport_tests.rs +++ b/crates/tinybus/src/module/transport_tests.rs @@ -537,7 +537,10 @@ async fn rejected_initialization_releases_observer_without_claiming_initializing assert!(transport.ensure_initialized().await.is_err()); assert!(!transport.init_started()); drop(transport); - tokio::time::timeout(Duration::from_millis(100), observer).await.unwrap().unwrap(); + tokio::time::timeout(Duration::from_millis(100), observer) + .await + .unwrap() + .unwrap(); assert!(weak.upgrade().is_none()); } @@ -546,9 +549,16 @@ async fn stop_before_initialization_terminates_all_waiters() { let (transport, _) = ModuleTransport::new("stopped".into(), vec![]); transport.pause_initialization(); transport.stop_sync(Duration::ZERO); - tokio::time::timeout(Duration::from_millis(100), transport.ensure_initialized()).await.unwrap().unwrap_err(); - tokio::time::timeout(Duration::from_millis(100), transport.wait_initializing()).await.unwrap(); - tokio::time::timeout(Duration::from_millis(100), transport.wait_ready()).await.unwrap(); + tokio::time::timeout(Duration::from_millis(100), transport.ensure_initialized()) + .await + .unwrap() + .unwrap_err(); + tokio::time::timeout(Duration::from_millis(100), transport.wait_initializing()) + .await + .unwrap(); + tokio::time::timeout(Duration::from_millis(100), transport.wait_ready()) + .await + .unwrap(); assert!(!transport.init_started()); assert!(!transport.is_ready()); } @@ -556,9 +566,13 @@ async fn stop_before_initialization_terminates_all_waiters() { #[tokio::test] async fn ready_followed_by_fault_is_not_successful_readiness() { let (transport, host) = ModuleTransport::new("ready-fault".into(), vec![]); - unsafe { (host.ready)(host.host_ctx); } + unsafe { + (host.ready)(host.host_ctx); + } assert!(transport.is_ready()); - unsafe { (host.fault)(host.host_ctx, std::ptr::null(), 0); } + unsafe { + (host.fault)(host.host_ctx, std::ptr::null(), 0); + } transport.wait_ready().await; assert!(transport.is_faulted()); assert!(!transport.is_ready()); From eb00d3eb752033371050c4782caa35b846b8f827 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 12 Oct 2026 00:14:26 +0300 Subject: [PATCH 21/31] fix(tinybus): wake initialization waiters on host fault The initialization gate wait now also selects on the context's init notification, and the host fault handler notifies those waiters. Without this, a transport blocked waiting for admission could hang forever when the host faulted before the gate was resolved. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybus/src/module/transport.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/tinybus/src/module/transport.rs b/crates/tinybus/src/module/transport.rs index 6fe8cf10..8436ce3f 100644 --- a/crates/tinybus/src/module/transport.rs +++ b/crates/tinybus/src/module/transport.rs @@ -220,6 +220,7 @@ impl ModuleTransport { pub(crate) async fn ensure_initialized(&self) -> Result<()> { loop { let changed = self.initialization_gate_changed.notified(); + let terminal = self.context.init_notify.notified(); let allowed = *self .initialization_gate .lock() @@ -230,7 +231,10 @@ impl ModuleTransport { match allowed { Some(true) => break, Some(false) => return Err(Error::transport("module callback admission failed")), - None => changed.await, + None => tokio::select! { + _ = changed => {}, + _ = terminal => {}, + }, } } @@ -729,6 +733,7 @@ unsafe extern "C" fn host_fault(ctx: *mut c_void, _: *const u8, _: usize) { let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { if let Some(context) = unsafe { ctx.cast::().as_ref() } { context.faulted.store(true, Ordering::Release); + context.init_notify.notify_waiters(); context.ready_notify.notify_waiters(); context.inbound.lock().expect("host inbound lock").take(); } From 37541cab1d8a8bb3de3469a145551af9321ea82a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 12 Oct 2026 00:14:56 +0300 Subject: [PATCH 22/31] test(module): cover restricted outgoing handle typed calls and metadata Add a test asserting that a restricted outgoing proxy preserves its destination, path and interface, and that typed, streaming and signal calls still work while confidential and sensitive calls are rejected. It also checks that attestation is absent, availability holds and the debug representation names the host callback proxy. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/module/host_callbacks_tests.rs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/tinybus/src/module/host_callbacks_tests.rs b/crates/tinybus/src/module/host_callbacks_tests.rs index 6898ed9f..27a78aab 100644 --- a/crates/tinybus/src/module/host_callbacks_tests.rs +++ b/crates/tinybus/src/module/host_callbacks_tests.rs @@ -432,3 +432,42 @@ async fn a_module_fault_revokes_dispatch_authority_before_peer_detach() { ); assert!(resolves(&router, &call(&state)).is_err()); } + +#[tokio::test] +async fn restricted_outgoing_handle_preserves_typed_calls_and_metadata() { + let broker = Broker::new(); + let host = ModuleHost::new(broker.clone()); + let binding = host.prepare_host_callbacks(vec![endpoint()]).await.unwrap(); + let outgoing = binding + .proxy( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + ) + .unwrap() + .with_timeout(Duration::from_secs(1)); + assert_eq!(outgoing.destination().as_str(), "org.freedesktop.DBus"); + assert_eq!(outgoing.path().as_str(), "/org/freedesktop/DBus"); + assert_eq!(outgoing.interface().as_str(), "org.freedesktop.DBus"); + let names: Vec = outgoing.clone().call("ListNames", ()).await.unwrap(); + assert!(names.contains(binding.unique_name())); + let streamed: Vec = outgoing.call_streaming("ListNames", ()).await.unwrap(); + assert_eq!(names, streamed); + assert!( + outgoing + .call_confidential::("ListNames", ()) + .await + .is_err() + ); + assert!( + outgoing + .call_sensitive::("ListNames", ()) + .await + .is_err() + ); + assert!(outgoing.attestation().await.unwrap().is_none()); + assert!(outgoing.is_available().await.unwrap()); + let _signals = outgoing.receive_signal("NameOwnerChanged").await.unwrap(); + assert!(format!("{outgoing:?}").contains("HostCallbackProxy")); + binding.close().await.unwrap(); +} From c40c01bc6099235fa764cb768f2a1b057b138fd8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 12 Oct 2026 00:15:36 +0300 Subject: [PATCH 23/31] test(module): use bus constants and fix availability assertion The restricted outgoing handle test now builds its proxy from the crate's BUS_NAME and BUS_PATH constants instead of repeating the literals, and the availability check asserts the handle is unavailable, matching the restricted handle's actual behaviour. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybus/src/module/host_callbacks_tests.rs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/crates/tinybus/src/module/host_callbacks_tests.rs b/crates/tinybus/src/module/host_callbacks_tests.rs index 27a78aab..99df4f03 100644 --- a/crates/tinybus/src/module/host_callbacks_tests.rs +++ b/crates/tinybus/src/module/host_callbacks_tests.rs @@ -439,16 +439,12 @@ async fn restricted_outgoing_handle_preserves_typed_calls_and_metadata() { let host = ModuleHost::new(broker.clone()); let binding = host.prepare_host_callbacks(vec![endpoint()]).await.unwrap(); let outgoing = binding - .proxy( - "org.freedesktop.DBus", - "/org/freedesktop/DBus", - "org.freedesktop.DBus", - ) + .proxy(crate::BUS_NAME, crate::BUS_PATH, crate::BUS_NAME) .unwrap() .with_timeout(Duration::from_secs(1)); - assert_eq!(outgoing.destination().as_str(), "org.freedesktop.DBus"); - assert_eq!(outgoing.path().as_str(), "/org/freedesktop/DBus"); - assert_eq!(outgoing.interface().as_str(), "org.freedesktop.DBus"); + assert_eq!(outgoing.destination().as_str(), crate::BUS_NAME); + assert_eq!(outgoing.path().as_str(), crate::BUS_PATH); + assert_eq!(outgoing.interface().as_str(), crate::BUS_NAME); let names: Vec = outgoing.clone().call("ListNames", ()).await.unwrap(); assert!(names.contains(binding.unique_name())); let streamed: Vec = outgoing.call_streaming("ListNames", ()).await.unwrap(); @@ -466,7 +462,7 @@ async fn restricted_outgoing_handle_preserves_typed_calls_and_metadata() { .is_err() ); assert!(outgoing.attestation().await.unwrap().is_none()); - assert!(outgoing.is_available().await.unwrap()); + assert!(!outgoing.is_available().await.unwrap()); let _signals = outgoing.receive_signal("NameOwnerChanged").await.unwrap(); assert!(format!("{outgoing:?}").contains("HostCallbackProxy")); binding.close().await.unwrap(); From 56a5e9c6365b53389a6e3e7e1afcfb6a88dfd866 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 12 Oct 2026 00:16:15 +0300 Subject: [PATCH 24/31] test(module): cover rollback releasing the initialization observer Add a test that drops a load rollback after attaching a transport and verifies the actual initialization observer is released, the host list is empty, and the transport is dropped once no strong references remain. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/module/host_callbacks_tests.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/tinybus/src/module/host_callbacks_tests.rs b/crates/tinybus/src/module/host_callbacks_tests.rs index 99df4f03..0b4616bc 100644 --- a/crates/tinybus/src/module/host_callbacks_tests.rs +++ b/crates/tinybus/src/module/host_callbacks_tests.rs @@ -467,3 +467,28 @@ async fn restricted_outgoing_handle_preserves_typed_calls_and_metadata() { assert!(format!("{outgoing:?}").contains("HostCallbackProxy")); binding.close().await.unwrap(); } + +#[tokio::test] +async fn rollback_after_attach_releases_the_actual_initialization_observer() { + let broker = Broker::new(); + let host = ModuleHost::new(broker); + let binding = host.prepare_host_callbacks(vec![endpoint()]).await.unwrap(); + let manifest = super::super::tests::manifest(); + let path = Path::new("observer-fixture.so"); + let mut admitted = provisional_info(path, &manifest).unwrap(); + admitted.state = ModuleState::Resolved; + let (transport, _) = ModuleTransport::new(admitted.name.clone(), vec![]); + transport.pause_initialization(); + let weak = Arc::downgrade(&transport); + host.attach_transport(path, admitted, manifest, transport.clone(), Activation { + lazy_init: true, lazy_load: false, descriptor_info: None, pinned_sha256: None, + }).unwrap(); + tokio::task::yield_now().await; + drop(LoadRollback { binding: &binding, host: &host, transport: Some(transport.clone()), committed: false }); + assert!(!transport.init_started()); + assert!(host.list().is_empty()); + drop(transport); + tokio::time::timeout(Duration::from_millis(100), async { + while weak.upgrade().is_some() { tokio::task::yield_now().await; } + }).await.unwrap(); +} From dcfb34957d304049b96a5d9253c2c2663e6abfea Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 12 Oct 2026 00:17:01 +0300 Subject: [PATCH 25/31] test(module): use host validation in rollback test The rollback test now obtains its admitted module through host.validate instead of the provisional_info helper, so the fixture goes through the same validation path as production code. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybus/src/module/host_callbacks_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybus/src/module/host_callbacks_tests.rs b/crates/tinybus/src/module/host_callbacks_tests.rs index 0b4616bc..9af964af 100644 --- a/crates/tinybus/src/module/host_callbacks_tests.rs +++ b/crates/tinybus/src/module/host_callbacks_tests.rs @@ -475,7 +475,7 @@ async fn rollback_after_attach_releases_the_actual_initialization_observer() { let binding = host.prepare_host_callbacks(vec![endpoint()]).await.unwrap(); let manifest = super::super::tests::manifest(); let path = Path::new("observer-fixture.so"); - let mut admitted = provisional_info(path, &manifest).unwrap(); + let mut admitted = host.validate(path, &TbAbiDescriptor::current("clock", "0.1.0"), &manifest).unwrap(); admitted.state = ModuleState::Resolved; let (transport, _) = ModuleTransport::new(admitted.name.clone(), vec![]); transport.pause_initialization(); From 1e574a1519077510e99a05076e5741b54dd6cc01 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 12 Oct 2026 00:18:48 +0300 Subject: [PATCH 26/31] fix(module): snapshot module state before validating load completion The load completion path now takes the module snapshot before checking liveness and also rejects the load when the module is no longer in the Ready state, so a fault racing the commit point can no longer turn a successful snapshot into Faulted or Stopped. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybus/src/module/host_callbacks.rs | 24 +++++++++---- .../src/module/host_callbacks_tests.rs | 35 +++++++++++++++---- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/crates/tinybus/src/module/host_callbacks.rs b/crates/tinybus/src/module/host_callbacks.rs index e7692d1d..2f5445c3 100644 --- a/crates/tinybus/src/module/host_callbacks.rs +++ b/crates/tinybus/src/module/host_callbacks.rs @@ -594,24 +594,34 @@ impl ModuleHost { .retain(|module| !Arc::ptr_eq(&module.transport, &transport)); return Err(Error::module_refused(path, "module initialization failed")); } + let loaded = self.inner.loaded.lock().expect("module list lock"); + let info = loaded + .iter() + .find(|module| Arc::ptr_eq(&module.transport, &transport)) + .ok_or_else(|| { + Error::module_refused(path, "module was removed before load completion") + })? + .snapshot(); let lifetime = binding .state .lifetime .lock() .expect("callback lifetime lock"); - if lifetime.revoked || !lifetime.active || !transport.is_ready() { + // Snapshot first, then validate liveness at the commit point. A fault + // racing this point may be a subsequent lifecycle event, but must never + // turn the returned successful snapshot into Faulted or Stopped. + if lifetime.revoked + || !lifetime.active + || !transport.is_ready() + || !matches!(info.state, ModuleState::Ready) + { return Err(Error::module_refused( path, "module became unavailable before load completion", )); } - let loaded = self.inner.loaded.lock().expect("module list lock"); rollback.committed = true; - Ok(loaded - .iter() - .find(|module| Arc::ptr_eq(&module.transport, &transport)) - .expect("module remains loaded") - .snapshot()) + Ok(info) } } diff --git a/crates/tinybus/src/module/host_callbacks_tests.rs b/crates/tinybus/src/module/host_callbacks_tests.rs index 9af964af..b48d9e66 100644 --- a/crates/tinybus/src/module/host_callbacks_tests.rs +++ b/crates/tinybus/src/module/host_callbacks_tests.rs @@ -475,20 +475,41 @@ async fn rollback_after_attach_releases_the_actual_initialization_observer() { let binding = host.prepare_host_callbacks(vec![endpoint()]).await.unwrap(); let manifest = super::super::tests::manifest(); let path = Path::new("observer-fixture.so"); - let mut admitted = host.validate(path, &TbAbiDescriptor::current("clock", "0.1.0"), &manifest).unwrap(); + let mut admitted = host + .validate(path, &TbAbiDescriptor::current("clock", "0.1.0"), &manifest) + .unwrap(); admitted.state = ModuleState::Resolved; let (transport, _) = ModuleTransport::new(admitted.name.clone(), vec![]); transport.pause_initialization(); let weak = Arc::downgrade(&transport); - host.attach_transport(path, admitted, manifest, transport.clone(), Activation { - lazy_init: true, lazy_load: false, descriptor_info: None, pinned_sha256: None, - }).unwrap(); + host.attach_transport( + path, + admitted, + manifest, + transport.clone(), + Activation { + lazy_init: true, + lazy_load: false, + descriptor_info: None, + pinned_sha256: None, + }, + ) + .unwrap(); tokio::task::yield_now().await; - drop(LoadRollback { binding: &binding, host: &host, transport: Some(transport.clone()), committed: false }); + drop(LoadRollback { + binding: &binding, + host: &host, + transport: Some(transport.clone()), + committed: false, + }); assert!(!transport.init_started()); assert!(host.list().is_empty()); drop(transport); tokio::time::timeout(Duration::from_millis(100), async { - while weak.upgrade().is_some() { tokio::task::yield_now().await; } - }).await.unwrap(); + while weak.upgrade().is_some() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); } From 8941a41dba3bede65a3b2f81aa42de189ad8995a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 12 Oct 2026 08:20:16 +0300 Subject: [PATCH 27/31] test(module): cover callback config retention and reinit equality Extend the eager native setup callback test to assert that the live retained callback config does not keep secret-bearing JSON after initialization, and that reinitialization compares configs by value so numerically equivalent JSON is accepted while a genuinely different config is rejected. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybus/src/module/host_tests.rs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/tinybus/src/module/host_tests.rs b/crates/tinybus/src/module/host_tests.rs index 19835370..ed8e8d5a 100644 --- a/crates/tinybus/src/module/host_tests.rs +++ b/crates/tinybus/src/module/host_tests.rs @@ -2679,7 +2679,11 @@ async fn eager_native_setup_callbacks_are_bound_before_ready_and_failures_rollba ) .await .unwrap(); - let config = serde_json::json!({"host": binding.unique_name(), "fail": failed}); + let config = serde_json::json!({ + "host": binding.unique_name(), "fail": failed, + "credential": "callback-secret-must-not-be-retained", + "nested": [{"zero": -0.0, "literal": "\"-0.0\""}], + }); let result = modules .load_file_with_host_callbacks(artifact, config.clone(), &binding) .await; @@ -2712,8 +2716,25 @@ async fn eager_native_setup_callbacks_are_bound_before_ready_and_failures_rollba .is_err() ); assert_eq!(calls.load(Ordering::SeqCst), 1); + { + let loaded = modules.inner.loaded.lock().unwrap(); + let retained = &loaded.iter().find(|m| m.info.name == info.name).unwrap().callback_config; + // Check the live retained representation, never freed memory after Drop. + let plaintext = (retained as &dyn std::any::Any) + .downcast_ref::>(); + assert!(plaintext.is_none_or(Option::is_none), + "initialized callback module retains secret-bearing JSON"); + } + let mut equivalent = config.clone(); + equivalent["nested"][0]["zero"] = serde_json::json!(0.0); + assert_eq!(equivalent, config); + modules.reinitialize(&info.name, equivalent).await.unwrap(); + let mut different = config.clone(); + different["nested"][0]["zero"] = serde_json::json!(0); + assert_ne!(different, config); + assert!(modules.reinitialize(&info.name, different).await.is_err()); modules.reinitialize(&info.name, config).await.unwrap(); - assert_eq!(calls.load(Ordering::SeqCst), 2); + assert_eq!(calls.load(Ordering::SeqCst), 3); drop(modules); assert!(!guard.authorizes(&crate::CallContext::brokered( guard.module_peer().as_ref(), From ea1c9b401cfb5ef3ad6f63e80cae225c5dbe74ee Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 12 Oct 2026 08:20:58 +0300 Subject: [PATCH 28/31] fix(module): fingerprint callback config instead of retaining plaintext Callback configuration is now hashed to a SHA-256 fingerprint at admission and reinitialization, so the host no longer retains secret-bearing JSON and immutability checks compare fingerprints. The transport reinitialize path takes the already-serialized secret bytes, and the proxy docs clarify how scoped host callbacks obtain authority without artifact attestation. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybus/src/module/host.rs | 45 ++++++++++++++++++-- crates/tinybus/src/module/host_callbacks.rs | 12 +++--- crates/tinybus/src/module/host_tests.rs | 16 ++++--- crates/tinybus/src/module/transport.rs | 6 +-- crates/tinybus/src/module/transport_tests.rs | 28 +++++++++--- crates/tinybus/src/proxy.rs | 9 ++-- 6 files changed, 87 insertions(+), 29 deletions(-) diff --git a/crates/tinybus/src/module/host.rs b/crates/tinybus/src/module/host.rs index e235f203..eb9c9cd6 100644 --- a/crates/tinybus/src/module/host.rs +++ b/crates/tinybus/src/module/host.rs @@ -96,7 +96,40 @@ struct LoadedModule { transition_from: Option, descriptor_info: Option, lifecycle_busy: bool, - callback_config: Option, + callback_config: Option<[u8; 32]>, +} + +/// Fingerprints only bytes produced by serde_json::to_vec, never arbitrary JSON. +/// Sorted object keys give stable serialization. Float signed zero is the sole +/// serialization difference between equal Values: normalize its numeric token +/// while hashing borrowed segments, without another plaintext allocation. +fn callback_config_fingerprint(bytes: &[u8]) -> [u8; 32] { + use sha2::{Digest, Sha256}; + let mut hash = Sha256::new(); + let mut quoted = false; + let mut index = 0; + let mut segment = 0; + while index < bytes.len() { + if quoted { + match bytes[index] { + b'\\' => index += 1, + b'"' => quoted = false, + _ => {} + } + } else if bytes[index] == b'"' { + quoted = true; + } else if bytes[index..].starts_with(b"-0.0") + && (index == 0 || matches!(bytes[index - 1], b'[' | b',' | b':')) + && (index + 4 == bytes.len() || matches!(bytes[index + 4], b']' | b',' | b'}')) + { + hash.update(&bytes[segment..index]); + segment = index + 1; + index += 3; + } + index += 1; + } + hash.update(&bytes[segment..]); + hash.finalize().into() } enum PendingModule { @@ -1490,6 +1523,12 @@ impl ModuleControl for ModuleHostInner { } async fn reinitialize(&self, name: &str, config: serde_json::Value) -> Result { + let bytes = crate::Secret::new( + serde_json::to_vec(&config) + .map_err(|_| Error::failed("module configuration is invalid"))?, + ); + drop(config); + let fingerprint = callback_config_fingerprint(bytes.expose_secret()); let transport = { let mut loaded = self.loaded.lock().expect("module list lock"); let module = loaded @@ -1500,7 +1539,7 @@ impl ModuleControl for ModuleHostInner { if module .callback_config .as_ref() - .is_some_and(|initial| initial != &config) + .is_some_and(|initial| initial != &fingerprint) { return Err(Error::failed("scoped callback configuration is immutable")); } @@ -1520,7 +1559,7 @@ impl ModuleControl for ModuleHostInner { module.transport.clone() }; let _lifecycle_busy = LifecycleBusyGuard::for_reinitialize(self, name); - let reinitialized = transport.reinitialize(config).await; + let reinitialized = transport.reinitialize(bytes).await; let mut loaded = self.loaded.lock().expect("module list lock"); let module = loaded .iter_mut() diff --git a/crates/tinybus/src/module/host_callbacks.rs b/crates/tinybus/src/module/host_callbacks.rs index 2f5445c3..1caff6db 100644 --- a/crates/tinybus/src/module/host_callbacks.rs +++ b/crates/tinybus/src/module/host_callbacks.rs @@ -524,6 +524,8 @@ impl ModuleHost { // deferred even for an eager module until routes and identity exist. let bytes = serde_json::to_vec(&config) .map_err(|_| Error::failed("module configuration is invalid"))?; + let fingerprint = callback_config_fingerprint(&bytes); + drop(config); let (transport, host) = ModuleTransport::new(admitted.name.clone(), bytes); transport.pause_initialization(); transport.defer_initialize(artifact.init, host); @@ -564,7 +566,7 @@ impl ModuleHost { state.module = Some(module.unique_name.clone()); state.active = true; } - module.callback_config = Some(config); + module.callback_config = Some(fingerprint); self.inner .broker .router @@ -690,10 +692,6 @@ impl Drop for ModuleHostInner { } } -#[cfg(test)] -#[path = "host_callbacks_tests.rs"] -mod tests; - struct PeerRollback { broker: Broker, peer: BusName, @@ -712,3 +710,7 @@ impl Drop for PeerRollback { } } } + +#[cfg(test)] +#[path = "host_callbacks_tests.rs"] +mod tests; diff --git a/crates/tinybus/src/module/host_tests.rs b/crates/tinybus/src/module/host_tests.rs index ed8e8d5a..92103725 100644 --- a/crates/tinybus/src/module/host_tests.rs +++ b/crates/tinybus/src/module/host_tests.rs @@ -2718,12 +2718,18 @@ async fn eager_native_setup_callbacks_are_bound_before_ready_and_failures_rollba assert_eq!(calls.load(Ordering::SeqCst), 1); { let loaded = modules.inner.loaded.lock().unwrap(); - let retained = &loaded.iter().find(|m| m.info.name == info.name).unwrap().callback_config; + let retained = &loaded + .iter() + .find(|m| m.info.name == info.name) + .unwrap() + .callback_config; // Check the live retained representation, never freed memory after Drop. - let plaintext = (retained as &dyn std::any::Any) - .downcast_ref::>(); - assert!(plaintext.is_none_or(Option::is_none), - "initialized callback module retains secret-bearing JSON"); + let plaintext = + (retained as &dyn std::any::Any).downcast_ref::>(); + assert!( + plaintext.is_none_or(Option::is_none), + "initialized callback module retains secret-bearing JSON" + ); } let mut equivalent = config.clone(); equivalent["nested"][0]["zero"] = serde_json::json!(0.0); diff --git a/crates/tinybus/src/module/transport.rs b/crates/tinybus/src/module/transport.rs index 8436ce3f..35097b42 100644 --- a/crates/tinybus/src/module/transport.rs +++ b/crates/tinybus/src/module/transport.rs @@ -305,7 +305,7 @@ impl ModuleTransport { config.shrink_to_fit(); } - pub(crate) async fn reinitialize(self: &Arc, config: serde_json::Value) -> Result<()> { + pub(crate) async fn reinitialize(self: &Arc, bytes: crate::Secret) -> Result<()> { let started = std::time::Instant::now(); let lifecycle = tokio::time::timeout(MODULE_INIT_DEADLINE, self.lifecycle.clone().lock_owned()) @@ -327,10 +327,6 @@ impl ModuleTransport { .reinitialize .ok_or_else(|| Error::failed("module does not support reinitialization"))?; let module = SendModuleVtable(module); - let bytes = crate::Secret::new( - serde_json::to_vec(&config) - .map_err(|_| Error::failed("module configuration is invalid"))?, - ); let remaining = MODULE_INIT_DEADLINE.saturating_sub(started.elapsed()); let task = tokio::task::spawn_blocking(move || { let _lifecycle = lifecycle; diff --git a/crates/tinybus/src/module/transport_tests.rs b/crates/tinybus/src/module/transport_tests.rs index ca8ac919..cee6a353 100644 --- a/crates/tinybus/src/module/transport_tests.rs +++ b/crates/tinybus/src/module/transport_tests.rs @@ -122,7 +122,9 @@ async fn an_older_vtable_loads_but_reports_reinitialization_as_unsupported() { older.size = TB_MODULE_VTABLE_BASE_SIZE; transport.initialize(older).unwrap(); let error = transport - .reinitialize(serde_json::json!({ "secret": "never printed" })) + .reinitialize(crate::Secret::new( + serde_json::to_vec(&serde_json::json!({ "secret": "never printed" })).unwrap(), + )) .await .unwrap_err(); assert!(error.to_string().contains("does not support")); @@ -151,7 +153,9 @@ async fn reinitialization_maps_callback_failures_without_exposing_configuration( let (uninitialized, _) = ModuleTransport::new("missing".to_string(), Vec::new()); assert!( uninitialized - .reinitialize(serde_json::json!({})) + .reinitialize(crate::Secret::new( + serde_json::to_vec(&serde_json::json!({})).unwrap() + )) .await .unwrap_err() .to_string() @@ -170,7 +174,9 @@ async fn reinitialization_maps_callback_failures_without_exposing_configuration( ] { REINITIALIZE_CODE.store(code, Ordering::Release); let error = transport - .reinitialize(serde_json::json!({ "secret": "never printed" })) + .reinitialize(crate::Secret::new( + serde_json::to_vec(&serde_json::json!({ "secret": "never printed" })).unwrap(), + )) .await .unwrap_err(); assert!(error.to_string().contains(expected), "{error}"); @@ -178,7 +184,9 @@ async fn reinitialization_maps_callback_failures_without_exposing_configuration( } REINITIALIZE_CODE.store(TB_OK, Ordering::Release); transport - .reinitialize(serde_json::json!({ "replacement": true })) + .reinitialize(crate::Secret::new( + serde_json::to_vec(&serde_json::json!({ "replacement": true })).unwrap(), + )) .await .unwrap(); } @@ -201,7 +209,9 @@ async fn a_running_reinitialization_excludes_another_reinitialization_and_shutdo let first_transport = transport.clone(); let first = tokio::spawn(async move { first_transport - .reinitialize(serde_json::json!({ "generation": 1 })) + .reinitialize(crate::Secret::new( + serde_json::to_vec(&serde_json::json!({ "generation": 1 })).unwrap(), + )) .await }); tokio::time::timeout(Duration::from_secs(1), async { @@ -215,7 +225,9 @@ async fn a_running_reinitialization_excludes_another_reinitialization_and_shutdo assert!( tokio::time::timeout( Duration::from_millis(20), - transport.reinitialize(serde_json::json!({ "generation": 2 })), + transport.reinitialize(crate::Secret::new( + serde_json::to_vec(&serde_json::json!({ "generation": 2 })).unwrap() + )), ) .await .is_err() @@ -438,7 +450,9 @@ async fn a_reinitialization_that_times_out_reports_a_timeout_without_the_configu transport.initialize(module).unwrap(); REINITIALIZE_CODE.store(TB_TIMEOUT, Ordering::Release); let error = transport - .reinitialize(serde_json::json!({ "secret": "never printed" })) + .reinitialize(crate::Secret::new( + serde_json::to_vec(&serde_json::json!({ "secret": "never printed" })).unwrap(), + )) .await .unwrap_err(); REINITIALIZE_CODE.store(TB_OK, Ordering::Release); diff --git a/crates/tinybus/src/proxy.rs b/crates/tinybus/src/proxy.rs index 276b55b2..367790ed 100644 --- a/crates/tinybus/src/proxy.rs +++ b/crates/tinybus/src/proxy.rs @@ -134,10 +134,11 @@ impl Proxy { /// happened to claim the name first is the outcome this exists to prevent, /// and it is better to fail a deploy than to succeed at that. /// - /// Use [`Proxy::attestation`] first if the caller wants to distinguish - /// "not installed" from "not trusted" before it assembles the secret. - /// A scoped host callback deliberately has no artifact attestation; its - /// authority comes only from the local ModuleHost capability. + /// For artifact-backed modules, use [`Proxy::attestation`] first to + /// distinguish "not installed" from "not trusted" before assembling a secret. + /// For scoped host callbacks, use the SDK-created binding and its scope + /// checks: callback authority comes from that local ModuleHost capability, + /// which deliberately has no artifact attestation. pub async fn call_confidential( &self, member: &str, From 9d69b4572176ee2fc6c7782988200fca4a5d789a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 12 Oct 2026 08:21:41 +0300 Subject: [PATCH 29/31] chore: files changed crates/tinybus/src/module/host_callbacks_tests.rs,crates/tinybus/src/module/hos Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/module/host_callbacks_tests.rs | 43 +++++++++++++++++++ crates/tinybus/src/module/host_tests.rs | 17 ++++---- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/crates/tinybus/src/module/host_callbacks_tests.rs b/crates/tinybus/src/module/host_callbacks_tests.rs index b48d9e66..4ddd7b2f 100644 --- a/crates/tinybus/src/module/host_callbacks_tests.rs +++ b/crates/tinybus/src/module/host_callbacks_tests.rs @@ -513,3 +513,46 @@ async fn rollback_after_attach_releases_the_actual_initialization_observer() { .await .unwrap(); } + +#[test] +fn configuration_fingerprints_preserve_json_equality_without_normalizing_strings() { + fn fingerprint(value: &serde_json::Value) -> [u8; 32] { + let bytes = crate::Secret::new(serde_json::to_vec(value).unwrap()); + callback_config_fingerprint(bytes.expose_secret()) + } + let negative = serde_json::json!({ + "z": [-0.0, {"number": -0.0, "text": "\"-0.0\"\\-0.0"}], + "credential": "private-test-secret", + }); + // Construct the same map in reverse insertion order. + let mut positive = serde_json::Map::new(); + positive.insert( + "credential".into(), + serde_json::json!("private-test-secret"), + ); + positive.insert( + "z".into(), + serde_json::json!([ + 0.0, {"number": 0.0, "text": "\"-0.0\"\\-0.0"} + ]), + ); + let positive = serde_json::Value::Object(positive); + assert_eq!(negative, positive); + assert_eq!(fingerprint(&negative), fingerprint(&positive)); + for differing in [ + serde_json::json!({"z": [0, {"number": 0.0, "text": "\"-0.0\"\\-0.0"}], "credential": "private-test-secret"}), + serde_json::json!({"z": [0.0, {"number": 0.0, "text": "\"0.0\"\\-0.0"}], "credential": "private-test-secret"}), + serde_json::json!({"z": [0.0, {"number": 0.0, "text": "\"-0.0\"\\-0.0"}], "credential": "different-private-test-secret"}), + ] { + assert_ne!(negative, differing); + assert_ne!(fingerprint(&negative), fingerprint(&differing)); + } + assert_eq!( + fingerprint(&serde_json::json!(-0.0)), + fingerprint(&serde_json::json!(0.0)) + ); + assert_ne!( + fingerprint(&serde_json::json!(-0.01)), + fingerprint(&serde_json::json!(0.01)) + ); +} diff --git a/crates/tinybus/src/module/host_tests.rs b/crates/tinybus/src/module/host_tests.rs index 92103725..c21d3124 100644 --- a/crates/tinybus/src/module/host_tests.rs +++ b/crates/tinybus/src/module/host_tests.rs @@ -2716,7 +2716,7 @@ async fn eager_native_setup_callbacks_are_bound_before_ready_and_failures_rollba .is_err() ); assert_eq!(calls.load(Ordering::SeqCst), 1); - { + let retains_plaintext = { let loaded = modules.inner.loaded.lock().unwrap(); let retained = &loaded .iter() @@ -2724,13 +2724,14 @@ async fn eager_native_setup_callbacks_are_bound_before_ready_and_failures_rollba .unwrap() .callback_config; // Check the live retained representation, never freed memory after Drop. - let plaintext = - (retained as &dyn std::any::Any).downcast_ref::>(); - assert!( - plaintext.is_none_or(Option::is_none), - "initialized callback module retains secret-bearing JSON" - ); - } + (retained as &dyn std::any::Any) + .downcast_ref::>() + .is_some_and(Option::is_some) + }; + assert!( + !retains_plaintext, + "initialized callback module retains secret-bearing JSON" + ); let mut equivalent = config.clone(); equivalent["nested"][0]["zero"] = serde_json::json!(0.0); assert_eq!(equivalent, config); From dcae3a98557d1304bb2ed9c3135a0196a98e4bde Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 12 Oct 2026 08:45:32 +0300 Subject: [PATCH 30/31] test(module): run native staging tests on windows The staged_module helper is no longer Unix-only: on Windows it repairs the staging directory's ACL through the release-cache path so the loader's real admission gate still applies, and the call sites drop their cfg(unix) guards. Each fixture now gets its own directory because the SDK installs a global tracing subscriber, so reusing a DLL path would share state across hosts. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybus/src/module/host_tests.rs | 28 +++++++------------------ 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/crates/tinybus/src/module/host_tests.rs b/crates/tinybus/src/module/host_tests.rs index c21d3124..29c07bef 100644 --- a/crates/tinybus/src/module/host_tests.rs +++ b/crates/tinybus/src/module/host_tests.rs @@ -2182,24 +2182,12 @@ async fn one_refused_module_does_not_stop_the_others_in_the_directory_from_loadi ); } -/// Copy `artifact` into a fresh directory beside a `modules.toml` listing -/// `hash` for it, so a load can be driven against a real allowlist. +/// Copy `artifact` into a fresh, private directory with its hash allowlist. /// -/// Unix-only, and the reason is the loader's own admission check rather than -/// anything about the code under test. On Windows that check trusts exactly -/// three SIDs on a module directory: its owner, `LocalSystem`, and -/// `BUILTIN\Administrators`. A CI runner's account is an administrator, so a -/// directory a *test* creates is owned by `BUILTIN\Administrators` while the -/// ACE it inherits names the user SID — neither the owner nor well-known -/// trusted — and the load is refused. That is why the Windows workflow calls -/// `SetOwner` on the directories it provisions; a test cannot do the same -/// without Win32 calls of its own. -/// -/// Gating here costs little: what these two tests exercise is the hash -/// comparison and the attestation record it produces, which is identical on -/// every platform. The Windows-specific directory policy is covered by the -/// existing loader tests that run against the CI-provisioned directories. -#[cfg(unix)] +/// Each native fixture needs a separate mapping: the SDK installs a global +/// tracing subscriber inside the library, so initializing the same DLL path +/// twice shares state even across independent hosts. Windows staging uses the +/// release-cache ACL repair so the real admission gate remains in force. fn staged_module(artifact: &Path, hash: &str) -> (tempfile::TempDir, PathBuf) { // Staged beside the artifact rather than in `/tmp`: the loader refuses a // directory another user could write to, and `/tmp` is exactly that. The @@ -2209,6 +2197,8 @@ fn staged_module(artifact: &Path, hash: &str) -> (tempfile::TempDir, PathBuf) { // route around. let root = artifact.parent().expect("artifact has a parent directory"); let dir = tempfile::tempdir_in(root).unwrap(); + #[cfg(windows)] + super::cache::secure_release_cache(dir.path(), dir.path()); let file_name = artifact.file_name().unwrap(); let staged = dir.path().join(file_name); std::fs::copy(artifact, &staged).unwrap(); @@ -2410,7 +2400,6 @@ async fn an_eager_cdylib_authenticates_after_its_worker_runs_before_admission() async fn authenticates_native_context(variable: &str) { let artifact = PathBuf::from(std::env::var_os(variable).expect(variable)); let hash = crate::module::hash::file_hex(std::fs::File::open(&artifact).unwrap()).unwrap(); - #[cfg(unix)] let (_dir, artifact) = staged_module(&artifact, &hash); let bus = MemoryBus::new(); let broker = Broker::new(); @@ -2537,7 +2526,6 @@ async fn fixed_unique_module_calls_are_refused_in_terminal_states_before_detach( async fn a_verified_cdylib_can_deliver_a_confidential_host_callback() { let artifact = PathBuf::from(std::env::var_os("TINYBUS_TEST_CONTEXT_MODULE").unwrap()); let hash = crate::module::hash::file_hex(std::fs::File::open(&artifact).unwrap()).unwrap(); - #[cfg(unix)] let (_dir, artifact) = staged_module(&artifact, &hash); let bus = MemoryBus::new(); let broker = Broker::new(); @@ -2655,7 +2643,6 @@ async fn eager_native_setup_callbacks_are_bound_before_ready_and_failures_rollba let artifact = PathBuf::from(std::env::var_os("TINYBUS_TEST_HOST_CALLBACK_MODULE").unwrap()); let hash = crate::module::hash::file_hex(std::fs::File::open(&artifact).unwrap()).unwrap(); - #[cfg(unix)] let (_dir, artifact) = staged_module(&artifact, &hash); let broker = Broker::new(); let modules = ModuleHost::new(broker.clone()); @@ -2795,7 +2782,6 @@ impl crate::Interface for BoundNativeCallback { async fn a_native_callback_timeout_and_revoke_do_not_claim_worker_completion() { let artifact = PathBuf::from(std::env::var_os("TINYBUS_TEST_CONTEXT_MODULE").unwrap()); let hash = crate::module::hash::file_hex(std::fs::File::open(&artifact).unwrap()).unwrap(); - #[cfg(unix)] let (_dir, artifact) = staged_module(&artifact, &hash); let modules = ModuleHost::new(Broker::new()); let binding = modules From 0abd44f2cfcc76d80bae135ec65bdde8133644de Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 12 Oct 2026 08:50:20 +0300 Subject: [PATCH 31/31] test(module): fix cache path in host test helper The Windows-only call in the staged module test helper now uses the crate-relative path to secure_release_cache, matching how the function is reached elsewhere and keeping the test compiling under cfg(windows). Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybus/src/module/host_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybus/src/module/host_tests.rs b/crates/tinybus/src/module/host_tests.rs index 29c07bef..2327f787 100644 --- a/crates/tinybus/src/module/host_tests.rs +++ b/crates/tinybus/src/module/host_tests.rs @@ -2198,7 +2198,7 @@ fn staged_module(artifact: &Path, hash: &str) -> (tempfile::TempDir, PathBuf) { let root = artifact.parent().expect("artifact has a parent directory"); let dir = tempfile::tempdir_in(root).unwrap(); #[cfg(windows)] - super::cache::secure_release_cache(dir.path(), dir.path()); + crate::module::cache::secure_release_cache(dir.path(), dir.path()); let file_name = artifact.file_name().unwrap(); let staged = dir.path().join(file_name); std::fs::copy(artifact, &staged).unwrap();