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.
This commit is contained in:
2026-10-08 03:56:17 +09:00
parent 827bef8478
commit a7186173b4
6 changed files with 619 additions and 55 deletions
+34 -7
View File
@@ -8,10 +8,12 @@ envelopes, and HTTP status mapping.
## Library assembly and migration ## Library assembly and migration
The existing `ServerConfig`, `WipFsServer::new`, `handle_http`, CLI flags and wire `ServerConfig`, `WipFsServer::new`, `handle_http` and CLI flags retain their
representation remain compatible. The formerly private projection has become a assembly shape. The scoped Host/Protocol API is a breaking Rust/wire migration:
public, generic library engine. No package version or publication policy changes Interface references are `{scope, name}` values, registration takes a `Scope`
are required. 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 Use `default_projection(root, access, limits)` to obtain the default Linux engine
without starting a listener. `projection.view(&principal)` is the effective view; 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, register generated definitions with `registry.register(&scope, name,
Definition, validator)`. `DefaultInterfaces::register(&mut registry, &scope)` Definition, validator)`. `DefaultInterfaces::register(&mut registry, &scope)`
optionally supplies reusable built-in tokens named `file.read`, optionally supplies reusable built-in tokens named `file.read`,
`symlink.inspect`, and `file.write`; tokens do not force publication. The `symlink.inspect`, and `file.write`; tokens do not force publication. For a
current engine binds only the root scope and fails closed for non-root non-root scope, create a handle at its canonical WIP path and implement
registrations; broader FS scope semantics remain follow-up work. `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<Entry>` on a local type. Its local Object wraps the entry 3. Implement `Composition<Entry>` on a local type. Its local Object wraps the entry
and implements both `WipObject` and the chosen generated traits, avoiding orphan and implements both `WipObject` and the chosen generated traits, avoiding orphan
rules. `PublishedObject::expose` proves each implementation at compile time. 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 not expose any interfaces. Calls check both the private lifetime and optional
`scope_ref`; normal root state updates do not retire the lifetime. `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 `with_object` (including fetch) and individual tree Object publications also
enter this backend boundary. Generic read callbacks use a `NotStarted` attempt 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 as an adapter to the existing execution API; no typed operation runs and no
+3 -1
View File
@@ -24,7 +24,9 @@ pub struct DefaultInterfaces {
impl DefaultInterfaces { impl DefaultInterfaces {
/// Register reusable descriptors at an explicit publication scope. /// 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( pub fn register(
registry: &mut InterfaceRegistry, registry: &mut InterfaceRegistry,
scope: &Scope, scope: &Scope,
+107 -36
View File
@@ -3,7 +3,7 @@
use crate::ResourceLimits; use crate::ResourceLimits;
use wip_host::{ use wip_host::{
EffectGuarantee, ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, ObjectProvider, EffectGuarantee, ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, ObjectProvider,
OperationAttempt, ProviderResult, PublishedObject, ScopeObservation, WipObject, OperationAttempt, ProviderResult, PublishedObject, Scope, ScopeObservation, WipObject,
}; };
use wip_protocol::{ use wip_protocol::{
FetchInterfaceRequest, FetchInterfaceResponse, ObjectObservation, ObserveRequest, FetchInterfaceRequest, FetchInterfaceResponse, ObjectObservation, ObserveRequest,
@@ -75,8 +75,8 @@ impl<E> ResolvedEntry<E> {
/// The root is a deployment-pinned Object: every resolution/execution lineage /// 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 /// 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 /// replaced. Replacing the deployment root requires a new projection and Scope
/// lifetime. The engine currently binds only `/`; non-root registrations are /// lifetime. The engine binds `/` to this pinned deployment root. Non-root
/// not published until a backend can attest their Object lifetimes. /// lifetimes are attested by [`Composition::scope`] from the refreshed lineage.
/// ///
/// `execute` must retain the same target and complete lineage and serialize /// `execute` must retain the same target and complete lineage and serialize
/// ancestor identity/membership checks, policy metadata, validator observation, /// ancestor identity/membership checks, policy metadata, validator observation,
@@ -126,12 +126,39 @@ pub trait FilesystemBackend {
pub trait Composition<E: BackendEntry> { pub trait Composition<E: BackendEntry> {
/// Local typed operation implementation and safe public metadata. /// Local typed operation implementation and safe public metadata.
type Object: WipObject; 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<Option<Scope>, HostError> {
Ok(None)
}
/// Build a request-scoped publication using this exact capability/context. /// Build a request-scoped publication using this exact capability/context.
/// ///
/// Publication and policy hooks must be side-effect-free with respect to the /// 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 /// backend. This hook may also publish retained scope ancestors to observe
/// metadata while another target is executing. Root publication must be /// their metadata while another target is executing. Scope publication must
/// deterministic within the view and use this same retained capability. /// be deterministic within the view and use this same retained capability.
/// A publication failure is a pre-dispatch rejection, never a /// A publication failure is a pre-dispatch rejection, never a
/// started Operation. Delegate metadata/validators to the boundary snapshot. /// started Operation. Delegate metadata/validators to the boundary snapshot.
fn publish( fn publish(
@@ -294,22 +321,78 @@ where
fn publication( fn publication(
&self, &self,
entry: B::Entry, resolved: &ResolvedEntry<B::Entry>,
root: &B::Entry,
context: &ExecutionContext, context: &ExecutionContext,
) -> Result<PublishedObject<C::Object>, HostError> { ) -> Result<PublishedObject<C::Object>, HostError> {
let is_root = entry.name().is_empty(); let entry = resolved.target();
let publication = self let publication = self
.projection .projection
.composition .composition
.publish(entry.clone(), context)?; .publish(entry.clone(), context)?;
// Scope refs describe the published root Object, not an independently let mut current_scopes = Vec::new();
// resolved raw path or the backend entry's possibly different metadata. let mut path = String::from("/");
let root_publication = if is_root { // 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::<Vec<_>>()
} else {
self.projection
.composition
.scope(&path, scope_entry, context)?
.into_iter()
.collect::<Vec<_>>()
};
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 None
} else { } else {
Some(self.projection.composition.publish(root.clone(), context)?) 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 let available = publication
.protocol_object() .protocol_object()
.interfaces .interfaces
@@ -324,12 +407,15 @@ where
let mut selected = self let mut selected = self
.projection .projection
.policy .policy
.interfaces(self.principal, &entry, &available); .interfaces(self.principal, entry, &available);
selected.retain(|reference| { selected.retain(|reference| {
// Non-root lifetimes are not attested by this engine yet. Never let reference_value = reference.protocol_reference();
// publish an artifact merely because the registry retains it. current_scopes.iter().any(|current| {
reference.protocol_reference().scope == "/" self.projection
&& self .registry
.fetch_interface(&reference_value, current)
.is_ok()
}) && self
.projection .projection
.policy .policy
.interface_visible(self.principal, reference) .interface_visible(self.principal, reference)
@@ -341,19 +427,7 @@ where
.operation_visible(self.principal, reference, operation) .operation_visible(self.principal, reference, operation)
}) })
.map_err(|_| internal())?; .map_err(|_| internal())?;
// Called only inside backend.execute, with its refreshed pinned lineage. for current in current_scopes {
// 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);
publication = publication.bind_scope(current); publication = publication.bind_scope(current);
} }
Ok(publication) Ok(publication)
@@ -382,10 +456,9 @@ where
observed = Some(ProviderResult::NotFound); observed = Some(ProviderResult::NotFound);
return OperationAttempt::reject(wip_host::CallErrorKind::NotFound); return OperationAttempt::reject(wip_host::CallErrorKind::NotFound);
} }
let root = resolved.ancestors().first().unwrap_or(resolved.target());
observed = Some( observed = Some(
match preflight(context).and_then(|()| { match preflight(context).and_then(|()| {
self.publication(resolved.target().clone(), root, context) self.publication(&resolved, context)
.map(|publication| operation(&publication)) .map(|publication| operation(&publication))
}) { }) {
Ok(result) => ProviderResult::Found(result), Ok(result) => ProviderResult::Found(result),
@@ -601,8 +674,6 @@ where
if !self.lineage_visible(&resolved) { if !self.lineage_visible(&resolved) {
return OperationAttempt::reject(wip_host::CallErrorKind::NotFound); 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) { if let Err(error) = preflight(context) {
return OperationAttempt::reject(match error.kind() { return OperationAttempt::reject(match error.kind() {
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal, 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), Ok(publication) => operation(&publication),
Err(error) => OperationAttempt::reject(match error.kind() { Err(error) => OperationAttempt::reject(match error.kind() {
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal, wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
+5 -4
View File
@@ -22,10 +22,11 @@
//! Registration uses `registry.register(&scope, name, Definition, validator)`. //! Registration uses `registry.register(&scope, name, Definition, validator)`.
//! [`DefaultInterfaces::register`] takes an explicit [`wip_host::Scope`]; built-ins //! [`DefaultInterfaces::register`] takes an explicit [`wip_host::Scope`]; built-ins
//! use root-local names `file.read`, `symlink.inspect` and `file.write`. The engine //! use root-local names `file.read`, `symlink.inspect` and `file.write`. The engine
//! binds only its deployment-pinned root lifetime, inside the backend execution //! binds its deployment-pinned root lifetime automatically inside the backend
//! boundary, using the current published root Object from the retained lineage. //! execution boundary. External [`Composition::scope`] implementations attest
//! Observation/fetch callbacks enter that same coherent boundary. Non-root //! non-root lifetimes using the refreshed connected lineage in that same boundary.
//! registrations fail closed until their Object lifetimes can be attested. //! 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; //! 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. //! fetch therefore omits `scope_ref` without weakening the private lifetime check.
//! //!
+8 -1
View File
@@ -125,6 +125,7 @@ struct Seen {
#[derive(Default)] #[derive(Default)]
struct Controls { struct Controls {
bump_before_execute: bool, bump_before_execute: bool,
bump_ancestor: Option<String>,
hide_before_execute: bool, hide_before_execute: bool,
hide_ancestor: Option<String>, hide_ancestor: Option<String>,
replace_ancestor: Option<String>, replace_ancestor: Option<String>,
@@ -289,6 +290,9 @@ impl FilesystemBackend for MemoryBackend {
entry.node.data.lock().unwrap().revision += 1; entry.node.data.lock().unwrap().revision += 1;
controls.bump_before_execute = false; 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 { if controls.hide_before_execute {
entry.node.data.lock().unwrap().hidden = true; 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] #[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 fixture = Fixture::new();
let mut registry = InterfaceRegistry::new(); let mut registry = InterfaceRegistry::new();
let root = Scope::new("/").unwrap(); let root = Scope::new("/").unwrap();
@@ -0,0 +1,456 @@
//! External non-root scope regression coverage, sharing the memory backend.
use super::*;
struct ScopedObject {
inner: Object,
reference: Option<String>,
}
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<u64>, length: Option<u64>) -> wip::OperationResult<wip::Bytes> {
FileRead::read(&self.inner, offset, length)
}
}
struct ScopedComposition {
shared: Arc<Shared>,
associations: BTreeMap<String, (Arc<Node>, Scope)>,
interfaces: Vec<RegisteredInterface<wip_fs_server::FileReadDefinition>>,
with_refs: bool,
}
impl Composition<Entry> for ScopedComposition {
type Object = ScopedObject;
fn scope(
&self,
path: &str,
entry: &Entry,
context: &ExecutionContext,
) -> Result<Option<Scope>, 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<PublishedObject<ScopedObject>, 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<Entry, ScopedObject> 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<MemoryBackend, ScopedComposition, ScopedPolicy>;
fn scoped_engine(shared: Arc<Shared>, with_refs: bool) -> ScopedEngine {
configured_engine(shared, with_refs, None)
}
fn configured_engine(
shared: Arc<Shared>,
with_refs: bool,
interface_validator: Option<Validator>,
) -> 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<String>) -> 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
);
}
}