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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
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(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
|
||||
.protocol_object()
|
||||
.interfaces
|
||||
@@ -324,12 +407,15 @@ 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
|
||||
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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
//!
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user