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
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<Entry>` 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
+3 -1
View File
@@ -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,
+113 -42
View File
@@ -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<E> ResolvedEntry<E> {
/// 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<E: BackendEntry> {
/// 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<Option<Scope>, 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<B::Entry>,
context: &ExecutionContext,
) -> Result<PublishedObject<C::Object>, 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::<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
} 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,
+5 -4
View File
@@ -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.
//!
+8 -1
View File
@@ -125,6 +125,7 @@ struct Seen {
#[derive(Default)]
struct Controls {
bump_before_execute: bool,
bump_ancestor: Option<String>,
hide_before_execute: bool,
hide_ancestor: Option<String>,
replace_ancestor: Option<String>,
@@ -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();
@@ -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
);
}
}