fix: validate resolved ancestry inside atomic projection dispatch
Resolve one connected lineage and retain its root-to-parent capabilities. Backends validate ancestor identity/membership and refresh all policy metadata inside the same boundary as target validators and typed dispatch; hidden/replaced ancestors reject as NotFound without invocation or effects. Preserve pinned leaf and configured-root capabilities. Covers MR ANCESTOR_VISIBILITY_TOCTOU with HTTP memory and Linux replacement/budget/guard regressions; all T-19 workspace acceptance commands pass (Clippy via Nix shell).
This commit is contained in:
@@ -23,8 +23,9 @@ For an external backend/composition:
|
|||||||
|
|
||||||
1. Implement `BackendEntry` on a local, cloneable, root-confined stable capability.
|
1. Implement `BackendEntry` on a local, cloneable, root-confined stable capability.
|
||||||
Its `WipObject` metadata is public-only; its validator binds identity and the
|
Its `WipObject` metadata is public-only; its validator binds identity and the
|
||||||
protected state. Implement `FilesystemBackend` to resolve capabilities,
|
protected state. Implement `FilesystemBackend` to resolve one connected
|
||||||
enumerate bounded children, and execute against an already-resolved entry.
|
`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
|
2. Register generated `#[wip_interface::interface]` definitions using the existing
|
||||||
`wip_host::InterfaceRegistry`. `DefaultInterfaces::register` optionally supplies
|
`wip_host::InterfaceRegistry`. `DefaultInterfaces::register` optionally supplies
|
||||||
reusable built-in tokens; using those tokens does not force their publication.
|
reusable built-in tokens; using those tokens does not force their publication.
|
||||||
@@ -55,10 +56,26 @@ listener, a global object registry, or dynamic plugin loading.
|
|||||||
### Extension security contract
|
### Extension security contract
|
||||||
|
|
||||||
Backend implementations and Operation authors are trusted code, not sandboxed
|
Backend implementations and Operation authors are trusted code, not sandboxed
|
||||||
plugins. `FilesystemBackend::execute` must refresh state/validators **inside** its
|
plugins. `FilesystemBackend::resolve` returns `ResolvedEntry::new(ancestors, target)`
|
||||||
transaction or lock before calling the engine callback, retain the same resolved
|
from one connected backend traversal/snapshot, not independent path-prefix
|
||||||
target through validation and dispatch, and return the committed post-operation
|
resolutions. A root target has no ancestors; other targets retain the root first,
|
||||||
validator. Confirm rollback only after rollback succeeds. Pre-dispatch failures
|
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`.
|
||||||
|
|
||||||
|
`FilesystemBackend::execute` must validate ancestor membership/identity and
|
||||||
|
refresh **all** lineage state/validators inside its transaction or lock before
|
||||||
|
calling the engine callback, retaining the same target and connected ancestry
|
||||||
|
through validation and dispatch. Replaced/detached ancestors must return
|
||||||
|
`NotFound` without callback invocation, even when the replacement is visible.
|
||||||
|
The engine evaluates Object visibility for every refreshed ancestor and target
|
||||||
|
inside that same boundary; a hidden ancestor rejects the entire subtree before
|
||||||
|
publication/typed dispatch. Never fix a changed lineage by resolving the raw path
|
||||||
|
again and substituting capabilities. Participating policy-relevant backend state
|
||||||
|
changes must be serialized by this boundary, not just target-content writes.
|
||||||
|
Return the committed post-operation validator. Retained ancestry is subject to
|
||||||
|
handle/work ceilings; resource exhaustion is an error, not missing ancestry.
|
||||||
|
Confirm rollback only after rollback succeeds. Pre-dispatch failures
|
||||||
must never commit; failed started operations without a non-commit guarantee remain
|
must never commit; failed started operations without a non-commit guarantee remain
|
||||||
`OperationOutcomeUnknown`. Object implementations must delegate validators to
|
`OperationOutcomeUnknown`. Object implementations must delegate validators to
|
||||||
that entry and declare requirements per `(InterfaceRef, operation)`, rather than
|
that entry and declare requirements per `(InterfaceRef, operation)`, rather than
|
||||||
@@ -68,13 +85,16 @@ cancellation and resource budgets throughout backend work and report effects
|
|||||||
truthfully. POSIX symlinks, identity, atomic mutations and locking cannot be assumed
|
truthfully. POSIX symlinks, identity, atomic mutations and locking cannot be assumed
|
||||||
for other backends.
|
for other backends.
|
||||||
|
|
||||||
The default Linux backend uses advisory exclusive locks on regular files,
|
The default Linux backend acquires advisory exclusive locks in root-to-parent
|
||||||
refreshes validators after acquiring the lock and holds it through post-dispatch
|
order and then on the lockable target (directory or regular file). It compares
|
||||||
state observation. This serializes cooperating library calls; it does **not**
|
retained ancestor identity to no-follow descriptor-relative lookup results,
|
||||||
promise transactional isolation from unrelated processes ignoring `flock`.
|
refreshes lineage snapshots after acquiring the locks, and holds the guards
|
||||||
Descriptor-relative pinned handles still prevent replacement from redirecting an
|
through post-dispatch state observation. The configured root remains its pinned
|
||||||
operation to a different file. Linux capabilities only expose bounded methods,
|
capability even if its textual path is renamed; replaced intermediate ancestors
|
||||||
not raw descriptors or backend paths.
|
are rejected. Leaf path replacement never redirects execution to a new inode.
|
||||||
|
This serializes cooperating library calls; it does **not** promise transactional
|
||||||
|
isolation from unrelated processes ignoring `flock`. Linux capabilities only
|
||||||
|
expose bounded methods, not raw descriptors or backend paths.
|
||||||
|
|
||||||
A policy must be stable/deterministic within one effective Worldspace view.
|
A policy must be stable/deterministic within one effective Worldspace view.
|
||||||
Interface/operation visibility is uniform per registered reference within that
|
Interface/operation visibility is uniform per registered reference within that
|
||||||
|
|||||||
@@ -32,6 +32,39 @@ pub trait BackendEntry: WipObject + Clone {
|
|||||||
fn kind(&self) -> EntryKind;
|
fn kind(&self) -> EntryKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A single resolved target together with its retained root-to-parent lineage.
|
||||||
|
///
|
||||||
|
/// The backend must resolve a connected lineage, never independent raw-path
|
||||||
|
/// prefixes. At execution it validates ancestor membership/identity, refreshes
|
||||||
|
/// their policy metadata, and pins that snapshot through dispatch. Target
|
||||||
|
/// replacement must not redirect the original capability. This value is private
|
||||||
|
/// backend context, not protocol metadata.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ResolvedEntry<E> {
|
||||||
|
ancestors: Vec<E>,
|
||||||
|
target: E,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<E> ResolvedEntry<E> {
|
||||||
|
/// Create backend resolution context. `/` has no ancestors; a non-root target
|
||||||
|
/// has the pinned root first, followed by its intermediate parent containers.
|
||||||
|
pub fn new(ancestors: Vec<E>, target: E) -> Self {
|
||||||
|
Self { ancestors, target }
|
||||||
|
}
|
||||||
|
/// Pinned ancestor capabilities, ordered from the root to the direct parent.
|
||||||
|
pub fn ancestors(&self) -> &[E] {
|
||||||
|
&self.ancestors
|
||||||
|
}
|
||||||
|
/// The original resolved target capability, never a redirected replacement.
|
||||||
|
pub fn target(&self) -> &E {
|
||||||
|
&self.target
|
||||||
|
}
|
||||||
|
/// Consume this snapshot for refresh or request-time publication.
|
||||||
|
pub fn into_parts(self) -> (Vec<E>, E) {
|
||||||
|
(self.ancestors, self.target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Trusted backend boundary. Implementations need not use POSIX semantics.
|
/// Trusted backend boundary. Implementations need not use POSIX semantics.
|
||||||
///
|
///
|
||||||
/// Resolve and enumerate pinned capabilities within the configured root. Enforce
|
/// Resolve and enumerate pinned capabilities within the configured root. Enforce
|
||||||
@@ -39,17 +72,28 @@ pub trait BackendEntry: WipObject + Clone {
|
|||||||
/// advertise capabilities the backend cannot guarantee. Errors must not include
|
/// advertise capabilities the backend cannot guarantee. Errors must not include
|
||||||
/// backend paths, credentials or hidden metadata.
|
/// backend paths, credentials or hidden metadata.
|
||||||
///
|
///
|
||||||
/// `execute` must retain the same target and serialize validator observation,
|
/// `execute` must retain the same target and complete lineage and serialize
|
||||||
/// publication, validation, typed dispatch and commit/rollback. Refresh entry
|
/// ancestor identity/membership checks, policy metadata, validator observation,
|
||||||
/// validators *inside* the boundary before invoking the callback, and return the
|
/// publication, validation, typed dispatch and commit/rollback. Changed/detached
|
||||||
/// committed post-operation validator on success. Never upgrade an unknown
|
/// ancestors must return `NotFound` before the callback; never substitute a new
|
||||||
/// outcome except after confirmed rollback. Locks only protect against actors
|
/// ancestor or redirect the target by re-resolving its raw path. Refresh *all*
|
||||||
/// participating in that locking protocol; document any external-writer limits.
|
/// lineage metadata/validators inside the boundary and retain that snapshot
|
||||||
|
/// through dispatch, then return the committed post-operation validator. Never
|
||||||
|
/// upgrade an unknown outcome except after confirmed rollback. Locks only protect
|
||||||
|
/// actors participating in the protocol; document any external-writer limits.
|
||||||
pub trait FilesystemBackend {
|
pub trait FilesystemBackend {
|
||||||
/// Stable capability supplied to the composition.
|
/// Stable capability supplied to the composition.
|
||||||
type Entry: BackendEntry;
|
type Entry: BackendEntry;
|
||||||
/// Resolve a canonical WIP path without escaping the configured root.
|
/// Resolve a canonical WIP path and retain its connected ancestry.
|
||||||
fn resolve(&self, context: &ExecutionContext, path: &str) -> ProviderResult<Self::Entry>;
|
///
|
||||||
|
/// Obtain the target and lineage in one backend traversal/snapshot. Enforce
|
||||||
|
/// configured handle/work budgets while retaining ancestors; do not silently
|
||||||
|
/// omit ancestry on resource exhaustion.
|
||||||
|
fn resolve(
|
||||||
|
&self,
|
||||||
|
context: &ExecutionContext,
|
||||||
|
path: &str,
|
||||||
|
) -> ProviderResult<ResolvedEntry<Self::Entry>>;
|
||||||
/// Enumerate direct children, at most `maximum`; excess must fail, not truncate.
|
/// Enumerate direct children, at most `maximum`; excess must fail, not truncate.
|
||||||
fn children(
|
fn children(
|
||||||
&self,
|
&self,
|
||||||
@@ -61,8 +105,8 @@ pub trait FilesystemBackend {
|
|||||||
fn execute(
|
fn execute(
|
||||||
&self,
|
&self,
|
||||||
context: &ExecutionContext,
|
context: &ExecutionContext,
|
||||||
entry: Self::Entry,
|
entry: ResolvedEntry<Self::Entry>,
|
||||||
operation: impl FnOnce(Self::Entry) -> OperationAttempt,
|
operation: impl FnOnce(ResolvedEntry<Self::Entry>) -> OperationAttempt,
|
||||||
) -> ProviderResult<OperationAttempt>;
|
) -> ProviderResult<OperationAttempt>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,39 +249,40 @@ where
|
|||||||
C: Composition<B::Entry>,
|
C: Composition<B::Entry>,
|
||||||
P: ProjectionPolicy<B::Entry, C::Object>,
|
P: ProjectionPolicy<B::Entry, C::Object>,
|
||||||
{
|
{
|
||||||
fn resolve(&self, context: &ExecutionContext, path: &str) -> ProviderResult<B::Entry> {
|
fn resolve(
|
||||||
|
&self,
|
||||||
|
context: &ExecutionContext,
|
||||||
|
path: &str,
|
||||||
|
) -> ProviderResult<ResolvedEntry<B::Entry>> {
|
||||||
if let Err(error) = preflight(context) {
|
if let Err(error) = preflight(context) {
|
||||||
return ProviderResult::Failure(error);
|
return ProviderResult::Failure(error);
|
||||||
}
|
}
|
||||||
if wip_protocol::validate_path(path).is_err() {
|
if wip_protocol::validate_path(path).is_err() {
|
||||||
return ProviderResult::NotFound;
|
return ProviderResult::NotFound;
|
||||||
}
|
}
|
||||||
// Check ancestors as well: guessing a descendant cannot bypass hidden parents.
|
match self.projection.backend.resolve(context, path) {
|
||||||
let mut end = 1;
|
ProviderResult::Found(resolved) => {
|
||||||
loop {
|
if !lineage_matches_path(&resolved, path) {
|
||||||
let prefix = if end == 1 { "/" } else { &path[..end] };
|
return ProviderResult::Failure(internal());
|
||||||
let entry = match self.projection.backend.resolve(context, prefix) {
|
}
|
||||||
ProviderResult::Found(entry) => entry,
|
if !self.lineage_visible(&resolved) {
|
||||||
ProviderResult::NotFound => return ProviderResult::NotFound,
|
|
||||||
ProviderResult::Failure(error) => return ProviderResult::Failure(error),
|
|
||||||
};
|
|
||||||
if !self
|
|
||||||
.projection
|
|
||||||
.policy
|
|
||||||
.object_visible(self.principal, &entry)
|
|
||||||
{
|
|
||||||
return ProviderResult::NotFound;
|
return ProviderResult::NotFound;
|
||||||
}
|
}
|
||||||
if prefix == path {
|
ProviderResult::Found(resolved)
|
||||||
return ProviderResult::Found(entry);
|
|
||||||
}
|
}
|
||||||
let start = if end == 1 { 1 } else { end + 1 };
|
ProviderResult::NotFound => ProviderResult::NotFound,
|
||||||
end = path[start..]
|
ProviderResult::Failure(error) => ProviderResult::Failure(error),
|
||||||
.find('/')
|
|
||||||
.map_or(path.len(), |index| start + index);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn lineage_visible(&self, resolved: &ResolvedEntry<B::Entry>) -> bool {
|
||||||
|
resolved
|
||||||
|
.ancestors()
|
||||||
|
.iter()
|
||||||
|
.chain(std::iter::once(resolved.target()))
|
||||||
|
.all(|entry| self.projection.policy.object_visible(self.principal, entry))
|
||||||
|
}
|
||||||
|
|
||||||
fn publication(
|
fn publication(
|
||||||
&self,
|
&self,
|
||||||
entry: B::Entry,
|
entry: B::Entry,
|
||||||
@@ -342,6 +387,7 @@ where
|
|||||||
}
|
}
|
||||||
ProviderResult::Failure(error) => return Err(read_error(error)),
|
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)
|
let mut remaining = u64::from(self.projection.limits.max_observation_nodes)
|
||||||
.min(context.resource_budget().unwrap_or(u64::MAX));
|
.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, entry, request.depth, &mut remaining)?;
|
||||||
@@ -426,10 +472,13 @@ where
|
|||||||
operation: impl FnOnce(&PublishedObject<Self::Object>) -> R,
|
operation: impl FnOnce(&PublishedObject<Self::Object>) -> R,
|
||||||
) -> ProviderResult<R> {
|
) -> ProviderResult<R> {
|
||||||
match self.resolve(context, path) {
|
match self.resolve(context, path) {
|
||||||
ProviderResult::Found(entry) => match self.publication(entry, context) {
|
ProviderResult::Found(resolved) => {
|
||||||
|
let (_ancestors, entry) = resolved.into_parts();
|
||||||
|
match self.publication(entry, context) {
|
||||||
Ok(publication) => ProviderResult::Found(operation(&publication)),
|
Ok(publication) => ProviderResult::Found(operation(&publication)),
|
||||||
Err(error) => ProviderResult::Failure(error),
|
Err(error) => ProviderResult::Failure(error),
|
||||||
},
|
}
|
||||||
|
}
|
||||||
ProviderResult::NotFound => ProviderResult::NotFound,
|
ProviderResult::NotFound => ProviderResult::NotFound,
|
||||||
ProviderResult::Failure(error) => ProviderResult::Failure(error),
|
ProviderResult::Failure(error) => ProviderResult::Failure(error),
|
||||||
}
|
}
|
||||||
@@ -445,16 +494,16 @@ where
|
|||||||
ProviderResult::NotFound => return ProviderResult::NotFound,
|
ProviderResult::NotFound => return ProviderResult::NotFound,
|
||||||
ProviderResult::Failure(error) => return ProviderResult::Failure(error),
|
ProviderResult::Failure(error) => return ProviderResult::Failure(error),
|
||||||
};
|
};
|
||||||
self.projection.backend.execute(context, entry, |entry| {
|
self.projection.backend.execute(context, entry, |resolved| {
|
||||||
// Recheck visibility using the atomic-boundary snapshot.
|
// Backend validates retained lineage membership/identity and refreshes
|
||||||
// Failure here is still pre-dispatch, with no operation effects.
|
// all metadata before this callback under the same boundary as dispatch.
|
||||||
if !self
|
if !lineage_matches_path(&resolved, path) {
|
||||||
.projection
|
return OperationAttempt::reject(wip_host::CallErrorKind::Internal);
|
||||||
.policy
|
}
|
||||||
.object_visible(self.principal, &entry)
|
if !self.lineage_visible(&resolved) {
|
||||||
{
|
|
||||||
return OperationAttempt::reject(wip_host::CallErrorKind::NotFound);
|
return OperationAttempt::reject(wip_host::CallErrorKind::NotFound);
|
||||||
}
|
}
|
||||||
|
let (_ancestors, entry) = resolved.into_parts();
|
||||||
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,
|
||||||
@@ -544,6 +593,24 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn lineage_matches_path<E: BackendEntry>(resolved: &ResolvedEntry<E>, path: &str) -> bool {
|
||||||
|
let names: Vec<_> = if path == "/" {
|
||||||
|
vec![""]
|
||||||
|
} else {
|
||||||
|
std::iter::once("").chain(path[1..].split('/')).collect()
|
||||||
|
};
|
||||||
|
if resolved.ancestors().len() + 1 != names.len()
|
||||||
|
|| resolved.target().name() != names[names.len() - 1]
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
resolved
|
||||||
|
.ancestors()
|
||||||
|
.iter()
|
||||||
|
.zip(names)
|
||||||
|
.all(|(entry, name)| entry.kind() == EntryKind::Directory && entry.name() == name)
|
||||||
|
}
|
||||||
|
|
||||||
fn read_boundary<T>(
|
fn read_boundary<T>(
|
||||||
operation: impl FnOnce() -> Result<T, ProtocolError>,
|
operation: impl FnOnce() -> Result<T, ProtocolError>,
|
||||||
) -> Result<T, ProtocolError> {
|
) -> Result<T, ProtocolError> {
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ use std::time::Duration;
|
|||||||
pub use defaults::{DefaultComposition, DefaultInterfaces, DefaultObject, default_projection};
|
pub use defaults::{DefaultComposition, DefaultInterfaces, DefaultObject, default_projection};
|
||||||
pub use engine::{
|
pub use engine::{
|
||||||
BackendEntry, Composition, DefaultPolicy, EntryKind, FilesystemBackend, Projection,
|
BackendEntry, Composition, DefaultPolicy, EntryKind, FilesystemBackend, Projection,
|
||||||
ProjectionPolicy, ProjectionView,
|
ProjectionPolicy, ProjectionView, ResolvedEntry,
|
||||||
};
|
};
|
||||||
pub use http_adapter::HttpAdapter;
|
pub use http_adapter::HttpAdapter;
|
||||||
pub use interfaces::{
|
pub use interfaces::{
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ use std::sync::{Arc, Mutex, MutexGuard, TryLockError};
|
|||||||
|
|
||||||
use crate::engine::{BackendEntry, EntryKind, FilesystemBackend};
|
use crate::engine::{BackendEntry, EntryKind, FilesystemBackend};
|
||||||
use crate::interfaces::{FileRead, FileWrite, SymlinkInspect};
|
use crate::interfaces::{FileRead, FileWrite, SymlinkInspect};
|
||||||
use crate::{AccessMode, ResourceLimits};
|
use crate::{AccessMode, ResolvedEntry, ResourceLimits};
|
||||||
use rustix::fs::{
|
use rustix::fs::{
|
||||||
CWD, FileType, FlockOperation, Mode as FsMode, OFlags, RawDir, flock, fstat, ftruncate, openat,
|
CWD, FileType, FlockOperation, Mode as FsMode, OFlags, RawDir, flock, fstat, ftruncate, openat,
|
||||||
readlinkat,
|
readlinkat,
|
||||||
};
|
};
|
||||||
use rustix::io::{dup, pread, pwrite};
|
use rustix::io::{pread, pwrite};
|
||||||
use wip_host::{
|
use wip_host::{
|
||||||
EffectGuarantee, ExecutionContext, HostError, OperationAttempt, ProviderResult, Validator,
|
EffectGuarantee, ExecutionContext, HostError, OperationAttempt, ProviderResult, Validator,
|
||||||
WipObject,
|
WipObject,
|
||||||
@@ -130,6 +130,7 @@ impl LinuxEntry {
|
|||||||
|
|
||||||
fn refresh_validator(&mut self) -> Result<(), ProjectionError> {
|
fn refresh_validator(&mut self) -> Result<(), ProjectionError> {
|
||||||
let stat = fstat(self.fd()).map_err(|_| ProjectionError::Internal)?;
|
let stat = fstat(self.fd()).map_err(|_| ProjectionError::Internal)?;
|
||||||
|
self.kind = entry_kind(stat.st_mode);
|
||||||
let validator = matches!(self.kind, EntryKind::Regular | EntryKind::Symlink)
|
let validator = matches!(self.kind, EntryKind::Regular | EntryKind::Symlink)
|
||||||
.then(|| validator_from_stat(&stat));
|
.then(|| validator_from_stat(&stat));
|
||||||
self.initial_validator = validator.clone();
|
self.initial_validator = validator.clone();
|
||||||
@@ -180,10 +181,12 @@ enum FailureInjection {
|
|||||||
|
|
||||||
/// Linux filesystem backend using pinned, no-follow handles.
|
/// Linux filesystem backend using pinned, no-follow handles.
|
||||||
///
|
///
|
||||||
/// Regular-file execution holds an advisory exclusive `flock` across validator
|
/// Execution holds advisory exclusive `flock` guards on directories in
|
||||||
/// refresh, dispatch, and post-operation observation. Serialization only covers
|
/// root-to-parent order and on directory or regular-file targets across lineage
|
||||||
/// actors that participate in this locking protocol; external writers can still
|
/// validation, validator refresh, dispatch, and post-operation observation.
|
||||||
/// modify the inode without taking the lock. Symlinks are inspected, not followed.
|
/// Serialization only covers actors that participate in this locking protocol;
|
||||||
|
/// external writers can still mutate entries without taking the locks. Symlinks
|
||||||
|
/// are inspected, not followed.
|
||||||
pub struct LinuxBackend {
|
pub struct LinuxBackend {
|
||||||
root: OwnedFd,
|
root: OwnedFd,
|
||||||
access: AccessMode,
|
access: AccessMode,
|
||||||
@@ -220,22 +223,28 @@ impl LinuxBackend {
|
|||||||
&self,
|
&self,
|
||||||
context: &ExecutionContext,
|
context: &ExecutionContext,
|
||||||
path: &str,
|
path: &str,
|
||||||
) -> Result<LinuxEntry, ProjectionError> {
|
) -> Result<ResolvedEntry<LinuxEntry>, ProjectionError> {
|
||||||
check_context(context)?;
|
check_context(context)?;
|
||||||
wip_protocol::validate_path(path).map_err(|_| ProjectionError::NotFound)?;
|
wip_protocol::validate_path(path).map_err(|_| ProjectionError::NotFound)?;
|
||||||
|
let root = self.entry(
|
||||||
|
context,
|
||||||
|
String::new(),
|
||||||
|
reopen(&self.root, OFlags::RDONLY | OFlags::DIRECTORY)?,
|
||||||
|
)?;
|
||||||
if path == "/" {
|
if path == "/" {
|
||||||
let fd = reopen(&self.root, OFlags::RDONLY | OFlags::DIRECTORY)?;
|
return Ok(ResolvedEntry::new(Vec::new(), root));
|
||||||
return self.entry(context, String::new(), fd);
|
|
||||||
}
|
}
|
||||||
let mut parent = dup(&self.root).map_err(|_| ProjectionError::Internal)?;
|
// Retain the exact directories traversed, rather than resolving prefixes again.
|
||||||
|
let mut ancestors = vec![root];
|
||||||
let mut segments = path[1..].split('/').peekable();
|
let mut segments = path[1..].split('/').peekable();
|
||||||
while let Some(segment) = segments.next() {
|
while let Some(segment) = segments.next() {
|
||||||
check_context(context)?;
|
check_context(context)?;
|
||||||
if segment.as_bytes().contains(&0) {
|
if segment.as_bytes().contains(&0) {
|
||||||
return Err(ProjectionError::NotFound);
|
return Err(ProjectionError::NotFound);
|
||||||
}
|
}
|
||||||
|
let parent = ancestors.last().ok_or(ProjectionError::Internal)?;
|
||||||
let probe = openat(
|
let probe = openat(
|
||||||
&parent,
|
parent.fd(),
|
||||||
segment,
|
segment,
|
||||||
OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||||
FsMode::empty(),
|
FsMode::empty(),
|
||||||
@@ -243,14 +252,15 @@ impl LinuxBackend {
|
|||||||
.map_err(classify_lookup)?;
|
.map_err(classify_lookup)?;
|
||||||
let stat = fstat(&probe).map_err(|_| ProjectionError::Internal)?;
|
let stat = fstat(&probe).map_err(|_| ProjectionError::Internal)?;
|
||||||
let kind = entry_kind(stat.st_mode);
|
let kind = entry_kind(stat.st_mode);
|
||||||
if segments.peek().is_some() {
|
if segments.peek().is_some() && kind != EntryKind::Directory {
|
||||||
if kind != EntryKind::Directory {
|
|
||||||
return Err(ProjectionError::NotFound);
|
return Err(ProjectionError::NotFound);
|
||||||
}
|
}
|
||||||
parent = reopen(&probe, OFlags::RDONLY | OFlags::DIRECTORY)?;
|
|
||||||
} else {
|
|
||||||
let fd = self.reopen_final(probe, kind)?;
|
let fd = self.reopen_final(probe, kind)?;
|
||||||
return self.entry(context, segment.to_owned(), fd);
|
let entry = self.entry(context, segment.to_owned(), fd)?;
|
||||||
|
if segments.peek().is_some() {
|
||||||
|
ancestors.push(entry);
|
||||||
|
} else {
|
||||||
|
return Ok(ResolvedEntry::new(ancestors, entry));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(ProjectionError::NotFound)
|
Err(ProjectionError::NotFound)
|
||||||
@@ -323,6 +333,49 @@ impl LinuxBackend {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_ancestry(
|
||||||
|
&self,
|
||||||
|
context: &ExecutionContext,
|
||||||
|
ancestors: &[LinuxEntry],
|
||||||
|
target: &LinuxEntry,
|
||||||
|
) -> Result<(), ProjectionError> {
|
||||||
|
check_context(context)?;
|
||||||
|
let root = ancestors.first().unwrap_or(target);
|
||||||
|
let configured = fstat(&self.root).map_err(|_| ProjectionError::Internal)?;
|
||||||
|
let retained = fstat(root.fd()).map_err(|_| ProjectionError::Internal)?;
|
||||||
|
// The configured root is a capability, not its possibly renamed textual path.
|
||||||
|
if entry_kind(retained.st_mode) != EntryKind::Directory
|
||||||
|
|| retained.st_dev != configured.st_dev
|
||||||
|
|| retained.st_ino != configured.st_ino
|
||||||
|
{
|
||||||
|
return Err(ProjectionError::NotFound);
|
||||||
|
}
|
||||||
|
for pair in ancestors.windows(2) {
|
||||||
|
check_context(context)?;
|
||||||
|
let parent = &pair[0];
|
||||||
|
let child = &pair[1];
|
||||||
|
let visible = openat(
|
||||||
|
parent.fd(),
|
||||||
|
child.name.as_str(),
|
||||||
|
OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||||
|
FsMode::empty(),
|
||||||
|
)
|
||||||
|
.map_err(classify_lookup)?;
|
||||||
|
let visible = fstat(&visible).map_err(|_| ProjectionError::Internal)?;
|
||||||
|
let retained = fstat(child.fd()).map_err(|_| ProjectionError::Internal)?;
|
||||||
|
if entry_kind(visible.st_mode) != EntryKind::Directory
|
||||||
|
|| entry_kind(retained.st_mode) != EntryKind::Directory
|
||||||
|
|| visible.st_dev != retained.st_dev
|
||||||
|
|| visible.st_ino != retained.st_ino
|
||||||
|
{
|
||||||
|
return Err(ProjectionError::NotFound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Deliberately do not look up the target name: leaf replacement must not
|
||||||
|
// redirect execution away from its original pinned inode.
|
||||||
|
check_context(context)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn set_injection(&self, injection: FailureInjection) {
|
fn set_injection(&self, injection: FailureInjection) {
|
||||||
*self
|
*self
|
||||||
@@ -341,7 +394,11 @@ impl BackendEntry for LinuxEntry {
|
|||||||
impl FilesystemBackend for LinuxBackend {
|
impl FilesystemBackend for LinuxBackend {
|
||||||
type Entry = LinuxEntry;
|
type Entry = LinuxEntry;
|
||||||
|
|
||||||
fn resolve(&self, context: &ExecutionContext, path: &str) -> ProviderResult<LinuxEntry> {
|
fn resolve(
|
||||||
|
&self,
|
||||||
|
context: &ExecutionContext,
|
||||||
|
path: &str,
|
||||||
|
) -> ProviderResult<ResolvedEntry<LinuxEntry>> {
|
||||||
match self.resolve_entry(context, path) {
|
match self.resolve_entry(context, path) {
|
||||||
Ok(entry) => ProviderResult::Found(entry),
|
Ok(entry) => ProviderResult::Found(entry),
|
||||||
Err(error) => error.host(),
|
Err(error) => error.host(),
|
||||||
@@ -393,16 +450,32 @@ impl FilesystemBackend for LinuxBackend {
|
|||||||
fn execute(
|
fn execute(
|
||||||
&self,
|
&self,
|
||||||
context: &ExecutionContext,
|
context: &ExecutionContext,
|
||||||
mut entry: LinuxEntry,
|
resolved: ResolvedEntry<LinuxEntry>,
|
||||||
callback: impl FnOnce(LinuxEntry) -> OperationAttempt,
|
callback: impl FnOnce(ResolvedEntry<LinuxEntry>) -> OperationAttempt,
|
||||||
) -> ProviderResult<OperationAttempt> {
|
) -> ProviderResult<OperationAttempt> {
|
||||||
if let Err(error) = check_context(context) {
|
if let Err(error) = check_context(context) {
|
||||||
return error.host();
|
return error.host();
|
||||||
}
|
}
|
||||||
let lock_entry = Arc::clone(&entry.inner);
|
let (mut ancestors, mut entry) = resolved.into_parts();
|
||||||
let _lock = if entry.kind == EntryKind::Regular {
|
// Independent resolutions have independent open-file descriptions; clones
|
||||||
match lock_with_context(&lock_entry, context, self.limits.request_timeout) {
|
// also need the per-entry mutex because flock alone treats them as one owner.
|
||||||
Ok(lock) => Some(lock),
|
// Always acquire directories root-to-parent, then the target (if lockable).
|
||||||
|
let mut lock_entries: Vec<_> = ancestors
|
||||||
|
.iter()
|
||||||
|
.map(|ancestor| Arc::clone(&ancestor.inner))
|
||||||
|
.collect();
|
||||||
|
if matches!(entry.kind, EntryKind::Directory | EntryKind::Regular) {
|
||||||
|
lock_entries.push(Arc::clone(&entry.inner));
|
||||||
|
}
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let mut locks = Vec::with_capacity(lock_entries.len());
|
||||||
|
for inner in &lock_entries {
|
||||||
|
let remaining = self
|
||||||
|
.limits
|
||||||
|
.request_timeout
|
||||||
|
.saturating_sub(started.elapsed());
|
||||||
|
match lock_with_context(inner, context, remaining) {
|
||||||
|
Ok(lock) => locks.push(lock),
|
||||||
Err(ProjectionError::Resource) => {
|
Err(ProjectionError::Resource) => {
|
||||||
return ProviderResult::Failure(HostError::resource_limit(
|
return ProviderResult::Failure(HostError::resource_limit(
|
||||||
"filesystem lock deadline exceeded",
|
"filesystem lock deadline exceeded",
|
||||||
@@ -416,15 +489,22 @@ impl FilesystemBackend for LinuxBackend {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
None
|
if let Err(error) = self.validate_ancestry(context, &ancestors, &entry) {
|
||||||
};
|
return error.host();
|
||||||
// Both snapshots must describe the state under the lock, not at resolve time.
|
}
|
||||||
|
for ancestor in &mut ancestors {
|
||||||
|
if ancestor.refresh_validator().is_err() {
|
||||||
|
return ProviderResult::Failure(internal_not_committed());
|
||||||
|
}
|
||||||
|
ancestor.context = context.clone();
|
||||||
|
}
|
||||||
|
// Both snapshots must describe the state under the locks, not at resolve time.
|
||||||
if entry.refresh_validator().is_err() {
|
if entry.refresh_validator().is_err() {
|
||||||
return ProviderResult::Failure(internal_not_committed());
|
return ProviderResult::Failure(internal_not_committed());
|
||||||
}
|
}
|
||||||
entry.context = context.clone();
|
entry.context = context.clone();
|
||||||
let mut attempt = callback(entry.clone());
|
let mut attempt = callback(ResolvedEntry::new(ancestors, entry.clone()));
|
||||||
if let OperationAttempt::Started(Ok(result)) = &mut attempt {
|
if let OperationAttempt::Started(Ok(result)) = &mut attempt {
|
||||||
if entry.refresh_validator().is_err() {
|
if entry.refresh_validator().is_err() {
|
||||||
return ProviderResult::Failure(HostError::internal(
|
return ProviderResult::Failure(HostError::internal(
|
||||||
@@ -735,7 +815,7 @@ mod tests {
|
|||||||
use super::{FailureInjection, FlockOperation, LinuxBackend, LinuxEntry};
|
use super::{FailureInjection, FlockOperation, LinuxBackend, LinuxEntry};
|
||||||
use crate::engine::{DefaultPolicy, FilesystemBackend, Projection};
|
use crate::engine::{DefaultPolicy, FilesystemBackend, Projection};
|
||||||
use crate::interfaces::{FileRead, FileWrite};
|
use crate::interfaces::{FileRead, FileWrite};
|
||||||
use crate::{AccessMode, DefaultComposition, ResourceLimits};
|
use crate::{AccessMode, DefaultComposition, ResolvedEntry, ResourceLimits};
|
||||||
|
|
||||||
type DefaultProjection = Projection<LinuxBackend, DefaultComposition, DefaultPolicy>;
|
type DefaultProjection = Projection<LinuxBackend, DefaultComposition, DefaultPolicy>;
|
||||||
|
|
||||||
@@ -765,7 +845,11 @@ mod tests {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolved(backend: &LinuxBackend, context: &ExecutionContext, path: &str) -> LinuxEntry {
|
fn resolved(
|
||||||
|
backend: &LinuxBackend,
|
||||||
|
context: &ExecutionContext,
|
||||||
|
path: &str,
|
||||||
|
) -> ResolvedEntry<LinuxEntry> {
|
||||||
match backend.resolve(context, path) {
|
match backend.resolve(context, path) {
|
||||||
ProviderResult::Found(entry) => entry,
|
ProviderResult::Found(entry) => entry,
|
||||||
ProviderResult::NotFound => panic!("entry should resolve"),
|
ProviderResult::NotFound => panic!("entry should resolve"),
|
||||||
@@ -786,8 +870,8 @@ mod tests {
|
|||||||
.expect("open backend");
|
.expect("open backend");
|
||||||
let context = ExecutionContext::new();
|
let context = ExecutionContext::new();
|
||||||
let entry = resolved(&backend, &context, "/file");
|
let entry = resolved(&backend, &context, "/file");
|
||||||
let original = entry.initial_validator.clone();
|
let original = entry.target().initial_validator.clone();
|
||||||
let pinned = entry.clone();
|
let pinned = entry.target().clone();
|
||||||
// Simulate a cooperating writer committing between resolution and execution.
|
// Simulate a cooperating writer committing between resolution and execution.
|
||||||
fs::write(&path, b"changed before lock acquisition").expect("external mutation");
|
fs::write(&path, b"changed before lock acquisition").expect("external mutation");
|
||||||
let lock_probe = fs::OpenOptions::new()
|
let lock_probe = fs::OpenOptions::new()
|
||||||
@@ -796,7 +880,8 @@ mod tests {
|
|||||||
.open(&path)
|
.open(&path)
|
||||||
.expect("open independent lock probe");
|
.expect("open independent lock probe");
|
||||||
let mut callback_validator = None;
|
let mut callback_validator = None;
|
||||||
let attempt = backend.execute(&context, entry, |entry| {
|
let attempt = backend.execute(&context, entry, |resolved| {
|
||||||
|
let entry = resolved.target();
|
||||||
assert!(std::sync::Arc::ptr_eq(&entry.inner, &pinned.inner));
|
assert!(std::sync::Arc::ptr_eq(&entry.inner, &pinned.inner));
|
||||||
assert_ne!(entry.initial_validator, original);
|
assert_ne!(entry.initial_validator, original);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -824,7 +909,9 @@ mod tests {
|
|||||||
assert_ne!(result.validator, callback_validator);
|
assert_ne!(result.validator, callback_validator);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result.validator,
|
result.validator,
|
||||||
resolved(&backend, &context, "/file").initial_validator,
|
resolved(&backend, &context, "/file")
|
||||||
|
.target()
|
||||||
|
.initial_validator,
|
||||||
);
|
);
|
||||||
assert_eq!(fs::read(&path).expect("read written file"), b"new");
|
assert_eq!(fs::read(&path).expect("read written file"), b"new");
|
||||||
// The retained clone must not keep the operation's flock held.
|
// The retained clone must not keep the operation's flock held.
|
||||||
@@ -846,10 +933,11 @@ mod tests {
|
|||||||
.expect("open backend");
|
.expect("open backend");
|
||||||
let context = ExecutionContext::new();
|
let context = ExecutionContext::new();
|
||||||
let entry = resolved(&backend, &context, "/file");
|
let entry = resolved(&backend, &context, "/file");
|
||||||
let pinned = entry.clone();
|
let pinned = entry.target().clone();
|
||||||
fs::remove_file(&path).expect("unlink pinned entry");
|
fs::remove_file(&path).expect("unlink pinned entry");
|
||||||
fs::write(&path, b"replacement").expect("replace path");
|
fs::write(&path, b"replacement").expect("replace path");
|
||||||
let attempt = backend.execute(&context, entry, |entry| {
|
let attempt = backend.execute(&context, entry, |resolved| {
|
||||||
|
let entry = resolved.target();
|
||||||
assert!(std::sync::Arc::ptr_eq(&entry.inner, &pinned.inner));
|
assert!(std::sync::Arc::ptr_eq(&entry.inner, &pinned.inner));
|
||||||
entry
|
entry
|
||||||
.replace(wip_interface::Bytes(b"pinned write".to_vec()))
|
.replace(wip_interface::Bytes(b"pinned write".to_vec()))
|
||||||
@@ -870,6 +958,239 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detached_or_replaced_ancestors_reject_before_callback_without_effects() {
|
||||||
|
// Exercise both the immediate parent and a higher ancestor, with a
|
||||||
|
// visible directory replacement, no replacement, and a symlink replacement.
|
||||||
|
for ancestor in ["a", "a/b"] {
|
||||||
|
for replacement in ["directory", "missing", "symlink"] {
|
||||||
|
let temporary = tempdir().expect("temporary root");
|
||||||
|
fs::create_dir_all(temporary.path().join("a/b")).expect("create lineage");
|
||||||
|
fs::write(temporary.path().join("a/b/file"), b"original").expect("write original");
|
||||||
|
let backend = LinuxBackend::open(
|
||||||
|
temporary.path(),
|
||||||
|
AccessMode::ReadWrite,
|
||||||
|
ResourceLimits::default(),
|
||||||
|
)
|
||||||
|
.expect("open backend");
|
||||||
|
let context = ExecutionContext::new();
|
||||||
|
let entry = resolved(&backend, &context, "/a/b/file");
|
||||||
|
let pinned = entry.target().clone();
|
||||||
|
let detached = temporary.path().join("detached");
|
||||||
|
let ancestor_path = temporary.path().join(ancestor);
|
||||||
|
fs::rename(&ancestor_path, &detached).expect("detach ancestor");
|
||||||
|
if replacement == "directory" {
|
||||||
|
fs::create_dir(&ancestor_path).expect("replace directory");
|
||||||
|
} else if replacement == "symlink" {
|
||||||
|
std::os::unix::fs::symlink(&detached, &ancestor_path)
|
||||||
|
.expect("replace ancestor with symlink to original");
|
||||||
|
}
|
||||||
|
let visible_leaf =
|
||||||
|
ancestor_path.join(if ancestor == "a" { "b/file" } else { "file" });
|
||||||
|
if replacement == "directory" {
|
||||||
|
fs::create_dir_all(visible_leaf.parent().expect("leaf parent"))
|
||||||
|
.expect("create replacement lineage");
|
||||||
|
fs::write(&visible_leaf, b"replacement").expect("write replacement");
|
||||||
|
}
|
||||||
|
let mut called = false;
|
||||||
|
let attempt = backend.execute(&context, entry, |entry| {
|
||||||
|
called = true;
|
||||||
|
entry
|
||||||
|
.target()
|
||||||
|
.replace(wip_interface::Bytes(b"bad".to_vec()))
|
||||||
|
.expect("would write pinned inode");
|
||||||
|
OperationAttempt::reject(CallErrorKind::ValidatorMismatch)
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
matches!(attempt, ProviderResult::NotFound),
|
||||||
|
"{ancestor}: {replacement}"
|
||||||
|
);
|
||||||
|
assert!(!called);
|
||||||
|
assert_eq!(
|
||||||
|
pinned.read(None, None).expect("read original inode").0,
|
||||||
|
b"original"
|
||||||
|
);
|
||||||
|
if replacement != "missing" {
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(&visible_leaf).expect("read visible leaf"),
|
||||||
|
if replacement == "directory" {
|
||||||
|
b"replacement".as_slice()
|
||||||
|
} else {
|
||||||
|
b"original".as_slice()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Rejection releases every guard, even while the target is retained.
|
||||||
|
let root_probe = fs::File::open(temporary.path()).expect("open root probe");
|
||||||
|
rustix::fs::flock(&root_probe, FlockOperation::NonBlockingLockExclusive)
|
||||||
|
.expect("rejection released root guard");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execution_retains_and_refreshes_ancestors_under_ordered_directory_guards() {
|
||||||
|
let temporary = tempdir().expect("temporary root");
|
||||||
|
fs::create_dir_all(temporary.path().join("a/b")).expect("create lineage");
|
||||||
|
fs::write(temporary.path().join("a/b/file"), b"original").expect("write file");
|
||||||
|
let backend = LinuxBackend::open(
|
||||||
|
temporary.path(),
|
||||||
|
AccessMode::ReadOnly,
|
||||||
|
ResourceLimits::default(),
|
||||||
|
)
|
||||||
|
.expect("open backend");
|
||||||
|
let context = ExecutionContext::new();
|
||||||
|
let entry = resolved(&backend, &context, "/a/b/file");
|
||||||
|
assert_eq!(
|
||||||
|
entry
|
||||||
|
.ancestors()
|
||||||
|
.iter()
|
||||||
|
.map(WipObject::name)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["", "a", "b"]
|
||||||
|
);
|
||||||
|
let pinned_ancestors = entry.ancestors().to_vec();
|
||||||
|
let (mut ancestors, target) = entry.into_parts();
|
||||||
|
// Stale snapshots must not escape the execution boundary.
|
||||||
|
for ancestor in &mut ancestors {
|
||||||
|
ancestor.validator = Some(wip_host::Validator::new(vec![1]));
|
||||||
|
ancestor.initial_validator = Some(vec![1]);
|
||||||
|
}
|
||||||
|
let probes: Vec<_> = ["", "a", "a/b", "a/b/file"]
|
||||||
|
.into_iter()
|
||||||
|
.map(|path| fs::File::open(temporary.path().join(path)).expect("open lock probe"))
|
||||||
|
.collect();
|
||||||
|
let attempt = backend.execute(&context, ResolvedEntry::new(ancestors, target), |entry| {
|
||||||
|
for (ancestor, pinned) in entry.ancestors().iter().zip(&pinned_ancestors) {
|
||||||
|
assert!(std::sync::Arc::ptr_eq(&ancestor.inner, &pinned.inner));
|
||||||
|
assert!(ancestor.validator().is_none());
|
||||||
|
assert!(ancestor.initial_validator.is_none());
|
||||||
|
}
|
||||||
|
for probe in &probes {
|
||||||
|
assert_eq!(
|
||||||
|
rustix::fs::flock(probe, FlockOperation::NonBlockingLockExclusive),
|
||||||
|
Err(rustix::io::Errno::WOULDBLOCK)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
OperationAttempt::reject(CallErrorKind::ValidatorMismatch)
|
||||||
|
});
|
||||||
|
assert!(matches!(attempt, ProviderResult::Found(_)));
|
||||||
|
for probe in &probes {
|
||||||
|
rustix::fs::flock(probe, FlockOperation::NonBlockingLockExclusive)
|
||||||
|
.expect("guard released after callback");
|
||||||
|
rustix::fs::flock(probe, FlockOperation::Unlock).expect("unlock probe");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configured_root_stays_pinned_after_textual_rename_and_replacement() {
|
||||||
|
let temporary = tempdir().expect("temporary parent");
|
||||||
|
let configured = temporary.path().join("root");
|
||||||
|
fs::create_dir_all(configured.join("dir")).expect("create original root");
|
||||||
|
fs::write(configured.join("dir/file"), b"original").expect("write original file");
|
||||||
|
let backend = LinuxBackend::open(
|
||||||
|
&configured,
|
||||||
|
AccessMode::ReadWrite,
|
||||||
|
ResourceLimits::default(),
|
||||||
|
)
|
||||||
|
.expect("open backend");
|
||||||
|
let context = ExecutionContext::new();
|
||||||
|
let root = resolved(&backend, &context, "/");
|
||||||
|
assert!(root.ancestors().is_empty());
|
||||||
|
let file = resolved(&backend, &context, "/dir/file");
|
||||||
|
let moved = temporary.path().join("moved");
|
||||||
|
fs::rename(&configured, &moved).expect("rename configured root");
|
||||||
|
fs::create_dir_all(configured.join("dir")).expect("replace textual root");
|
||||||
|
fs::write(configured.join("dir/file"), b"replacement").expect("write replacement file");
|
||||||
|
for entry in [
|
||||||
|
root,
|
||||||
|
resolved(&backend, &context, "/"),
|
||||||
|
resolved(&backend, &context, "/dir"),
|
||||||
|
] {
|
||||||
|
let attempt = backend.execute(&context, entry, |entry| {
|
||||||
|
assert_eq!(entry.target().kind, crate::EntryKind::Directory);
|
||||||
|
OperationAttempt::reject(CallErrorKind::ValidatorMismatch)
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
matches!(attempt, ProviderResult::Found(_)),
|
||||||
|
"root/directory locks once"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let attempt = backend.execute(&context, file, |entry| {
|
||||||
|
entry
|
||||||
|
.target()
|
||||||
|
.replace(wip_interface::Bytes(b"pinned write".to_vec()))
|
||||||
|
.expect("write beneath original root");
|
||||||
|
OperationAttempt::Started(Ok(wip_host::CallResult {
|
||||||
|
result: Value::Bytes(Vec::new()),
|
||||||
|
validator: None,
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
attempt,
|
||||||
|
ProviderResult::Found(OperationAttempt::Started(Ok(_)))
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(moved.join("dir/file")).expect("read original root"),
|
||||||
|
b"pinned write"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(configured.join("dir/file")).expect("read textual replacement"),
|
||||||
|
b"replacement"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolved(&backend, &context, "/dir/file")
|
||||||
|
.target()
|
||||||
|
.read(None, None)
|
||||||
|
.expect("resolution still uses pinned root")
|
||||||
|
.0,
|
||||||
|
b"pinned write"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deep_resolution_counts_all_retained_ancestors_against_handle_budget() {
|
||||||
|
let temporary = tempdir().expect("temporary root");
|
||||||
|
fs::create_dir_all(temporary.path().join("a/b")).expect("create lineage");
|
||||||
|
fs::write(temporary.path().join("a/b/file"), b"original").expect("write file");
|
||||||
|
let context = ExecutionContext::new();
|
||||||
|
for maximum in [3, 4] {
|
||||||
|
let backend = LinuxBackend::open(
|
||||||
|
temporary.path(),
|
||||||
|
AccessMode::ReadOnly,
|
||||||
|
ResourceLimits {
|
||||||
|
max_open_handles: maximum,
|
||||||
|
..ResourceLimits::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("open backend");
|
||||||
|
match backend.resolve(&context, "/a/b/file") {
|
||||||
|
ProviderResult::Failure(error) if maximum == 3 => {
|
||||||
|
assert_eq!(error.kind(), wip_host::HostErrorKind::ResourceLimitExceeded);
|
||||||
|
assert_eq!(
|
||||||
|
error.effect_guarantee(),
|
||||||
|
wip_host::EffectGuarantee::NotCommitted
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ProviderResult::Found(entry) if maximum == 4 => {
|
||||||
|
assert_eq!(entry.ancestors().len(), 3);
|
||||||
|
assert_eq!(backend.handles.current.load(super::Ordering::Acquire), 4);
|
||||||
|
let cloned =
|
||||||
|
ResolvedEntry::new(entry.ancestors().to_vec(), entry.target().clone());
|
||||||
|
drop(entry);
|
||||||
|
assert_eq!(backend.handles.current.load(super::Ordering::Acquire), 4);
|
||||||
|
drop(cloned);
|
||||||
|
}
|
||||||
|
_ => panic!("unexpected resolution outcome for budget {maximum}"),
|
||||||
|
}
|
||||||
|
assert_eq!(backend.handles.current.load(super::Ordering::Acquire), 0);
|
||||||
|
let root = resolved(&backend, &context, "/");
|
||||||
|
assert_eq!(backend.handles.current.load(super::Ordering::Acquire), 1);
|
||||||
|
drop(root);
|
||||||
|
assert_eq!(backend.handles.current.load(super::Ordering::Acquire), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejected_execution_releases_lock_even_when_callback_retains_entry() {
|
fn rejected_execution_releases_lock_even_when_callback_retains_entry() {
|
||||||
let temporary = tempdir().expect("temporary root");
|
let temporary = tempdir().expect("temporary root");
|
||||||
@@ -914,7 +1235,7 @@ mod tests {
|
|||||||
.expect("open backend");
|
.expect("open backend");
|
||||||
let context = ExecutionContext::new();
|
let context = ExecutionContext::new();
|
||||||
let entry = resolved(&backend, &context, "/file");
|
let entry = resolved(&backend, &context, "/file");
|
||||||
let cloned = entry.clone();
|
let cloned = ResolvedEntry::new(entry.ancestors().to_vec(), entry.target().clone());
|
||||||
let attempt = backend.execute(&context, entry, |entry| {
|
let attempt = backend.execute(&context, entry, |entry| {
|
||||||
let started = std::time::Instant::now();
|
let started = std::time::Instant::now();
|
||||||
let nested = backend.execute(&context, entry, |_| {
|
let nested = backend.execute(&context, entry, |_| {
|
||||||
@@ -956,10 +1277,10 @@ mod tests {
|
|||||||
.expect("open backend");
|
.expect("open backend");
|
||||||
let context = ExecutionContext::new();
|
let context = ExecutionContext::new();
|
||||||
let root = resolved(&backend, &context, "/");
|
let root = resolved(&backend, &context, "/");
|
||||||
assert!(backend.children(&context, &root, 1).is_err());
|
assert!(backend.children(&context, root.target(), 1).is_err());
|
||||||
assert_eq!(backend.handles.current.load(super::Ordering::Acquire), 1);
|
assert_eq!(backend.handles.current.load(super::Ordering::Acquire), 1);
|
||||||
let children = backend
|
let children = backend
|
||||||
.children(&context, &root, 2)
|
.children(&context, root.target(), 2)
|
||||||
.expect("complete children");
|
.expect("complete children");
|
||||||
assert_eq!(children.len(), 2);
|
assert_eq!(children.len(), 2);
|
||||||
let cloned = children[0].clone();
|
let cloned = children[0].clone();
|
||||||
@@ -986,13 +1307,14 @@ mod tests {
|
|||||||
.expect("open backend");
|
.expect("open backend");
|
||||||
let context = ExecutionContext::new();
|
let context = ExecutionContext::new();
|
||||||
let entry = resolved(&backend, &context, "/file");
|
let entry = resolved(&backend, &context, "/file");
|
||||||
assert_eq!(entry.access_mode(), AccessMode::ReadOnly);
|
let target = entry.target();
|
||||||
assert!(entry.supports_file_read());
|
assert_eq!(target.access_mode(), AccessMode::ReadOnly);
|
||||||
assert!(!entry.supports_file_write());
|
assert!(target.supports_file_read());
|
||||||
assert!(!entry.supports_symlink_inspect());
|
assert!(!target.supports_file_write());
|
||||||
assert!(entry.read(None, None).is_err());
|
assert!(!target.supports_symlink_inspect());
|
||||||
assert_eq!(entry.read(None, Some(2)).expect("bounded read").0, b"ol");
|
assert!(target.read(None, None).is_err());
|
||||||
assert!(entry.replace(wip_interface::Bytes(Vec::new())).is_err());
|
assert_eq!(target.read(None, Some(2)).expect("bounded read").0, b"ol");
|
||||||
|
assert!(target.replace(wip_interface::Bytes(Vec::new())).is_err());
|
||||||
assert_eq!(fs::read(&path).expect("read unchanged file"), b"old");
|
assert_eq!(fs::read(&path).expect("read unchanged file"), b"old");
|
||||||
let cancellation = wip_host::CancellationToken::new();
|
let cancellation = wip_host::CancellationToken::new();
|
||||||
cancellation.cancel();
|
cancellation.cancel();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use wip_fs_server::{
|
use wip_fs_server::{
|
||||||
BackendEntry, Composition, DefaultInterfaces, EntryKind, FileRead, FilesystemBackend,
|
BackendEntry, Composition, DefaultInterfaces, EntryKind, FileRead, FilesystemBackend,
|
||||||
HttpAdapter, Projection, ProjectionPolicy, ResourceLimits,
|
HttpAdapter, Projection, ProjectionPolicy, ResolvedEntry, ResourceLimits,
|
||||||
};
|
};
|
||||||
use wip_host::{
|
use wip_host::{
|
||||||
CancellationToken, EffectGuarantee, ExecutionContext, HostError, InterfaceRef,
|
CancellationToken, EffectGuarantee, ExecutionContext, HostError, InterfaceRef,
|
||||||
@@ -77,15 +77,20 @@ struct Entry {
|
|||||||
path: String,
|
path: String,
|
||||||
node: Arc<Node>,
|
node: Arc<Node>,
|
||||||
validator: Validator,
|
validator: Validator,
|
||||||
|
hidden: bool,
|
||||||
}
|
}
|
||||||
impl Entry {
|
impl Entry {
|
||||||
fn snapshot(path: String, node: Arc<Node>) -> Self {
|
fn snapshot(path: String, node: Arc<Node>) -> Self {
|
||||||
let revision = node.data.lock().unwrap().revision;
|
let data = node.data.lock().unwrap();
|
||||||
|
let revision = data.revision;
|
||||||
|
let hidden = data.hidden;
|
||||||
|
drop(data);
|
||||||
let validator = Validator::new(format!("{}:{revision}", node.id).into_bytes());
|
let validator = Validator::new(format!("{}:{revision}", node.id).into_bytes());
|
||||||
Self {
|
Self {
|
||||||
path,
|
path,
|
||||||
node,
|
node,
|
||||||
validator,
|
validator,
|
||||||
|
hidden,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,6 +123,8 @@ struct Seen {
|
|||||||
struct Controls {
|
struct Controls {
|
||||||
bump_before_execute: bool,
|
bump_before_execute: bool,
|
||||||
hide_before_execute: bool,
|
hide_before_execute: bool,
|
||||||
|
hide_ancestor: Option<String>,
|
||||||
|
replace_ancestor: Option<String>,
|
||||||
replace_mapping: bool,
|
replace_mapping: bool,
|
||||||
rollback: bool,
|
rollback: bool,
|
||||||
cancel_on_execute: bool,
|
cancel_on_execute: bool,
|
||||||
@@ -168,7 +175,11 @@ fn check_context(context: &ExecutionContext) -> Result<(), HostError> {
|
|||||||
}
|
}
|
||||||
impl FilesystemBackend for MemoryBackend {
|
impl FilesystemBackend for MemoryBackend {
|
||||||
type Entry = Entry;
|
type Entry = Entry;
|
||||||
fn resolve(&self, context: &ExecutionContext, path: &str) -> ProviderResult<Entry> {
|
fn resolve(
|
||||||
|
&self,
|
||||||
|
context: &ExecutionContext,
|
||||||
|
path: &str,
|
||||||
|
) -> ProviderResult<ResolvedEntry<Entry>> {
|
||||||
self.0.record("resolve", path, context);
|
self.0.record("resolve", path, context);
|
||||||
if let Err(error) = check_context(context) {
|
if let Err(error) = check_context(context) {
|
||||||
return ProviderResult::Failure(error);
|
return ProviderResult::Failure(error);
|
||||||
@@ -179,11 +190,33 @@ impl FilesystemBackend for MemoryBackend {
|
|||||||
EffectGuarantee::NotCommitted,
|
EffectGuarantee::NotCommitted,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// Map lookup only: there is no host path to reopen or escape.
|
// One connected map snapshot, serialized with participating mutations.
|
||||||
match self.0.nodes.lock().unwrap().get(path).cloned() {
|
let _transaction = self.0.transaction.lock().unwrap();
|
||||||
Some(node) => ProviderResult::Found(Entry::snapshot(path.into(), node)),
|
let nodes = self.0.nodes.lock().unwrap();
|
||||||
None => ProviderResult::NotFound,
|
let Some(node) = nodes.get(path) else {
|
||||||
|
return ProviderResult::NotFound;
|
||||||
|
};
|
||||||
|
let mut ancestors = Vec::new();
|
||||||
|
if path != "/" {
|
||||||
|
let prefixes = std::iter::once("/").chain(
|
||||||
|
path.match_indices('/')
|
||||||
|
.skip(1)
|
||||||
|
.map(|(index, _)| &path[..index]),
|
||||||
|
);
|
||||||
|
for prefix in prefixes {
|
||||||
|
let Some(parent) = nodes.get(prefix) else {
|
||||||
|
return ProviderResult::NotFound;
|
||||||
|
};
|
||||||
|
if parent.kind != EntryKind::Directory {
|
||||||
|
return ProviderResult::NotFound;
|
||||||
}
|
}
|
||||||
|
ancestors.push(Entry::snapshot(prefix.into(), parent.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ProviderResult::Found(ResolvedEntry::new(
|
||||||
|
ancestors,
|
||||||
|
Entry::snapshot(path.into(), node.clone()),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
fn children(
|
fn children(
|
||||||
&self,
|
&self,
|
||||||
@@ -224,10 +257,11 @@ impl FilesystemBackend for MemoryBackend {
|
|||||||
fn execute(
|
fn execute(
|
||||||
&self,
|
&self,
|
||||||
context: &ExecutionContext,
|
context: &ExecutionContext,
|
||||||
entry: Entry,
|
resolved: ResolvedEntry<Entry>,
|
||||||
operation: impl FnOnce(Entry) -> OperationAttempt,
|
operation: impl FnOnce(ResolvedEntry<Entry>) -> OperationAttempt,
|
||||||
) -> ProviderResult<OperationAttempt> {
|
) -> ProviderResult<OperationAttempt> {
|
||||||
let _lock = self.0.transaction.lock().unwrap();
|
let _lock = self.0.transaction.lock().unwrap();
|
||||||
|
let (ancestors, entry) = resolved.into_parts();
|
||||||
self.0.record("execute", &entry.path, context);
|
self.0.record("execute", &entry.path, context);
|
||||||
if let Err(error) = check_context(context) {
|
if let Err(error) = check_context(context) {
|
||||||
return ProviderResult::Failure(error);
|
return ProviderResult::Failure(error);
|
||||||
@@ -243,6 +277,24 @@ impl FilesystemBackend for MemoryBackend {
|
|||||||
if controls.hide_before_execute {
|
if controls.hide_before_execute {
|
||||||
entry.node.data.lock().unwrap().hidden = true;
|
entry.node.data.lock().unwrap().hidden = true;
|
||||||
}
|
}
|
||||||
|
if let Some(path) = controls.hide_ancestor.take() {
|
||||||
|
self.0.node(&path).data.lock().unwrap().hidden = true;
|
||||||
|
}
|
||||||
|
if let Some(path) = controls.replace_ancestor.take() {
|
||||||
|
self.0.nodes.lock().unwrap().insert(
|
||||||
|
path,
|
||||||
|
Arc::new(Node {
|
||||||
|
id: 1000,
|
||||||
|
kind: EntryKind::Directory,
|
||||||
|
data: Mutex::new(Data {
|
||||||
|
bytes: b"replacement ancestor".to_vec(),
|
||||||
|
revision: 1,
|
||||||
|
hidden: false,
|
||||||
|
}),
|
||||||
|
calls: Mutex::new(Vec::new()),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
if controls.replace_mapping {
|
if controls.replace_mapping {
|
||||||
// The callback must still receive the original capability, not this decoy.
|
// The callback must still receive the original capability, not this decoy.
|
||||||
self.0.nodes.lock().unwrap().insert(
|
self.0.nodes.lock().unwrap().insert(
|
||||||
@@ -265,9 +317,25 @@ impl FilesystemBackend for MemoryBackend {
|
|||||||
}
|
}
|
||||||
let rollback = controls.rollback;
|
let rollback = controls.rollback;
|
||||||
drop(controls);
|
drop(controls);
|
||||||
|
// 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();
|
||||||
|
for ancestor in &ancestors {
|
||||||
|
if !nodes
|
||||||
|
.get(&ancestor.path)
|
||||||
|
.is_some_and(|node| Arc::ptr_eq(node, &ancestor.node))
|
||||||
|
{
|
||||||
|
return ProviderResult::NotFound;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(nodes);
|
||||||
|
let refreshed_ancestors = ancestors
|
||||||
|
.into_iter()
|
||||||
|
.map(|ancestor| Entry::snapshot(ancestor.path, ancestor.node))
|
||||||
|
.collect();
|
||||||
let before = entry.node.data.lock().unwrap().clone();
|
let before = entry.node.data.lock().unwrap().clone();
|
||||||
let refreshed = Entry::snapshot(entry.path.clone(), entry.node.clone());
|
let refreshed = Entry::snapshot(entry.path.clone(), entry.node.clone());
|
||||||
let attempt = operation(refreshed);
|
let attempt = operation(ResolvedEntry::new(refreshed_ancestors, refreshed));
|
||||||
let attempt = match attempt {
|
let attempt = match attempt {
|
||||||
OperationAttempt::Started(Ok(mut result)) => {
|
OperationAttempt::Started(Ok(mut result)) => {
|
||||||
result.validator = Some(
|
result.validator = Some(
|
||||||
@@ -437,8 +505,7 @@ struct Policy {
|
|||||||
impl ProjectionPolicy<Entry, Object> for Policy {
|
impl ProjectionPolicy<Entry, Object> for Policy {
|
||||||
type Principal = Principal;
|
type Principal = Principal;
|
||||||
fn object_visible(&self, principal: &Principal, entry: &Entry) -> bool {
|
fn object_visible(&self, principal: &Principal, entry: &Entry) -> bool {
|
||||||
!entry.node.data.lock().unwrap().hidden
|
!entry.hidden && (!matches!(principal, Principal::Limited) || entry.path != "/secret")
|
||||||
&& (!matches!(principal, Principal::Limited) || entry.path != "/secret")
|
|
||||||
}
|
}
|
||||||
fn interface_visible(&self, principal: &Principal, interface: &InterfaceRef) -> bool {
|
fn interface_visible(&self, principal: &Principal, interface: &InterfaceRef) -> bool {
|
||||||
!(matches!(principal, Principal::Limited) && self.hide_read && interface == &self.read)
|
!(matches!(principal, Principal::Limited) && self.hide_read && interface == &self.read)
|
||||||
@@ -690,6 +757,109 @@ impl Fixture {
|
|||||||
(response, decoded)
|
(response, decoded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[test]
|
||||||
|
fn ancestor_visibility_refresh_inside_boundary_prevents_descendant_invocation_and_effects() {
|
||||||
|
for (target, ancestor) in [
|
||||||
|
("/added", "/"),
|
||||||
|
("/secret/nested/data", "/"),
|
||||||
|
("/secret/nested/data", "/secret"),
|
||||||
|
("/secret/nested/data", "/secret/nested"),
|
||||||
|
] {
|
||||||
|
for operation in ["denied", "read", "change"] {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let observation = fixture.observe(&Principal::Admin, target, 0);
|
||||||
|
let descriptor = fixture.descriptor(&Principal::Admin, &fixture.custom);
|
||||||
|
let mut request = fixture.request(
|
||||||
|
target,
|
||||||
|
&fixture.custom,
|
||||||
|
operation,
|
||||||
|
observation.object.validator,
|
||||||
|
);
|
||||||
|
if operation == "change" {
|
||||||
|
request
|
||||||
|
.arguments
|
||||||
|
.insert("contents".into(), Value::Bytes(b"must not commit".to_vec()));
|
||||||
|
}
|
||||||
|
let leaf = fixture.shared.node(target);
|
||||||
|
let before = leaf.data.lock().unwrap().clone();
|
||||||
|
fixture.shared.controls.lock().unwrap().hide_ancestor = Some(ancestor.into());
|
||||||
|
let (wire, response) = fixture.http_call(
|
||||||
|
&Principal::Admin,
|
||||||
|
&request,
|
||||||
|
&descriptor,
|
||||||
|
&ExecutionContext::new(),
|
||||||
|
);
|
||||||
|
wire_error(response, ProtocolErrorCode::NotFound);
|
||||||
|
assert!(leaf.calls.lock().unwrap().is_empty());
|
||||||
|
assert_eq!(*leaf.data.lock().unwrap(), before);
|
||||||
|
assert!(!String::from_utf8_lossy(wire.body()).contains(BACKEND_SECRET));
|
||||||
|
let error = fixture
|
||||||
|
.engine
|
||||||
|
.view(&Principal::Admin)
|
||||||
|
.observe(&ExecutionContext::new(), &observe_request(target, 0))
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.code, ProtocolErrorCode::NotFound);
|
||||||
|
// The entire target resolves once, not as unrelated path prefixes.
|
||||||
|
let seen = fixture.shared.seen.lock().unwrap();
|
||||||
|
assert!(
|
||||||
|
seen.iter()
|
||||||
|
.filter(|event| event.stage == "resolve")
|
||||||
|
.all(|event| event.path == target)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replaced_visible_ancestors_reject_without_redirecting_or_dispatching_the_pinned_target() {
|
||||||
|
for (target, ancestor) in [
|
||||||
|
("/added", "/"),
|
||||||
|
("/secret/nested/data", "/secret"),
|
||||||
|
("/secret/nested/data", "/secret/nested"),
|
||||||
|
] {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let observation = fixture.observe(&Principal::Admin, target, 0);
|
||||||
|
let descriptor = fixture.descriptor(&Principal::Admin, &fixture.custom);
|
||||||
|
let mut request = fixture.request(
|
||||||
|
target,
|
||||||
|
&fixture.custom,
|
||||||
|
"change",
|
||||||
|
observation.object.validator,
|
||||||
|
);
|
||||||
|
request
|
||||||
|
.arguments
|
||||||
|
.insert("contents".into(), Value::Bytes(b"must not commit".to_vec()));
|
||||||
|
let leaf = fixture.shared.node(target);
|
||||||
|
let original_ancestor = fixture.shared.node(ancestor);
|
||||||
|
let before = leaf.data.lock().unwrap().clone();
|
||||||
|
fixture.shared.controls.lock().unwrap().replace_ancestor = Some(ancestor.into());
|
||||||
|
let (_, response) = fixture.http_call(
|
||||||
|
&Principal::Admin,
|
||||||
|
&request,
|
||||||
|
&descriptor,
|
||||||
|
&ExecutionContext::new(),
|
||||||
|
);
|
||||||
|
wire_error(response, ProtocolErrorCode::NotFound);
|
||||||
|
assert!(leaf.calls.lock().unwrap().is_empty());
|
||||||
|
assert_eq!(*leaf.data.lock().unwrap(), before);
|
||||||
|
assert!(!Arc::ptr_eq(
|
||||||
|
&original_ancestor,
|
||||||
|
&fixture.shared.node(ancestor)
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.shared
|
||||||
|
.node(ancestor)
|
||||||
|
.calls
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
// A replacement is visible but cannot authorize a previously resolved lineage.
|
||||||
|
assert!(!fixture.shared.node(ancestor).data.lock().unwrap().hidden);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn observe_request(path: &str, depth: u32) -> ObserveRequest {
|
fn observe_request(path: &str, depth: u32) -> ObserveRequest {
|
||||||
ObserveRequest {
|
ObserveRequest {
|
||||||
path: path.into(),
|
path: path.into(),
|
||||||
@@ -868,8 +1038,8 @@ fn principal_views_hide_children_and_guessed_descendants_for_observation_and_cal
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.all(|seen| !seen.path.starts_with("/secret/")),
|
.all(|seen| seen.stage == "resolve"),
|
||||||
"hidden descendants were resolved"
|
"hidden lineage was published, enumerated, or dispatched"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
fixture
|
fixture
|
||||||
|
|||||||
Reference in New Issue
Block a user