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
+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();