From a7186173b40651cba8143e15038ebfab114446b4 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 8 Oct 2026 03:56:17 +0900 Subject: [PATCH] feat(fs): bind external scopes to current execution lineage Add fail-closed Composition lifetime attestation for non-root scopes; preserve pinned deployment-root semantics and principal visibility. Fetch and dispatch bind optional published scope refs inside the refreshed backend boundary. Validation: cargo test -p wip-fs-server (25 unit, 8 HTTP, 31 extension tests and 1 doc-test); Nix clippy for FS and public example with -D warnings; git diff --check. --- crates/wip-fs-server/README.md | 41 +- crates/wip-fs-server/src/defaults.rs | 4 +- crates/wip-fs-server/src/engine.rs | 155 +++++-- crates/wip-fs-server/src/lib.rs | 9 +- crates/wip-fs-server/tests/extensions.rs | 9 +- crates/wip-fs-server/tests/support/scoped.rs | 456 +++++++++++++++++++ 6 files changed, 619 insertions(+), 55 deletions(-) create mode 100644 crates/wip-fs-server/tests/support/scoped.rs diff --git a/crates/wip-fs-server/README.md b/crates/wip-fs-server/README.md index e821a72..fbb14ac 100644 --- a/crates/wip-fs-server/README.md +++ b/crates/wip-fs-server/README.md @@ -8,10 +8,12 @@ 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. +`ServerConfig`, `WipFsServer::new`, `handle_http` and CLI flags retain their +assembly shape. The scoped Host/Protocol API is a breaking Rust/wire migration: +Interface references are `{scope, name}` values, registration takes a `Scope` +and local name, and calls use `interface.reference` plus optional `scope_ref`. +Fetch returns the same structured reference and current scope Object's optional +ref. This is the standard WIP wire format, not an FS-specific profile. Use `default_projection(root, access, limits)` to obtain the default Linux engine without starting a listener. `projection.view(&principal)` is the effective view; @@ -30,9 +32,11 @@ For an external backend/composition: register generated definitions with `registry.register(&scope, name, Definition, validator)`. `DefaultInterfaces::register(&mut registry, &scope)` optionally supplies reusable built-in tokens named `file.read`, - `symlink.inspect`, and `file.write`; tokens do not force publication. The - current engine binds only the root scope and fails closed for non-root - registrations; broader FS scope semantics remain follow-up work. + `symlink.inspect`, and `file.write`; tokens do not force publication. For a + non-root scope, create a handle at its canonical WIP path and implement + `Composition::scope(path, &entry, context)` to attest that handle only for + its current Object lifetime. Local names are exact names, not parsed `::`/`#` + display strings; the same local name can be registered at different scopes. 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. @@ -78,6 +82,29 @@ It never independently looks up the raw configured root path. Root Objects need not expose any interfaces. Calls check both the private lifetime and optional `scope_ref`; normal root state updates do not retire the lifetime. +`Composition::scope` is the trusted non-root lifetime attestation hook. It runs +only for registered scope paths in the refreshed, principal-visible execution +lineage, never through independent scope-path resolution. Return `Some(scope)` +only when the retained entry is still the current Object published at that path; +use retained backend identity and application publication-lifecycle state under +the backend boundary, not ref or validator equality. Deletion, replacement or +movement needs a fresh handle and registration, even if a ref is absent or reused. +Ordinary state changes retain the handle. A different returned path is an internal +publication failure; `None` or a fresh unregistered lifetime hides old artifacts. +The default hook returns `None`, so existing compositions fail closed outside `/`. + +Self-scope needs special care: a backend may allow ordinary execution against an +unlinked/replaced pinned leaf, but that does not establish that leaf as the current +scope Object. The hook must attest current membership as well as retained identity +within the same boundary. The default Linux composition does not attest non-root +lifetimes or invent inode-based Object refs. External compositions must provide +that guarantee themselves, including their documented external-writer limits. +Scope metadata is obtained by publishing that retained entry (reusing the target +publication for self-scope); scope Objects need not expose interfaces. Optional +refs are taken from the published Object, not from a raw backend entry. Calls +without `scope_ref` remain valid when the lifetime is current; supplied stale refs +reject with `InterfaceMismatch` after Object validator checks. + `with_object` (including fetch) and individual tree Object publications also enter this backend boundary. Generic read callbacks use a `NotStarted` attempt as an adapter to the existing execution API; no typed operation runs and no diff --git a/crates/wip-fs-server/src/defaults.rs b/crates/wip-fs-server/src/defaults.rs index 96af39a..f7522d9 100644 --- a/crates/wip-fs-server/src/defaults.rs +++ b/crates/wip-fs-server/src/defaults.rs @@ -24,7 +24,9 @@ pub struct DefaultInterfaces { impl DefaultInterfaces { /// Register reusable descriptors at an explicit publication scope. /// - /// The current FS engine binds only its deployment root scope (`/`). + /// Built-in local names are `file.read`, `symlink.inspect`, and `file.write`. + /// The default deployment registers them at `/`; external compositions may + /// register them at non-root scopes attested by `Composition::scope`. pub fn register( registry: &mut InterfaceRegistry, scope: &Scope, diff --git a/crates/wip-fs-server/src/engine.rs b/crates/wip-fs-server/src/engine.rs index 32a610f..87a4c5b 100644 --- a/crates/wip-fs-server/src/engine.rs +++ b/crates/wip-fs-server/src/engine.rs @@ -3,7 +3,7 @@ use crate::ResourceLimits; use wip_host::{ EffectGuarantee, ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, ObjectProvider, - OperationAttempt, ProviderResult, PublishedObject, ScopeObservation, WipObject, + OperationAttempt, ProviderResult, PublishedObject, Scope, ScopeObservation, WipObject, }; use wip_protocol::{ FetchInterfaceRequest, FetchInterfaceResponse, ObjectObservation, ObserveRequest, @@ -75,8 +75,8 @@ impl ResolvedEntry { /// The root is a deployment-pinned Object: every resolution/execution lineage /// must retain that same root, even if its textual backend path is renamed or /// replaced. Replacing the deployment root requires a new projection and Scope -/// lifetime. The engine currently binds only `/`; non-root registrations are -/// not published until a backend can attest their Object lifetimes. +/// lifetime. The engine binds `/` to this pinned deployment root. Non-root +/// lifetimes are attested by [`Composition::scope`] from the refreshed lineage. /// /// `execute` must retain the same target and complete lineage and serialize /// ancestor identity/membership checks, policy metadata, validator observation, @@ -126,12 +126,39 @@ pub trait FilesystemBackend { pub trait Composition { /// Local typed operation implementation and safe public metadata. type Object: WipObject; + /// Attest the current non-root scope lifetime of this exact backend entry. + /// + /// Called only within `FilesystemBackend::execute`, for refreshed entries in + /// its connected lineage. Return the same handle used for registration only + /// while this same Object remains published at `path`. After deletion, + /// replacement or movement return `None` or a fresh Scope, even without an + /// Object ref (or when the replacement reuses that ref). Ordinary state + /// changes retain the handle. In particular a self-scope target must still + /// be the current member at `path`: a backend's ordinary pinned-leaf guarantee + /// alone may allow execution after leaf replacement and is not sufficient + /// evidence for a scope lifetime. Do not re-resolve `path` or infer lifetime from + /// a validator/ref alone: use the entry's retained backend identity and the + /// application's publication lifecycle, serialized by the backend boundary. + /// + /// The default attests no non-root scopes. The deployment-pinned root is + /// bound automatically; this hook is not called for `/`. Returning a Scope + /// for a different path is an internal publication error. A scope Object + /// need not expose any interfaces and its public ref remains optional. + fn scope( + &self, + _path: &str, + _entry: &E, + _context: &ExecutionContext, + ) -> Result, HostError> { + Ok(None) + } + /// Build a request-scoped publication using this exact capability/context. /// /// Publication and policy hooks must be side-effect-free with respect to the - /// backend. This hook may also publish the retained root to observe its scope - /// metadata while another target is executing. Root publication must be - /// deterministic within the view and use this same retained capability. + /// backend. This hook may also publish retained scope ancestors to observe + /// their metadata while another target is executing. Scope publication must + /// be deterministic within the view and use this same retained capability. /// A publication failure is a pre-dispatch rejection, never a /// started Operation. Delegate metadata/validators to the boundary snapshot. fn publish( @@ -294,22 +321,78 @@ where fn publication( &self, - entry: B::Entry, - root: &B::Entry, + resolved: &ResolvedEntry, context: &ExecutionContext, ) -> Result, HostError> { - let is_root = entry.name().is_empty(); + let entry = resolved.target(); let publication = self .projection .composition .publish(entry.clone(), context)?; - // Scope refs describe the published root Object, not an independently - // resolved raw path or the backend entry's possibly different metadata. - let root_publication = if is_root { - None - } else { - Some(self.projection.composition.publish(root.clone(), context)?) - }; + let mut current_scopes = Vec::new(); + let mut path = String::from("/"); + // Only this refreshed, visible, connected lineage can attest scopes. + // Never independently resolve a scope path while a target is executing. + for (index, scope_entry) in resolved + .ancestors() + .iter() + .chain(std::iter::once(entry)) + .enumerate() + { + if index > 0 { + if path != "/" { + path.push('/'); + } + path.push_str(scope_entry.name()); + } + if !self + .projection + .registry + .scopes() + .any(|scope| scope.path() == path) + { + continue; + } + let scopes = if path == "/" { + self.projection + .registry + .scopes() + .filter(|scope| scope.path() == "/") + .cloned() + .collect::>() + } else { + self.projection + .composition + .scope(&path, scope_entry, context)? + .into_iter() + .collect::>() + }; + if scopes.iter().any(|scope| scope.path() != path) { + return Err(internal()); + } + if scopes.is_empty() { + continue; + } + // Refs belong to the Composition's published Object, not necessarily + // the raw entry. Self-scope reuses exactly the target publication. + let scope_publication = if index == resolved.ancestors().len() { + None + } else { + Some( + self.projection + .composition + .publish(scope_entry.clone(), context)?, + ) + }; + let object = scope_publication + .as_ref() + .map_or(publication.implementation(), |scope| scope.implementation()); + current_scopes.extend( + scopes + .iter() + .map(|scope| ScopeObservation::new(scope, object)), + ); + } let available = publication .protocol_object() .interfaces @@ -324,15 +407,18 @@ where let mut selected = self .projection .policy - .interfaces(self.principal, &entry, &available); + .interfaces(self.principal, entry, &available); selected.retain(|reference| { - // Non-root lifetimes are not attested by this engine yet. Never - // publish an artifact merely because the registry retains it. - reference.protocol_reference().scope == "/" - && self - .projection - .policy - .interface_visible(self.principal, reference) + let reference_value = reference.protocol_reference(); + current_scopes.iter().any(|current| { + self.projection + .registry + .fetch_interface(&reference_value, current) + .is_ok() + }) && self + .projection + .policy + .interface_visible(self.principal, reference) }); let mut publication = publication .project(&selected, |reference, operation| { @@ -341,19 +427,7 @@ where .operation_visible(self.principal, reference, operation) }) .map_err(|_| internal())?; - // Called only inside backend.execute, with its refreshed pinned lineage. - // scopes() may repeat the handle for different names; bind_scope replaces - // the same-path snapshot rather than treating repetition as a collision. - for scope in self - .projection - .registry - .scopes() - .filter(|scope| scope.path() == "/") - { - let object = root_publication - .as_ref() - .map_or(publication.implementation(), |root| root.implementation()); - let current = ScopeObservation::new(scope, object); + for current in current_scopes { publication = publication.bind_scope(current); } Ok(publication) @@ -382,10 +456,9 @@ where observed = Some(ProviderResult::NotFound); return OperationAttempt::reject(wip_host::CallErrorKind::NotFound); } - let root = resolved.ancestors().first().unwrap_or(resolved.target()); observed = Some( match preflight(context).and_then(|()| { - self.publication(resolved.target().clone(), root, context) + self.publication(&resolved, context) .map(|publication| operation(&publication)) }) { Ok(result) => ProviderResult::Found(result), @@ -601,8 +674,6 @@ where if !self.lineage_visible(&resolved) { return OperationAttempt::reject(wip_host::CallErrorKind::NotFound); } - let root = resolved.ancestors().first().unwrap_or(resolved.target()); - let entry = resolved.target().clone(); if let Err(error) = preflight(context) { return OperationAttempt::reject(match error.kind() { wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal, @@ -611,7 +682,7 @@ where } }); } - match self.publication(entry, root, context) { + match self.publication(&resolved, context) { Ok(publication) => operation(&publication), Err(error) => OperationAttempt::reject(match error.kind() { wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal, diff --git a/crates/wip-fs-server/src/lib.rs b/crates/wip-fs-server/src/lib.rs index 944633e..8654c5e 100644 --- a/crates/wip-fs-server/src/lib.rs +++ b/crates/wip-fs-server/src/lib.rs @@ -22,10 +22,11 @@ //! Registration uses `registry.register(&scope, name, Definition, validator)`. //! [`DefaultInterfaces::register`] takes an explicit [`wip_host::Scope`]; built-ins //! use root-local names `file.read`, `symlink.inspect` and `file.write`. The engine -//! binds only its deployment-pinned root lifetime, inside the backend execution -//! boundary, using the current published root Object from the retained lineage. -//! Observation/fetch callbacks enter that same coherent boundary. Non-root -//! registrations fail closed until their Object lifetimes can be attested. +//! binds its deployment-pinned root lifetime automatically inside the backend +//! execution boundary. External [`Composition::scope`] implementations attest +//! non-root lifetimes using the refreshed connected lineage in that same boundary. +//! Without an attestation non-root registrations fail closed. Scope refs come +//! from the current published scope Object and remain optional. //! The Linux root survives textual root renames and does not issue an Object ref; //! fetch therefore omits `scope_ref` without weakening the private lifetime check. //! diff --git a/crates/wip-fs-server/tests/extensions.rs b/crates/wip-fs-server/tests/extensions.rs index 0fddc47..993a4fe 100644 --- a/crates/wip-fs-server/tests/extensions.rs +++ b/crates/wip-fs-server/tests/extensions.rs @@ -125,6 +125,7 @@ struct Seen { #[derive(Default)] struct Controls { bump_before_execute: bool, + bump_ancestor: Option, hide_before_execute: bool, hide_ancestor: Option, replace_ancestor: Option, @@ -289,6 +290,9 @@ impl FilesystemBackend for MemoryBackend { entry.node.data.lock().unwrap().revision += 1; controls.bump_before_execute = false; } + if let Some(path) = controls.bump_ancestor.take() { + self.0.node(&path).data.lock().unwrap().revision += 1; + } if controls.hide_before_execute { entry.node.data.lock().unwrap().hidden = true; } @@ -2188,8 +2192,11 @@ fn root_scope_replacement_during_call_rejects_the_prelookup() { ); } +#[path = "support/scoped.rs"] +mod scoped; + #[test] -fn nonroot_registrations_fail_closed_until_the_engine_can_attest_their_lifetimes() { +fn nonroot_registrations_fail_closed_without_composition_lifetime_attestation() { let fixture = Fixture::new(); let mut registry = InterfaceRegistry::new(); let root = Scope::new("/").unwrap(); diff --git a/crates/wip-fs-server/tests/support/scoped.rs b/crates/wip-fs-server/tests/support/scoped.rs new file mode 100644 index 0000000..444cdec --- /dev/null +++ b/crates/wip-fs-server/tests/support/scoped.rs @@ -0,0 +1,456 @@ +//! External non-root scope regression coverage, sharing the memory backend. +use super::*; + +struct ScopedObject { + inner: Object, + reference: Option, +} +impl WipObject for ScopedObject { + fn name(&self) -> &str { + self.inner.name() + } + fn reference(&self) -> Option<&str> { + self.reference.as_deref() + } + fn validator(&self) -> Option<&Validator> { + self.inner.validator() + } + fn requires_validator(&self, _: &InterfaceRef, operation: &str) -> bool { + operation == "read" + } +} +impl FileRead for ScopedObject { + fn read(&self, offset: Option, length: Option) -> wip::OperationResult { + FileRead::read(&self.inner, offset, length) + } +} +struct ScopedComposition { + shared: Arc, + associations: BTreeMap, Scope)>, + interfaces: Vec>, + with_refs: bool, +} +impl Composition for ScopedComposition { + type Object = ScopedObject; + fn scope( + &self, + path: &str, + entry: &Entry, + context: &ExecutionContext, + ) -> Result, HostError> { + assert!(matches!( + self.shared.transaction.try_lock(), + Err(TryLockError::WouldBlock) + )); + assert_eq!(path, entry.path); + self.shared.record("scope", path, context); + assert_eq!( + entry.validator, + Entry::snapshot(entry.path.clone(), entry.node.clone()).validator + ); + let nodes = self.shared.nodes.lock().unwrap(); + // Self-scope also requires current membership; ordinary pinned-leaf + // execution intentionally need not check its path mapping. + Ok(self.associations.get(path).and_then(|(identity, scope)| { + (Arc::ptr_eq(identity, &entry.node) + && nodes + .get(path) + .is_some_and(|node| Arc::ptr_eq(node, identity))) + .then(|| scope.clone()) + })) + } + fn publish( + &self, + entry: Entry, + context: &ExecutionContext, + ) -> Result, HostError> { + assert!(matches!( + self.shared.transaction.try_lock(), + Err(TryLockError::WouldBlock) + )); + self.shared.record("scoped_publish", &entry.path, context); + let path = entry.path.clone(); + let regular = entry.kind() == EntryKind::Regular; + let reference = self + .with_refs + .then(|| format!("published-object-{}", entry.node.id)); + let mut publication = PublishedObject::new(ScopedObject { + inner: Object { + entry, + custom: self.interfaces[0].reference().clone(), + shared: self.shared.clone(), + context: context.clone(), + }, + reference, + }); + // Containers expose no interfaces but remain valid fetch subjects. + if regular { + for interface in &self.interfaces { + if interface + .reference() + .protocol_reference() + .validate_for_path(&path) + .is_ok() + { + publication = publication.expose(interface).unwrap(); + } + } + } + Ok(publication) + } +} +struct ScopedPolicy; +impl ProjectionPolicy for ScopedPolicy { + type Principal = Principal; + fn object_visible(&self, principal: &Principal, entry: &Entry) -> bool { + !entry.hidden && (!matches!(principal, Principal::Limited) || entry.path != "/secret") + } + fn interface_visible(&self, principal: &Principal, interface: &InterfaceRef) -> bool { + !(matches!(principal, Principal::Limited) + && interface.protocol_reference().scope == "/added") + } +} +type ScopedEngine = Projection; +fn scoped_engine(shared: Arc, with_refs: bool) -> ScopedEngine { + configured_engine(shared, with_refs, None) +} +fn configured_engine( + shared: Arc, + with_refs: bool, + interface_validator: Option, +) -> ScopedEngine { + let mut registry = InterfaceRegistry::new(); + let mut associations = BTreeMap::new(); + let mut interfaces = Vec::new(); + for path in ["/", "/secret", "/secret/nested", "/added"] { + let scope = Scope::new(path).unwrap(); + let validator = if path == "/secret" { + interface_validator.clone() + } else { + None + }; + interfaces.push( + registry + .register( + &scope, + "file.read", + wip_fs_server::FileReadDefinition, + validator, + ) + .unwrap(), + ); + associations.insert(path.into(), (shared.node(path), scope)); + } + Projection::new( + MemoryBackend::new(shared.clone()), + registry, + ScopedComposition { + shared, + associations, + interfaces, + with_refs, + }, + ScopedPolicy, + ResourceLimits::default(), + ) +} +fn reference(scope: &str) -> wip_protocol::InterfaceReference { + wip_protocol::InterfaceReference { + scope: scope.into(), + name: "file.read".into(), + } +} +fn request(path: &str, scope: &str, scope_ref: Option) -> CallOperationRequest { + CallOperationRequest { + target: Target { + path: path.into(), + validator: None, + }, + interface: InterfaceTarget { + reference: reference(scope), + scope_ref, + validator: None, + }, + operation: "read".into(), + arguments: BTreeMap::new(), + } +} + +#[test] +fn nonroot_observe_fetch_call_with_optional_refs_over_http() { + for with_refs in [false, true] { + let fixture = Fixture::new(); + let engine = scoped_engine(fixture.shared.clone(), with_refs); + let view = engine.view(&Principal::Admin); + let limits = ResourceLimits::default(); + let endpoint = Endpoint::parse("https://example.test/scopes").unwrap(); + let adapter = HttpAdapter::new(&view, &endpoint, limits); + for (path, scope) in [("/secret/nested/data", "/secret"), ("/added", "/added")] { + let observe = observe_request(path, 0); + let response = adapter.handle_http_with_context( + encode_observe_request(&endpoint, &observe, limits.wire).unwrap(), + &ExecutionContext::new(), + ); + let DecodedResponse::Success(observed) = + decode_observe_response(&observe, &response, limits.wire).unwrap() + else { + panic!("observation must succeed"); + }; + assert!(observed.object.interfaces.contains(&reference(scope))); + let fetch = fetch_request(&reference(scope)); + let response = adapter.handle_http_with_context( + encode_fetch_interface_request(&endpoint, &fetch, limits.wire).unwrap(), + &ExecutionContext::new(), + ); + let DecodedResponse::Success(fetched) = + decode_fetch_interface_response(&fetch, &response, limits.wire).unwrap() + else { + panic!("fetch must succeed"); + }; + assert_eq!( + fetched.scope_ref, + with_refs.then(|| format!("published-object-{}", fixture.shared.node(scope).id)) + ); + let mut call = request(path, scope, fetched.scope_ref); + call.target.validator = observed.object.validator; + let response = adapter.handle_http_with_context( + encode_call_operation_request(&endpoint, &call, &fetched.descriptor, limits.wire) + .unwrap(), + &ExecutionContext::new(), + ); + let DecodedResponse::Success(result) = + decode_call_operation_response(&call, &fetched.descriptor, &response, limits.wire) + .unwrap() + else { + panic!("call must succeed"); + }; + assert_eq!(result.result, Value::Bytes(b"hello memory".to_vec())); + if !with_refs { + call.interface.scope_ref = Some("ref-not-provided-by-publication".into()); + assert_eq!( + view.call_operation(call).unwrap_err().kind(), + wip_host::CallErrorKind::InterfaceMismatch + ); + } + } + assert!( + view.observe_object("/secret") + .unwrap() + .interfaces + .is_empty() + ); + assert_eq!( + view.observe_object("/secret/nested/data") + .unwrap() + .interfaces, + [ + reference("/"), + reference("/secret"), + reference("/secret/nested") + ] + ); + let limited = engine.view(&Principal::Limited); + for scope in ["/secret", "/secret/nested", "/added"] { + assert_eq!( + limited + .fetch_interface(&fetch_request(&reference(scope))) + .unwrap_err() + .code, + ProtocolErrorCode::InterfaceNotFound + ); + } + assert_eq!( + limited + .observe_object("/secret/nested/data") + .unwrap_err() + .kind(), + wip_host::CallErrorKind::NotFound + ); + assert!( + !limited + .observe_object("/added") + .unwrap() + .interfaces + .contains(&reference("/added")) + ); + } +} + +#[test] +fn scope_refs_and_object_validator_precedence_use_current_atomic_lineage() { + let fixture = Fixture::new(); + let engine = scoped_engine(fixture.shared.clone(), true); + let view = engine.view(&Principal::Admin); + let mut call = request("/secret/nested/data", "/secret", Some("stale".into())); + call.target.validator = Some(vec![0]); + assert_eq!( + view.call_operation(call.clone()).unwrap_err().kind(), + wip_host::CallErrorKind::ValidatorMismatch + ); + call.target.validator = view.observe_object(&call.target.path).unwrap().validator; + assert_eq!( + view.call_operation(call.clone()).unwrap_err().kind(), + wip_host::CallErrorKind::InterfaceMismatch + ); + call.interface.scope_ref = view + .fetch_interface(&fetch_request(&call.interface.reference)) + .unwrap() + .scope_ref; + // Scope state changes do not retire its lifetime; the published ref is not + // its validator, and the hook runs on refreshed metadata under the lock. + fixture.shared.controls.lock().unwrap().bump_ancestor = Some("/secret".into()); + fixture.shared.seen.lock().unwrap().clear(); + view.call_operation(call).unwrap(); + let seen = fixture.shared.seen.lock().unwrap(); + let resolves: Vec<_> = seen + .iter() + .filter(|event| event.stage == "resolve") + .map(|event| event.path.as_str()) + .collect(); + assert_eq!(resolves, ["/secret/nested/data"]); + assert!( + seen.iter() + .any(|event| event.stage == "scope" && event.path == "/secret") + ); +} + +#[test] +fn nonroot_interface_validator_checks_follow_scope_ref_checks() { + let fixture = Fixture::new(); + let engine = configured_engine(fixture.shared.clone(), true, Some(Validator::new(vec![7]))); + let view = engine.view(&Principal::Admin); + let fetched = view + .fetch_interface(&fetch_request(&reference("/secret"))) + .unwrap(); + assert_eq!(fetched.validator, Some(vec![7])); + let mut call = request("/secret/nested/data", "/secret", Some("stale".into())); + call.target.validator = view.observe_object(&call.target.path).unwrap().validator; + assert_eq!( + view.call_operation(call.clone()).unwrap_err().kind(), + wip_host::CallErrorKind::InterfaceMismatch + ); + call.interface.scope_ref = fetched.scope_ref; + assert_eq!( + view.call_operation(call.clone()).unwrap_err().kind(), + wip_host::CallErrorKind::InterfaceValidatorRequired + ); + call.interface.validator = Some(vec![8]); + assert_eq!( + view.call_operation(call.clone()).unwrap_err().kind(), + wip_host::CallErrorKind::InterfaceValidatorMismatch + ); + assert!( + fixture + .shared + .node(&call.target.path) + .calls + .lock() + .unwrap() + .is_empty() + ); + call.interface.validator = fetched.validator; + view.call_operation(call).unwrap(); +} + +#[test] +fn visibility_and_replacement_races_reject_before_dispatch() { + for replace in [false, true] { + let fixture = Fixture::new(); + let engine = scoped_engine(fixture.shared.clone(), false); + let view = engine.view(&Principal::Admin); + let mut call = request("/secret/nested/data", "/secret", None); + call.target.validator = view.observe_object(&call.target.path).unwrap().validator; + let leaf = fixture.shared.node(&call.target.path); + if replace { + fixture.shared.controls.lock().unwrap().replace_ancestor = Some("/secret".into()); + } else { + fixture.shared.controls.lock().unwrap().hide_ancestor = Some("/secret".into()); + } + assert_eq!( + view.call_operation(call).unwrap_err().kind(), + wip_host::CallErrorKind::NotFound + ); + assert!(leaf.calls.lock().unwrap().is_empty()); + assert_eq!( + view.fetch_interface(&fetch_request(&reference("/secret"))) + .unwrap_err() + .code, + ProtocolErrorCode::InterfaceNotFound + ); + } +} + +#[test] +fn deletion_replacement_and_self_scope_do_not_reuse_registry_lifetimes() { + for with_refs in [false, true] { + let fixture = Fixture::new(); + let engine = scoped_engine(fixture.shared.clone(), with_refs); + let view = engine.view(&Principal::Admin); + let old_scope = fixture.shared.node("/secret"); + let old_ref = view + .fetch_interface(&fetch_request(&reference("/secret"))) + .unwrap() + .scope_ref; + fixture.shared.nodes.lock().unwrap().remove("/secret"); + assert_eq!( + view.fetch_interface(&fetch_request(&reference("/secret"))) + .unwrap_err() + .code, + ProtocolErrorCode::InterfaceNotFound + ); + // Reuse even the same id/public ref: refs cannot attest a lifetime. + fixture.shared.nodes.lock().unwrap().insert( + "/secret".into(), + Arc::new(Node { + id: old_scope.id, + kind: EntryKind::Directory, + data: Mutex::new(old_scope.data.lock().unwrap().clone()), + calls: Mutex::new(Vec::new()), + }), + ); + assert_eq!( + view.fetch_interface(&fetch_request(&reference("/secret"))) + .unwrap_err() + .code, + ProtocolErrorCode::InterfaceNotFound + ); + let mut call = request("/secret/nested/data", "/secret", old_ref); + call.target.validator = view.observe_object(&call.target.path).unwrap().validator; + assert_eq!( + view.call_operation(call).unwrap_err().kind(), + wip_host::CallErrorKind::InterfaceMismatch + ); + assert!( + fixture + .shared + .node("/secret/nested/data") + .calls + .lock() + .unwrap() + .is_empty() + ); + // Fresh registration/engine attests the new publication. + let republished = scoped_engine(fixture.shared.clone(), with_refs); + republished + .view(&Principal::Admin) + .fetch_interface(&fetch_request(&reference("/secret"))) + .unwrap(); + // Scope == target: ordinary execution pins the old leaf, but cannot + // attest it as the current scope after its mapping is replaced. + let leaf = fixture.shared.node("/added"); + let mut call = request("/added", "/added", None); + call.target.validator = view.observe_object("/added").unwrap().validator; + fixture.shared.controls.lock().unwrap().replace_mapping = true; + assert_eq!( + view.call_operation(call).unwrap_err().kind(), + wip_host::CallErrorKind::InterfaceMismatch + ); + assert!(leaf.calls.lock().unwrap().is_empty()); + assert_eq!( + view.fetch_interface(&fetch_request(&reference("/added"))) + .unwrap_err() + .code, + ProtocolErrorCode::InterfaceNotFound + ); + } +}