use std::collections::HashMap;
use std::fmt;
use std::path::Path;
#[cfg(unix)]
use std::path::PathBuf;
#[cfg(unix)]
use std::io::{Read, Seek, Write};
use crate::constraints::{Constraint, ConstraintValue, Subpath};
use crate::warrant::Warrant;
use crate::Error;
#[cfg(unix)]
use path_jail::guard::OpenOptions as JailOpenOptions;
#[cfg(unix)]
use path_jail::guard::{FdJail, GuardedFile};
#[cfg(unix)]
use path_jail::JailError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Containment {
Atomic,
BestEffort,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CapabilityAccess {
read: bool,
write: bool,
append: bool,
truncate: bool,
create: bool,
create_new: bool,
}
impl CapabilityAccess {
pub fn read_only() -> Self {
Self {
read: true,
write: false,
append: false,
truncate: false,
create: false,
create_new: false,
}
}
pub fn write_existing() -> Self {
Self {
read: false,
write: true,
append: false,
truncate: true,
create: false,
create_new: false,
}
}
pub fn append_existing() -> Self {
Self {
read: false,
write: false,
append: true,
truncate: false,
create: false,
create_new: false,
}
}
pub fn create_new() -> Self {
Self {
read: false,
write: true,
append: false,
truncate: false,
create: false,
create_new: true,
}
}
pub fn full_access() -> Self {
Self {
read: true,
write: true,
append: true,
truncate: true,
create: true,
create_new: true,
}
}
fn permits(&self, options: &OpenOptions) -> bool {
(!options.read || self.read)
&& (!options.write || self.write)
&& (!options.append || self.append)
&& (!options.truncate || self.truncate)
&& (!options.create || self.create)
&& (!options.create_new || self.create_new)
}
}
pub struct Workspace {
logical: Subpath,
containment: Containment,
limits: HashMap<String, CapabilityAccess>,
strict_limits: bool,
#[cfg(unix)]
jail: FdJail,
}
impl fmt::Debug for Workspace {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Workspace")
.field("logical_root", &self.logical.root)
.field("containment", &self.containment)
.field("limits", &self.limits)
.field("strict_limits", &self.strict_limits)
.finish_non_exhaustive()
}
}
impl Workspace {
pub fn new(
logical_root: impl AsRef<str>,
local_root: impl AsRef<Path>,
containment: Containment,
) -> Result<Self, FilesystemError> {
let logical = Subpath::new(logical_root.as_ref()).map_err(FilesystemError::Policy)?;
if logical.root == "/" {
return Err(FilesystemError::LogicalRootIsFilesystemRoot);
}
#[cfg(not(unix))]
{
let _ = (local_root, containment);
Err(FilesystemError::UnsupportedPlatform)
}
#[cfg(unix)]
{
if !atomic_platform() && containment == Containment::Atomic {
return Err(FilesystemError::AtomicUnavailable);
}
let jail = FdJail::new(local_root.as_ref())
.map_err(|err| map_jail_new(err, containment, local_root.as_ref()))?;
probe_descriptor_paths()?;
Ok(Self {
logical,
containment,
limits: HashMap::new(),
strict_limits: true,
jail,
})
}
}
pub fn limit_capability(
mut self,
capability: impl Into<String>,
access: CapabilityAccess,
) -> Self {
self.limits.insert(capability.into(), access);
self
}
pub fn require_capability_limits(mut self) -> Self {
self.strict_limits = true;
self
}
pub fn allow_handler_selected_access(mut self) -> Self {
self.strict_limits = false;
self
}
pub(crate) fn open_authorized(
&self,
argument: &str,
capability: &str,
execution_args: &HashMap<String, ConstraintValue>,
chain: &[Warrant],
options: &OpenOptions,
) -> Result<OpenedFile, FilesystemError> {
let path =
execution_args
.get(argument)
.ok_or_else(|| FilesystemError::UnknownArgument {
name: argument.to_string(),
})?;
let path = path
.as_str()
.ok_or_else(|| FilesystemError::ArgumentNotString {
name: argument.to_string(),
})?;
if !Subpath::spelling_is_normalized(path) {
return Err(FilesystemError::UnnormalizedPath);
}
let constraint = leaf_constraint(chain, capability, argument)?;
let covering = covering_subpaths(constraint, path, argument)?;
for subpath in &covering {
if !self
.logical
.contains_path(&subpath.root)
.map_err(FilesystemError::Policy)?
{
return Err(FilesystemError::WarrantOutsideCeiling);
}
}
let _narrowest = select_narrowest(&covering)?;
if !self
.logical
.contains_path(path)
.map_err(FilesystemError::Policy)?
{
return Err(FilesystemError::PathOutsideCeiling);
}
let relative = self
.logical
.lexical_remainder(path)
.map_err(FilesystemError::Policy)?
.ok_or(FilesystemError::PathOutsideCeiling)?;
if !relative_is_file(&relative) {
return Err(FilesystemError::EmptyRelativePath);
}
options.validate()?;
if let Some(allowed) = self.limits.get(capability) {
if !allowed.permits(options) {
return Err(FilesystemError::AccessNotPermitted {
name: capability.to_string(),
});
}
} else if self.strict_limits {
return Err(FilesystemError::CapabilityLimitMissing {
name: capability.to_string(),
});
}
#[cfg(not(unix))]
{
Err(FilesystemError::UnsupportedPlatform)
}
#[cfg(unix)]
{
if (options.create || options.create_new) && !atomic_platform() {
return Err(FilesystemError::CreateRequiresAtomic);
}
reject_symlink_entries(self.jail.root(), &relative)?;
let opened = self
.jail
.open(&relative, options.to_jail())
.map_err(map_open)?;
if self.containment == Containment::Atomic && !opened.attestation().toctou_safe {
return Err(FilesystemError::AtomicUnavailable);
}
for check in [named_entry, stored_entry] {
match check(self.jail.root(), &relative, &opened) {
NamedEntry::Match => {}
NamedEntry::Mismatch => return Err(FilesystemError::NotTheNamedFile),
NamedEntry::Unavailable => {
return Err(FilesystemError::EntryCheckUnavailable);
}
}
}
if options.truncate {
opened
.file()
.set_len(0)
.map_err(|_| FilesystemError::OpenFailed)?;
}
Ok(OpenedFile { file: opened })
}
}
}
fn relative_is_file(relative: &str) -> bool {
!relative.is_empty()
&& !relative.starts_with('/')
&& relative
.split('/')
.all(|component| !component.is_empty() && component != "." && component != "..")
}
fn leaf_constraint<'a>(
chain: &'a [Warrant],
capability: &str,
argument: &str,
) -> Result<&'a Constraint, FilesystemError> {
let leaf = chain.last().ok_or_else(|| FilesystemError::NotASubpath {
name: argument.to_string(),
})?;
let tools = leaf
.capabilities()
.ok_or_else(|| FilesystemError::NotASubpath {
name: argument.to_string(),
})?;
let set = tools
.get(capability)
.ok_or_else(|| FilesystemError::NotASubpath {
name: argument.to_string(),
})?;
set.get(argument)
.ok_or_else(|| FilesystemError::NotASubpath {
name: argument.to_string(),
})
}
fn covering_subpaths<'a>(
constraint: &'a Constraint,
path: &str,
argument: &str,
) -> Result<Vec<&'a Subpath>, FilesystemError> {
let mut found = Vec::new();
collect_covering(constraint, path, &mut found)?;
if found.is_empty() {
return Err(FilesystemError::NotASubpath {
name: argument.to_string(),
});
}
if found.iter().any(|subpath| !subpath.case_sensitive) {
return Err(FilesystemError::CaseInsensitive {
name: argument.to_string(),
});
}
Ok(found)
}
#[cfg(test)]
fn effective_subpath<'a>(
constraint: &'a Constraint,
path: &str,
argument: &str,
) -> Result<&'a Subpath, FilesystemError> {
select_narrowest(&covering_subpaths(constraint, path, argument)?)
}
fn collect_covering<'a>(
constraint: &'a Constraint,
path: &str,
out: &mut Vec<&'a Subpath>,
) -> Result<(), FilesystemError> {
let value = ConstraintValue::String(path.to_string());
match constraint {
Constraint::Subpath(subpath) => {
if subpath
.contains_path(path)
.map_err(FilesystemError::Policy)?
{
out.push(subpath);
}
Ok(())
}
Constraint::All(all) => {
for child in &all.constraints {
collect_covering(child, path, out)?;
}
Ok(())
}
Constraint::Any(any) => {
for child in &any.constraints {
if child.matches(&value).map_err(FilesystemError::Policy)? {
collect_covering(child, path, out)?;
}
}
Ok(())
}
Constraint::Not(_) => Ok(()),
_ => Ok(()),
}
}
fn select_narrowest<'a>(roots: &[&'a Subpath]) -> Result<&'a Subpath, FilesystemError> {
let mut best = roots[0];
for candidate in roots.iter().copied().skip(1) {
if candidate.root == best.root {
continue;
}
let best_holds = best
.contains_path(&candidate.root)
.map_err(FilesystemError::Policy)?;
let candidate_holds = candidate
.contains_path(&best.root)
.map_err(FilesystemError::Policy)?;
if best_holds {
best = candidate;
} else if !candidate_holds {
return Err(FilesystemError::AmbiguousRoot);
}
}
for candidate in roots {
if candidate.root != best.root
&& !candidate
.contains_path(&best.root)
.map_err(FilesystemError::Policy)?
{
return Err(FilesystemError::AmbiguousRoot);
}
}
Ok(best)
}
#[cfg(unix)]
fn atomic_platform() -> bool {
cfg!(all(
target_os = "linux",
any(target_arch = "x86_64", target_arch = "aarch64")
))
}
#[cfg(unix)]
fn map_open(err: JailError) -> FilesystemError {
match err {
JailError::FileTypeRejected { .. } => FilesystemError::NotRegularFile,
JailError::HardLinkRejected { .. } => FilesystemError::HardLinkRejected,
JailError::Io(err) => map_io(err),
JailError::Escape { .. } | JailError::EscapedRoot { .. } => FilesystemError::EscapedJail,
JailError::SymlinkRejected { .. } | JailError::BrokenSymlink(_) => {
FilesystemError::NotTheNamedFile
}
_ => FilesystemError::OpenFailed,
}
}
#[cfg(unix)]
fn map_io(err: std::io::Error) -> FilesystemError {
match err.kind() {
std::io::ErrorKind::NotFound => FilesystemError::NotFound,
std::io::ErrorKind::AlreadyExists => FilesystemError::AlreadyExists,
std::io::ErrorKind::PermissionDenied => FilesystemError::PermissionDenied,
_ => FilesystemError::OpenFailed,
}
}
#[cfg(unix)]
fn reject_symlink_entries(root: &Path, relative: &str) -> Result<(), FilesystemError> {
let mut current = root.to_path_buf();
let parts: Vec<&str> = relative.split('/').collect();
for (index, component) in parts.iter().enumerate() {
current.push(component);
match std::fs::symlink_metadata(¤t) {
Ok(meta) if meta.file_type().is_symlink() => {
return Err(FilesystemError::NotTheNamedFile);
}
Ok(_) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
if index + 1 == parts.len() {
return Ok(());
}
return Err(FilesystemError::NotFound);
}
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
return Err(FilesystemError::PermissionDenied);
}
Err(_) => return Err(FilesystemError::OpenFailed),
}
}
Ok(())
}
#[cfg(unix)]
enum NamedEntry {
Match,
Mismatch,
Unavailable,
}
#[cfg(unix)]
fn named_entry(root: &Path, relative: &str, opened: &GuardedFile) -> NamedEntry {
let Ok(actual) = fd_path(opened.file()) else {
return NamedEntry::Unavailable;
};
if path_is_named_entry(root, relative, &actual) {
NamedEntry::Match
} else {
NamedEntry::Mismatch
}
}
#[cfg(unix)]
fn stored_entry(root: &Path, relative: &str, opened: &GuardedFile) -> NamedEntry {
use std::os::unix::fs::MetadataExt;
let mut current = root.to_path_buf();
for component in relative.split('/') {
match directory_lists(¤t, component) {
Ok(true) => {}
Ok(false) => return NamedEntry::Mismatch,
Err(_) => return NamedEntry::Unavailable,
}
current.push(component);
}
let (Ok(named), Ok(handle)) = (
std::fs::symlink_metadata(¤t),
opened.file().metadata(),
) else {
return NamedEntry::Unavailable;
};
if named.dev() == handle.dev() && named.ino() == handle.ino() {
NamedEntry::Match
} else {
NamedEntry::Mismatch
}
}
#[cfg(unix)]
fn directory_lists(dir: &Path, name: &str) -> std::io::Result<bool> {
use std::os::unix::ffi::OsStrExt;
for entry in std::fs::read_dir(dir)? {
if entry?.file_name().as_bytes() == name.as_bytes() {
return Ok(true);
}
}
Ok(false)
}
#[cfg(unix)]
fn path_is_named_entry(root: &Path, relative: &str, actual: &Path) -> bool {
use std::os::unix::ffi::OsStrExt;
let root_bytes = root.as_os_str().as_bytes();
let actual_bytes = actual.as_os_str().as_bytes();
let Some(rest) = actual_bytes.strip_prefix(root_bytes) else {
return false;
};
let Some(rest) = rest.strip_prefix(b"/") else {
return false;
};
rest == relative.as_bytes()
}
#[cfg(unix)]
fn probe_descriptor_paths() -> Result<(), FilesystemError> {
let probe =
std::fs::File::open("/dev/null").map_err(|_| FilesystemError::EntryCheckUnavailable)?;
fd_path(&probe)
.map(|_| ())
.map_err(|_| FilesystemError::EntryCheckUnavailable)
}
#[cfg(all(unix, target_os = "linux"))]
fn fd_path(file: &std::fs::File) -> std::io::Result<PathBuf> {
use std::os::unix::io::AsRawFd;
std::fs::read_link(format!("/proc/self/fd/{}", file.as_raw_fd()))
}
#[cfg(all(unix, target_os = "macos"))]
fn fd_path(file: &std::fs::File) -> std::io::Result<PathBuf> {
use std::os::unix::ffi::OsStrExt;
use std::os::unix::io::AsRawFd;
extern "C" {
fn fcntl(fd: std::os::raw::c_int, cmd: std::os::raw::c_int, ...) -> std::os::raw::c_int;
}
const F_GETPATH: std::os::raw::c_int = 50;
let mut buf = [0u8; 1024];
let rc = unsafe { fcntl(file.as_raw_fd(), F_GETPATH, buf.as_mut_ptr()) };
if rc < 0 {
return Err(std::io::Error::last_os_error());
}
let len = buf
.iter()
.position(|byte| *byte == 0)
.ok_or_else(|| std::io::Error::other("fd path was not returned"))?;
Ok(PathBuf::from(std::ffi::OsStr::from_bytes(&buf[..len])))
}
#[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))]
fn fd_path(file: &std::fs::File) -> std::io::Result<PathBuf> {
use std::os::unix::io::AsRawFd;
std::fs::read_link(format!("/dev/fd/{}", file.as_raw_fd()))
}
#[cfg(unix)]
fn map_jail_new(err: JailError, containment: Containment, local_root: &Path) -> FilesystemError {
#[cfg(all(
target_os = "linux",
any(target_arch = "x86_64", target_arch = "aarch64")
))]
if matches!(err, JailError::UnsupportedKernel { .. }) {
return FilesystemError::KernelJailUnavailable;
}
let _ = (err, containment);
FilesystemError::InvalidJailRoot {
path: local_root.display().to_string(),
}
}
#[derive(Debug, Clone)]
pub struct OpenOptions {
read: bool,
write: bool,
append: bool,
truncate: bool,
create: bool,
create_new: bool,
require_regular_file: bool,
reject_hard_links: bool,
no_xdev: bool,
}
impl Default for OpenOptions {
fn default() -> Self {
Self {
read: false,
write: false,
append: false,
truncate: false,
create: false,
create_new: false,
require_regular_file: true,
reject_hard_links: true,
no_xdev: false,
}
}
}
impl OpenOptions {
pub fn new() -> Self {
Self::default()
}
pub fn read_only() -> Self {
Self::new().read(true)
}
pub fn write_truncate() -> Self {
Self::new().write(true).truncate(true)
}
pub fn create_new() -> Self {
Self {
write: true,
create_new: true,
..Self::new()
}
}
pub fn read(mut self, yes: bool) -> Self {
self.read = yes;
self
}
pub fn write(mut self, yes: bool) -> Self {
self.write = yes;
self
}
pub fn append(mut self, yes: bool) -> Self {
self.append = yes;
self
}
pub fn truncate(mut self, yes: bool) -> Self {
self.truncate = yes;
self
}
pub fn create(mut self, yes: bool) -> Self {
self.create = yes;
self
}
pub fn require_regular_file(mut self, yes: bool) -> Self {
self.require_regular_file = yes;
self
}
pub fn reject_hard_links(mut self, yes: bool) -> Self {
self.reject_hard_links = yes;
self
}
pub fn no_xdev(mut self, yes: bool) -> Self {
self.no_xdev = yes;
self
}
#[cfg(unix)]
fn to_jail(&self) -> JailOpenOptions {
JailOpenOptions::new()
.read(self.read)
.write(self.write)
.append(self.append)
.truncate(false)
.create(self.create)
.create_new(self.create_new)
.require_regular_file(self.require_regular_file)
.reject_hard_links(self.reject_hard_links)
.no_symlinks(true)
.no_xdev(self.no_xdev)
}
fn validate(&self) -> Result<(), FilesystemError> {
if !self.read && !self.write && !self.append {
return Err(FilesystemError::AccessModeMissing);
}
let can_write = self.write || self.append;
if (self.create || self.create_new) && !can_write {
return Err(FilesystemError::InvalidOpenOptions);
}
if self.truncate && !self.write {
return Err(FilesystemError::InvalidOpenOptions);
}
if self.truncate && self.append {
return Err(FilesystemError::InvalidOpenOptions);
}
Ok(())
}
}
pub struct OpenedFile {
#[cfg(unix)]
file: GuardedFile,
}
impl fmt::Debug for OpenedFile {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OpenedFile")
.field("toctou_safe", &self.toctou_safe())
.finish_non_exhaustive()
}
}
impl OpenedFile {
pub fn toctou_safe(&self) -> bool {
#[cfg(unix)]
{
self.file.attestation().toctou_safe
}
#[cfg(not(unix))]
{
false
}
}
pub fn has_hard_links(&self) -> bool {
#[cfg(unix)]
{
self.file.has_hard_links()
}
#[cfg(not(unix))]
{
false
}
}
#[cfg(unix)]
pub fn into_std(self) -> std::fs::File {
self.file.into_file()
}
}
#[cfg(unix)]
impl Read for OpenedFile {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.file.read(buf)
}
}
#[cfg(unix)]
impl Write for OpenedFile {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.file.write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
self.file.flush()
}
}
#[cfg(unix)]
impl Seek for OpenedFile {
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
self.file.seek(pos)
}
}
#[cfg(unix)]
impl std::os::unix::io::AsFd for OpenedFile {
fn as_fd(&self) -> std::os::unix::io::BorrowedFd<'_> {
std::os::unix::io::AsFd::as_fd(&self.file)
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum FilesystemError {
WorkspaceMissing,
UnsupportedPlatform,
AtomicUnavailable,
LogicalRootIsFilesystemRoot,
UnknownArgument {
name: String,
},
ArgumentNotString {
name: String,
},
NotASubpath {
name: String,
},
CaseInsensitive {
name: String,
},
WarrantOutsideCeiling,
PathOutsideCeiling,
AmbiguousRoot,
UnnormalizedPath,
EmptyRelativePath,
NotRegularFile,
HardLinkRejected,
AccessNotPermitted {
name: String,
},
CapabilityLimitMissing {
name: String,
},
CreateRequiresAtomic,
KernelJailUnavailable,
EntryCheckUnavailable,
EscapedJail,
PermissionDenied,
AccessModeMissing,
InvalidOpenOptions,
NotFound,
AlreadyExists,
InvalidJailRoot {
path: String,
},
NotTheNamedFile,
OpenFailed,
Policy(Error),
}
impl fmt::Display for FilesystemError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::WorkspaceMissing => write!(f, "guard has no filesystem workspace"),
Self::UnsupportedPlatform => {
write!(f, "filesystem open is unavailable on this platform")
}
Self::AtomicUnavailable => {
write!(
f,
"atomic containment is unavailable on this platform; use Containment::BestEffort"
)
}
Self::LogicalRootIsFilesystemRoot => {
write!(f, "logical root must not be the filesystem root")
}
Self::UnknownArgument { name } => {
write!(f, "authorized call has no argument {name}")
}
Self::ArgumentNotString { name } => {
write!(f, "authorized argument {name} is not a string")
}
Self::NotASubpath { name } => {
write!(f, "authorized argument {name} is not bounded by Subpath")
}
Self::CaseInsensitive { name } => {
write!(
f,
"authorized argument {name} uses a case-insensitive Subpath"
)
}
Self::WarrantOutsideCeiling => {
write!(f, "warrant root is outside the executor logical root")
}
Self::PathOutsideCeiling => {
write!(f, "authorized path is outside the executor logical root")
}
Self::AmbiguousRoot => write!(f, "warrant Subpath roots do not nest"),
Self::UnnormalizedPath => write!(
f,
"authorized path must already be normalized (no '.', '..', repeated separators, backslashes, or a trailing slash)"
),
Self::EmptyRelativePath => {
write!(f, "authorized path is the logical root and names no file")
}
Self::NotRegularFile => write!(f, "opened handle is not a regular file"),
Self::HardLinkRejected => {
write!(f, "opened file has more than one hard link")
}
Self::AccessNotPermitted { name } => {
write!(f, "capability {name} does not allow this open")
}
Self::CapabilityLimitMissing { name } => {
write!(f, "capability {name} has no configured filesystem access")
}
Self::CreateRequiresAtomic => {
write!(
f,
"creating a file requires Linux x86_64 or aarch64; on other platforms create the file in the executor and open it with write_truncate"
)
}
Self::KernelJailUnavailable => {
write!(f, "openat2 is unavailable, so this host cannot pin a jail")
}
Self::EntryCheckUnavailable => {
write!(f, "file descriptor path verification is unavailable on this host")
}
Self::EscapedJail => write!(f, "the path leaves the jail"),
Self::PermissionDenied => write!(f, "permission denied"),
Self::AccessModeMissing => write!(f, "open needs read, write, or append"),
Self::InvalidOpenOptions => write!(f, "open flags are not a valid combination"),
Self::NotFound => write!(f, "authorized file does not exist"),
Self::AlreadyExists => write!(f, "authorized file already exists"),
Self::InvalidJailRoot { path } => {
write!(f, "local jail root {path} could not be pinned")
}
Self::NotTheNamedFile => {
write!(f, "opened file is not the named directory entry")
}
Self::OpenFailed => write!(f, "open was rejected"),
Self::Policy(err) => write!(f, "{err}"),
}
}
}
impl std::error::Error for FilesystemError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Policy(err) => Some(err),
_ => None,
}
}
}
impl From<FilesystemError> for std::io::Error {
fn from(err: FilesystemError) -> Self {
let kind = match &err {
FilesystemError::NotFound => std::io::ErrorKind::NotFound,
FilesystemError::AlreadyExists => std::io::ErrorKind::AlreadyExists,
FilesystemError::PermissionDenied | FilesystemError::AccessNotPermitted { .. } => {
std::io::ErrorKind::PermissionDenied
}
FilesystemError::InvalidOpenOptions
| FilesystemError::AccessModeMissing
| FilesystemError::UnnormalizedPath
| FilesystemError::ArgumentNotString { .. }
| FilesystemError::UnknownArgument { .. } => std::io::ErrorKind::InvalidInput,
_ => std::io::ErrorKind::Other,
};
std::io::Error::new(kind, err)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::constraints::{Any, Constraint};
fn sub(root: &str) -> Subpath {
Subpath::new(root).expect("root")
}
#[test]
fn narrowest_matching_branch_wins() {
let reports = sub("/workspace/reports");
let other = sub("/other");
let constraint = Constraint::Any(Any::new([
Constraint::Subpath(reports),
Constraint::Subpath(other),
]));
let chosen =
effective_subpath(&constraint, "/workspace/reports/q4.md", "path").expect("root");
assert_eq!(chosen.root, "/workspace/reports");
}
#[test]
fn pattern_without_subpath_is_not_a_jail() {
let constraint = Constraint::Pattern(crate::Pattern::new("/workspace/*").expect("glob"));
let err = effective_subpath(&constraint, "/workspace/q4.md", "path").expect_err("deny");
assert!(matches!(err, FilesystemError::NotASubpath { .. }));
}
#[test]
fn all_keeps_the_narrower_root() {
let constraint = Constraint::All(crate::constraints::All::new([
Constraint::Subpath(sub("/workspace")),
Constraint::Subpath(sub("/workspace/reports")),
]));
let chosen =
effective_subpath(&constraint, "/workspace/reports/q4.md", "path").expect("root");
assert_eq!(chosen.root, "/workspace/reports");
}
#[test]
fn spelling_helper_rejects_rewritten_forms() {
assert!(Subpath::spelling_is_normalized("/workspace/reports/q4.md"));
assert!(!Subpath::spelling_is_normalized("/workspace//secret/key"));
assert!(!Subpath::spelling_is_normalized("/workspace/./secret/key"));
assert!(!Subpath::spelling_is_normalized(
"/workspace/reports/../secret/key"
));
assert!(!Subpath::spelling_is_normalized("/workspace/secret\\key"));
assert!(!Subpath::spelling_is_normalized(
"/workspace/reports/q4.md/"
));
}
#[test]
fn io_error_keeps_not_found_and_permission_denied() {
let missing = std::io::Error::from(FilesystemError::NotFound);
assert_eq!(missing.kind(), std::io::ErrorKind::NotFound);
let denied = std::io::Error::from(FilesystemError::PermissionDenied);
assert_eq!(denied.kind(), std::io::ErrorKind::PermissionDenied);
let other = std::io::Error::from(FilesystemError::OpenFailed);
assert_eq!(other.kind(), std::io::ErrorKind::Other);
}
#[test]
fn create_error_names_the_platform_and_the_workaround() {
let text = FilesystemError::CreateRequiresAtomic.to_string();
assert!(text.contains("Linux x86_64 or aarch64"), "{text}");
assert!(text.contains("write_truncate"), "{text}");
}
#[test]
fn capability_access_presets_are_narrow() {
assert!(CapabilityAccess::read_only().permits(&OpenOptions::read_only()));
assert!(!CapabilityAccess::read_only().permits(&OpenOptions::write_truncate()));
assert!(CapabilityAccess::write_existing().permits(&OpenOptions::write_truncate()));
assert!(!CapabilityAccess::write_existing().permits(&OpenOptions::read_only()));
assert!(!CapabilityAccess::write_existing().permits(&OpenOptions::create_new()));
let append = OpenOptions::new().append(true);
assert!(CapabilityAccess::append_existing().permits(&append));
assert!(!CapabilityAccess::append_existing().permits(&OpenOptions::write_truncate()));
assert!(CapabilityAccess::create_new().permits(&OpenOptions::create_new()));
assert!(
!CapabilityAccess::create_new().permits(&OpenOptions::new().write(true).create(true))
);
}
#[test]
fn remainder_strips_the_logical_prefix() {
let ceiling = sub("/workspace");
assert_eq!(
ceiling
.lexical_remainder("/workspace/reports/q4.md")
.expect("remainder")
.as_deref(),
Some("reports/q4.md")
);
assert_eq!(
ceiling
.lexical_remainder("/workspace-evil/q4.md")
.expect("remainder")
.as_deref(),
None
);
}
#[cfg(unix)]
#[test]
fn directory_listing_requires_the_stored_spelling() {
let dir = tempfile::tempdir().expect("dir");
std::fs::create_dir(dir.path().join("secret")).expect("secret");
assert!(directory_lists(dir.path(), "secret").expect("list"));
assert!(!directory_lists(dir.path(), "SECRET").expect("list"));
assert!(!directory_lists(dir.path(), "secre").expect("list"));
}
#[cfg(unix)]
#[test]
fn named_entry_requires_the_exact_spelling() {
let root = Path::new("/srv/jobs/job-1842");
assert!(path_is_named_entry(
root,
"secret/key",
Path::new("/srv/jobs/job-1842/secret/key")
));
assert!(!path_is_named_entry(
root,
"SECRET/key",
Path::new("/srv/jobs/job-1842/secret/key")
));
assert!(!path_is_named_entry(
root,
"secret/key",
Path::new("/srv/jobs/job-1842-evil/secret/key")
));
}
}