diff --git a/crates/wip-fs-server/README.md b/crates/wip-fs-server/README.md index 15c3e8c..80a9d22 100644 --- a/crates/wip-fs-server/README.md +++ b/crates/wip-fs-server/README.md @@ -6,6 +6,99 @@ keep one object instance per entry. It uses `wip-host` for publication and typed operation dispatch and uses the standard `wip-http` routes, JSON codecs, envelopes, and HTTP status mapping. +## Library assembly and migration + +The existing `ServerConfig`, `WipFsServer::new`, `handle_http`, CLI flags and wire +representation remain compatible. The formerly private projection has become a +public, generic library engine. No package version or publication policy changes +are required. + +Use `default_projection(root, access, limits)` to obtain the default Linux engine +without starting a listener. `projection.view(&principal)` is the effective view; +it owns canonical `observe` and `fetch_interface` and implements the standard +`wip_host::ObjectProvider` typed `call_operation` API. `DefaultPolicy` has the unit +principal `()`; default engines also offer unit-principal convenience methods. + +For an external backend/composition: + +1. Implement `BackendEntry` on a local, cloneable, root-confined stable capability. + Its `WipObject` metadata is public-only; its validator binds identity and the + protected state. Implement `FilesystemBackend` to resolve capabilities, + enumerate bounded children, and execute against an already-resolved entry. +2. Register generated `#[wip_interface::interface]` definitions using the existing + `wip_host::InterfaceRegistry`. `DefaultInterfaces::register` optionally supplies + reusable built-in tokens; using those tokens does not force their publication. +3. Implement `Composition` on a local type. Its local Object wraps the entry + and implements both `WipObject` and the chosen generated traits, avoiding orphan + rules. `PublishedObject::expose` proves each implementation at compile time. + Publication can add, replace, reorder or omit built-ins based on entry metadata + and actual backend capabilities, not just entry kind. `LinuxEntry` implements + bounded `FileRead`, `FileWrite` and `SymlinkInspect` for safe delegation; never + advertise write in read-only mode or link operations on a non-link capability. +4. Assemble `Projection::new(backend, registry, composition, policy, limits)`. + `ProjectionPolicy` supplies an opaque associated `Principal`, hides Objects and + entire hidden subtrees, selects an ordered subset of implemented interfaces, + controls descriptor/operation visibility, and permits or denies execution. +5. An authenticated deployment chooses the principal and `ExecutionContext`. To + use HTTP, construct `HttpAdapter::new(&view, &endpoint, limits)` and invoke + `handle_http_with_context`. This uses the standard `wip-http` binding, not a + filesystem wire profile. Enforce body collection, header, concurrency and + deadline bounds in the deployment's listener; the adapter owns none of these + intake resources. The bundled loopback listener remains a thin default user. + +A complete external-style memory backend, local Object with multiple custom +Operations, typed registration, principal policy and HTTP flow is exercised in +[`tests/extensions.rs`](tests/extensions.rs). It uses no Linux filesystem for its +backend. The public library APIs do not require private types, a fork, an HTTP +listener, a global object registry, or dynamic plugin loading. + +### Extension security contract + +Backend implementations and Operation authors are trusted code, not sandboxed +plugins. `FilesystemBackend::execute` must refresh state/validators **inside** its +transaction or lock before calling the engine callback, retain the same resolved +target through validation and dispatch, and return the committed post-operation +validator. Confirm rollback only after rollback succeeds. Pre-dispatch failures +must never commit; failed started operations without a non-commit guarantee remain +`OperationOutcomeUnknown`. Object implementations must delegate validators to +that entry and declare requirements per `(InterfaceRef, operation)`, rather than +classify all operations by a read/write name. Operations use the scoped entry and +propagated context, not a fresh raw-path resolution; they must check deadlines, +cancellation and resource budgets throughout backend work and report effects +truthfully. POSIX symlinks, identity, atomic mutations and locking cannot be assumed +for other backends. + +The default Linux backend uses advisory exclusive locks on regular files, +refreshes validators after acquiring the lock and holds it through post-dispatch +state observation. This serializes cooperating library calls; it does **not** +promise transactional isolation from unrelated processes ignoring `flock`. +Descriptor-relative pinned handles still prevent replacement from redirecting an +operation to a different file. Linux capabilities only expose bounded methods, +not raw descriptors or backend paths. + +A policy must be stable/deterministic within one effective Worldspace view. +Interface/operation visibility is uniform per registered reference within that +view, so observation, fetch and dispatch use the identical filtered descriptor. +For entry-specific operation sets, register distinct interface definitions and +select them by entry. Different principals may have different effective views. +Changing a policy must establish a new view/reference lifetime (e.g. rebuild the +registry/engine); do not keep validator-free references while changing their +meaning. Execution permission may deny a visible Operation without hiding it. +A published interface is always fetchable by that principal; hidden operations +are omitted (along with their unreachable named types and interface-wide docs) +and guessed calls return `OperationNotFound`, whereas visible denied +operations return `PermissionDenied`. Hidden Objects and descendants return +`NotFound` and are absent from parent observations. Work spent scanning hidden +entries still counts against traversal bounds; resource limits are not a +constant-time/side-channel guarantee. + +Never put absolute paths, hidden metadata, credentials or policy internals in +public `WipObject` metadata, descriptions, validators or normal logs. Validators +must be opaque public tokens, not confidential content. Engine/HTTP failures use +bounded generic diagnostics; arbitrary trusted implementation metadata/logging +cannot be sanitized automatically. Do not log credentials or sensitive panic +payloads from extensions. + ## Security model - The effective root is opened once as a directory capability. Root path diff --git a/crates/wip-fs-server/tests/extensions.rs b/crates/wip-fs-server/tests/extensions.rs new file mode 100644 index 0000000..8fbdc0e --- /dev/null +++ b/crates/wip-fs-server/tests/extensions.rs @@ -0,0 +1,1863 @@ +//! External-crate contract tests: no Linux backend, private modules, or listener. +use std::collections::BTreeMap; +use std::sync::{Arc, Barrier, Mutex, TryLockError}; +use std::time::{Duration, Instant}; + +use wip_fs_server::{ + BackendEntry, Composition, DefaultInterfaces, EntryKind, FileRead, FilesystemBackend, + HttpAdapter, Projection, ProjectionPolicy, ResourceLimits, +}; +use wip_host::{ + CancellationToken, EffectGuarantee, ExecutionContext, HostError, InterfaceRef, + InterfaceRegistry, ObjectProvider, OperationAttempt, ProviderResult, PublishedObject, + RegisteredInterface, Validator, WipObject, +}; +use wip_http::{ + DecodedResponse, Endpoint, decode_call_operation_response, decode_fetch_interface_response, + decode_observe_response, encode_call_operation_request, encode_fetch_interface_request, + encode_observe_request, +}; +use wip_interface as wip; +use wip_protocol::{ + CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, FetchInterfaceResponse, + InterfaceDescriptor, InterfaceTarget, ObjectObservation, ObserveRequest, ProtocolErrorCode, + Target, Value, +}; + +const BACKEND_SECRET: &str = "/private/backend/credentials.key"; +const POLICY_SECRET: &str = "policy-secret-tenant-42"; + +/// Application-defined capabilities, independent of built-in filesystem traits. +#[wip::interface] +pub trait MemoryOperations { + /// Read through the application capability. + /// # Returns + /// Current bytes. + fn read(&self) -> wip::OperationResult; + /// Change the pinned target. + /// # Returns + /// Unit after changing the data. + fn change( + &self, + /// New data. + contents: wip::Bytes, + ) -> wip::OperationResult<()>; + /// A principal-hidden operation. + /// # Returns + /// Unit. + fn hidden(&self) -> wip::OperationResult<()>; + /// A visible but principal-denied operation. + /// # Returns + /// Unit. + fn denied(&self) -> wip::OperationResult<()>; + /// Inject a typed backend failure with an explicit effect guarantee. + /// # Returns + /// Unit on success (this test implementation always fails). + fn fail( + &self, + /// Failure category and effect disposition. + mode: u64, + ) -> wip::OperationResult<()>; +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Data { + bytes: Vec, + revision: u64, + hidden: bool, +} +struct Node { + id: u64, + kind: EntryKind, + data: Mutex, + calls: Mutex>, +} +#[derive(Clone)] +struct Entry { + path: String, + node: Arc, + validator: Validator, +} +impl Entry { + fn snapshot(path: String, node: Arc) -> Self { + let revision = node.data.lock().unwrap().revision; + let validator = Validator::new(format!("{}:{revision}", node.id).into_bytes()); + Self { + path, + node, + validator, + } + } +} +impl WipObject for Entry { + fn name(&self) -> &str { + self.path.rsplit('/').next().unwrap() + } + fn description(&self) -> Option<&str> { + Some("public memory entry") + } + fn validator(&self) -> Option<&Validator> { + Some(&self.validator) + } +} +impl BackendEntry for Entry { + fn kind(&self) -> EntryKind { + self.node.kind + } +} + +#[derive(Debug)] +struct Seen { + stage: &'static str, + path: String, + deadline: Option, + budget: Option, + token: CancellationToken, +} +#[derive(Default)] +struct Controls { + bump_before_execute: bool, + hide_before_execute: bool, + replace_mapping: bool, + rollback: bool, + cancel_on_execute: bool, + cancel_on_publish: bool, + resolve_error: bool, + children_error: bool, + publish_error: bool, + execute_error: Option, +} +struct Shared { + nodes: Mutex>>, + // Participating memory writers serialize validation, dispatch and disposition. + // Deliberate out-of-band mutation below simulates the documented writer limit. + transaction: Mutex<()>, + controls: Mutex, + seen: Mutex>, +} +impl Shared { + fn record(&self, stage: &'static str, path: &str, context: &ExecutionContext) { + self.seen.lock().unwrap().push(Seen { + stage, + path: path.into(), + deadline: context.deadline(), + budget: context.resource_budget(), + token: context.cancellation().clone(), + }); + } + fn node(&self, path: &str) -> Arc { + self.nodes.lock().unwrap()[path].clone() + } +} +#[derive(Clone)] +struct MemoryBackend(Arc); +fn check_context(context: &ExecutionContext) -> Result<(), HostError> { + if context.cancellation().is_cancelled() + || context + .deadline() + .is_some_and(|deadline| Instant::now() >= deadline) + || context.resource_budget() == Some(0) + { + Err(HostError::resource_limit( + BACKEND_SECRET, + EffectGuarantee::NotCommitted, + )) + } else { + Ok(()) + } +} +impl FilesystemBackend for MemoryBackend { + type Entry = Entry; + fn resolve(&self, context: &ExecutionContext, path: &str) -> ProviderResult { + self.0.record("resolve", path, context); + if let Err(error) = check_context(context) { + return ProviderResult::Failure(error); + } + if self.0.controls.lock().unwrap().resolve_error { + return ProviderResult::Failure(HostError::internal( + BACKEND_SECRET, + EffectGuarantee::NotCommitted, + )); + } + // Map lookup only: there is no host path to reopen or escape. + match self.0.nodes.lock().unwrap().get(path).cloned() { + Some(node) => ProviderResult::Found(Entry::snapshot(path.into(), node)), + None => ProviderResult::NotFound, + } + } + fn children( + &self, + context: &ExecutionContext, + entry: &Entry, + maximum: usize, + ) -> Result, HostError> { + self.0.record("children", &entry.path, context); + check_context(context)?; + if self.0.controls.lock().unwrap().children_error { + return Err(HostError::internal( + BACKEND_SECRET, + EffectGuarantee::NotCommitted, + )); + } + let prefix = if entry.path == "/" { + "/".into() + } else { + format!("{}/", entry.path) + }; + let nodes = self.0.nodes.lock().unwrap(); + let children: Vec<_> = nodes + .iter() + .filter(|(path, _)| { + path.strip_prefix(&prefix) + .is_some_and(|rest| !rest.is_empty() && !rest.contains('/')) + }) + .map(|(path, node)| Entry::snapshot(path.clone(), node.clone())) + .collect(); + if children.len() > maximum { + return Err(HostError::resource_limit( + BACKEND_SECRET, + EffectGuarantee::NotCommitted, + )); + } + Ok(children) + } + fn execute( + &self, + context: &ExecutionContext, + entry: Entry, + operation: impl FnOnce(Entry) -> OperationAttempt, + ) -> ProviderResult { + let _lock = self.0.transaction.lock().unwrap(); + self.0.record("execute", &entry.path, context); + if let Err(error) = check_context(context) { + return ProviderResult::Failure(error); + } + let mut controls = self.0.controls.lock().unwrap(); + if let Some(guarantee) = controls.execute_error { + return ProviderResult::Failure(HostError::internal(BACKEND_SECRET, guarantee)); + } + if controls.bump_before_execute { + entry.node.data.lock().unwrap().revision += 1; + controls.bump_before_execute = false; + } + if controls.hide_before_execute { + entry.node.data.lock().unwrap().hidden = true; + } + if controls.replace_mapping { + // The callback must still receive the original capability, not this decoy. + self.0.nodes.lock().unwrap().insert( + entry.path.clone(), + Arc::new(Node { + id: 999, + kind: EntryKind::Regular, + data: Mutex::new(Data { + bytes: b"decoy".to_vec(), + revision: 1, + hidden: false, + }), + calls: Mutex::new(vec![]), + }), + ); + controls.replace_mapping = false; + } + if controls.cancel_on_execute { + context.cancellation().cancel(); + } + let rollback = controls.rollback; + drop(controls); + let before = entry.node.data.lock().unwrap().clone(); + let refreshed = Entry::snapshot(entry.path.clone(), entry.node.clone()); + let attempt = operation(refreshed); + let attempt = match attempt { + OperationAttempt::Started(Ok(mut result)) => { + result.validator = Some( + Entry::snapshot(entry.path, entry.node.clone()) + .validator + .as_bytes() + .to_vec(), + ); + OperationAttempt::Started(Ok(result)) + } + failed @ OperationAttempt::Started(Err(_)) if rollback => { + *entry.node.data.lock().unwrap() = before; + // The guarantee is upgraded only after the data is restored. + failed.confirm_rollback() + } + other => other, + }; + ProviderResult::Found(attempt) + } +} + +// Local wrapper avoids orphan rules and implements both external and local traits. +struct Object { + entry: Entry, + custom: InterfaceRef, + shared: Arc, + context: ExecutionContext, +} +impl WipObject for Object { + fn name(&self) -> &str { + self.entry.name() + } + fn description(&self) -> Option<&str> { + self.entry.description() + } + fn validator(&self) -> Option<&Validator> { + self.entry.validator() + } + fn requires_validator(&self, interface: &InterfaceRef, operation: &str) -> bool { + interface == &self.custom && matches!(operation, "read" | "change") + } +} +impl Object { + fn invoked(&self, operation: &'static str) { + assert!( + matches!( + self.shared.transaction.try_lock(), + Err(TryLockError::WouldBlock) + ), + "typed dispatch escaped the backend lock" + ); + self.shared.record("typed", &self.entry.path, &self.context); + self.entry.node.calls.lock().unwrap().push(operation); + } + fn change_data(&self, bytes: Vec) { + let mut data = self.entry.node.data.lock().unwrap(); + data.bytes = bytes; + data.revision += 1; + } +} +impl MemoryOperations for Object { + fn read(&self) -> wip::OperationResult { + self.invoked("custom.read"); + Ok(wip::Bytes( + self.entry.node.data.lock().unwrap().bytes.clone(), + )) + } + fn change(&self, contents: wip::Bytes) -> wip::OperationResult<()> { + self.invoked("custom.change"); + self.change_data(contents.0); + Ok(()) + } + fn hidden(&self) -> wip::OperationResult<()> { + self.invoked("custom.hidden"); + Ok(()) + } + fn denied(&self) -> wip::OperationResult<()> { + self.invoked("custom.denied"); + Ok(()) + } + fn fail(&self, mode: u64) -> wip::OperationResult<()> { + self.invoked("custom.fail"); + let guarantee = if mode < 2 { + EffectGuarantee::NotCommitted + } else { + self.change_data(b"uncertain effects".to_vec()); + EffectGuarantee::Unknown + }; + let diagnostic = format!("{BACKEND_SECRET}: {POLICY_SECRET}"); + Err(if mode.is_multiple_of(2) { + HostError::internal(diagnostic, guarantee) + } else { + HostError::resource_limit(diagnostic, guarantee) + }) + } +} +impl FileRead for Object { + fn read(&self, offset: Option, length: Option) -> wip::OperationResult { + self.invoked("default.read"); + let data = self.entry.node.data.lock().unwrap(); + let start = usize::try_from(offset.unwrap_or(0)) + .unwrap_or(usize::MAX) + .min(data.bytes.len()); + let count = usize::try_from(length.unwrap_or(u64::MAX)).unwrap_or(usize::MAX); + Ok(wip::Bytes( + data.bytes[start..start.saturating_add(count).min(data.bytes.len())].to_vec(), + )) + } +} +struct MemoryComposition { + defaults: DefaultInterfaces, + custom: RegisteredInterface, + shared: Arc, +} +impl Composition for MemoryComposition { + type Object = Object; + fn publish( + &self, + entry: Entry, + context: &ExecutionContext, + ) -> Result, HostError> { + self.shared.record("publish", &entry.path, context); + let controls = self.shared.controls.lock().unwrap(); + if controls.publish_error { + return Err(HostError::internal( + format!("{BACKEND_SECRET}: {POLICY_SECRET}"), + EffectGuarantee::NotCommitted, + )); + } + if controls.cancel_on_publish { + context.cancellation().cancel(); + } + drop(controls); + let path = entry.path.clone(); + let kind = entry.kind(); + let mut publication = PublishedObject::new(Object { + entry, + custom: self.custom.reference().clone(), + shared: self.shared.clone(), + context: context.clone(), + }); + // Entry conditions add, replace, or hide the default capability. + if kind == EntryKind::Regular && !matches!(path.as_str(), "/replacement" | "/metadata") { + publication = publication.expose(&self.defaults.read).unwrap(); + } + if matches!( + path.as_str(), + "/added" | "/replacement" | "/secret/nested/data" + ) { + publication = publication.expose(&self.custom).unwrap(); + } + Ok(publication) + } +} +#[derive(Clone, Copy, Debug)] +enum Principal { + Admin, + Limited, +} +struct Policy { + custom: InterfaceRef, + read: InterfaceRef, + hide_read: bool, + invalid_selection: bool, + panic_authorize: bool, +} +impl ProjectionPolicy for Policy { + type Principal = Principal; + fn object_visible(&self, principal: &Principal, entry: &Entry) -> bool { + !entry.node.data.lock().unwrap().hidden + && (!matches!(principal, Principal::Limited) || entry.path != "/secret") + } + fn interface_visible(&self, principal: &Principal, interface: &InterfaceRef) -> bool { + !(matches!(principal, Principal::Limited) && self.hide_read && interface == &self.read) + } + fn interfaces( + &self, + principal: &Principal, + entry: &Entry, + available: &[InterfaceRef], + ) -> Vec { + if self.invalid_selection && entry.path == "/plain" { + return vec![self.custom.clone()]; + } + let mut selected = available.to_vec(); + if matches!(principal, Principal::Limited) { + selected.reverse(); + } + if matches!(principal, Principal::Limited) && entry.path == "/policy-metadata" { + selected.clear(); + } + selected + } + fn operation_visible( + &self, + principal: &Principal, + interface: &InterfaceRef, + operation: &str, + ) -> bool { + !(matches!(principal, Principal::Limited) + && interface == &self.custom + && operation == "hidden") + } + fn authorize( + &self, + principal: &Principal, + _: &Object, + interface: &InterfaceRef, + operation: &str, + ) -> bool { + if self.panic_authorize { + panic!("{POLICY_SECRET}"); + } + !(matches!(principal, Principal::Limited) + && interface == &self.custom + && operation == "denied") + } +} +#[test] +fn observation_and_fetch_policy_panics_remain_sanitized_read_failures() { + struct PanickingPolicy(bool); + impl ProjectionPolicy for PanickingPolicy { + type Principal = (); + fn object_visible(&self, _: &(), _: &Entry) -> bool { + if self.0 { + panic!("{POLICY_SECRET}"); + } + true + } + fn interface_visible(&self, _: &(), _: &InterfaceRef) -> bool { + if !self.0 { + panic!("{POLICY_SECRET}"); + } + true + } + } + for panic_object in [true, false] { + let fixture = Fixture::new(); + let mut registry = InterfaceRegistry::new(); + let defaults = DefaultInterfaces::register(&mut registry).unwrap(); + let custom = registry.register(MemoryOperationsDefinition, None).unwrap(); + let reference = custom.reference().as_str().to_owned(); + let engine = Projection::new( + MemoryBackend(fixture.shared.clone()), + registry, + MemoryComposition { + defaults, + custom, + shared: fixture.shared.clone(), + }, + PanickingPolicy(panic_object), + fixture.limits, + ); + let view = engine.view(&()); + let error = view + .observe( + &ExecutionContext::new(), + &ObserveRequest { + path: "/added".into(), + depth: 0, + }, + ) + .unwrap_err(); + assert_eq!(error.code, ProtocolErrorCode::Internal); + assert!(!error.message.contains(POLICY_SECRET)); + if !panic_object { + let error = view + .fetch_interface(&FetchInterfaceRequest { + interface: reference, + }) + .unwrap_err(); + assert_eq!(error.code, ProtocolErrorCode::Internal); + assert!(!error.message.contains(POLICY_SECRET)); + } + } +} + +type Engine = Projection; +struct Fixture { + engine: Engine, + shared: Arc, + custom: String, + read: String, + limits: ResourceLimits, +} +impl Fixture { + fn new() -> Self { + Self::configured(false, false, false, None, ResourceLimits::default()) + } + fn configured( + hide_read: bool, + invalid_selection: bool, + panic_authorize: bool, + interface_validator: Option, + limits: ResourceLimits, + ) -> Self { + let mut nodes = BTreeMap::new(); + for (index, (path, kind)) in [ + ("/", EntryKind::Directory), + ("/plain", EntryKind::Regular), + ("/added", EntryKind::Regular), + ("/replacement", EntryKind::Regular), + ("/metadata", EntryKind::Regular), + ("/policy-metadata", EntryKind::Regular), + ("/unsupported", EntryKind::Unsupported), + ("/link", EntryKind::Symlink), + ("/secret", EntryKind::Directory), + ("/secret/nested", EntryKind::Directory), + ("/secret/nested/data", EntryKind::Regular), + ] + .into_iter() + .enumerate() + { + nodes.insert( + path.into(), + Arc::new(Node { + id: index as u64, + kind, + data: Mutex::new(Data { + bytes: b"hello memory".to_vec(), + revision: 1, + hidden: false, + }), + calls: Mutex::new(vec![]), + }), + ); + } + let shared = Arc::new(Shared { + nodes: Mutex::new(nodes), + transaction: Mutex::new(()), + controls: Mutex::new(Controls::default()), + seen: Mutex::new(vec![]), + }); + let mut registry = InterfaceRegistry::new(); + let defaults = DefaultInterfaces::register(&mut registry).unwrap(); + let custom = registry + .register(MemoryOperationsDefinition, interface_validator) + .unwrap(); + let read_ref = defaults.read.reference().clone(); + let custom_ref = custom.reference().clone(); + let policy = Policy { + custom: custom_ref.clone(), + read: read_ref.clone(), + hide_read, + invalid_selection, + panic_authorize, + }; + let composition = MemoryComposition { + defaults, + custom, + shared: shared.clone(), + }; + Self { + engine: Projection::new( + MemoryBackend(shared.clone()), + registry, + composition, + policy, + limits, + ), + shared, + custom: custom_ref.as_str().into(), + read: read_ref.as_str().into(), + limits, + } + } + fn observe(&self, principal: &Principal, path: &str, depth: u32) -> ObjectObservation { + self.engine + .view(principal) + .observe(&ExecutionContext::new(), &observe_request(path, depth)) + .unwrap() + } + fn descriptor(&self, principal: &Principal, reference: &str) -> InterfaceDescriptor { + self.engine + .view(principal) + .fetch_interface(&fetch_request(reference)) + .unwrap() + .descriptor + } + fn request( + &self, + path: &str, + reference: &str, + operation: &str, + validator: Option>, + ) -> CallOperationRequest { + CallOperationRequest { + target: Target { + path: path.into(), + validator, + }, + interface: InterfaceTarget { + reference: reference.into(), + validator: None, + }, + operation: operation.into(), + arguments: BTreeMap::new(), + } + } + fn http_call( + &self, + principal: &Principal, + request: &CallOperationRequest, + descriptor: &InterfaceDescriptor, + context: &ExecutionContext, + ) -> ( + http::Response>, + DecodedResponse, + ) { + let endpoint = Endpoint::parse("https://example.test/extensions").unwrap(); + let view = self.engine.view(principal); + let response = HttpAdapter::new(&view, &endpoint, self.limits).handle_http_with_context( + encode_call_operation_request(&endpoint, request, descriptor, self.limits.wire) + .unwrap(), + context, + ); + let decoded = + decode_call_operation_response(request, descriptor, &response, self.limits.wire) + .unwrap(); + (response, decoded) + } +} +fn observe_request(path: &str, depth: u32) -> ObserveRequest { + ObserveRequest { + path: path.into(), + depth, + } +} +fn fetch_request(reference: &str) -> FetchInterfaceRequest { + FetchInterfaceRequest { + interface: reference.into(), + } +} +fn success(response: DecodedResponse) -> T { + match response { + DecodedResponse::Success(value) => value, + other => panic!("expected success, got {other:?}"), + } +} +fn wire_error(response: DecodedResponse, expected: ProtocolErrorCode) { + match response { + DecodedResponse::ProtocolFailure { + error, + status_mismatch, + } => { + assert_eq!(error.code, expected); + assert!(status_mismatch.is_none(), "noncanonical HTTP error status"); + } + other => panic!("expected {expected:?}, got {other:?}"), + } +} +fn no_secrets(response: &http::Response>) { + let body = std::str::from_utf8(response.body()).unwrap(); + assert!( + !body.contains(BACKEND_SECRET), + "backend path leaked: {body}" + ); + assert!( + !body.contains(POLICY_SECRET), + "policy diagnostic leaked: {body}" + ); + assert!( + response.body().len() < 1024, + "failure diagnostic was not bounded" + ); +} + +#[test] +fn external_composition_adds_replaces_and_hides_default_interfaces_by_entry_condition() { + let fixture = Fixture::new(); + assert!(Arc::ptr_eq(&fixture.engine.backend().0, &fixture.shared)); + for (path, expected) in [ + ("/plain", vec![fixture.read.clone()]), + ("/added", vec![fixture.read.clone(), fixture.custom.clone()]), + ("/replacement", vec![fixture.custom.clone()]), + ("/metadata", vec![]), + ("/unsupported", vec![]), + ("/link", vec![]), + ("/", vec![]), + ] { + let observation = fixture.observe(&Principal::Admin, path, 0); + assert_eq!(observation.object.interfaces, expected, "{path}"); + assert_eq!( + observation.object.description.as_deref(), + Some("public memory entry") + ); + assert!(observation.children.is_none()); + assert!(observation.object.r#ref.is_none()); + for reference in &observation.object.interfaces { + let fetched = fixture + .engine + .view(&Principal::Admin) + .fetch_interface(&fetch_request(reference)) + .unwrap(); + assert_eq!(&fetched.interface, reference); + fetched.descriptor.validate().unwrap(); + } + } + let request = fixture.request("/added", &fixture.read, "read", None); + assert_eq!( + fixture + .engine + .view(&Principal::Admin) + .call_operation(request) + .unwrap() + .result, + Value::Bytes(b"hello memory".to_vec()) + ); + let observation = fixture.observe(&Principal::Admin, "/replacement", 0); + let request = fixture.request( + "/replacement", + &fixture.custom, + "read", + observation.object.validator, + ); + assert_eq!( + fixture + .engine + .view(&Principal::Admin) + .call_operation(request) + .unwrap() + .result, + Value::Bytes(b"hello memory".to_vec()) + ); + let request = fixture.request("/replacement", &fixture.read, "read", None); + assert_eq!( + fixture + .engine + .view(&Principal::Admin) + .call_operation(request) + .unwrap_err() + .protocol_error() + .code, + ProtocolErrorCode::InterfaceMismatch + ); +} + +#[test] +fn principal_views_hide_children_and_guessed_descendants_for_observation_and_calls() { + let fixture = Fixture::new(); + let endpoint = Endpoint::parse("https://example.test/extensions").unwrap(); + let context = ExecutionContext::new(); + for principal in [Principal::Admin, Principal::Limited] { + let view = fixture.engine.view(&principal); + let adapter = HttpAdapter::new(&view, &endpoint, fixture.limits); + let request = observe_request("/", 3); + let response = adapter.handle_http_with_context( + encode_observe_request(&endpoint, &request, fixture.limits.wire).unwrap(), + &context, + ); + let tree = + success(decode_observe_response(&request, &response, fixture.limits.wire).unwrap()); + let secret = tree + .children + .unwrap() + .into_iter() + .find(|child| child.object.name == "secret"); + match principal { + Principal::Admin => assert_eq!( + secret.unwrap().children.unwrap()[0] + .children + .as_ref() + .unwrap()[0] + .object + .name, + "data" + ), + Principal::Limited => assert!(secret.is_none()), + } + } + fixture.shared.seen.lock().unwrap().clear(); + for path in ["/secret", "/secret/nested", "/secret/nested/data"] { + let view = fixture.engine.view(&Principal::Limited); + assert_eq!( + view.observe(&context, &observe_request(path, 0)) + .unwrap_err() + .code, + ProtocolErrorCode::NotFound + ); + assert_eq!( + view.observe_object(path).unwrap_err().protocol_error().code, + ProtocolErrorCode::NotFound + ); + let request = fixture.request(path, &fixture.custom, "denied", None); + let (response, decoded) = fixture.http_call( + &Principal::Limited, + &request, + &fixture.descriptor(&Principal::Admin, &fixture.custom), + &context, + ); + wire_error(decoded, ProtocolErrorCode::NotFound); + no_secrets(&response); + } + assert!( + fixture + .shared + .seen + .lock() + .unwrap() + .iter() + .all(|seen| !seen.path.starts_with("/secret/")), + "hidden descendants were resolved" + ); + assert!( + fixture + .shared + .node("/secret/nested/data") + .calls + .lock() + .unwrap() + .is_empty() + ); + // A principal does not mutate the other principal's descriptor or tree. + assert_eq!( + fixture + .observe(&Principal::Admin, "/secret/nested/data", 0) + .object + .name, + "data" + ); +} + +#[test] +fn projected_descriptor_and_dispatch_distinguish_hidden_missing_and_denied_operations() { + let fixture = Fixture::new(); + let admin = fixture.descriptor(&Principal::Admin, &fixture.custom); + let limited = fixture.descriptor(&Principal::Limited, &fixture.custom); + assert!( + admin + .operations + .iter() + .any(|operation| operation.name == "hidden") + ); + assert!( + !limited + .operations + .iter() + .any(|operation| operation.name == "hidden") + ); + assert!( + limited + .operations + .iter() + .any(|operation| operation.name == "denied") + ); + assert_eq!( + fixture + .observe(&Principal::Limited, "/added", 0) + .object + .interfaces, + vec![fixture.custom.clone(), fixture.read.clone()] + ); + let view = fixture.engine.view(&Principal::Limited); + for (operation, code) in [ + ("hidden", ProtocolErrorCode::OperationNotFound), + ("absent", ProtocolErrorCode::OperationNotFound), + ("denied", ProtocolErrorCode::PermissionDenied), + ] { + let request = fixture.request("/added", &fixture.custom, operation, None); + assert_eq!( + view.call_operation(request.clone()) + .unwrap_err() + .protocol_error() + .code, + code + ); + if operation != "absent" { + // Encode against the full descriptor to simulate a guessed hidden operation. + let (response, decoded) = fixture.http_call( + &Principal::Limited, + &request, + &admin, + &ExecutionContext::new(), + ); + wire_error(decoded, code); + no_secrets(&response); + } + } + assert!( + fixture + .shared + .node("/added") + .calls + .lock() + .unwrap() + .is_empty() + ); + let request = fixture.request("/added", &fixture.custom, "denied", None); + fixture + .engine + .view(&Principal::Admin) + .call_operation(request) + .unwrap(); + assert_eq!( + *fixture.shared.node("/added").calls.lock().unwrap(), + vec!["custom.denied"] + ); + assert_eq!( + fixture.descriptor(&Principal::Admin, &fixture.custom), + admin + ); +} + +#[test] +fn interface_visibility_filters_publication_and_fetch_together() { + let fixture = Fixture::configured(true, false, false, None, ResourceLimits::default()); + let endpoint = Endpoint::parse("https://example.test/extensions").unwrap(); + let view = fixture.engine.view(&Principal::Limited); + let adapter = HttpAdapter::new(&view, &endpoint, fixture.limits); + assert_eq!( + fixture + .observe(&Principal::Limited, "/added", 0) + .object + .interfaces, + vec![fixture.custom.clone()] + ); + assert!( + fixture + .observe(&Principal::Limited, "/plain", 0) + .object + .interfaces + .is_empty() + ); + let tree = fixture.observe(&Principal::Limited, "/", 1); + for child in tree.children.unwrap() { + for reference in child.object.interfaces { + let request = fetch_request(&reference); + let response = adapter.handle_http_with_context( + encode_fetch_interface_request(&endpoint, &request, fixture.limits.wire).unwrap(), + &ExecutionContext::new(), + ); + let fetched: FetchInterfaceResponse = success( + decode_fetch_interface_response(&request, &response, fixture.limits.wire).unwrap(), + ); + assert_eq!(fetched.interface, reference); + } + } + let request = fetch_request(&fixture.read); + let response = adapter.handle_http_with_context( + encode_fetch_interface_request(&endpoint, &request, fixture.limits.wire).unwrap(), + &ExecutionContext::new(), + ); + wire_error( + decode_fetch_interface_response(&request, &response, fixture.limits.wire).unwrap(), + ProtocolErrorCode::InterfaceNotFound, + ); + let request = fixture.request("/added", &fixture.read, "read", None); + assert_eq!( + view.call_operation(request) + .unwrap_err() + .protocol_error() + .code, + ProtocolErrorCode::InterfaceMismatch + ); + assert_eq!( + fixture + .observe(&Principal::Admin, "/added", 0) + .object + .interfaces, + vec![fixture.read.clone(), fixture.custom.clone()] + ); + assert!( + fixture + .engine + .view(&Principal::Admin) + .fetch_interface(&fetch_request(&fixture.read)) + .is_ok() + ); +} + +#[test] +fn entry_specific_policy_selects_a_subset_without_globally_hiding_the_descriptor() { + let fixture = Fixture::new(); + let admin = fixture.observe(&Principal::Admin, "/policy-metadata", 0); + let limited = fixture.observe(&Principal::Limited, "/policy-metadata", 0); + assert_eq!(admin.object.interfaces, vec![fixture.read.clone()]); + assert!(limited.object.interfaces.is_empty()); + assert_eq!(admin.object.validator, limited.object.validator); + let request = fixture.request("/policy-metadata", &fixture.read, "read", None); + let (response, decoded) = fixture.http_call( + &Principal::Limited, + &request, + &fixture.descriptor(&Principal::Limited, &fixture.read), + &ExecutionContext::new(), + ); + wire_error(decoded, ProtocolErrorCode::InterfaceMismatch); + no_secrets(&response); + assert!( + fixture + .shared + .node("/policy-metadata") + .calls + .lock() + .unwrap() + .is_empty() + ); + fixture + .engine + .view(&Principal::Admin) + .call_operation(request) + .unwrap(); + assert_eq!( + *fixture + .shared + .node("/policy-metadata") + .calls + .lock() + .unwrap(), + vec!["default.read"] + ); +} + +#[test] +fn http_observe_fetch_and_call_round_trip_uses_only_client_observed_publications() { + for principal in [Principal::Admin, Principal::Limited] { + let fixture = Fixture::new(); + let context = ExecutionContext::new(); + let endpoint = Endpoint::parse("https://example.test/extensions").unwrap(); + let view = fixture.engine.view(&principal); + let adapter = HttpAdapter::new(&view, &endpoint, fixture.limits); + let observe = observe_request("/added", 0); + let response = adapter.handle_http_with_context( + encode_observe_request(&endpoint, &observe, fixture.limits.wire).unwrap(), + &context, + ); + let observed = + success(decode_observe_response(&observe, &response, fixture.limits.wire).unwrap()) + .object; + let mut custom_descriptor = None; + for reference in observed.interfaces { + let fetch = fetch_request(&reference); + let response = adapter.handle_http_with_context( + encode_fetch_interface_request(&endpoint, &fetch, fixture.limits.wire).unwrap(), + &context, + ); + let fetched = success( + decode_fetch_interface_response(&fetch, &response, fixture.limits.wire).unwrap(), + ); + let application_interface = fetched + .descriptor + .operations + .iter() + .any(|operation| operation.name == "change"); + let mut request = fixture.request( + "/added", + &fetched.interface, + "read", + if application_interface { + observed.validator.clone() + } else { + None + }, + ); + request.interface.validator = fetched.validator; + if !application_interface { + request.arguments.insert("offset".into(), Value::Integer(6)); + request.arguments.insert("length".into(), Value::Integer(6)); + } + let response = adapter.handle_http_with_context( + encode_call_operation_request( + &endpoint, + &request, + &fetched.descriptor, + fixture.limits.wire, + ) + .unwrap(), + &context, + ); + let result = success( + decode_call_operation_response( + &request, + &fetched.descriptor, + &response, + fixture.limits.wire, + ) + .unwrap(), + ); + assert_eq!( + result.result, + Value::Bytes(if application_interface { + b"hello memory".to_vec() + } else { + b"memory".to_vec() + }) + ); + assert_eq!(result.validator, observed.validator); + if application_interface { + assert_eq!( + fetched + .descriptor + .operations + .iter() + .any(|operation| operation.name == "hidden"), + matches!(principal, Principal::Admin) + ); + custom_descriptor = Some((fetched.interface, fetched.descriptor)); + } + } + let (reference, descriptor) = custom_descriptor.unwrap(); + let mut request = + fixture.request("/added", &reference, "change", observed.validator.clone()); + request + .arguments + .insert("contents".into(), Value::Bytes(b"HTTP commit".to_vec())); + let response = adapter.handle_http_with_context( + encode_call_operation_request(&endpoint, &request, &descriptor, fixture.limits.wire) + .unwrap(), + &context, + ); + let result = success( + decode_call_operation_response(&request, &descriptor, &response, fixture.limits.wire) + .unwrap(), + ); + assert_eq!(result.result, Value::Unit); + assert_ne!(result.validator, observed.validator); + let response = adapter.handle_http_with_context( + encode_observe_request(&endpoint, &observe, fixture.limits.wire).unwrap(), + &context, + ); + let committed = + success(decode_observe_response(&observe, &response, fixture.limits.wire).unwrap()); + assert_eq!(result.validator, committed.object.validator); + assert_eq!( + fixture.shared.node("/added").data.lock().unwrap().bytes, + b"HTTP commit" + ); + } +} + +#[test] +fn policy_cannot_add_an_unimplemented_interface() { + let fixture = Fixture::configured(false, true, false, None, ResourceLimits::default()); + let view = fixture.engine.view(&Principal::Admin); + assert_eq!( + view.observe(&ExecutionContext::new(), &observe_request("/plain", 0)) + .unwrap_err() + .code, + ProtocolErrorCode::Internal + ); + let request = fixture.request("/plain", &fixture.custom, "denied", None); + let (response, decoded) = fixture.http_call( + &Principal::Admin, + &request, + &fixture.descriptor(&Principal::Admin, &fixture.custom), + &ExecutionContext::new(), + ); + wire_error(decoded, ProtocolErrorCode::Internal); + no_secrets(&response); + assert!( + fixture + .shared + .node("/plain") + .calls + .lock() + .unwrap() + .is_empty() + ); +} + +#[test] +fn validators_are_required_per_qualified_operation_and_preconditions_precede_permission() { + let fixture = Fixture::new(); + let view = fixture.engine.view(&Principal::Limited); + let validator = fixture + .observe(&Principal::Limited, "/added", 0) + .object + .validator; + // The same operation name needs a validator on only one interface. + view.call_operation(fixture.request("/added", &fixture.read, "read", None)) + .unwrap(); + for operation in ["read", "change"] { + let request = fixture.request("/added", &fixture.custom, operation, None); + assert_eq!( + view.call_operation(request) + .unwrap_err() + .protocol_error() + .code, + ProtocolErrorCode::ValidatorRequired + ); + } + for (operation, supplied, code) in [ + ( + "denied", + Some(b"stale".to_vec()), + ProtocolErrorCode::ValidatorMismatch, + ), + ( + "hidden", + validator.clone(), + ProtocolErrorCode::OperationNotFound, + ), + ( + "denied", + validator.clone(), + ProtocolErrorCode::PermissionDenied, + ), + ] { + let request = fixture.request("/added", &fixture.custom, operation, supplied); + assert_eq!( + view.call_operation(request) + .unwrap_err() + .protocol_error() + .code, + code + ); + } + let mut request = fixture.request("/added", &fixture.custom, "denied", validator.clone()); + request + .arguments + .insert("unexpected".into(), Value::String(POLICY_SECRET.into())); + assert_eq!( + view.call_operation(request) + .unwrap_err() + .protocol_error() + .code, + ProtocolErrorCode::InvalidArguments + ); + view.call_operation(fixture.request("/added", &fixture.custom, "read", validator)) + .unwrap(); + assert_eq!( + *fixture.shared.node("/added").calls.lock().unwrap(), + vec!["default.read", "custom.read"] + ); +} + +#[test] +fn registered_interface_validators_are_independent_call_preconditions() { + let fixture = Fixture::configured( + false, + false, + false, + Some(Validator::new(b"schema-1".to_vec())), + ResourceLimits::default(), + ); + let fetched = fixture + .engine + .view(&Principal::Admin) + .fetch_interface(&fetch_request(&fixture.custom)) + .unwrap(); + assert_eq!(fetched.validator, Some(b"schema-1".to_vec())); + let view = fixture.engine.view(&Principal::Limited); + let mut request = fixture.request("/added", &fixture.custom, "hidden", None); + assert_eq!( + view.call_operation(request.clone()) + .unwrap_err() + .protocol_error() + .code, + ProtocolErrorCode::InterfaceValidatorRequired + ); + request.interface.validator = Some(b"schema-old".to_vec()); + assert_eq!( + view.call_operation(request.clone()) + .unwrap_err() + .protocol_error() + .code, + ProtocolErrorCode::InterfaceValidatorMismatch + ); + request.interface.validator = fetched.validator; + assert_eq!( + view.call_operation(request) + .unwrap_err() + .protocol_error() + .code, + ProtocolErrorCode::OperationNotFound + ); + assert!( + fixture + .shared + .node("/added") + .calls + .lock() + .unwrap() + .is_empty() + ); +} + +#[test] +fn backend_refreshes_validators_under_execution_lock_before_dispatch() { + let fixture = Fixture::new(); + let validator = fixture + .observe(&Principal::Admin, "/added", 0) + .object + .validator; + fixture.shared.controls.lock().unwrap().bump_before_execute = true; + let mut request = fixture.request("/added", &fixture.custom, "change", validator); + request + .arguments + .insert("contents".into(), Value::Bytes(b"should not run".to_vec())); + let (response, decoded) = fixture.http_call( + &Principal::Admin, + &request, + &fixture.descriptor(&Principal::Admin, &fixture.custom), + &ExecutionContext::new(), + ); + wire_error(decoded, ProtocolErrorCode::ValidatorMismatch); + no_secrets(&response); + let node = fixture.shared.node("/added"); + assert!(node.calls.lock().unwrap().is_empty()); + assert_eq!(node.data.lock().unwrap().bytes, b"hello memory"); + assert_ne!( + fixture + .observe(&Principal::Admin, "/added", 0) + .object + .validator, + request.target.validator + ); +} + +#[test] +fn operation_keeps_the_resolved_target_and_returns_its_committed_post_validator() { + let fixture = Fixture::new(); + let original = fixture.shared.node("/added"); + let validator = fixture + .observe(&Principal::Admin, "/added", 0) + .object + .validator; + fixture.shared.controls.lock().unwrap().replace_mapping = true; + let mut request = fixture.request("/added", &fixture.custom, "change", validator.clone()); + request + .arguments + .insert("contents".into(), Value::Bytes(b"committed".to_vec())); + let (_, decoded) = fixture.http_call( + &Principal::Admin, + &request, + &fixture.descriptor(&Principal::Admin, &fixture.custom), + &ExecutionContext::new(), + ); + let result = success(decoded); + assert_eq!(result.result, Value::Unit); + assert_ne!(result.validator, validator); + assert_eq!( + result.validator, + Some( + Entry::snapshot("/added".into(), original.clone()) + .validator + .as_bytes() + .to_vec() + ) + ); + assert_eq!(original.data.lock().unwrap().bytes, b"committed"); + assert_eq!(*original.calls.lock().unwrap(), vec!["custom.change"]); + let decoy = fixture.shared.node("/added"); + assert!(!Arc::ptr_eq(&original, &decoy)); + assert_eq!(decoy.data.lock().unwrap().bytes, b"decoy"); + assert!(decoy.calls.lock().unwrap().is_empty()); +} + +#[test] +fn concurrent_conditional_calls_are_serialized_across_validation_and_commit() { + let fixture = Fixture::new(); + let validator = fixture + .observe(&Principal::Admin, "/added", 0) + .object + .validator; + let barrier = Barrier::new(2); + let results = std::thread::scope(|scope| { + let mut workers = Vec::new(); + for bytes in [b"first".to_vec(), b"second".to_vec()] { + let mut request = + fixture.request("/added", &fixture.custom, "change", validator.clone()); + request + .arguments + .insert("contents".into(), Value::Bytes(bytes)); + let engine = &fixture.engine; + let barrier = &barrier; + workers.push(scope.spawn(move || { + barrier.wait(); + engine.view(&Principal::Admin).call_operation(request) + })); + } + workers + .into_iter() + .map(|worker| worker.join().unwrap()) + .collect::>() + }); + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + assert_eq!( + results + .iter() + .filter_map(|result| result.as_ref().err()) + .next() + .unwrap() + .protocol_error() + .code, + ProtocolErrorCode::ValidatorMismatch + ); + assert_eq!( + *fixture.shared.node("/added").calls.lock().unwrap(), + vec!["custom.change"] + ); + let returned = results.into_iter().find_map(Result::ok).unwrap().validator; + assert_eq!( + returned, + fixture + .observe(&Principal::Admin, "/added", 0) + .object + .validator + ); +} + +#[test] +fn explicit_effect_guarantees_and_confirmed_rollback_control_failure_classification() { + for rollback in [false, true] { + for mode in 0..4 { + let fixture = Fixture::new(); + fixture.shared.controls.lock().unwrap().rollback = rollback; + let before = fixture.shared.node("/added").data.lock().unwrap().clone(); + let before_validator = fixture + .observe(&Principal::Admin, "/added", 0) + .object + .validator; + let mut request = fixture.request("/added", &fixture.custom, "fail", None); + request + .arguments + .insert("mode".into(), Value::Integer(mode)); + let (response, decoded) = fixture.http_call( + &Principal::Admin, + &request, + &fixture.descriptor(&Principal::Admin, &fixture.custom), + &ExecutionContext::new(), + ); + let code = if mode >= 2 && !rollback { + ProtocolErrorCode::OperationOutcomeUnknown + } else if mode % 2 == 0 { + ProtocolErrorCode::Internal + } else { + ProtocolErrorCode::ResourceLimitExceeded + }; + wire_error(decoded, code); + no_secrets(&response); + let after = fixture.shared.node("/added").data.lock().unwrap().clone(); + assert_eq!( + *fixture.shared.node("/added").calls.lock().unwrap(), + vec!["custom.fail"] + ); + if rollback || mode < 2 { + assert_eq!(after, before, "mode={mode}, rollback={rollback}"); + assert_eq!( + fixture + .observe(&Principal::Admin, "/added", 0) + .object + .validator, + before_validator + ); + } else { + assert_eq!(after.bytes, b"uncertain effects"); + assert_ne!(after.revision, before.revision); + } + } + } +} + +#[test] +fn backend_boundary_failures_never_upgrade_unknown_effects() { + for (guarantee, code) in [ + (EffectGuarantee::NotCommitted, ProtocolErrorCode::Internal), + ( + EffectGuarantee::Unknown, + ProtocolErrorCode::OperationOutcomeUnknown, + ), + ] { + let fixture = Fixture::new(); + fixture.shared.controls.lock().unwrap().execute_error = Some(guarantee); + let request = fixture.request("/added", &fixture.custom, "denied", None); + let (response, decoded) = fixture.http_call( + &Principal::Admin, + &request, + &fixture.descriptor(&Principal::Admin, &fixture.custom), + &ExecutionContext::new(), + ); + wire_error(decoded, code); + no_secrets(&response); + assert!( + fixture + .shared + .node("/added") + .calls + .lock() + .unwrap() + .is_empty() + ); + } +} + +#[test] +fn backend_and_composition_secret_diagnostics_are_sanitized_on_observe_and_call() { + for stage in ["resolve", "children", "publish"] { + let fixture = Fixture::new(); + let endpoint = Endpoint::parse("https://example.test/extensions").unwrap(); + { + let mut controls = fixture.shared.controls.lock().unwrap(); + match stage { + "resolve" => controls.resolve_error = true, + "children" => controls.children_error = true, + "publish" => controls.publish_error = true, + _ => unreachable!(), + } + } + let view = fixture.engine.view(&Principal::Admin); + let request = observe_request("/", 1); + assert_eq!( + view.observe(&ExecutionContext::new(), &request) + .unwrap_err() + .code, + ProtocolErrorCode::Internal + ); + let response = HttpAdapter::new(&view, &endpoint, fixture.limits).handle_http_with_context( + encode_observe_request(&endpoint, &request, fixture.limits.wire).unwrap(), + &ExecutionContext::new(), + ); + wire_error( + decode_observe_response(&request, &response, fixture.limits.wire).unwrap(), + ProtocolErrorCode::Internal, + ); + no_secrets(&response); + if stage != "children" { + let request = fixture.request("/added", &fixture.custom, "denied", None); + let (response, decoded) = fixture.http_call( + &Principal::Admin, + &request, + &fixture.descriptor(&Principal::Admin, &fixture.custom), + &ExecutionContext::new(), + ); + wire_error(decoded, ProtocolErrorCode::Internal); + no_secrets(&response); + assert!( + fixture + .shared + .node("/added") + .calls + .lock() + .unwrap() + .is_empty() + ); + } + } +} + +#[test] +fn policy_panics_and_guessed_operation_diagnostics_do_not_leak_to_http() { + let fixture = Fixture::configured(false, false, true, None, ResourceLimits::default()); + let request = fixture.request("/added", &fixture.custom, "denied", None); + let (response, decoded) = fixture.http_call( + &Principal::Limited, + &request, + &fixture.descriptor(&Principal::Admin, &fixture.custom), + &ExecutionContext::new(), + ); + wire_error(decoded, ProtocolErrorCode::OperationOutcomeUnknown); + no_secrets(&response); + assert!( + fixture + .shared + .node("/added") + .calls + .lock() + .unwrap() + .is_empty() + ); + + let fixture = Fixture::new(); + let endpoint = Endpoint::parse("https://example.test/extensions").unwrap(); + let view = fixture.engine.view(&Principal::Limited); + // This valid metadata names a nonexistent operation; bypass client-side + // descriptor validation to exercise server diagnostics for untrusted names. + let body = format!( + r#"{{"target":{{"path":"/added"}},"interface":{{"reference":"{}"}},"operation":"{}","arguments":{{}}}}"#, + fixture.custom, POLICY_SECRET + ); + let request = http::Request::builder() + .method(http::Method::POST) + .uri(endpoint.route(wip_http::Route::CallOperation).as_str()) + .header(http::header::CONTENT_TYPE, wip_http::JSON_CONTENT_TYPE) + .body(body.into_bytes()) + .unwrap(); + let response = HttpAdapter::new(&view, &endpoint, fixture.limits) + .handle_http_with_context(request, &ExecutionContext::new()); + no_secrets(&response); + assert!( + std::str::from_utf8(response.body()) + .unwrap() + .contains("operation_not_found") + ); +} + +#[test] +fn http_context_reaches_resolve_children_publication_boundary_and_typed_operation_unchanged() { + let fixture = Fixture::new(); + let token = CancellationToken::new(); + let deadline = Instant::now() + Duration::from_secs(60); + let context = ExecutionContext::new() + .with_cancellation(token.clone()) + .with_deadline(deadline) + .with_resource_budget(77); + let endpoint = Endpoint::parse("https://example.test/extensions").unwrap(); + let view = fixture.engine.view(&Principal::Admin); + let adapter = HttpAdapter::new(&view, &endpoint, fixture.limits); + let request = observe_request("/", 1); + let response = adapter.handle_http_with_context( + encode_observe_request(&endpoint, &request, fixture.limits.wire).unwrap(), + &context, + ); + success(decode_observe_response(&request, &response, fixture.limits.wire).unwrap()); + let request = fixture.request("/added", &fixture.read, "read", None); + let (_, decoded) = fixture.http_call( + &Principal::Admin, + &request, + &fixture.descriptor(&Principal::Admin, &fixture.read), + &context, + ); + success(decoded); + let seen = fixture.shared.seen.lock().unwrap(); + for stage in ["resolve", "children", "publish", "execute", "typed"] { + assert!( + seen.iter().any(|record| record.stage == stage), + "missing {stage}" + ); + } + for record in seen.iter() { + assert_eq!(record.deadline, Some(deadline), "{}", record.stage); + assert_eq!(record.budget, Some(77), "{}", record.stage); + assert!(!record.token.is_cancelled()); + } + token.cancel(); + assert!( + seen.iter().all(|record| record.token.is_cancelled()), + "a context stage received an unrelated cancellation token" + ); +} + +#[test] +fn cancelled_elapsed_and_empty_budget_requests_reject_without_backend_work() { + for condition in ["cancelled", "deadline", "budget"] { + let fixture = Fixture::new(); + let token = CancellationToken::new(); + let context = match condition { + "cancelled" => { + token.cancel(); + ExecutionContext::new().with_cancellation(token) + } + "deadline" => { + ExecutionContext::new().with_deadline(Instant::now() - Duration::from_secs(1)) + } + "budget" => ExecutionContext::new().with_resource_budget(0), + _ => unreachable!(), + }; + let view = fixture.engine.view(&Principal::Admin); + let request = observe_request("/added", 0); + assert_eq!( + view.observe(&context, &request).unwrap_err().code, + ProtocolErrorCode::ResourceLimitExceeded + ); + let call = fixture.request("/added", &fixture.read, "read", None); + assert_eq!( + view.call_operation_with_context(&context, call.clone()) + .unwrap_err() + .protocol_error() + .code, + ProtocolErrorCode::ResourceLimitExceeded + ); + let endpoint = Endpoint::parse("https://example.test/extensions").unwrap(); + let adapter = HttpAdapter::new(&view, &endpoint, fixture.limits); + let response = adapter.handle_http_with_context( + encode_observe_request(&endpoint, &request, fixture.limits.wire).unwrap(), + &context, + ); + if condition == "deadline" { + assert_eq!(response.status(), http::StatusCode::GATEWAY_TIMEOUT); + assert!(response.body().is_empty()); + } else { + wire_error( + decode_observe_response(&request, &response, fixture.limits.wire).unwrap(), + ProtocolErrorCode::ResourceLimitExceeded, + ); + let (_, decoded) = fixture.http_call( + &Principal::Admin, + &call, + &fixture.descriptor(&Principal::Admin, &fixture.read), + &context, + ); + wire_error(decoded, ProtocolErrorCode::ResourceLimitExceeded); + } + assert!(fixture.shared.seen.lock().unwrap().is_empty()); + assert!( + fixture + .shared + .node("/added") + .calls + .lock() + .unwrap() + .is_empty() + ); + } +} + +#[test] +fn cancellation_or_visibility_change_inside_boundary_prevents_typed_dispatch() { + for condition in ["execute", "publish", "visibility"] { + let fixture = Fixture::new(); + { + let mut controls = fixture.shared.controls.lock().unwrap(); + match condition { + "execute" => controls.cancel_on_execute = true, + "publish" => controls.cancel_on_publish = true, + "visibility" => controls.hide_before_execute = true, + _ => unreachable!(), + } + } + let request = fixture.request("/added", &fixture.custom, "denied", None); + let context = ExecutionContext::new(); + let (response, decoded) = fixture.http_call( + &Principal::Admin, + &request, + &fixture.descriptor(&Principal::Admin, &fixture.custom), + &context, + ); + wire_error( + decoded, + if condition == "visibility" { + ProtocolErrorCode::NotFound + } else { + ProtocolErrorCode::ResourceLimitExceeded + }, + ); + no_secrets(&response); + assert!( + fixture + .shared + .node("/added") + .calls + .lock() + .unwrap() + .is_empty() + ); + if condition != "visibility" { + assert!(context.cancellation().is_cancelled()); + } + } +} + +#[test] +fn observation_depth_node_and_context_budgets_fail_without_truncating_the_tree() { + let limits = ResourceLimits { + max_observation_depth: 1, + max_observation_nodes: 2, + ..ResourceLimits::default() + }; + let fixture = Fixture::configured(false, false, false, None, limits); + let view = fixture.engine.view(&Principal::Admin); + assert_eq!( + view.observe(&ExecutionContext::new(), &observe_request("/", 2)) + .unwrap_err() + .code, + ProtocolErrorCode::ResourceLimitExceeded + ); + assert!( + fixture.shared.seen.lock().unwrap().is_empty(), + "depth limit should precede resolution" + ); + assert_eq!( + view.observe(&ExecutionContext::new(), &observe_request("/", 1)) + .unwrap_err() + .code, + ProtocolErrorCode::ResourceLimitExceeded + ); + assert!( + view.observe(&ExecutionContext::new(), &observe_request("/added", 1)) + .unwrap() + .children + .unwrap() + .is_empty() + ); + let fixture = Fixture::new(); + let view = fixture.engine.view(&Principal::Limited); + let context = ExecutionContext::new().with_resource_budget(2); + assert_eq!( + view.observe(&context, &observe_request("/", 1)) + .unwrap_err() + .code, + ProtocolErrorCode::ResourceLimitExceeded + ); + let endpoint = Endpoint::parse("https://example.test/extensions").unwrap(); + let request = observe_request("/", 1); + let response = HttpAdapter::new(&view, &endpoint, fixture.limits).handle_http_with_context( + encode_observe_request(&endpoint, &request, fixture.limits.wire).unwrap(), + &context, + ); + wire_error( + decode_observe_response(&request, &response, fixture.limits.wire).unwrap(), + ProtocolErrorCode::ResourceLimitExceeded, + ); + no_secrets(&response); +}