From bf5fa54f4488b9727f16202aee80ae1042c5d873 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 6 Oct 2026 20:21:15 +0900 Subject: [PATCH] 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). --- crates/wip-fs-server/README.md | 46 ++- crates/wip-fs-server/src/engine.rs | 157 ++++++--- crates/wip-fs-server/src/lib.rs | 2 +- crates/wip-fs-server/src/projection.rs | 416 ++++++++++++++++++++--- crates/wip-fs-server/tests/extensions.rs | 198 ++++++++++- 5 files changed, 699 insertions(+), 120 deletions(-) diff --git a/crates/wip-fs-server/README.md b/crates/wip-fs-server/README.md index 80a9d22..19461ea 100644 --- a/crates/wip-fs-server/README.md +++ b/crates/wip-fs-server/README.md @@ -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` (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 diff --git a/crates/wip-fs-server/src/engine.rs b/crates/wip-fs-server/src/engine.rs index e00224d..0d16aae 100644 --- a/crates/wip-fs-server/src/engine.rs +++ b/crates/wip-fs-server/src/engine.rs @@ -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 { + ancestors: Vec, + target: E, +} + +impl ResolvedEntry { + /// 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, 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) { + (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; + /// 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>; /// 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, + operation: impl FnOnce(ResolvedEntry) -> OperationAttempt, ) -> ProviderResult; } @@ -205,39 +249,40 @@ where C: Composition, P: ProjectionPolicy, { - fn resolve(&self, context: &ExecutionContext, path: &str) -> ProviderResult { + fn resolve( + &self, + context: &ExecutionContext, + path: &str, + ) -> ProviderResult> { 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) -> 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) -> R, ) -> ProviderResult { 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(resolved: &ResolvedEntry, 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( operation: impl FnOnce() -> Result, ) -> Result { diff --git a/crates/wip-fs-server/src/lib.rs b/crates/wip-fs-server/src/lib.rs index 0470423..ac14d42 100644 --- a/crates/wip-fs-server/src/lib.rs +++ b/crates/wip-fs-server/src/lib.rs @@ -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::{ diff --git a/crates/wip-fs-server/src/projection.rs b/crates/wip-fs-server/src/projection.rs index 4f2c7c0..a618848 100644 --- a/crates/wip-fs-server/src/projection.rs +++ b/crates/wip-fs-server/src/projection.rs @@ -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 { + ) -> Result, 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 { + fn resolve( + &self, + context: &ExecutionContext, + path: &str, + ) -> ProviderResult> { 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, + callback: impl FnOnce(ResolvedEntry) -> OperationAttempt, ) -> ProviderResult { 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; @@ -765,7 +845,11 @@ mod tests { }) } - fn resolved(backend: &LinuxBackend, context: &ExecutionContext, path: &str) -> LinuxEntry { + fn resolved( + backend: &LinuxBackend, + context: &ExecutionContext, + path: &str, + ) -> ResolvedEntry { 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::>(), + ["", "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(); diff --git a/crates/wip-fs-server/tests/extensions.rs b/crates/wip-fs-server/tests/extensions.rs index 8fbdc0e..4b8271b 100644 --- a/crates/wip-fs-server/tests/extensions.rs +++ b/crates/wip-fs-server/tests/extensions.rs @@ -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, validator: Validator, + hidden: bool, } impl Entry { fn snapshot(path: String, node: Arc) -> 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, + replace_ancestor: Option, 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 { + fn resolve( + &self, + context: &ExecutionContext, + path: &str, + ) -> ProviderResult> { 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, + operation: impl FnOnce(ResolvedEntry) -> OperationAttempt, ) -> ProviderResult { 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 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