T-20: identify interfaces by exact scope/name pairs; add optional opaque scope_ref metadata and separate shape validation from path-dependent scope compatibility. Preserve call error precedence, strict JSON, validators and transport boundaries. Mechanically migrate consumers and document API/wire compatibility and deferred lifecycle semantics. Validation: cargo fmt --all --check; git diff --check; cargo check --workspace --all-targets; cargo test -p wip-protocol -p wip-http; cargo test --workspace (221 passing tests including doctests); cargo test --workspace --all-targets; nix develop --command cargo clippy --workspace --all-targets -- -D warnings; RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps. All pass. cargo/rustc 1.95.0; development-shell Clippy/rustc 1.97.1.
1649 lines
62 KiB
Rust
1649 lines
62 KiB
Rust
use std::ffi::CStr;
|
|
use std::mem::MaybeUninit;
|
|
use std::os::fd::{AsRawFd, OwnedFd};
|
|
use std::path::{Component, Path, PathBuf};
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::{Arc, Mutex, MutexGuard, TryLockError};
|
|
|
|
use crate::engine::{BackendEntry, EntryKind, FilesystemBackend};
|
|
use crate::interfaces::{FileRead, FileWrite, SymlinkInspect};
|
|
use crate::{AccessMode, ResolvedEntry, ResourceLimits};
|
|
use rustix::fs::{
|
|
CWD, FileType, FlockOperation, Mode as FsMode, OFlags, RawDir, flock, fstat, ftruncate, openat,
|
|
readlinkat,
|
|
};
|
|
use rustix::io::{pread, pwrite};
|
|
use wip_host::{
|
|
EffectGuarantee, ExecutionContext, HostError, OperationAttempt, ProviderResult, Validator,
|
|
WipObject,
|
|
};
|
|
use wip_interface as wip;
|
|
|
|
fn entry_kind(mode: u32) -> EntryKind {
|
|
match FileType::from_raw_mode(mode) {
|
|
FileType::Directory => EntryKind::Directory,
|
|
FileType::RegularFile => EntryKind::Regular,
|
|
FileType::Symlink => EntryKind::Symlink,
|
|
_ => EntryKind::Unsupported,
|
|
}
|
|
}
|
|
|
|
fn entry_description(kind: EntryKind) -> &'static str {
|
|
match kind {
|
|
EntryKind::Directory => "Filesystem directory",
|
|
EntryKind::Regular => "Regular filesystem file",
|
|
EntryKind::Symlink => "Symbolic link (not followed)",
|
|
EntryKind::Unsupported => "Unsupported filesystem entry",
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct HandleCounter {
|
|
current: AtomicUsize,
|
|
maximum: usize,
|
|
}
|
|
|
|
impl HandleCounter {
|
|
fn acquire(self: &Arc<Self>) -> Result<HandlePermit, ProjectionError> {
|
|
let mut current = self.current.load(Ordering::Acquire);
|
|
loop {
|
|
if current >= self.maximum {
|
|
return Err(ProjectionError::Resource);
|
|
}
|
|
match self.current.compare_exchange_weak(
|
|
current,
|
|
current + 1,
|
|
Ordering::AcqRel,
|
|
Ordering::Acquire,
|
|
) {
|
|
Ok(_) => {
|
|
return Ok(HandlePermit {
|
|
counter: Arc::clone(self),
|
|
});
|
|
}
|
|
Err(actual) => current = actual,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct HandlePermit {
|
|
counter: Arc<HandleCounter>,
|
|
}
|
|
|
|
impl Drop for HandlePermit {
|
|
fn drop(&mut self) {
|
|
self.counter.current.fetch_sub(1, Ordering::AcqRel);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct EntryInner {
|
|
fd: OwnedFd,
|
|
execution: Mutex<()>,
|
|
_permit: HandlePermit,
|
|
}
|
|
|
|
/// A pinned Linux filesystem entry with bounded request-time capabilities.
|
|
///
|
|
/// Cloning retains the same open descriptor and handle permit. Use capabilities
|
|
/// within [`FilesystemBackend::execute`] for serialized validator checks and
|
|
/// dispatch; direct calls still enforce entry kind, access, and request limits.
|
|
#[derive(Clone)]
|
|
pub struct LinuxEntry {
|
|
name: String,
|
|
kind: EntryKind,
|
|
validator: Option<Validator>,
|
|
initial_validator: Option<Vec<u8>>,
|
|
inner: Arc<EntryInner>,
|
|
context: ExecutionContext,
|
|
access: AccessMode,
|
|
limits: ResourceLimits,
|
|
injection: FailureInjection,
|
|
}
|
|
|
|
impl LinuxEntry {
|
|
/// Returns the access mode used to open this entry.
|
|
#[must_use]
|
|
pub fn access_mode(&self) -> AccessMode {
|
|
self.access
|
|
}
|
|
|
|
/// Returns whether this entry supports regular-file reads.
|
|
#[must_use]
|
|
pub fn supports_file_read(&self) -> bool {
|
|
self.kind == EntryKind::Regular
|
|
}
|
|
|
|
/// Returns whether this entry supports regular-file replacement.
|
|
#[must_use]
|
|
pub fn supports_file_write(&self) -> bool {
|
|
self.kind == EntryKind::Regular && self.access == AccessMode::ReadWrite
|
|
}
|
|
|
|
/// Returns whether this entry supports inspecting a symbolic link itself.
|
|
#[must_use]
|
|
pub fn supports_symlink_inspect(&self) -> bool {
|
|
self.kind == EntryKind::Symlink
|
|
}
|
|
|
|
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();
|
|
self.validator = validator.map(Validator::new);
|
|
Ok(())
|
|
}
|
|
|
|
fn fd(&self) -> &OwnedFd {
|
|
&self.inner.fd
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum ProjectionError {
|
|
NotFound,
|
|
Resource,
|
|
Internal,
|
|
}
|
|
|
|
impl ProjectionError {
|
|
fn host<T>(self) -> ProviderResult<T> {
|
|
match self {
|
|
Self::NotFound => ProviderResult::NotFound,
|
|
error => ProviderResult::Failure(error.host_error()),
|
|
}
|
|
}
|
|
|
|
fn host_error(self) -> HostError {
|
|
match self {
|
|
Self::Resource => HostError::resource_limit(
|
|
"filesystem projection resource limit exceeded",
|
|
EffectGuarantee::NotCommitted,
|
|
),
|
|
Self::NotFound | Self::Internal => HostError::internal(
|
|
"filesystem projection failed",
|
|
EffectGuarantee::NotCommitted,
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum FailureInjection {
|
|
None,
|
|
BeforeWrite,
|
|
AfterTruncate,
|
|
}
|
|
|
|
/// Linux filesystem backend using pinned, no-follow handles.
|
|
///
|
|
/// 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,
|
|
limits: ResourceLimits,
|
|
handles: Arc<HandleCounter>,
|
|
injection: Mutex<FailureInjection>,
|
|
}
|
|
|
|
impl LinuxBackend {
|
|
/// Opens and pins a root directory without following symlink components.
|
|
pub fn open(
|
|
root: &Path,
|
|
access: AccessMode,
|
|
limits: ResourceLimits,
|
|
) -> Result<Self, std::io::Error> {
|
|
Ok(Self {
|
|
root: open_root(root)?,
|
|
access,
|
|
limits,
|
|
handles: Arc::new(HandleCounter {
|
|
current: AtomicUsize::new(0),
|
|
maximum: limits.max_open_handles,
|
|
}),
|
|
injection: Mutex::new(FailureInjection::None),
|
|
})
|
|
}
|
|
|
|
/// Returns the current filesystem path of the pinned root directory.
|
|
pub fn effective_root(&self) -> Result<PathBuf, std::io::Error> {
|
|
std::fs::read_link(format!("/proc/self/fd/{}", self.root.as_raw_fd()))
|
|
}
|
|
|
|
fn resolve_entry(
|
|
&self,
|
|
context: &ExecutionContext,
|
|
path: &str,
|
|
) -> 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 == "/" {
|
|
return Ok(ResolvedEntry::new(Vec::new(), root));
|
|
}
|
|
// 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.fd(),
|
|
segment,
|
|
OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
|
FsMode::empty(),
|
|
)
|
|
.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() {
|
|
ancestors.push(entry);
|
|
} else {
|
|
return Ok(ResolvedEntry::new(ancestors, entry));
|
|
}
|
|
}
|
|
Err(ProjectionError::NotFound)
|
|
}
|
|
|
|
fn open_child(
|
|
&self,
|
|
context: &ExecutionContext,
|
|
parent: &OwnedFd,
|
|
name: &str,
|
|
) -> Result<LinuxEntry, ProjectionError> {
|
|
check_context(context)?;
|
|
let probe = openat(
|
|
parent,
|
|
name,
|
|
OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
|
FsMode::empty(),
|
|
)
|
|
.map_err(classify_lookup)?;
|
|
let stat = fstat(&probe).map_err(|_| ProjectionError::Internal)?;
|
|
let kind = entry_kind(stat.st_mode);
|
|
let fd = self.reopen_final(probe, kind)?;
|
|
self.entry(context, name.to_owned(), fd)
|
|
}
|
|
|
|
fn reopen_final(&self, probe: OwnedFd, kind: EntryKind) -> Result<OwnedFd, ProjectionError> {
|
|
match kind {
|
|
EntryKind::Directory => reopen(&probe, OFlags::RDONLY | OFlags::DIRECTORY),
|
|
EntryKind::Regular => {
|
|
let access = if self.access == AccessMode::ReadWrite {
|
|
OFlags::RDWR
|
|
} else {
|
|
OFlags::RDONLY
|
|
};
|
|
reopen(&probe, access)
|
|
}
|
|
EntryKind::Symlink | EntryKind::Unsupported => Ok(probe),
|
|
}
|
|
}
|
|
|
|
fn entry(
|
|
&self,
|
|
context: &ExecutionContext,
|
|
name: String,
|
|
fd: OwnedFd,
|
|
) -> Result<LinuxEntry, ProjectionError> {
|
|
check_context(context)?;
|
|
let permit = self.handles.acquire()?;
|
|
let stat = fstat(&fd).map_err(|_| ProjectionError::Internal)?;
|
|
let kind = entry_kind(stat.st_mode);
|
|
let validator = matches!(kind, EntryKind::Regular | EntryKind::Symlink)
|
|
.then(|| validator_from_stat(&stat));
|
|
Ok(LinuxEntry {
|
|
name,
|
|
kind,
|
|
initial_validator: validator.clone(),
|
|
validator: validator.map(Validator::new),
|
|
context: context.clone(),
|
|
access: self.access,
|
|
limits: self.limits,
|
|
injection: *self
|
|
.injection
|
|
.lock()
|
|
.unwrap_or_else(|error| error.into_inner()),
|
|
inner: Arc::new(EntryInner {
|
|
fd,
|
|
execution: Mutex::new(()),
|
|
_permit: permit,
|
|
}),
|
|
})
|
|
}
|
|
|
|
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
|
|
.injection
|
|
.lock()
|
|
.unwrap_or_else(|error| error.into_inner()) = injection;
|
|
}
|
|
}
|
|
|
|
impl BackendEntry for LinuxEntry {
|
|
fn kind(&self) -> EntryKind {
|
|
self.kind
|
|
}
|
|
}
|
|
|
|
impl FilesystemBackend for LinuxBackend {
|
|
type Entry = 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(),
|
|
}
|
|
}
|
|
|
|
fn children(
|
|
&self,
|
|
context: &ExecutionContext,
|
|
entry: &LinuxEntry,
|
|
maximum: usize,
|
|
) -> Result<Vec<LinuxEntry>, HostError> {
|
|
check_context(context).map_err(resource_host)?;
|
|
if entry.kind != EntryKind::Directory {
|
|
return Ok(Vec::new());
|
|
}
|
|
let directory_fd = reopen(entry.fd(), OFlags::RDONLY | OFlags::DIRECTORY)
|
|
.map_err(ProjectionError::host_error)?;
|
|
let mut buffer = [MaybeUninit::<u8>::uninit(); 8192];
|
|
let mut directory = RawDir::new(directory_fd, &mut buffer);
|
|
let mut children = Vec::new();
|
|
while let Some(item) = directory.next() {
|
|
check_context(context).map_err(resource_host)?;
|
|
let item = item.map_err(|_| internal_not_committed())?;
|
|
let name = item.file_name();
|
|
if name == cstr_dot() || name == cstr_dot_dot() {
|
|
continue;
|
|
}
|
|
let Ok(name) = name.to_str() else {
|
|
// WIP names are Unicode. Non-Unicode backend names are not published.
|
|
continue;
|
|
};
|
|
if name.contains('/') || name.is_empty() || name == "." || name == ".." {
|
|
continue;
|
|
}
|
|
let child = match self.open_child(context, entry.fd(), name) {
|
|
Ok(child) => child,
|
|
Err(ProjectionError::NotFound) => continue,
|
|
Err(error) => return Err(error.host_error()),
|
|
};
|
|
if children.len() >= maximum {
|
|
return Err(resource_not_committed());
|
|
}
|
|
children.push(child);
|
|
}
|
|
Ok(children)
|
|
}
|
|
|
|
fn execute(
|
|
&self,
|
|
context: &ExecutionContext,
|
|
resolved: ResolvedEntry<LinuxEntry>,
|
|
callback: impl FnOnce(ResolvedEntry<LinuxEntry>) -> OperationAttempt,
|
|
) -> ProviderResult<OperationAttempt> {
|
|
if let Err(error) = check_context(context) {
|
|
return error.host();
|
|
}
|
|
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",
|
|
EffectGuarantee::NotCommitted,
|
|
));
|
|
}
|
|
Err(ProjectionError::Internal | ProjectionError::NotFound) => {
|
|
return ProviderResult::Failure(HostError::internal(
|
|
"filesystem object could not be locked",
|
|
EffectGuarantee::NotCommitted,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
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(ResolvedEntry::new(ancestors, entry.clone()));
|
|
if let OperationAttempt::Started(Ok(result)) = &mut attempt {
|
|
if entry.refresh_validator().is_err() {
|
|
return ProviderResult::Failure(HostError::internal(
|
|
"filesystem state could not be observed after dispatch",
|
|
EffectGuarantee::Unknown,
|
|
));
|
|
}
|
|
result.validator = entry.initial_validator.clone();
|
|
}
|
|
ProviderResult::Found(attempt)
|
|
}
|
|
}
|
|
|
|
// Unlock before releasing clone serialization, including on refresh/dispatch failure.
|
|
struct EntryLock<'a> {
|
|
inner: &'a EntryInner,
|
|
_guard: MutexGuard<'a, ()>,
|
|
}
|
|
|
|
impl Drop for EntryLock<'_> {
|
|
fn drop(&mut self) {
|
|
let _ = flock(&self.inner.fd, FlockOperation::Unlock);
|
|
}
|
|
}
|
|
|
|
impl WipObject for LinuxEntry {
|
|
fn name(&self) -> &str {
|
|
&self.name
|
|
}
|
|
|
|
fn description(&self) -> Option<&str> {
|
|
Some(entry_description(self.kind))
|
|
}
|
|
|
|
fn validator(&self) -> Option<&Validator> {
|
|
self.validator.as_ref()
|
|
}
|
|
}
|
|
|
|
impl FileRead for LinuxEntry {
|
|
fn read(&self, offset: Option<u64>, length: Option<u64>) -> wip::OperationResult<wip::Bytes> {
|
|
check_context(&self.context).map_err(resource_host)?;
|
|
if !self.supports_file_read() {
|
|
return Err(internal_not_committed());
|
|
}
|
|
let offset = offset.unwrap_or(0);
|
|
let stat = fstat(self.fd()).map_err(|_| internal_not_committed())?;
|
|
let size = u64::try_from(stat.st_size).map_err(|_| internal_not_committed())?;
|
|
let available = size.saturating_sub(offset);
|
|
let length = match length {
|
|
Some(length) if length <= self.limits.max_file_read_bytes as u64 => {
|
|
length.min(available)
|
|
}
|
|
Some(_) => return Err(resource_not_committed()),
|
|
None if available <= self.limits.max_file_read_bytes as u64 => available,
|
|
None => return Err(resource_not_committed()),
|
|
};
|
|
let length = usize::try_from(length).map_err(|_| resource_not_committed())?;
|
|
let mut bytes = vec![0_u8; length];
|
|
let mut read = 0_usize;
|
|
while read < length {
|
|
check_context(&self.context).map_err(resource_host)?;
|
|
let count = pread(self.fd(), &mut bytes[read..], offset + read as u64)
|
|
.map_err(|_| internal_not_committed())?;
|
|
if count == 0 {
|
|
break;
|
|
}
|
|
read += count;
|
|
}
|
|
bytes.truncate(read);
|
|
Ok(wip::Bytes(bytes))
|
|
}
|
|
}
|
|
|
|
impl SymlinkInspect for LinuxEntry {
|
|
fn read_link(&self) -> wip::OperationResult<wip::Bytes> {
|
|
check_context(&self.context).map_err(resource_host)?;
|
|
if !self.supports_symlink_inspect() {
|
|
return Err(internal_not_committed());
|
|
}
|
|
let target = readlinkat(self.fd(), "", Vec::new()).map_err(|_| internal_not_committed())?;
|
|
Ok(wip::Bytes(target.as_bytes().to_vec()))
|
|
}
|
|
}
|
|
|
|
impl FileWrite for LinuxEntry {
|
|
fn replace(&self, contents: wip::Bytes) -> wip::OperationResult<()> {
|
|
check_context(&self.context).map_err(resource_host)?;
|
|
if !self.supports_file_write() {
|
|
return Err(internal_not_committed());
|
|
}
|
|
if contents.0.len() > self.limits.max_file_write_bytes {
|
|
return Err(resource_not_committed());
|
|
}
|
|
let current = fstat(self.fd()).map_err(|_| internal_not_committed())?;
|
|
if self.initial_validator.as_deref() != Some(validator_from_stat(¤t).as_slice()) {
|
|
return Err(HostError::internal(
|
|
"filesystem object changed before write",
|
|
EffectGuarantee::NotCommitted,
|
|
));
|
|
}
|
|
if self.injection == FailureInjection::BeforeWrite {
|
|
return Err(internal_not_committed());
|
|
}
|
|
ftruncate(self.fd(), 0).map_err(|_| internal_not_committed())?;
|
|
if self.injection == FailureInjection::AfterTruncate {
|
|
return Err(HostError::internal(
|
|
"injected failure after write began",
|
|
EffectGuarantee::Unknown,
|
|
));
|
|
}
|
|
let mut written = 0_usize;
|
|
while written < contents.0.len() {
|
|
if context_expired(&self.context) {
|
|
return Err(HostError::resource_limit(
|
|
"write interrupted after execution began",
|
|
EffectGuarantee::Unknown,
|
|
));
|
|
}
|
|
let count =
|
|
pwrite(self.fd(), &contents.0[written..], written as u64).map_err(|_| {
|
|
HostError::internal("filesystem write failed", EffectGuarantee::Unknown)
|
|
})?;
|
|
if count == 0 {
|
|
return Err(HostError::internal(
|
|
"filesystem write made no progress",
|
|
EffectGuarantee::Unknown,
|
|
));
|
|
}
|
|
written += count;
|
|
}
|
|
rustix::fs::fdatasync(self.fd()).map_err(|_| {
|
|
HostError::internal(
|
|
"filesystem synchronization failed",
|
|
EffectGuarantee::Unknown,
|
|
)
|
|
})?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn open_root(path: &Path) -> Result<OwnedFd, std::io::Error> {
|
|
let start = if path.is_absolute() { "/" } else { "." };
|
|
let mut current = openat(
|
|
CWD,
|
|
start,
|
|
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
|
FsMode::empty(),
|
|
)
|
|
.map_err(std::io::Error::from)?;
|
|
for component in path.components() {
|
|
let name = match component {
|
|
Component::RootDir | Component::CurDir => continue,
|
|
Component::Normal(name) => name,
|
|
Component::ParentDir | Component::Prefix(_) => {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
"filesystem root must not contain parent or platform-prefix components",
|
|
));
|
|
}
|
|
};
|
|
let probe = openat(
|
|
¤t,
|
|
name,
|
|
OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
|
FsMode::empty(),
|
|
)
|
|
.map_err(std::io::Error::from)?;
|
|
let stat = fstat(&probe).map_err(std::io::Error::from)?;
|
|
if entry_kind(stat.st_mode) != EntryKind::Directory {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::NotADirectory,
|
|
"filesystem root contains a symlink or non-directory component",
|
|
));
|
|
}
|
|
current = reopen(&probe, OFlags::RDONLY | OFlags::DIRECTORY)
|
|
.map_err(|_| std::io::Error::other("filesystem root could not be pinned"))?;
|
|
}
|
|
Ok(current)
|
|
}
|
|
|
|
fn lock_with_context<'a>(
|
|
inner: &'a EntryInner,
|
|
context: &ExecutionContext,
|
|
maximum_wait: std::time::Duration,
|
|
) -> Result<EntryLock<'a>, ProjectionError> {
|
|
let now = std::time::Instant::now();
|
|
let fallback_deadline = now.checked_add(maximum_wait).unwrap_or(now);
|
|
let deadline = context.deadline().map_or(fallback_deadline, |configured| {
|
|
configured.min(fallback_deadline)
|
|
});
|
|
loop {
|
|
check_context(context)?;
|
|
if std::time::Instant::now() >= deadline {
|
|
return Err(ProjectionError::Resource);
|
|
}
|
|
// flock treats clones' shared open-file description as a single owner.
|
|
let guard = match inner.execution.try_lock() {
|
|
Ok(guard) => guard,
|
|
Err(TryLockError::Poisoned(error)) => error.into_inner(),
|
|
Err(TryLockError::WouldBlock) => {
|
|
std::thread::sleep(std::time::Duration::from_millis(1));
|
|
continue;
|
|
}
|
|
};
|
|
match flock(&inner.fd, FlockOperation::NonBlockingLockExclusive) {
|
|
Ok(()) => {
|
|
return Ok(EntryLock {
|
|
inner,
|
|
_guard: guard,
|
|
});
|
|
}
|
|
Err(rustix::io::Errno::WOULDBLOCK) => {
|
|
drop(guard);
|
|
std::thread::sleep(std::time::Duration::from_millis(1));
|
|
}
|
|
Err(_) => return Err(ProjectionError::Internal),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn reopen(fd: &OwnedFd, flags: OFlags) -> Result<OwnedFd, ProjectionError> {
|
|
let path = format!("/proc/self/fd/{}", fd.as_raw_fd());
|
|
openat(CWD, path, flags | OFlags::CLOEXEC, FsMode::empty()).map_err(classify_lookup)
|
|
}
|
|
|
|
fn validator_from_stat(stat: &rustix::fs::Stat) -> Vec<u8> {
|
|
let mut bytes = Vec::with_capacity(56);
|
|
bytes.extend_from_slice(&stat.st_dev.to_be_bytes());
|
|
bytes.extend_from_slice(&stat.st_ino.to_be_bytes());
|
|
bytes.extend_from_slice(&u64::from(stat.st_mode).to_be_bytes());
|
|
bytes.extend_from_slice(&stat.st_size.to_be_bytes());
|
|
bytes.extend_from_slice(&stat.st_mtime.to_be_bytes());
|
|
bytes.extend_from_slice(&stat.st_mtime_nsec.to_be_bytes());
|
|
bytes.extend_from_slice(&stat.st_ctime.to_be_bytes());
|
|
bytes.extend_from_slice(&stat.st_ctime_nsec.to_be_bytes());
|
|
bytes
|
|
}
|
|
|
|
fn classify_lookup(error: rustix::io::Errno) -> ProjectionError {
|
|
use rustix::io::Errno;
|
|
if matches!(
|
|
error,
|
|
Errno::NOENT
|
|
| Errno::NOTDIR
|
|
| Errno::LOOP
|
|
| Errno::ACCESS
|
|
| Errno::PERM
|
|
| Errno::INVAL
|
|
| Errno::NAMETOOLONG
|
|
) {
|
|
ProjectionError::NotFound
|
|
} else {
|
|
ProjectionError::Internal
|
|
}
|
|
}
|
|
|
|
fn context_expired(context: &ExecutionContext) -> bool {
|
|
context.cancellation().is_cancelled()
|
|
|| context
|
|
.deadline()
|
|
.is_some_and(|deadline| std::time::Instant::now() >= deadline)
|
|
}
|
|
|
|
fn check_context(context: &ExecutionContext) -> Result<(), ProjectionError> {
|
|
if context_expired(context) || context.resource_budget() == Some(0) {
|
|
Err(ProjectionError::Resource)
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn resource_host(_: ProjectionError) -> HostError {
|
|
resource_not_committed()
|
|
}
|
|
|
|
fn resource_not_committed() -> HostError {
|
|
HostError::resource_limit(
|
|
"filesystem operation resource limit exceeded",
|
|
EffectGuarantee::NotCommitted,
|
|
)
|
|
}
|
|
|
|
fn internal_not_committed() -> HostError {
|
|
HostError::internal("filesystem operation failed", EffectGuarantee::NotCommitted)
|
|
}
|
|
|
|
fn cstr_dot() -> &'static CStr {
|
|
c"."
|
|
}
|
|
|
|
fn cstr_dot_dot() -> &'static CStr {
|
|
c".."
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::collections::BTreeMap;
|
|
use std::fs;
|
|
|
|
use tempfile::tempdir;
|
|
use wip_host::{
|
|
CallErrorKind, CallRequest, ExecutionContext, InterfaceTarget, ObjectProvider,
|
|
OperationAttempt, ProviderResult, Target, WipObject,
|
|
};
|
|
use wip_protocol::{ObserveRequest, ProtocolErrorCode, Value};
|
|
|
|
use super::{FailureInjection, FlockOperation, LinuxBackend, LinuxEntry};
|
|
use crate::engine::{DefaultPolicy, FilesystemBackend, Projection};
|
|
use crate::interfaces::{FileRead, FileWrite};
|
|
use crate::{AccessMode, DefaultComposition, ResolvedEntry, ResourceLimits};
|
|
|
|
type DefaultProjection = Projection<LinuxBackend, DefaultComposition, DefaultPolicy>;
|
|
|
|
fn projection(root: &std::path::Path, access: AccessMode) -> DefaultProjection {
|
|
crate::default_projection(root, access, ResourceLimits::default()).expect("open projection")
|
|
}
|
|
|
|
fn call(
|
|
projection: &DefaultProjection,
|
|
path: &str,
|
|
validator: Option<Vec<u8>>,
|
|
interface: wip_protocol::InterfaceReference,
|
|
operation: &str,
|
|
arguments: BTreeMap<String, Value>,
|
|
) -> Result<wip_protocol::CallOperationResponse, wip_host::CallError> {
|
|
projection.call_operation(CallRequest {
|
|
target: Target {
|
|
path: path.into(),
|
|
validator,
|
|
},
|
|
interface: InterfaceTarget {
|
|
scope_ref: None,
|
|
reference: interface,
|
|
validator: None,
|
|
},
|
|
operation: operation.into(),
|
|
arguments,
|
|
})
|
|
}
|
|
|
|
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"),
|
|
ProviderResult::Failure(error) => panic!("resolution failed: {error:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn execution_refreshes_both_validators_under_lock_and_returns_post_write_validator() {
|
|
let temporary = tempdir().expect("temporary root");
|
|
let path = temporary.path().join("file");
|
|
fs::write(&path, b"old").expect("write file");
|
|
let backend = LinuxBackend::open(
|
|
temporary.path(),
|
|
AccessMode::ReadWrite,
|
|
ResourceLimits::default(),
|
|
)
|
|
.expect("open backend");
|
|
let context = ExecutionContext::new();
|
|
let entry = resolved(&backend, &context, "/file");
|
|
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()
|
|
.read(true)
|
|
.write(true)
|
|
.open(&path)
|
|
.expect("open independent lock probe");
|
|
let mut callback_validator = None;
|
|
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!(
|
|
entry
|
|
.validator()
|
|
.map(|validator| validator.as_bytes().to_vec()),
|
|
entry.initial_validator,
|
|
);
|
|
callback_validator = entry.initial_validator.clone();
|
|
assert_eq!(
|
|
rustix::fs::flock(&lock_probe, FlockOperation::NonBlockingLockExclusive),
|
|
Err(rustix::io::Errno::WOULDBLOCK),
|
|
);
|
|
entry
|
|
.replace(wip_interface::Bytes(b"new".to_vec()))
|
|
.expect("write uses refreshed initial validator");
|
|
OperationAttempt::Started(Ok(wip_host::CallResult {
|
|
result: Value::Bytes(Vec::new()),
|
|
validator: None,
|
|
}))
|
|
});
|
|
let ProviderResult::Found(OperationAttempt::Started(Ok(result))) = attempt else {
|
|
panic!("execution should succeed: {attempt:?}");
|
|
};
|
|
assert_ne!(result.validator, callback_validator);
|
|
assert_eq!(
|
|
result.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.
|
|
rustix::fs::flock(&lock_probe, FlockOperation::NonBlockingLockExclusive)
|
|
.expect("execution released lock");
|
|
rustix::fs::flock(&lock_probe, FlockOperation::Unlock).expect("unlock probe");
|
|
}
|
|
|
|
#[test]
|
|
fn execution_uses_the_pinned_entry_after_path_replacement() {
|
|
let temporary = tempdir().expect("temporary root");
|
|
let path = temporary.path().join("file");
|
|
fs::write(&path, b"old").expect("write file");
|
|
let backend = LinuxBackend::open(
|
|
temporary.path(),
|
|
AccessMode::ReadWrite,
|
|
ResourceLimits::default(),
|
|
)
|
|
.expect("open backend");
|
|
let context = ExecutionContext::new();
|
|
let entry = resolved(&backend, &context, "/file");
|
|
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, |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()))
|
|
.expect("write pinned inode");
|
|
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(path).expect("read replacement"), b"replacement");
|
|
assert_eq!(
|
|
pinned.read(None, None).expect("read pinned inode").0,
|
|
b"pinned write"
|
|
);
|
|
}
|
|
|
|
#[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");
|
|
let path = temporary.path().join("file");
|
|
fs::write(&path, b"old").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, "/file");
|
|
let mut retained = None;
|
|
let attempt = backend.execute(&context, entry, |entry| {
|
|
retained = Some(entry);
|
|
OperationAttempt::reject(CallErrorKind::ValidatorMismatch)
|
|
});
|
|
assert!(matches!(
|
|
attempt,
|
|
ProviderResult::Found(OperationAttempt::NotStarted(_))
|
|
));
|
|
let lock_probe = fs::File::open(path).expect("open independent lock probe");
|
|
rustix::fs::flock(&lock_probe, FlockOperation::NonBlockingLockExclusive)
|
|
.expect("rejected execution released lock");
|
|
rustix::fs::flock(&lock_probe, FlockOperation::Unlock).expect("unlock probe");
|
|
assert!(retained.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn cloned_entries_cannot_bypass_execution_lock_and_wait_is_bounded() {
|
|
let temporary = tempdir().expect("temporary root");
|
|
fs::write(temporary.path().join("file"), b"old").expect("write file");
|
|
let backend = LinuxBackend::open(
|
|
temporary.path(),
|
|
AccessMode::ReadOnly,
|
|
ResourceLimits {
|
|
request_timeout: std::time::Duration::from_millis(20),
|
|
..ResourceLimits::default()
|
|
},
|
|
)
|
|
.expect("open backend");
|
|
let context = ExecutionContext::new();
|
|
let entry = resolved(&backend, &context, "/file");
|
|
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, |_| {
|
|
panic!("shared open-file description must not bypass execution lock");
|
|
});
|
|
let ProviderResult::Failure(error) = nested else {
|
|
panic!("nested clone execution must time out: {nested:?}");
|
|
};
|
|
assert_eq!(error.kind(), wip_host::HostErrorKind::ResourceLimitExceeded);
|
|
assert_eq!(
|
|
error.effect_guarantee(),
|
|
wip_host::EffectGuarantee::NotCommitted
|
|
);
|
|
assert!(started.elapsed() < std::time::Duration::from_secs(1));
|
|
OperationAttempt::reject(CallErrorKind::ValidatorMismatch)
|
|
});
|
|
assert!(matches!(attempt, ProviderResult::Found(_)));
|
|
assert!(matches!(
|
|
backend.execute(&context, cloned, |_| {
|
|
OperationAttempt::reject(CallErrorKind::ValidatorMismatch)
|
|
}),
|
|
ProviderResult::Found(_)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn children_budget_fails_completely_and_drops_temporary_handles() {
|
|
let temporary = tempdir().expect("temporary root");
|
|
fs::write(temporary.path().join("a"), b"a").expect("write file");
|
|
fs::write(temporary.path().join("b"), b"b").expect("write file");
|
|
let backend = LinuxBackend::open(
|
|
temporary.path(),
|
|
AccessMode::ReadOnly,
|
|
ResourceLimits {
|
|
max_open_handles: 4,
|
|
..ResourceLimits::default()
|
|
},
|
|
)
|
|
.expect("open backend");
|
|
let context = ExecutionContext::new();
|
|
let root = resolved(&backend, &context, "/");
|
|
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.target(), 2)
|
|
.expect("complete children");
|
|
assert_eq!(children.len(), 2);
|
|
let cloned = children[0].clone();
|
|
assert_eq!(backend.handles.current.load(super::Ordering::Acquire), 3);
|
|
drop(children);
|
|
assert_eq!(backend.handles.current.load(super::Ordering::Acquire), 2);
|
|
drop(cloned);
|
|
assert_eq!(backend.handles.current.load(super::Ordering::Acquire), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn direct_capabilities_enforce_access_limits_and_execution_context() {
|
|
let temporary = tempdir().expect("temporary root");
|
|
let path = temporary.path().join("file");
|
|
fs::write(&path, b"old").expect("write file");
|
|
let backend = LinuxBackend::open(
|
|
temporary.path(),
|
|
AccessMode::ReadOnly,
|
|
ResourceLimits {
|
|
max_file_read_bytes: 2,
|
|
..ResourceLimits::default()
|
|
},
|
|
)
|
|
.expect("open backend");
|
|
let context = ExecutionContext::new();
|
|
let entry = resolved(&backend, &context, "/file");
|
|
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();
|
|
let attempt = backend.execute(
|
|
&ExecutionContext::new().with_cancellation(cancellation),
|
|
entry,
|
|
|_| panic!("cancelled execution must not invoke callback"),
|
|
);
|
|
assert!(matches!(attempt, ProviderResult::Failure(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn projects_entry_kinds_without_following_symlinks() {
|
|
let temporary = tempdir().expect("temporary root");
|
|
fs::write(temporary.path().join("file.txt"), b"hello").expect("write file");
|
|
fs::create_dir(temporary.path().join("dir")).expect("create directory");
|
|
#[cfg(unix)]
|
|
std::os::unix::fs::symlink("/etc/passwd", temporary.path().join("link"))
|
|
.expect("create symlink");
|
|
let projection = projection(temporary.path(), AccessMode::ReadOnly);
|
|
|
|
let root = projection.observe_object("/").expect("observe root");
|
|
assert_eq!(root.name, "");
|
|
assert_eq!(root.description.as_deref(), Some("Filesystem directory"));
|
|
assert!(root.interfaces.is_empty());
|
|
assert!(root.validator.is_none());
|
|
|
|
let file = projection
|
|
.observe_object("/file.txt")
|
|
.expect("observe file");
|
|
assert_eq!(file.interfaces.len(), 1);
|
|
assert!(file.validator.is_some());
|
|
assert!(file.r#ref.is_none());
|
|
|
|
let directory = projection
|
|
.observe_object("/dir")
|
|
.expect("observe directory");
|
|
assert!(directory.interfaces.is_empty());
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
let link = projection
|
|
.observe_object("/link")
|
|
.expect("observe link itself");
|
|
assert_eq!(
|
|
link.description.as_deref(),
|
|
Some("Symbolic link (not followed)")
|
|
);
|
|
assert_eq!(link.interfaces.len(), 1);
|
|
assert_eq!(
|
|
projection
|
|
.observe_object("/link/shadow")
|
|
.expect_err("must not follow")
|
|
.kind(),
|
|
CallErrorKind::NotFound
|
|
);
|
|
let target = call(
|
|
&projection,
|
|
"/link",
|
|
link.validator,
|
|
link.interfaces[0].clone(),
|
|
"read_link",
|
|
BTreeMap::new(),
|
|
)
|
|
.expect("inspect symlink");
|
|
assert_eq!(target.result, Value::Bytes(b"/etc/passwd".to_vec()));
|
|
}
|
|
|
|
assert_eq!(
|
|
projection
|
|
.observe_object("/missing")
|
|
.expect_err("missing")
|
|
.kind(),
|
|
CallErrorKind::NotFound
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reads_are_bounded_and_observation_is_complete() {
|
|
let temporary = tempdir().expect("temporary root");
|
|
fs::write(temporary.path().join("a"), b"abcdef").expect("write file");
|
|
fs::write(temporary.path().join("b"), b"b").expect("write file");
|
|
let projection = projection(temporary.path(), AccessMode::ReadOnly);
|
|
let file = projection.observe_object("/a").expect("observe file");
|
|
let result = call(
|
|
&projection,
|
|
"/a",
|
|
file.validator.clone(),
|
|
file.interfaces[0].clone(),
|
|
"read",
|
|
BTreeMap::from([
|
|
("offset".into(), Value::Integer(2)),
|
|
("length".into(), Value::Integer(3)),
|
|
]),
|
|
)
|
|
.expect("read range");
|
|
assert_eq!(result.result, Value::Bytes(b"cde".to_vec()));
|
|
|
|
let complete = projection
|
|
.observe(
|
|
&ExecutionContext::new(),
|
|
&ObserveRequest {
|
|
path: "/".into(),
|
|
depth: 1,
|
|
},
|
|
)
|
|
.expect("complete observation");
|
|
let children = complete.children.as_ref().expect("root children observed");
|
|
assert_eq!(children.len(), 2);
|
|
assert!(children.iter().all(|child| child.children.is_none()));
|
|
}
|
|
|
|
#[test]
|
|
fn writes_require_current_validator_and_return_new_validator() {
|
|
let temporary = tempdir().expect("temporary root");
|
|
let path = temporary.path().join("file");
|
|
fs::write(&path, b"old").expect("write file");
|
|
let projection = projection(temporary.path(), AccessMode::ReadWrite);
|
|
let observed = projection.observe_object("/file").expect("observe file");
|
|
assert_eq!(observed.interfaces.len(), 2);
|
|
let write_interface = observed.interfaces[1].clone();
|
|
|
|
let missing = call(
|
|
&projection,
|
|
"/file",
|
|
None,
|
|
write_interface.clone(),
|
|
"replace",
|
|
BTreeMap::from([("contents".into(), Value::Bytes(b"new".to_vec()))]),
|
|
)
|
|
.expect_err("write validator required");
|
|
assert_eq!(missing.kind(), CallErrorKind::ValidatorRequired);
|
|
|
|
let stale_validator = observed.validator.clone();
|
|
fs::remove_file(&path).expect("remove old entry");
|
|
fs::write(&path, b"replacement").expect("replace path");
|
|
let stale = call(
|
|
&projection,
|
|
"/file",
|
|
stale_validator,
|
|
write_interface,
|
|
"replace",
|
|
BTreeMap::from([("contents".into(), Value::Bytes(b"bad".to_vec()))]),
|
|
)
|
|
.expect_err("replacement identity must mismatch");
|
|
assert_eq!(stale.kind(), CallErrorKind::ValidatorMismatch);
|
|
assert_eq!(fs::read(&path).expect("read replacement"), b"replacement");
|
|
|
|
let current = projection
|
|
.observe_object("/file")
|
|
.expect("observe replacement");
|
|
let result = call(
|
|
&projection,
|
|
"/file",
|
|
current.validator.clone(),
|
|
current.interfaces[1].clone(),
|
|
"replace",
|
|
BTreeMap::from([("contents".into(), Value::Bytes(b"committed".to_vec()))]),
|
|
)
|
|
.expect("replace file");
|
|
assert_ne!(result.validator, current.validator);
|
|
assert_eq!(fs::read(path).expect("read committed file"), b"committed");
|
|
}
|
|
|
|
#[test]
|
|
fn post_dispatch_failures_preserve_effect_guarantees() {
|
|
let temporary = tempdir().expect("temporary root");
|
|
let path = temporary.path().join("file");
|
|
fs::write(&path, b"old").expect("write file");
|
|
let projection = projection(temporary.path(), AccessMode::ReadWrite);
|
|
let observed = projection.observe_object("/file").expect("observe file");
|
|
let request = || {
|
|
call(
|
|
&projection,
|
|
"/file",
|
|
observed.validator.clone(),
|
|
observed.interfaces[1].clone(),
|
|
"replace",
|
|
BTreeMap::from([("contents".into(), Value::Bytes(b"new".to_vec()))]),
|
|
)
|
|
};
|
|
|
|
projection
|
|
.backend()
|
|
.set_injection(FailureInjection::BeforeWrite);
|
|
assert_eq!(
|
|
request().expect_err("pre-write failure").kind(),
|
|
CallErrorKind::Internal
|
|
);
|
|
assert_eq!(fs::read(&path).expect("old file remains"), b"old");
|
|
|
|
projection
|
|
.backend()
|
|
.set_injection(FailureInjection::AfterTruncate);
|
|
assert_eq!(
|
|
request().expect_err("post-truncate failure").kind(),
|
|
CallErrorKind::OperationOutcomeUnknown
|
|
);
|
|
assert_eq!(fs::read(path).expect("truncated file"), b"");
|
|
}
|
|
|
|
#[test]
|
|
fn large_directory_fails_without_partial_observation() {
|
|
let temporary = tempdir().expect("temporary root");
|
|
for index in 0..2_000 {
|
|
fs::write(temporary.path().join(format!("entry-{index}")), b"x")
|
|
.expect("write directory entry");
|
|
}
|
|
let projection = projection(temporary.path(), AccessMode::ReadOnly);
|
|
let error = projection
|
|
.observe(
|
|
&ExecutionContext::new(),
|
|
&ObserveRequest {
|
|
path: "/".into(),
|
|
depth: 1,
|
|
},
|
|
)
|
|
.expect_err("complete observation exceeds node budget");
|
|
assert_eq!(error.code, ProtocolErrorCode::ResourceLimitExceeded);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn non_unicode_names_are_hidden_and_special_files_are_explicit() {
|
|
use std::ffi::OsString;
|
|
use std::os::unix::ffi::OsStringExt;
|
|
use std::os::unix::net::UnixListener;
|
|
|
|
let temporary = tempdir().expect("temporary root");
|
|
let invalid_name = OsString::from_vec(vec![b'n', b'o', b'n', 0xff]);
|
|
fs::write(temporary.path().join(invalid_name), b"hidden").expect("write non-Unicode file");
|
|
let socket_path = temporary.path().join("socket");
|
|
let _socket = UnixListener::bind(&socket_path).expect("bind filesystem socket");
|
|
let projection = projection(temporary.path(), AccessMode::ReadOnly);
|
|
|
|
let tree = projection
|
|
.observe(
|
|
&ExecutionContext::new(),
|
|
&ObserveRequest {
|
|
path: "/".into(),
|
|
depth: 1,
|
|
},
|
|
)
|
|
.expect("list root");
|
|
let children = tree.children.as_ref().expect("root children observed");
|
|
assert_eq!(children.len(), 1);
|
|
assert_eq!(children[0].object.name, "socket");
|
|
assert_eq!(
|
|
children[0].object.description.as_deref(),
|
|
Some("Unsupported filesystem entry")
|
|
);
|
|
assert!(children[0].object.interfaces.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn cancellation_is_not_clean_tree_exhaustion() {
|
|
let temporary = tempdir().expect("temporary root");
|
|
fs::write(temporary.path().join("entry"), b"x").expect("write file");
|
|
let projection = projection(temporary.path(), AccessMode::ReadOnly);
|
|
let cancellation = wip_host::CancellationToken::new();
|
|
cancellation.cancel();
|
|
let error = projection
|
|
.observe(
|
|
&ExecutionContext::new().with_cancellation(cancellation),
|
|
&ObserveRequest {
|
|
path: "/".into(),
|
|
depth: 1,
|
|
},
|
|
)
|
|
.expect_err("cancelled traversal");
|
|
assert_eq!(error.code, ProtocolErrorCode::ResourceLimitExceeded);
|
|
}
|
|
|
|
#[test]
|
|
fn filesystem_invalid_names_are_not_published() {
|
|
let temporary = tempdir().expect("temporary root");
|
|
let projection = projection(temporary.path(), AccessMode::ReadOnly);
|
|
let error = projection
|
|
.observe_object("/bad\0name")
|
|
.expect_err("NUL cannot name a backend entry");
|
|
assert_eq!(error.kind(), CallErrorKind::NotFound);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn configured_root_rejects_symlink_components() {
|
|
let temporary = tempdir().expect("temporary parent");
|
|
let actual = temporary.path().join("actual");
|
|
fs::create_dir(&actual).expect("create actual root");
|
|
let linked = temporary.path().join("linked");
|
|
std::os::unix::fs::symlink(&actual, &linked).expect("create root symlink");
|
|
assert!(
|
|
LinuxBackend::open(&linked, AccessMode::ReadOnly, ResourceLimits::default()).is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn file_lock_wait_observes_the_operation_deadline() {
|
|
let temporary = tempdir().expect("temporary root");
|
|
let path = temporary.path().join("file");
|
|
fs::write(&path, b"locked").expect("write file");
|
|
let locked = fs::OpenOptions::new()
|
|
.read(true)
|
|
.write(true)
|
|
.open(&path)
|
|
.expect("open lock owner");
|
|
rustix::fs::flock(&locked, FlockOperation::LockExclusive).expect("hold external lock");
|
|
|
|
let limits = ResourceLimits {
|
|
request_timeout: std::time::Duration::from_millis(20),
|
|
..ResourceLimits::default()
|
|
};
|
|
let projection = crate::default_projection(temporary.path(), AccessMode::ReadOnly, limits)
|
|
.expect("open projection");
|
|
let observed = projection.observe_object("/file").expect("observe file");
|
|
let started = std::time::Instant::now();
|
|
let error = call(
|
|
&projection,
|
|
"/file",
|
|
observed.validator,
|
|
observed.interfaces[0].clone(),
|
|
"read",
|
|
BTreeMap::from([("length".into(), Value::Integer(1))]),
|
|
)
|
|
.expect_err("lock wait must time out");
|
|
assert_eq!(error.kind(), CallErrorKind::ResourceLimitExceeded);
|
|
assert!(started.elapsed() < std::time::Duration::from_secs(1));
|
|
rustix::fs::flock(&locked, FlockOperation::Unlock).expect("release lock");
|
|
}
|
|
}
|