fix: bound filesystem execution and preserve guarantees
This commit is contained in:
@@ -2,6 +2,7 @@ use std::collections::VecDeque;
|
||||
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};
|
||||
|
||||
@@ -173,7 +174,6 @@ pub(crate) struct Projection {
|
||||
access: AccessMode,
|
||||
limits: ResourceLimits,
|
||||
handles: Arc<HandleCounter>,
|
||||
write_gate: Mutex<()>,
|
||||
injection: Mutex<FailureInjection>,
|
||||
}
|
||||
|
||||
@@ -183,13 +183,7 @@ impl Projection {
|
||||
access: AccessMode,
|
||||
limits: ResourceLimits,
|
||||
) -> Result<Self, std::io::Error> {
|
||||
let root = openat(
|
||||
CWD,
|
||||
root,
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
FsMode::empty(),
|
||||
)
|
||||
.map_err(std::io::Error::from)?;
|
||||
let root = open_root(root)?;
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let read_interface = registry
|
||||
.register(FileReadDefinition, None)
|
||||
@@ -212,11 +206,14 @@ impl Projection {
|
||||
current: AtomicUsize::new(0),
|
||||
maximum: limits.max_open_handles,
|
||||
}),
|
||||
write_gate: Mutex::new(()),
|
||||
injection: Mutex::new(FailureInjection::None),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn effective_root(&self) -> Result<PathBuf, std::io::Error> {
|
||||
std::fs::read_link(format!("/proc/self/fd/{}", self.root.as_raw_fd()))
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_interface(
|
||||
&self,
|
||||
request: &FetchInterfaceRequest,
|
||||
@@ -367,6 +364,9 @@ impl Projection {
|
||||
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 probe = openat(
|
||||
&parent,
|
||||
segment,
|
||||
@@ -486,15 +486,6 @@ impl ObjectProvider for Projection {
|
||||
path: &str,
|
||||
operation: impl FnOnce(&PublishedObject<Self::Object>) -> OperationAttempt,
|
||||
) -> ProviderResult<OperationAttempt> {
|
||||
let _gate = match self.write_gate.lock() {
|
||||
Ok(gate) => gate,
|
||||
Err(_) => {
|
||||
return ProviderResult::Failure(HostError::internal(
|
||||
"filesystem operation lock failed",
|
||||
EffectGuarantee::NotCommitted,
|
||||
));
|
||||
}
|
||||
};
|
||||
let entry = match self.resolve(context, path) {
|
||||
Ok(entry) => entry,
|
||||
Err(ProjectionError::NotFound) => return ProviderResult::NotFound,
|
||||
@@ -511,13 +502,22 @@ impl ObjectProvider for Projection {
|
||||
));
|
||||
}
|
||||
};
|
||||
if entry.kind == EntryKind::Regular
|
||||
&& flock(entry.fd(), FlockOperation::LockExclusive).is_err()
|
||||
{
|
||||
return ProviderResult::Failure(HostError::internal(
|
||||
"filesystem object could not be locked",
|
||||
EffectGuarantee::NotCommitted,
|
||||
));
|
||||
if entry.kind == EntryKind::Regular {
|
||||
match lock_with_context(entry.fd(), context, self.limits.request_timeout) {
|
||||
Ok(()) => {}
|
||||
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,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let publication = self.publication(entry.clone(), context.clone());
|
||||
let mut attempt = operation(&publication);
|
||||
@@ -661,6 +661,71 @@ impl FileWrite for FilesystemObject {
|
||||
}
|
||||
}
|
||||
|
||||
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 EntryKind::from_mode(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(
|
||||
fd: &OwnedFd,
|
||||
context: &ExecutionContext,
|
||||
maximum_wait: std::time::Duration,
|
||||
) -> Result<(), 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);
|
||||
}
|
||||
match flock(fd, FlockOperation::NonBlockingLockExclusive) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(rustix::io::Errno::WOULDBLOCK) => {
|
||||
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)
|
||||
@@ -683,7 +748,13 @@ 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::NOENT
|
||||
| Errno::NOTDIR
|
||||
| Errno::LOOP
|
||||
| Errno::ACCESS
|
||||
| Errno::PERM
|
||||
| Errno::INVAL
|
||||
| Errno::NAMETOOLONG
|
||||
) {
|
||||
ProjectionError::NotFound
|
||||
} else {
|
||||
@@ -740,7 +811,7 @@ mod tests {
|
||||
};
|
||||
use wip_protocol::{FetchTreeRequest, ProtocolErrorCode, Value};
|
||||
|
||||
use super::{FailureInjection, Projection};
|
||||
use super::{FailureInjection, FlockOperation, Projection};
|
||||
use crate::{AccessMode, ResourceLimits};
|
||||
|
||||
fn projection(root: &std::path::Path, access: AccessMode) -> Projection {
|
||||
@@ -1031,4 +1102,61 @@ mod tests {
|
||||
.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
|
||||
.fetch("/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!(
|
||||
Projection::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 = Projection::open(temporary.path(), AccessMode::ReadOnly, limits)
|
||||
.expect("open projection");
|
||||
let observed = projection.fetch("/file").expect("fetch 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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user