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:
2026-10-06 20:21:15 +09:00
parent c6528c2887
commit bf5fa54f44
5 changed files with 699 additions and 120 deletions
+33 -13
View File
@@ -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
+112 -45
View File
@@ -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, return ProviderResult::NotFound;
ProviderResult::Failure(error) => return ProviderResult::Failure(error), }
}; ProviderResult::Found(resolved)
if !self
.projection
.policy
.object_visible(self.principal, &entry)
{
return ProviderResult::NotFound;
} }
if prefix == path { ProviderResult::NotFound => ProviderResult::NotFound,
return ProviderResult::Found(entry); ProviderResult::Failure(error) => ProviderResult::Failure(error),
}
let start = if end == 1 { 1 } else { end + 1 };
end = path[start..]
.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) => {
Ok(publication) => ProviderResult::Found(operation(&publication)), let (_ancestors, entry) = resolved.into_parts();
Err(error) => ProviderResult::Failure(error), match self.publication(entry, context) {
}, Ok(publication) => ProviderResult::Found(operation(&publication)),
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> {
+1 -1
View File
@@ -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::{
+369 -47
View File
@@ -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() && kind != EntryKind::Directory {
return Err(ProjectionError::NotFound);
}
let fd = self.reopen_final(probe, kind)?;
let entry = self.entry(context, segment.to_owned(), fd)?;
if segments.peek().is_some() { if segments.peek().is_some() {
if kind != EntryKind::Directory { ancestors.push(entry);
return Err(ProjectionError::NotFound);
}
parent = reopen(&probe, OFlags::RDONLY | OFlags::DIRECTORY)?;
} else { } else {
let fd = self.reopen_final(probe, kind)?; return Ok(ResolvedEntry::new(ancestors, entry));
return self.entry(context, segment.to_owned(), fd);
} }
} }
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();
+184 -14
View File
@@ -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