feat: bind Host interfaces to scope Object lifetimes
Register explicit scope/name coordinates independently of Definition type. Require provider-attested current scope snapshots for fetch and dispatch, compare optional scope_ref at membership precedence, and retain the same Descriptor through result validation. Migrate FS pinned-root boundaries and public self-scope example; document provider atomicity/lifecycle and root-only FS support. Add lifecycle, ref, replacement-race, root-lineage and compile-time exposure regressions. Validation: cargo fmt --all --check; git diff --check; cargo check --workspace --all-targets; cargo test --workspace --all-targets and --doc (232 passing test-harness cases including doctests); nix develop --command cargo clippy --workspace --all-targets -- -D warnings; RUSTDOCFLAGS=-Dwarnings cargo doc --workspace --no-deps. Narrow wip-host suite also passed.
This commit is contained in:
@@ -26,9 +26,13 @@ For an external backend/composition:
|
||||
protected state. Implement `FilesystemBackend` to resolve one connected
|
||||
`ResolvedEntry<Entry>` (root-to-parent ancestry plus pinned target), enumerate
|
||||
bounded children, and execute against that already-resolved lineage.
|
||||
2. Register generated `#[wip_interface::interface]` definitions using the existing
|
||||
`wip_host::InterfaceRegistry`. `DefaultInterfaces::register` optionally supplies
|
||||
reusable built-in tokens; using those tokens does not force their publication.
|
||||
2. Create `wip_host::Scope::new("/")` for the deployment-pinned root, then
|
||||
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.
|
||||
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.
|
||||
@@ -61,7 +65,26 @@ from one connected backend traversal/snapshot, not independent path-prefix
|
||||
resolutions. A root target has no ancestors; other targets retain the root first,
|
||||
then intermediate parent containers. This is backend-private context, never wire
|
||||
metadata. Backend `resolve` and `execute` signatures both carry this type;
|
||||
compositions still receive only the exact scoped target `Entry`.
|
||||
compositions receive exact retained entries, including publication of the root
|
||||
scope Object while another target is executing, never independent raw-path
|
||||
lookups. Root publication must be deterministic and side-effect-free.
|
||||
|
||||
Every lineage must retain the same deployment-pinned root Object. Replacing that
|
||||
Object requires a new projection and fresh `Scope`, not reuse of registrations
|
||||
at the same path. The engine constructs `ScopeObservation` from the published
|
||||
root Object's metadata using the refreshed retained root capability **inside**
|
||||
`FilesystemBackend::execute`, then attaches it with `PublishedObject::bind_scope`.
|
||||
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.
|
||||
|
||||
`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
|
||||
commit is implied. Tree observations are bounded traversals, not one global tree
|
||||
transaction. Fetch resolves the visible scope Object through the provider and
|
||||
returns its ref, Descriptor and Interface validator together; retained registry
|
||||
artifacts alone never establish publication.
|
||||
|
||||
`FilesystemBackend::execute` must validate ancestor membership/identity and
|
||||
refresh **all** lineage state/validators inside its transaction or lock before
|
||||
@@ -196,14 +219,16 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/observe \
|
||||
```
|
||||
|
||||
A regular-file observation returns a structured read interface reference and an
|
||||
object validator. Built-in legacy registrations use scope `/` and copy the opaque
|
||||
registration string verbatim into `name`; they do not emit `scope_ref`. Copy the
|
||||
complete reference from the observation to retrieve that descriptor:
|
||||
object validator. Built-ins explicitly use scope `/` and the local names
|
||||
`file.read`, `symlink.inspect`, and `file.write`. The Linux root does not issue an
|
||||
Object ref, so fetch omits `scope_ref`; its private publication lifetime still
|
||||
binds fetch and call to the pinned deployment root. Copy the complete reference
|
||||
from the observation to retrieve that descriptor:
|
||||
|
||||
```console
|
||||
curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch_interface \
|
||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||
--data '{"interface":{"scope":"/","name":"<read-interface-name-from-observation>"}}'
|
||||
--data '{"interface":{"scope":"/","name":"file.read"}}'
|
||||
```
|
||||
|
||||
Then call a bounded range read:
|
||||
@@ -213,7 +238,7 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/call_operation \
|
||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||
--data '{
|
||||
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
||||
"interface":{"reference":{"scope":"/","name":"<read-interface-name>"}},
|
||||
"interface":{"reference":{"scope":"/","name":"file.read"}},
|
||||
"operation":"read",
|
||||
"arguments":{"offset":0,"length":4096}
|
||||
}'
|
||||
@@ -227,7 +252,7 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/call_operation \
|
||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||
--data '{
|
||||
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
||||
"interface":{"reference":{"scope":"/","name":"<write-interface-name>"}},
|
||||
"interface":{"reference":{"scope":"/","name":"file.write"}},
|
||||
"operation":"replace",
|
||||
"arguments":{"contents":"dXBkYXRlZA=="}
|
||||
}'
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::projection::{LinuxBackend, LinuxEntry};
|
||||
use crate::{AccessMode, ResourceLimits};
|
||||
use wip_host::{
|
||||
ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, PublishedObject,
|
||||
RegisteredInterface, RegistrationError, Validator, WipObject,
|
||||
RegisteredInterface, RegistrationError, Scope, Validator, WipObject,
|
||||
};
|
||||
use wip_interface as wip;
|
||||
|
||||
@@ -22,12 +22,17 @@ pub struct DefaultInterfaces {
|
||||
pub write: RegisteredInterface<FileWriteDefinition>,
|
||||
}
|
||||
impl DefaultInterfaces {
|
||||
/// Register reusable default descriptors without publishing any objects.
|
||||
pub fn register(registry: &mut InterfaceRegistry) -> Result<Self, RegistrationError> {
|
||||
/// Register reusable descriptors at an explicit publication scope.
|
||||
///
|
||||
/// The current FS engine binds only its deployment root scope (`/`).
|
||||
pub fn register(
|
||||
registry: &mut InterfaceRegistry,
|
||||
scope: &Scope,
|
||||
) -> Result<Self, RegistrationError> {
|
||||
Ok(Self {
|
||||
read: registry.register(FileReadDefinition, None)?,
|
||||
symlink: registry.register(SymlinkInspectDefinition, None)?,
|
||||
write: registry.register(FileWriteDefinition, None)?,
|
||||
read: registry.register(scope, "file.read", FileReadDefinition, None)?,
|
||||
symlink: registry.register(scope, "symlink.inspect", SymlinkInspectDefinition, None)?,
|
||||
write: registry.register(scope, "file.write", FileWriteDefinition, None)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -135,7 +140,9 @@ pub fn default_projection(
|
||||
) -> Result<Projection<LinuxBackend, DefaultComposition, DefaultPolicy>, std::io::Error> {
|
||||
let backend = LinuxBackend::open(root, access, limits)?;
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let interfaces = DefaultInterfaces::register(&mut registry).map_err(std::io::Error::other)?;
|
||||
let scope = Scope::new("/").map_err(std::io::Error::other)?;
|
||||
let interfaces =
|
||||
DefaultInterfaces::register(&mut registry, &scope).map_err(std::io::Error::other)?;
|
||||
Ok(Projection::new(
|
||||
backend,
|
||||
registry,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::ResourceLimits;
|
||||
use wip_host::{
|
||||
EffectGuarantee, ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, ObjectProvider,
|
||||
OperationAttempt, ProviderResult, PublishedObject, WipObject,
|
||||
OperationAttempt, ProviderResult, PublishedObject, ScopeObservation, WipObject,
|
||||
};
|
||||
use wip_protocol::{
|
||||
FetchInterfaceRequest, FetchInterfaceResponse, ObjectObservation, ObserveRequest,
|
||||
@@ -72,6 +72,12 @@ impl<E> ResolvedEntry<E> {
|
||||
/// advertise capabilities the backend cannot guarantee. Errors must not include
|
||||
/// backend paths, credentials or hidden metadata.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// `execute` must retain the same target and complete lineage and serialize
|
||||
/// ancestor identity/membership checks, policy metadata, validator observation,
|
||||
/// publication, validation, typed dispatch and commit/rollback. Changed/detached
|
||||
@@ -123,7 +129,10 @@ pub trait Composition<E: BackendEntry> {
|
||||
/// Build a request-scoped publication using this exact capability/context.
|
||||
///
|
||||
/// Publication and policy hooks must be side-effect-free with respect to the
|
||||
/// backend. A publication failure is a pre-dispatch rejection, never a
|
||||
/// 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.
|
||||
/// A publication failure is a pre-dispatch rejection, never a
|
||||
/// started Operation. Delegate metadata/validators to the boundary snapshot.
|
||||
fn publish(
|
||||
&self,
|
||||
@@ -286,12 +295,21 @@ where
|
||||
fn publication(
|
||||
&self,
|
||||
entry: B::Entry,
|
||||
root: &B::Entry,
|
||||
context: &ExecutionContext,
|
||||
) -> Result<PublishedObject<C::Object>, HostError> {
|
||||
let is_root = entry.name().is_empty();
|
||||
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 available = publication
|
||||
.protocol_object()
|
||||
.interfaces
|
||||
@@ -308,17 +326,81 @@ where
|
||||
.policy
|
||||
.interfaces(self.principal, &entry, &available);
|
||||
selected.retain(|reference| {
|
||||
self.projection
|
||||
.policy
|
||||
.interface_visible(self.principal, 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)
|
||||
});
|
||||
publication
|
||||
let mut publication = publication
|
||||
.project(&selected, |reference, operation| {
|
||||
self.projection
|
||||
.policy
|
||||
.operation_visible(self.principal, reference, operation)
|
||||
})
|
||||
.map_err(|_| internal())
|
||||
.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);
|
||||
publication = publication.bind_scope(current);
|
||||
}
|
||||
Ok(publication)
|
||||
}
|
||||
|
||||
// Adapt read-only callbacks to the existing backend execution boundary. The
|
||||
// sentinel is NotStarted: no typed operation ran and no commit/rollback may
|
||||
// be inferred. The actual callback result never escapes until execute exits.
|
||||
fn with_publication<R>(
|
||||
&self,
|
||||
context: &ExecutionContext,
|
||||
path: &str,
|
||||
resolved: ResolvedEntry<B::Entry>,
|
||||
operation: impl FnOnce(&PublishedObject<C::Object>) -> R,
|
||||
) -> ProviderResult<R> {
|
||||
let mut observed = None;
|
||||
let boundary = self
|
||||
.projection
|
||||
.backend
|
||||
.execute(context, resolved, |resolved| {
|
||||
if !lineage_matches_path(&resolved, path) {
|
||||
observed = Some(ProviderResult::Failure(internal()));
|
||||
return OperationAttempt::reject(wip_host::CallErrorKind::Internal);
|
||||
}
|
||||
if !self.lineage_visible(&resolved) {
|
||||
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)
|
||||
.map(|publication| operation(&publication))
|
||||
}) {
|
||||
Ok(result) => ProviderResult::Found(result),
|
||||
Err(error) => ProviderResult::Failure(error),
|
||||
},
|
||||
);
|
||||
OperationAttempt::reject(wip_host::CallErrorKind::Internal)
|
||||
});
|
||||
match boundary {
|
||||
ProviderResult::Found(_) => {
|
||||
observed.unwrap_or_else(|| ProviderResult::Failure(internal()))
|
||||
}
|
||||
ProviderResult::NotFound => ProviderResult::NotFound,
|
||||
ProviderResult::Failure(error) => ProviderResult::Failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve the descriptor with precisely this principal's visible operations.
|
||||
@@ -333,10 +415,8 @@ where
|
||||
&self,
|
||||
request: &FetchInterfaceRequest,
|
||||
) -> Result<FetchInterfaceResponse, ProtocolError> {
|
||||
let observation = self
|
||||
.projection
|
||||
.registry
|
||||
.fetch_interface(&request.interface)?;
|
||||
let observation =
|
||||
ObjectProvider::fetch_interface(self, &self.projection.registry, &request.interface)?;
|
||||
if !self
|
||||
.projection
|
||||
.policy
|
||||
@@ -387,10 +467,10 @@ where
|
||||
}
|
||||
ProviderResult::Failure(error) => return Err(read_error(error)),
|
||||
};
|
||||
let (_ancestors, entry) = entry.into_parts();
|
||||
let mut remaining = u64::from(self.projection.limits.max_observation_nodes)
|
||||
.min(context.resource_budget().unwrap_or(u64::MAX));
|
||||
let response = self.observe_entry(context, entry, request.depth, &mut remaining)?;
|
||||
let response =
|
||||
self.observe_entry(context, &request.path, entry, request.depth, &mut remaining)?;
|
||||
request.validate_response(&response).map_err(|_| {
|
||||
protocol(
|
||||
ProtocolErrorCode::Internal,
|
||||
@@ -403,7 +483,8 @@ where
|
||||
fn observe_entry(
|
||||
&self,
|
||||
context: &ExecutionContext,
|
||||
entry: B::Entry,
|
||||
path: &str,
|
||||
resolved: ResolvedEntry<B::Entry>,
|
||||
depth: u32,
|
||||
remaining: &mut u64,
|
||||
) -> Result<ObjectObservation, ProtocolError> {
|
||||
@@ -412,10 +493,19 @@ where
|
||||
return Err(resource_protocol());
|
||||
}
|
||||
*remaining -= 1;
|
||||
let object = self
|
||||
.publication(entry.clone(), context)
|
||||
.map_err(read_error)?
|
||||
.protocol_object();
|
||||
let object = match self.with_publication(context, path, resolved.clone(), |publication| {
|
||||
publication.protocol_object_at(path)
|
||||
}) {
|
||||
ProviderResult::Found(object) => object.map_err(|error| error.protocol_error())?,
|
||||
ProviderResult::NotFound => {
|
||||
return Err(protocol(
|
||||
ProtocolErrorCode::NotFound,
|
||||
"target path is not published",
|
||||
));
|
||||
}
|
||||
ProviderResult::Failure(error) => return Err(read_error(error)),
|
||||
};
|
||||
let (mut ancestors, entry) = resolved.into_parts();
|
||||
if depth == 0 {
|
||||
return Ok(ObjectObservation {
|
||||
object,
|
||||
@@ -436,13 +526,25 @@ where
|
||||
if entries.len() as u64 > *remaining {
|
||||
return Err(resource_protocol());
|
||||
}
|
||||
ancestors.push(entry.clone());
|
||||
for child in entries {
|
||||
if self
|
||||
.projection
|
||||
.policy
|
||||
.object_visible(self.principal, &child)
|
||||
{
|
||||
children.push(self.observe_entry(context, child, depth - 1, remaining)?);
|
||||
let child_path = if path == "/" {
|
||||
format!("/{}", child.name())
|
||||
} else {
|
||||
format!("{path}/{}", child.name())
|
||||
};
|
||||
children.push(self.observe_entry(
|
||||
context,
|
||||
&child_path,
|
||||
ResolvedEntry::new(ancestors.clone(), child),
|
||||
depth - 1,
|
||||
remaining,
|
||||
)?);
|
||||
} else {
|
||||
if *remaining == 0 {
|
||||
return Err(resource_protocol());
|
||||
@@ -473,11 +575,7 @@ where
|
||||
) -> ProviderResult<R> {
|
||||
match self.resolve(context, path) {
|
||||
ProviderResult::Found(resolved) => {
|
||||
let (_ancestors, entry) = resolved.into_parts();
|
||||
match self.publication(entry, context) {
|
||||
Ok(publication) => ProviderResult::Found(operation(&publication)),
|
||||
Err(error) => ProviderResult::Failure(error),
|
||||
}
|
||||
self.with_publication(context, path, resolved, operation)
|
||||
}
|
||||
ProviderResult::NotFound => ProviderResult::NotFound,
|
||||
ProviderResult::Failure(error) => ProviderResult::Failure(error),
|
||||
@@ -503,7 +601,8 @@ where
|
||||
if !self.lineage_visible(&resolved) {
|
||||
return OperationAttempt::reject(wip_host::CallErrorKind::NotFound);
|
||||
}
|
||||
let (_ancestors, entry) = resolved.into_parts();
|
||||
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,
|
||||
@@ -512,7 +611,7 @@ where
|
||||
}
|
||||
});
|
||||
}
|
||||
match self.publication(entry, context) {
|
||||
match self.publication(entry, root, context) {
|
||||
Ok(publication) => operation(&publication),
|
||||
Err(error) => OperationAttempt::reject(match error.kind() {
|
||||
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
|
||||
|
||||
@@ -17,6 +17,18 @@
|
||||
//! listener is needed to use [`ProjectionView`]. [`HttpAdapter`] applies the
|
||||
//! existing standard HTTP binding to a deployment-established principal view.
|
||||
//!
|
||||
//! # Scope publication migration
|
||||
//!
|
||||
//! 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.
|
||||
//! 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.
|
||||
//!
|
||||
//! Backends and Operations are trusted code, responsible for truthful effects,
|
||||
//! public-only metadata and ongoing execution-context bounds. Policies must be
|
||||
//! stable within a view; changing descriptor visibility requires new reference
|
||||
|
||||
@@ -1612,6 +1612,55 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_scoped_interfaces_follow_the_pinned_deployment_not_textual_replacement() {
|
||||
let temporary = tempdir().expect("temporary parent");
|
||||
let configured = temporary.path().join("root");
|
||||
let moved = temporary.path().join("moved");
|
||||
fs::create_dir(&configured).unwrap();
|
||||
fs::write(configured.join("file"), b"original").unwrap();
|
||||
let engine = projection(&configured, AccessMode::ReadOnly);
|
||||
let object = engine.observe_object("/file").unwrap();
|
||||
let reference = object.interfaces[0].clone();
|
||||
assert_eq!(reference.scope, "/");
|
||||
assert_eq!(reference.name, "file.read");
|
||||
fs::rename(&configured, &moved).unwrap();
|
||||
fs::create_dir(&configured).unwrap();
|
||||
fs::write(configured.join("file"), b"replacement").unwrap();
|
||||
let fetched = engine
|
||||
.fetch_interface(&wip_protocol::FetchInterfaceRequest {
|
||||
interface: reference.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(fetched.scope_ref.is_none());
|
||||
let result = call(
|
||||
&engine,
|
||||
"/file",
|
||||
object.validator,
|
||||
reference.clone(),
|
||||
"read",
|
||||
BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.result, Value::Bytes(b"original".to_vec()));
|
||||
let error = engine
|
||||
.call_operation(wip_host::CallRequest {
|
||||
target: wip_host::Target {
|
||||
path: "/file".into(),
|
||||
validator: None,
|
||||
},
|
||||
interface: wip_host::InterfaceTarget {
|
||||
reference,
|
||||
scope_ref: Some("invented-root-ref".into()),
|
||||
validator: None,
|
||||
},
|
||||
operation: "read".into(),
|
||||
arguments: BTreeMap::new(),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), CallErrorKind::InterfaceMismatch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_lock_wait_observes_the_operation_deadline() {
|
||||
let temporary = tempdir().expect("temporary root");
|
||||
@@ -1622,8 +1671,6 @@ mod tests {
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.expect("open lock owner");
|
||||
rustix::fs::flock(&locked, FlockOperation::LockExclusive).expect("hold external lock");
|
||||
|
||||
let limits = ResourceLimits {
|
||||
request_timeout: std::time::Duration::from_millis(20),
|
||||
..ResourceLimits::default()
|
||||
@@ -1631,6 +1678,9 @@ mod tests {
|
||||
let projection = crate::default_projection(temporary.path(), AccessMode::ReadOnly, limits)
|
||||
.expect("open projection");
|
||||
let observed = projection.observe_object("/file").expect("observe file");
|
||||
// Observation also enters the coherent backend boundary now. Acquire
|
||||
// the interfering lock only after obtaining Client-known metadata.
|
||||
rustix::fs::flock(&locked, FlockOperation::LockExclusive).expect("hold external lock");
|
||||
let started = std::time::Instant::now();
|
||||
let error = call(
|
||||
&projection,
|
||||
|
||||
@@ -10,7 +10,7 @@ use wip_fs_server::{
|
||||
use wip_host::{
|
||||
CancellationToken, EffectGuarantee, ExecutionContext, HostError, InterfaceRef,
|
||||
InterfaceRegistry, ObjectProvider, OperationAttempt, ProviderResult, PublishedObject,
|
||||
RegisteredInterface, Validator, WipObject,
|
||||
RegisteredInterface, Scope, Validator, WipObject,
|
||||
};
|
||||
use wip_http::{
|
||||
DecodedResponse, Endpoint, decode_call_operation_response, decode_fetch_interface_response,
|
||||
@@ -101,6 +101,9 @@ impl WipObject for Entry {
|
||||
fn description(&self) -> Option<&str> {
|
||||
Some("public memory entry")
|
||||
}
|
||||
fn reference(&self) -> Option<&str> {
|
||||
(self.path == "/").then_some("entry-root")
|
||||
}
|
||||
fn validator(&self) -> Option<&Validator> {
|
||||
Some(&self.validator)
|
||||
}
|
||||
@@ -157,7 +160,13 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct MemoryBackend(Arc<Shared>);
|
||||
struct MemoryBackend(Arc<Shared>, Arc<Node>);
|
||||
impl MemoryBackend {
|
||||
fn new(shared: Arc<Shared>) -> Self {
|
||||
let root = shared.node("/");
|
||||
Self(shared, root)
|
||||
}
|
||||
}
|
||||
fn check_context(context: &ExecutionContext) -> Result<(), HostError> {
|
||||
if context.cancellation().is_cancelled()
|
||||
|| context
|
||||
@@ -193,6 +202,12 @@ impl FilesystemBackend for MemoryBackend {
|
||||
// One connected map snapshot, serialized with participating mutations.
|
||||
let _transaction = self.0.transaction.lock().unwrap();
|
||||
let nodes = self.0.nodes.lock().unwrap();
|
||||
if !nodes
|
||||
.get("/")
|
||||
.is_some_and(|root| Arc::ptr_eq(root, &self.1))
|
||||
{
|
||||
return ProviderResult::NotFound;
|
||||
}
|
||||
let Some(node) = nodes.get(path) else {
|
||||
return ProviderResult::NotFound;
|
||||
};
|
||||
@@ -320,6 +335,12 @@ impl FilesystemBackend for MemoryBackend {
|
||||
// Verify lineage identity/membership within the same transaction. The
|
||||
// callback cannot see a substituted ancestor, nor a redirected target.
|
||||
let nodes = self.0.nodes.lock().unwrap();
|
||||
if !nodes
|
||||
.get("/")
|
||||
.is_some_and(|root| Arc::ptr_eq(root, &self.1))
|
||||
{
|
||||
return ProviderResult::NotFound;
|
||||
}
|
||||
for ancestor in &ancestors {
|
||||
if !nodes
|
||||
.get(&ancestor.path)
|
||||
@@ -371,6 +392,11 @@ impl WipObject for Object {
|
||||
fn description(&self) -> Option<&str> {
|
||||
self.entry.description()
|
||||
}
|
||||
fn reference(&self) -> Option<&str> {
|
||||
// Public representation identity need not be the backend entry ref.
|
||||
// ScopeObservation must capture this published Object's ref.
|
||||
(self.entry.path == "/").then_some("memory-root")
|
||||
}
|
||||
fn validator(&self) -> Option<&Validator> {
|
||||
self.entry.validator()
|
||||
}
|
||||
@@ -574,11 +600,19 @@ fn observation_and_fetch_policy_panics_remain_sanitized_read_failures() {
|
||||
for panic_object in [true, false] {
|
||||
let fixture = Fixture::new();
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let defaults = DefaultInterfaces::register(&mut registry).unwrap();
|
||||
let custom = registry.register(MemoryOperationsDefinition, None).unwrap();
|
||||
let scope = Scope::new("/").unwrap();
|
||||
let defaults = DefaultInterfaces::register(&mut registry, &scope).unwrap();
|
||||
let custom = registry
|
||||
.register(
|
||||
&scope,
|
||||
"memory.operations",
|
||||
MemoryOperationsDefinition,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let reference = custom.reference().protocol_reference();
|
||||
let engine = Projection::new(
|
||||
MemoryBackend(fixture.shared.clone()),
|
||||
MemoryBackend::new(fixture.shared.clone()),
|
||||
registry,
|
||||
MemoryComposition {
|
||||
defaults,
|
||||
@@ -619,6 +653,8 @@ struct Fixture {
|
||||
custom: wip_protocol::InterfaceReference,
|
||||
read: wip_protocol::InterfaceReference,
|
||||
limits: ResourceLimits,
|
||||
// Client-known descriptors acquired coherently before execution fault injection.
|
||||
descriptors: BTreeMap<(bool, wip_protocol::InterfaceReference), InterfaceDescriptor>,
|
||||
}
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
@@ -669,9 +705,15 @@ impl Fixture {
|
||||
seen: Mutex::new(vec![]),
|
||||
});
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let defaults = DefaultInterfaces::register(&mut registry).unwrap();
|
||||
let scope = Scope::new("/").unwrap();
|
||||
let defaults = DefaultInterfaces::register(&mut registry, &scope).unwrap();
|
||||
let custom = registry
|
||||
.register(MemoryOperationsDefinition, interface_validator)
|
||||
.register(
|
||||
&scope,
|
||||
"memory.operations",
|
||||
MemoryOperationsDefinition,
|
||||
interface_validator,
|
||||
)
|
||||
.unwrap();
|
||||
let read_ref = defaults.read.reference().clone();
|
||||
let custom_ref = custom.reference().clone();
|
||||
@@ -687,18 +729,38 @@ impl Fixture {
|
||||
custom,
|
||||
shared: shared.clone(),
|
||||
};
|
||||
let engine = Projection::new(
|
||||
MemoryBackend::new(shared.clone()),
|
||||
registry,
|
||||
composition,
|
||||
policy,
|
||||
limits,
|
||||
);
|
||||
let mut descriptors = BTreeMap::new();
|
||||
if !invalid_selection {
|
||||
for principal in [Principal::Admin, Principal::Limited] {
|
||||
for reference in [&read_ref, &custom_ref] {
|
||||
let reference = reference.protocol_reference();
|
||||
if let Ok(observation) = engine
|
||||
.view(&principal)
|
||||
.fetch_interface(&fetch_request(&reference))
|
||||
{
|
||||
descriptors.insert(
|
||||
(matches!(principal, Principal::Admin), reference),
|
||||
observation.descriptor,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
shared.seen.lock().unwrap().clear();
|
||||
Self {
|
||||
engine: Projection::new(
|
||||
MemoryBackend(shared.clone()),
|
||||
registry,
|
||||
composition,
|
||||
policy,
|
||||
limits,
|
||||
),
|
||||
engine,
|
||||
shared,
|
||||
custom: custom_ref.protocol_reference(),
|
||||
read: read_ref.protocol_reference(),
|
||||
limits,
|
||||
descriptors,
|
||||
}
|
||||
}
|
||||
fn observe(&self, principal: &Principal, path: &str, depth: u32) -> ObjectObservation {
|
||||
@@ -712,11 +774,7 @@ impl Fixture {
|
||||
principal: &Principal,
|
||||
reference: &wip_protocol::InterfaceReference,
|
||||
) -> InterfaceDescriptor {
|
||||
self.engine
|
||||
.view(principal)
|
||||
.fetch_interface(&fetch_request(reference))
|
||||
.unwrap()
|
||||
.descriptor
|
||||
self.descriptors[&(matches!(principal, Principal::Admin), reference.clone())].clone()
|
||||
}
|
||||
fn request(
|
||||
&self,
|
||||
@@ -930,7 +988,10 @@ fn external_composition_adds_replaces_and_hides_default_interfaces_by_entry_cond
|
||||
Some("public memory entry")
|
||||
);
|
||||
assert!(observation.children.is_none());
|
||||
assert!(observation.object.r#ref.is_none());
|
||||
assert_eq!(
|
||||
observation.object.r#ref.as_deref(),
|
||||
(path == "/").then_some("memory-root")
|
||||
);
|
||||
for reference in &observation.object.interfaces {
|
||||
let fetched = fixture
|
||||
.engine
|
||||
@@ -1383,10 +1444,12 @@ fn policy_cannot_add_an_unimplemented_interface() {
|
||||
ProtocolErrorCode::Internal
|
||||
);
|
||||
let request = fixture.request("/plain", &fixture.custom, "denied", None);
|
||||
// This deliberately invalid publication cannot supply a fetch response.
|
||||
// Use the application's authored schema solely to encode the test request.
|
||||
let (response, decoded) = fixture.http_call(
|
||||
&Principal::Admin,
|
||||
&request,
|
||||
&fixture.descriptor(&Principal::Admin, &fixture.custom),
|
||||
&MemoryOperationsDefinition::descriptor(),
|
||||
&ExecutionContext::new(),
|
||||
);
|
||||
wire_error(decoded, ProtocolErrorCode::Internal);
|
||||
@@ -2036,3 +2099,150 @@ fn observation_depth_node_and_context_budgets_fail_without_truncating_the_tree()
|
||||
);
|
||||
no_secrets(&response);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_scope_fetch_and_call_use_the_current_boundary_snapshot() {
|
||||
let fixture = Fixture::new();
|
||||
let view = fixture.engine.view(&Principal::Admin);
|
||||
let fetched = view
|
||||
.fetch_interface(&fetch_request(&fixture.custom))
|
||||
.unwrap();
|
||||
assert_eq!(fetched.scope_ref.as_deref(), Some("memory-root"));
|
||||
assert!(
|
||||
fixture
|
||||
.observe(&Principal::Admin, "/", 0)
|
||||
.object
|
||||
.interfaces
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
let mut request = fixture.request("/added", &fixture.custom, "denied", None);
|
||||
request.interface.scope_ref = Some("wrong-root".into());
|
||||
assert_eq!(
|
||||
view.call_operation(request.clone()).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.shared
|
||||
.node("/added")
|
||||
.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
// Ordinary state updates do not replace the publication lifetime or ref.
|
||||
fixture.shared.node("/").data.lock().unwrap().revision += 1;
|
||||
request.interface.scope_ref = fetched.scope_ref;
|
||||
view.call_operation(request).unwrap();
|
||||
|
||||
// Fetch cannot attest a stale prelookup: visibility is refreshed under the
|
||||
// same boundary even though resolve initially sees a visible root.
|
||||
fixture.shared.controls.lock().unwrap().hide_before_execute = true;
|
||||
assert_eq!(
|
||||
view.fetch_interface(&fetch_request(&fixture.custom))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_scope_replacement_during_call_rejects_the_prelookup() {
|
||||
let fixture = Fixture::new();
|
||||
let view = fixture.engine.view(&Principal::Admin);
|
||||
// Simulate a retained non-root lineage whose root is replaced before call.
|
||||
fixture.shared.controls.lock().unwrap().replace_ancestor = Some("/".into());
|
||||
let request = fixture.request("/added", &fixture.custom, "denied", None);
|
||||
assert_eq!(
|
||||
view.call_operation(request).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::NotFound
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.shared
|
||||
.node("/added")
|
||||
.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
view.fetch_interface(&fetch_request(&fixture.custom))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
|
||||
let fixture = Fixture::new();
|
||||
fixture.shared.controls.lock().unwrap().replace_ancestor = Some("/".into());
|
||||
assert_eq!(
|
||||
fixture
|
||||
.engine
|
||||
.view(&Principal::Admin)
|
||||
.fetch_interface(&fetch_request(&fixture.custom))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonroot_registrations_fail_closed_until_the_engine_can_attest_their_lifetimes() {
|
||||
let fixture = Fixture::new();
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let root = Scope::new("/").unwrap();
|
||||
let local = Scope::new("/added").unwrap();
|
||||
let defaults = DefaultInterfaces::register(&mut registry, &root).unwrap();
|
||||
let custom = registry
|
||||
.register(
|
||||
&local,
|
||||
"memory.operations",
|
||||
MemoryOperationsDefinition,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let reference = custom.reference().protocol_reference();
|
||||
let policy = Policy {
|
||||
custom: custom.reference().clone(),
|
||||
read: defaults.read.reference().clone(),
|
||||
hide_read: false,
|
||||
invalid_selection: false,
|
||||
panic_authorize: false,
|
||||
};
|
||||
let engine = Projection::new(
|
||||
MemoryBackend::new(fixture.shared.clone()),
|
||||
registry,
|
||||
MemoryComposition {
|
||||
defaults,
|
||||
custom,
|
||||
shared: fixture.shared.clone(),
|
||||
},
|
||||
policy,
|
||||
fixture.limits,
|
||||
);
|
||||
let view = engine.view(&Principal::Admin);
|
||||
let object = view.observe_object("/added").unwrap();
|
||||
assert!(!object.interfaces.contains(&reference));
|
||||
assert_eq!(
|
||||
view.fetch_interface(&fetch_request(&reference))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
let request = fixture.request("/added", &reference, "denied", None);
|
||||
assert_eq!(
|
||||
view.call_operation(request).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.shared
|
||||
.node("/added")
|
||||
.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user