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.
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
+112 -45
View File
@@ -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)
{
return ProviderResult::NotFound;
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;
}
ProviderResult::Found(resolved)
}
if prefix == path {
return ProviderResult::Found(entry);
}
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) {
Ok(publication) => ProviderResult::Found(operation(&publication)),
Err(error) => ProviderResult::Failure(error),
},
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> {
+1 -1
View File
@@ -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::{
+369 -47
View File
@@ -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() && 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 kind != EntryKind::Directory {
return Err(ProjectionError::NotFound);
}
parent = reopen(&probe, OFlags::RDONLY | OFlags::DIRECTORY)?;
ancestors.push(entry);
} else {
let fd = self.reopen_final(probe, kind)?;
return self.entry(context, segment.to_owned(), fd);
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();
+184 -14
View File
@@ -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