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
|
## 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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
None
|
// Never independently resolve a scope path while a target is executing.
|
||||||
} else {
|
for (index, scope_entry) in resolved
|
||||||
Some(self.projection.composition.publish(root.clone(), context)?)
|
.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
|
let available = publication
|
||||||
.protocol_object()
|
.protocol_object()
|
||||||
.interfaces
|
.interfaces
|
||||||
@@ -324,15 +407,18 @@ 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
|
||||||
.projection
|
.fetch_interface(&reference_value, current)
|
||||||
.policy
|
.is_ok()
|
||||||
.interface_visible(self.principal, reference)
|
}) && self
|
||||||
|
.projection
|
||||||
|
.policy
|
||||||
|
.interface_visible(self.principal, reference)
|
||||||
});
|
});
|
||||||
let mut publication = publication
|
let mut publication = publication
|
||||||
.project(&selected, |reference, operation| {
|
.project(&selected, |reference, operation| {
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
//!
|
//!
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user