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.
|
||||
Its `WipObject` metadata is public-only; its validator binds identity and the
|
||||
protected state. Implement `FilesystemBackend` to resolve capabilities,
|
||||
enumerate bounded children, and execute against an already-resolved entry.
|
||||
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.
|
||||
@@ -55,10 +56,26 @@ listener, a global object registry, or dynamic plugin loading.
|
||||
### Extension security contract
|
||||
|
||||
Backend implementations and Operation authors are trusted code, not sandboxed
|
||||
plugins. `FilesystemBackend::execute` must refresh state/validators **inside** its
|
||||
transaction or lock before calling the engine callback, retain the same resolved
|
||||
target through validation and dispatch, and return the committed post-operation
|
||||
validator. Confirm rollback only after rollback succeeds. Pre-dispatch failures
|
||||
plugins. `FilesystemBackend::resolve` returns `ResolvedEntry::new(ancestors, target)`
|
||||
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`.
|
||||
|
||||
`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
|
||||
`OperationOutcomeUnknown`. Object implementations must delegate validators to
|
||||
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
|
||||
for other backends.
|
||||
|
||||
The default Linux backend uses advisory exclusive locks on regular files,
|
||||
refreshes validators after acquiring the lock and holds it through post-dispatch
|
||||
state observation. This serializes cooperating library calls; it does **not**
|
||||
promise transactional isolation from unrelated processes ignoring `flock`.
|
||||
Descriptor-relative pinned handles still prevent replacement from redirecting an
|
||||
operation to a different file. Linux capabilities only expose bounded methods,
|
||||
not raw descriptors or backend paths.
|
||||
The default Linux backend acquires advisory exclusive locks in root-to-parent
|
||||
order and then on the lockable target (directory or regular file). It compares
|
||||
retained ancestor identity to no-follow descriptor-relative lookup results,
|
||||
refreshes lineage snapshots after acquiring the locks, and holds the guards
|
||||
through post-dispatch state observation. The configured root remains its pinned
|
||||
capability even if its textual path is renamed; replaced intermediate ancestors
|
||||
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.
|
||||
Interface/operation visibility is uniform per registered reference within that
|
||||
|
||||
@@ -32,6 +32,39 @@ pub trait BackendEntry: WipObject + Clone {
|
||||
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.
|
||||
///
|
||||
/// 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
|
||||
/// backend paths, credentials or hidden metadata.
|
||||
///
|
||||
/// `execute` must retain the same target and serialize validator observation,
|
||||
/// publication, validation, typed dispatch and commit/rollback. Refresh entry
|
||||
/// validators *inside* the boundary before invoking the callback, and return the
|
||||
/// committed post-operation validator on success. Never upgrade an unknown
|
||||
/// outcome except after confirmed rollback. Locks only protect against actors
|
||||
/// participating in that locking protocol; document any external-writer limits.
|
||||
/// `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
|
||||
/// ancestors must return `NotFound` before the callback; never substitute a new
|
||||
/// ancestor or redirect the target by re-resolving its raw path. Refresh *all*
|
||||
/// 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 {
|
||||
/// Stable capability supplied to the composition.
|
||||
type Entry: BackendEntry;
|
||||
/// Resolve a canonical WIP path without escaping the configured root.
|
||||
fn resolve(&self, context: &ExecutionContext, path: &str) -> ProviderResult<Self::Entry>;
|
||||
/// Resolve a canonical WIP path and retain its connected ancestry.
|
||||
///
|
||||
/// 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.
|
||||
fn children(
|
||||
&self,
|
||||
@@ -61,8 +105,8 @@ pub trait FilesystemBackend {
|
||||
fn execute(
|
||||
&self,
|
||||
context: &ExecutionContext,
|
||||
entry: Self::Entry,
|
||||
operation: impl FnOnce(Self::Entry) -> OperationAttempt,
|
||||
entry: ResolvedEntry<Self::Entry>,
|
||||
operation: impl FnOnce(ResolvedEntry<Self::Entry>) -> OperationAttempt,
|
||||
) -> ProviderResult<OperationAttempt>;
|
||||
}
|
||||
|
||||
@@ -205,39 +249,40 @@ where
|
||||
C: Composition<B::Entry>,
|
||||
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) {
|
||||
return ProviderResult::Failure(error);
|
||||
}
|
||||
if wip_protocol::validate_path(path).is_err() {
|
||||
return ProviderResult::NotFound;
|
||||
}
|
||||
// Check ancestors as well: guessing a descendant cannot bypass hidden parents.
|
||||
let mut end = 1;
|
||||
loop {
|
||||
let prefix = if end == 1 { "/" } else { &path[..end] };
|
||||
let entry = match self.projection.backend.resolve(context, prefix) {
|
||||
ProviderResult::Found(entry) => entry,
|
||||
ProviderResult::NotFound => return ProviderResult::NotFound,
|
||||
ProviderResult::Failure(error) => return ProviderResult::Failure(error),
|
||||
};
|
||||
if !self
|
||||
.projection
|
||||
.policy
|
||||
.object_visible(self.principal, &entry)
|
||||
{
|
||||
match self.projection.backend.resolve(context, path) {
|
||||
ProviderResult::Found(resolved) => {
|
||||
if !lineage_matches_path(&resolved, path) {
|
||||
return ProviderResult::Failure(internal());
|
||||
}
|
||||
if !self.lineage_visible(&resolved) {
|
||||
return ProviderResult::NotFound;
|
||||
}
|
||||
if prefix == path {
|
||||
return ProviderResult::Found(entry);
|
||||
ProviderResult::Found(resolved)
|
||||
}
|
||||
let start = if end == 1 { 1 } else { end + 1 };
|
||||
end = path[start..]
|
||||
.find('/')
|
||||
.map_or(path.len(), |index| start + index);
|
||||
ProviderResult::NotFound => ProviderResult::NotFound,
|
||||
ProviderResult::Failure(error) => ProviderResult::Failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
&self,
|
||||
entry: B::Entry,
|
||||
@@ -342,6 +387,7 @@ 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)?;
|
||||
@@ -426,10 +472,13 @@ where
|
||||
operation: impl FnOnce(&PublishedObject<Self::Object>) -> R,
|
||||
) -> ProviderResult<R> {
|
||||
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)),
|
||||
Err(error) => ProviderResult::Failure(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
ProviderResult::NotFound => ProviderResult::NotFound,
|
||||
ProviderResult::Failure(error) => ProviderResult::Failure(error),
|
||||
}
|
||||
@@ -445,16 +494,16 @@ where
|
||||
ProviderResult::NotFound => return ProviderResult::NotFound,
|
||||
ProviderResult::Failure(error) => return ProviderResult::Failure(error),
|
||||
};
|
||||
self.projection.backend.execute(context, entry, |entry| {
|
||||
// Recheck visibility using the atomic-boundary snapshot.
|
||||
// Failure here is still pre-dispatch, with no operation effects.
|
||||
if !self
|
||||
.projection
|
||||
.policy
|
||||
.object_visible(self.principal, &entry)
|
||||
{
|
||||
self.projection.backend.execute(context, entry, |resolved| {
|
||||
// Backend validates retained lineage membership/identity and refreshes
|
||||
// all metadata before this callback under the same boundary as dispatch.
|
||||
if !lineage_matches_path(&resolved, path) {
|
||||
return OperationAttempt::reject(wip_host::CallErrorKind::Internal);
|
||||
}
|
||||
if !self.lineage_visible(&resolved) {
|
||||
return OperationAttempt::reject(wip_host::CallErrorKind::NotFound);
|
||||
}
|
||||
let (_ancestors, entry) = resolved.into_parts();
|
||||
if let Err(error) = preflight(context) {
|
||||
return OperationAttempt::reject(match error.kind() {
|
||||
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>(
|
||||
operation: impl FnOnce() -> Result<T, ProtocolError>,
|
||||
) -> Result<T, ProtocolError> {
|
||||
|
||||
@@ -58,7 +58,7 @@ use std::time::Duration;
|
||||
pub use defaults::{DefaultComposition, DefaultInterfaces, DefaultObject, default_projection};
|
||||
pub use engine::{
|
||||
BackendEntry, Composition, DefaultPolicy, EntryKind, FilesystemBackend, Projection,
|
||||
ProjectionPolicy, ProjectionView,
|
||||
ProjectionPolicy, ProjectionView, ResolvedEntry,
|
||||
};
|
||||
pub use http_adapter::HttpAdapter;
|
||||
pub use interfaces::{
|
||||
|
||||
@@ -7,12 +7,12 @@ use std::sync::{Arc, Mutex, MutexGuard, TryLockError};
|
||||
|
||||
use crate::engine::{BackendEntry, EntryKind, FilesystemBackend};
|
||||
use crate::interfaces::{FileRead, FileWrite, SymlinkInspect};
|
||||
use crate::{AccessMode, ResourceLimits};
|
||||
use crate::{AccessMode, ResolvedEntry, ResourceLimits};
|
||||
use rustix::fs::{
|
||||
CWD, FileType, FlockOperation, Mode as FsMode, OFlags, RawDir, flock, fstat, ftruncate, openat,
|
||||
readlinkat,
|
||||
};
|
||||
use rustix::io::{dup, pread, pwrite};
|
||||
use rustix::io::{pread, pwrite};
|
||||
use wip_host::{
|
||||
EffectGuarantee, ExecutionContext, HostError, OperationAttempt, ProviderResult, Validator,
|
||||
WipObject,
|
||||
@@ -130,6 +130,7 @@ impl LinuxEntry {
|
||||
|
||||
fn refresh_validator(&mut self) -> Result<(), ProjectionError> {
|
||||
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)
|
||||
.then(|| validator_from_stat(&stat));
|
||||
self.initial_validator = validator.clone();
|
||||
@@ -180,10 +181,12 @@ enum FailureInjection {
|
||||
|
||||
/// Linux filesystem backend using pinned, no-follow handles.
|
||||
///
|
||||
/// Regular-file execution holds an advisory exclusive `flock` across validator
|
||||
/// refresh, dispatch, and post-operation observation. Serialization only covers
|
||||
/// actors that participate in this locking protocol; external writers can still
|
||||
/// modify the inode without taking the lock. Symlinks are inspected, not followed.
|
||||
/// Execution holds advisory exclusive `flock` guards on directories in
|
||||
/// root-to-parent order and on directory or regular-file targets across lineage
|
||||
/// validation, validator refresh, dispatch, and post-operation observation.
|
||||
/// 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 {
|
||||
root: OwnedFd,
|
||||
access: AccessMode,
|
||||
@@ -220,22 +223,28 @@ impl LinuxBackend {
|
||||
&self,
|
||||
context: &ExecutionContext,
|
||||
path: &str,
|
||||
) -> Result<LinuxEntry, ProjectionError> {
|
||||
) -> Result<ResolvedEntry<LinuxEntry>, ProjectionError> {
|
||||
check_context(context)?;
|
||||
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 == "/" {
|
||||
let fd = reopen(&self.root, OFlags::RDONLY | OFlags::DIRECTORY)?;
|
||||
return self.entry(context, String::new(), fd);
|
||||
return Ok(ResolvedEntry::new(Vec::new(), root));
|
||||
}
|
||||
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();
|
||||
while let Some(segment) = segments.next() {
|
||||
check_context(context)?;
|
||||
if segment.as_bytes().contains(&0) {
|
||||
return Err(ProjectionError::NotFound);
|
||||
}
|
||||
let parent = ancestors.last().ok_or(ProjectionError::Internal)?;
|
||||
let probe = openat(
|
||||
&parent,
|
||||
parent.fd(),
|
||||
segment,
|
||||
OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
FsMode::empty(),
|
||||
@@ -243,14 +252,15 @@ impl LinuxBackend {
|
||||
.map_err(classify_lookup)?;
|
||||
let stat = fstat(&probe).map_err(|_| ProjectionError::Internal)?;
|
||||
let kind = entry_kind(stat.st_mode);
|
||||
if segments.peek().is_some() {
|
||||
if kind != EntryKind::Directory {
|
||||
if segments.peek().is_some() && kind != EntryKind::Directory {
|
||||
return Err(ProjectionError::NotFound);
|
||||
}
|
||||
parent = reopen(&probe, OFlags::RDONLY | OFlags::DIRECTORY)?;
|
||||
} else {
|
||||
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)
|
||||
@@ -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)]
|
||||
fn set_injection(&self, injection: FailureInjection) {
|
||||
*self
|
||||
@@ -341,7 +394,11 @@ impl BackendEntry for LinuxEntry {
|
||||
impl FilesystemBackend for LinuxBackend {
|
||||
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) {
|
||||
Ok(entry) => ProviderResult::Found(entry),
|
||||
Err(error) => error.host(),
|
||||
@@ -393,16 +450,32 @@ impl FilesystemBackend for LinuxBackend {
|
||||
fn execute(
|
||||
&self,
|
||||
context: &ExecutionContext,
|
||||
mut entry: LinuxEntry,
|
||||
callback: impl FnOnce(LinuxEntry) -> OperationAttempt,
|
||||
resolved: ResolvedEntry<LinuxEntry>,
|
||||
callback: impl FnOnce(ResolvedEntry<LinuxEntry>) -> OperationAttempt,
|
||||
) -> ProviderResult<OperationAttempt> {
|
||||
if let Err(error) = check_context(context) {
|
||||
return error.host();
|
||||
}
|
||||
let lock_entry = Arc::clone(&entry.inner);
|
||||
let _lock = if entry.kind == EntryKind::Regular {
|
||||
match lock_with_context(&lock_entry, context, self.limits.request_timeout) {
|
||||
Ok(lock) => Some(lock),
|
||||
let (mut ancestors, mut entry) = resolved.into_parts();
|
||||
// Independent resolutions have independent open-file descriptions; clones
|
||||
// also need the per-entry mutex because flock alone treats them as one owner.
|
||||
// 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) => {
|
||||
return ProviderResult::Failure(HostError::resource_limit(
|
||||
"filesystem lock deadline exceeded",
|
||||
@@ -416,15 +489,22 @@ impl FilesystemBackend for LinuxBackend {
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Both snapshots must describe the state under the lock, not at resolve time.
|
||||
}
|
||||
if let Err(error) = self.validate_ancestry(context, &ancestors, &entry) {
|
||||
return error.host();
|
||||
}
|
||||
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() {
|
||||
return ProviderResult::Failure(internal_not_committed());
|
||||
}
|
||||
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 entry.refresh_validator().is_err() {
|
||||
return ProviderResult::Failure(HostError::internal(
|
||||
@@ -735,7 +815,7 @@ mod tests {
|
||||
use super::{FailureInjection, FlockOperation, LinuxBackend, LinuxEntry};
|
||||
use crate::engine::{DefaultPolicy, FilesystemBackend, Projection};
|
||||
use crate::interfaces::{FileRead, FileWrite};
|
||||
use crate::{AccessMode, DefaultComposition, ResourceLimits};
|
||||
use crate::{AccessMode, DefaultComposition, ResolvedEntry, ResourceLimits};
|
||||
|
||||
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) {
|
||||
ProviderResult::Found(entry) => entry,
|
||||
ProviderResult::NotFound => panic!("entry should resolve"),
|
||||
@@ -786,8 +870,8 @@ mod tests {
|
||||
.expect("open backend");
|
||||
let context = ExecutionContext::new();
|
||||
let entry = resolved(&backend, &context, "/file");
|
||||
let original = entry.initial_validator.clone();
|
||||
let pinned = entry.clone();
|
||||
let original = entry.target().initial_validator.clone();
|
||||
let pinned = entry.target().clone();
|
||||
// Simulate a cooperating writer committing between resolution and execution.
|
||||
fs::write(&path, b"changed before lock acquisition").expect("external mutation");
|
||||
let lock_probe = fs::OpenOptions::new()
|
||||
@@ -796,7 +880,8 @@ mod tests {
|
||||
.open(&path)
|
||||
.expect("open independent lock probe");
|
||||
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_ne!(entry.initial_validator, original);
|
||||
assert_eq!(
|
||||
@@ -824,7 +909,9 @@ mod tests {
|
||||
assert_ne!(result.validator, callback_validator);
|
||||
assert_eq!(
|
||||
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");
|
||||
// The retained clone must not keep the operation's flock held.
|
||||
@@ -846,10 +933,11 @@ mod tests {
|
||||
.expect("open backend");
|
||||
let context = ExecutionContext::new();
|
||||
let entry = resolved(&backend, &context, "/file");
|
||||
let pinned = entry.clone();
|
||||
let pinned = entry.target().clone();
|
||||
fs::remove_file(&path).expect("unlink pinned entry");
|
||||
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));
|
||||
entry
|
||||
.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]
|
||||
fn rejected_execution_releases_lock_even_when_callback_retains_entry() {
|
||||
let temporary = tempdir().expect("temporary root");
|
||||
@@ -914,7 +1235,7 @@ mod tests {
|
||||
.expect("open backend");
|
||||
let context = ExecutionContext::new();
|
||||
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 started = std::time::Instant::now();
|
||||
let nested = backend.execute(&context, entry, |_| {
|
||||
@@ -956,10 +1277,10 @@ mod tests {
|
||||
.expect("open backend");
|
||||
let context = ExecutionContext::new();
|
||||
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);
|
||||
let children = backend
|
||||
.children(&context, &root, 2)
|
||||
.children(&context, root.target(), 2)
|
||||
.expect("complete children");
|
||||
assert_eq!(children.len(), 2);
|
||||
let cloned = children[0].clone();
|
||||
@@ -986,13 +1307,14 @@ mod tests {
|
||||
.expect("open backend");
|
||||
let context = ExecutionContext::new();
|
||||
let entry = resolved(&backend, &context, "/file");
|
||||
assert_eq!(entry.access_mode(), AccessMode::ReadOnly);
|
||||
assert!(entry.supports_file_read());
|
||||
assert!(!entry.supports_file_write());
|
||||
assert!(!entry.supports_symlink_inspect());
|
||||
assert!(entry.read(None, None).is_err());
|
||||
assert_eq!(entry.read(None, Some(2)).expect("bounded read").0, b"ol");
|
||||
assert!(entry.replace(wip_interface::Bytes(Vec::new())).is_err());
|
||||
let target = entry.target();
|
||||
assert_eq!(target.access_mode(), AccessMode::ReadOnly);
|
||||
assert!(target.supports_file_read());
|
||||
assert!(!target.supports_file_write());
|
||||
assert!(!target.supports_symlink_inspect());
|
||||
assert!(target.read(None, None).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");
|
||||
let cancellation = wip_host::CancellationToken::new();
|
||||
cancellation.cancel();
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use wip_fs_server::{
|
||||
BackendEntry, Composition, DefaultInterfaces, EntryKind, FileRead, FilesystemBackend,
|
||||
HttpAdapter, Projection, ProjectionPolicy, ResourceLimits,
|
||||
HttpAdapter, Projection, ProjectionPolicy, ResolvedEntry, ResourceLimits,
|
||||
};
|
||||
use wip_host::{
|
||||
CancellationToken, EffectGuarantee, ExecutionContext, HostError, InterfaceRef,
|
||||
@@ -77,15 +77,20 @@ struct Entry {
|
||||
path: String,
|
||||
node: Arc<Node>,
|
||||
validator: Validator,
|
||||
hidden: bool,
|
||||
}
|
||||
impl Entry {
|
||||
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());
|
||||
Self {
|
||||
path,
|
||||
node,
|
||||
validator,
|
||||
hidden,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,6 +123,8 @@ struct Seen {
|
||||
struct Controls {
|
||||
bump_before_execute: bool,
|
||||
hide_before_execute: bool,
|
||||
hide_ancestor: Option<String>,
|
||||
replace_ancestor: Option<String>,
|
||||
replace_mapping: bool,
|
||||
rollback: bool,
|
||||
cancel_on_execute: bool,
|
||||
@@ -168,7 +175,11 @@ fn check_context(context: &ExecutionContext) -> Result<(), HostError> {
|
||||
}
|
||||
impl FilesystemBackend for MemoryBackend {
|
||||
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);
|
||||
if let Err(error) = check_context(context) {
|
||||
return ProviderResult::Failure(error);
|
||||
@@ -179,11 +190,33 @@ impl FilesystemBackend for MemoryBackend {
|
||||
EffectGuarantee::NotCommitted,
|
||||
));
|
||||
}
|
||||
// Map lookup only: there is no host path to reopen or escape.
|
||||
match self.0.nodes.lock().unwrap().get(path).cloned() {
|
||||
Some(node) => ProviderResult::Found(Entry::snapshot(path.into(), node)),
|
||||
None => ProviderResult::NotFound,
|
||||
// One connected map snapshot, serialized with participating mutations.
|
||||
let _transaction = self.0.transaction.lock().unwrap();
|
||||
let nodes = self.0.nodes.lock().unwrap();
|
||||
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(
|
||||
&self,
|
||||
@@ -224,10 +257,11 @@ impl FilesystemBackend for MemoryBackend {
|
||||
fn execute(
|
||||
&self,
|
||||
context: &ExecutionContext,
|
||||
entry: Entry,
|
||||
operation: impl FnOnce(Entry) -> OperationAttempt,
|
||||
resolved: ResolvedEntry<Entry>,
|
||||
operation: impl FnOnce(ResolvedEntry<Entry>) -> OperationAttempt,
|
||||
) -> ProviderResult<OperationAttempt> {
|
||||
let _lock = self.0.transaction.lock().unwrap();
|
||||
let (ancestors, entry) = resolved.into_parts();
|
||||
self.0.record("execute", &entry.path, context);
|
||||
if let Err(error) = check_context(context) {
|
||||
return ProviderResult::Failure(error);
|
||||
@@ -243,6 +277,24 @@ impl FilesystemBackend for MemoryBackend {
|
||||
if controls.hide_before_execute {
|
||||
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 {
|
||||
// The callback must still receive the original capability, not this decoy.
|
||||
self.0.nodes.lock().unwrap().insert(
|
||||
@@ -265,9 +317,25 @@ impl FilesystemBackend for MemoryBackend {
|
||||
}
|
||||
let rollback = controls.rollback;
|
||||
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 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 {
|
||||
OperationAttempt::Started(Ok(mut result)) => {
|
||||
result.validator = Some(
|
||||
@@ -437,8 +505,7 @@ struct Policy {
|
||||
impl ProjectionPolicy<Entry, Object> for Policy {
|
||||
type Principal = Principal;
|
||||
fn object_visible(&self, principal: &Principal, entry: &Entry) -> bool {
|
||||
!entry.node.data.lock().unwrap().hidden
|
||||
&& (!matches!(principal, Principal::Limited) || entry.path != "/secret")
|
||||
!entry.hidden && (!matches!(principal, Principal::Limited) || entry.path != "/secret")
|
||||
}
|
||||
fn interface_visible(&self, principal: &Principal, interface: &InterfaceRef) -> bool {
|
||||
!(matches!(principal, Principal::Limited) && self.hide_read && interface == &self.read)
|
||||
@@ -690,6 +757,109 @@ impl Fixture {
|
||||
(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 {
|
||||
ObserveRequest {
|
||||
path: path.into(),
|
||||
@@ -868,8 +1038,8 @@ fn principal_views_hide_children_and_guessed_descendants_for_observation_and_cal
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|seen| !seen.path.starts_with("/secret/")),
|
||||
"hidden descendants were resolved"
|
||||
.all(|seen| seen.stage == "resolve"),
|
||||
"hidden lineage was published, enumerated, or dispatched"
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
|
||||
Reference in New Issue
Block a user