use crate::plain::{canonicalize_plain, plain_path};
use std::ffi::{OsStr, OsString};
use std::fmt;
use std::io;
use std::path::{Component, Path, PathBuf};
#[derive(Debug)]
#[non_exhaustive]
pub enum ContainError {
InvalidRelative,
NotAbsolute,
Escapes,
Symlink,
NotAFile,
HardLinked,
Io(io::Error),
}
impl fmt::Display for ContainError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidRelative => f.write_str("the path is not a plain relative path"),
Self::NotAbsolute => f.write_str("the path is not absolute"),
Self::Escapes => f.write_str("the path resolves outside the root"),
Self::Symlink => f.write_str("the path passes through a symbolic link"),
Self::NotAFile => f.write_str("the path is not a regular file"),
Self::HardLinked => f.write_str("the file has more than one hard link"),
Self::Io(e) => write!(f, "the path cannot be resolved: {e}"),
}
}
}
impl std::error::Error for ContainError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(e) => Some(e),
_ => None,
}
}
}
impl From<io::Error> for ContainError {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
impl From<ContainError> for io::Error {
fn from(e: ContainError) -> Self {
if let ContainError::Io(inner) = e {
return inner;
}
let kind = match &e {
ContainError::InvalidRelative
| ContainError::NotAbsolute
| ContainError::Symlink
| ContainError::NotAFile => io::ErrorKind::InvalidInput,
ContainError::Escapes | ContainError::HardLinked => io::ErrorKind::PermissionDenied,
ContainError::Io(_) => io::ErrorKind::Other,
};
io::Error::new(kind, e)
}
}
fn unusable_new_name(name: &OsStr) -> bool {
cfg!(windows)
&& name
.to_str()
.is_none_or(crate::names::is_unsafe_windows_component)
}
fn refuse_dot_dot_in_missing_part(path: &Path) -> Result<(), ContainError> {
let mut cur = PathBuf::new();
let mut exists = true;
for component in path.components() {
match component {
Component::ParentDir if !exists => return Err(ContainError::InvalidRelative),
Component::ParentDir => cur.push(".."),
other => {
cur.push(other.as_os_str());
if exists && matches!(other, Component::Normal(_)) {
exists = std::fs::symlink_metadata(&cur).is_ok();
}
}
}
}
Ok(())
}
fn resolve_lenient(path: &Path) -> Result<PathBuf, ContainError> {
refuse_dot_dot_in_missing_part(path)?;
let mut tail: Vec<OsString> = Vec::new();
let mut head = path.to_path_buf();
loop {
let e = match canonicalize_plain(&head) {
Ok(mut real) => {
if !tail.is_empty() && !real.is_dir() {
return Err(ContainError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
"a path component is not a directory",
)));
}
real.extend(tail.iter().rev());
return Ok(plain_path(real));
}
Err(e) => e,
};
match std::fs::symlink_metadata(&head) {
Ok(_) => return Err(ContainError::Io(e)),
Err(m) if m.kind() == io::ErrorKind::NotFound => {}
Err(m) => return Err(ContainError::Io(m)),
}
match head.file_name() {
Some(name) if unusable_new_name(name) => return Err(ContainError::InvalidRelative),
Some(name) => tail.push(name.to_os_string()),
None if head.components().next_back() == Some(Component::ParentDir) => {
return Err(ContainError::InvalidRelative)
}
None => return Err(ContainError::Io(e)),
}
if !head.pop() {
return Err(ContainError::Io(e));
}
}
}
#[cfg(feature = "fs")]
pub(crate) fn resolved_within(root: &Path, path: &Path) -> Result<bool, ContainError> {
let root = resolve_lenient(root)?;
let path = resolve_lenient(path)?;
Ok(is_below(&root, &path))
}
fn fold_unc(text: &str) -> String {
match text.strip_prefix(r"\\?\UNC\") {
Some(rest) => format!(r"\\{rest}"),
None => text.to_string(),
}
}
#[cfg(any(windows, target_vendor = "apple"))]
const CASE_INSENSITIVE: bool = true;
#[cfg(not(any(windows, target_vendor = "apple")))]
const CASE_INSENSITIVE: bool = false;
fn comparable(path: &Path, fold_case: bool) -> Vec<OsString> {
let folded: PathBuf = match path.to_str() {
Some(text) => PathBuf::from(fold_unc(text)),
None => path.to_path_buf(),
};
folded
.components()
.map(|c| {
let name = c.as_os_str();
match name.to_str() {
Some(text) if fold_case => OsString::from(text.to_lowercase()),
_ => name.to_os_string(),
}
})
.collect()
}
fn is_below_with(root: &Path, path: &Path, fold_case: bool) -> bool {
let (root, path) = (comparable(root, fold_case), comparable(path, fold_case));
path.len() >= root.len() && path[..root.len()] == root[..]
}
#[cfg(feature = "fs")]
fn normalized(path: &Path) -> PathBuf {
match path.to_str() {
Some(text) => PathBuf::from(fold_unc(text)),
None => path.to_path_buf(),
}
}
fn is_below(root: &Path, path: &Path) -> bool {
if is_below_with(root, path, false) {
return true;
}
if !CASE_INSENSITIVE || !is_below_with(root, path, true) {
return false;
}
same_directory_prefix(root, path)
}
#[cfg(feature = "fs")]
fn same_directory_prefix(root: &Path, path: &Path) -> bool {
let count = normalized(root).components().count();
let prefix: PathBuf = normalized(path).components().take(count).collect();
match (
crate::file_key::FileKey::of_path(root),
crate::file_key::FileKey::of_path(&prefix),
) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}
#[cfg(not(feature = "fs"))]
fn same_directory_prefix(_root: &Path, _path: &Path) -> bool {
false
}
fn inside(root: &Path, resolved: PathBuf) -> Result<PathBuf, ContainError> {
if is_below(root, &resolved) {
Ok(resolved)
} else {
Err(ContainError::Escapes)
}
}
fn plain_components(rel: &Path) -> Result<Vec<&OsStr>, ContainError> {
if rel.as_os_str().as_encoded_bytes().contains(&0) {
return Err(ContainError::InvalidRelative);
}
let mut names = Vec::new();
for component in rel.components() {
match component {
Component::Normal(name) => {
if unusable_new_name(name) {
return Err(ContainError::InvalidRelative);
}
names.push(name);
}
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(ContainError::InvalidRelative)
}
}
}
if names.is_empty() {
return Err(ContainError::InvalidRelative);
}
Ok(names)
}
pub fn is_within(root: &Path, path: &Path) -> bool {
if !root.is_absolute() || !path.is_absolute() {
return false;
}
match (resolve_lenient(root), resolve_lenient(path)) {
(Ok(root), Ok(path)) => is_below(&root, &path),
_ => false,
}
}
pub fn contain(root: &Path, rel: impl AsRef<Path>) -> Result<PathBuf, ContainError> {
let names = plain_components(rel.as_ref())?;
let root = canonicalize_plain(root).map_err(ContainError::Io)?;
let mut joined = root.clone();
joined.extend(names);
let full = canonicalize_plain(&joined).map_err(ContainError::Io)?;
inside(&root, full)
}
pub fn contain_lenient(root: &Path, rel: impl AsRef<Path>) -> Result<PathBuf, ContainError> {
let names = plain_components(rel.as_ref())?;
let root = canonicalize_plain(root).map_err(ContainError::Io)?;
let mut head = root.clone();
head.extend(names);
inside(&root, resolve_lenient(&head)?)
}
#[derive(Debug, Clone, Default)]
pub struct Roots {
roots: Vec<PathBuf>,
#[cfg(feature = "fs")]
keys: Vec<Option<crate::file_key::FileKey>>,
}
impl Roots {
pub fn new() -> Self {
Self::default()
}
pub fn with_roots<I, P>(roots: I) -> Result<Self, ContainError>
where
I: IntoIterator<Item = P>,
P: AsRef<Path>,
{
let mut set = Self::new();
for root in roots {
set.add(root)?;
}
Ok(set)
}
pub fn add(&mut self, root: impl AsRef<Path>) -> Result<(), ContainError> {
let root = root.as_ref();
if !root.is_absolute() {
return Err(ContainError::NotAbsolute);
}
let real = canonicalize_plain(root)?;
if !std::fs::metadata(&real)?.is_dir() {
return Err(ContainError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
"an approved root must be a directory",
)));
}
if !self.roots.contains(&real) {
#[cfg(feature = "fs")]
self.keys
.push(crate::file_key::FileKey::of_path(&real).ok());
self.roots.push(real);
}
Ok(())
}
pub fn roots(&self) -> &[PathBuf] {
&self.roots
}
pub fn is_empty(&self) -> bool {
self.roots.is_empty()
}
pub fn contains(&self, path: &Path) -> bool {
path.is_absolute() && self.resolve_confined_lenient(path).is_ok()
}
pub fn resolve_confined(&self, path: &Path) -> Result<PathBuf, ContainError> {
if !path.is_absolute() {
return Err(ContainError::NotAbsolute);
}
self.within_any(canonicalize_plain(path)?)
}
pub fn resolve_confined_lenient(&self, path: &Path) -> Result<PathBuf, ContainError> {
if !path.is_absolute() {
return Err(ContainError::NotAbsolute);
}
self.within_any(resolve_lenient(path)?)
}
fn within_any(&self, resolved: PathBuf) -> Result<PathBuf, ContainError> {
match self.roots.iter().find(|root| is_below(root, &resolved)) {
Some(_) => Ok(resolved),
None => Err(ContainError::Escapes),
}
}
#[cfg(feature = "fs")]
fn locate(&self, path: &Path) -> Result<(usize, Vec<OsString>), ContainError> {
if !path.is_absolute() {
return Err(ContainError::NotAbsolute);
}
if path.as_os_str().as_encoded_bytes().contains(&0) {
return Err(ContainError::InvalidRelative);
}
let name = match path.components().next_back() {
Some(Component::Normal(name)) => name.to_os_string(),
_ => return Err(ContainError::InvalidRelative),
};
if unusable_new_name(&name) {
return Err(ContainError::InvalidRelative);
}
let parent = path.parent().ok_or(ContainError::InvalidRelative)?;
let parent = resolve_lenient(parent)?;
let index = self
.roots
.iter()
.position(|root| is_below(root, &parent))
.ok_or(ContainError::Escapes)?;
let skip = self.roots[index].components().count();
let mut names: Vec<OsString> = parent
.components()
.skip(skip)
.map(|c| c.as_os_str().to_os_string())
.collect();
names.push(name);
Ok((index, names))
}
#[cfg(feature = "fs")]
pub fn open_confined(
&self,
path: &Path,
options: &ConfinedOptions,
) -> Result<std::fs::File, ContainError> {
let (index, names) = self.locate(path)?;
open_strict(&self.roots[index], &names, options, self.keys[index])
}
}
#[cfg(feature = "fs")]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ConfinedOptions {
read: bool,
write: bool,
append: bool,
create: bool,
create_new: bool,
truncate: bool,
require_single_link: bool,
share_read_only: bool,
}
#[cfg(feature = "fs")]
impl ConfinedOptions {
pub fn read_only() -> Self {
Self::default().read(true)
}
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 create(mut self, yes: bool) -> Self {
self.create = yes;
self
}
pub fn create_new(mut self, yes: bool) -> Self {
self.create_new = yes;
self
}
pub fn truncate(mut self, yes: bool) -> Self {
self.truncate = yes;
self
}
pub fn require_single_link(mut self, yes: bool) -> Self {
self.require_single_link = yes;
self
}
pub fn share_read_only(mut self, yes: bool) -> Self {
self.share_read_only = yes;
self
}
fn check(&self) -> Result<(), ContainError> {
let bad = |why: &'static str| {
Err(ContainError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
why,
)))
};
if !(self.read || self.write || self.append) {
return bad("no access mode set");
}
if (self.create || self.create_new) && !(self.write || self.append) {
return bad("create needs write or append");
}
if self.truncate && !self.write {
return bad("truncate needs write");
}
if self.share_read_only && (self.write || self.append) {
return bad("share_read_only is for a read-only open");
}
Ok(())
}
}
#[cfg(feature = "fs")]
pub fn open_confined(
root: &Path,
rel: impl AsRef<Path>,
options: &ConfinedOptions,
) -> Result<std::fs::File, ContainError> {
let names: Vec<OsString> = plain_components(rel.as_ref())?
.into_iter()
.map(OsStr::to_os_string)
.collect();
let root = canonicalize_plain(root)?;
open_strict(&root, &names, options, None)
}
#[cfg(all(feature = "fs", unix))]
fn open_strict(
root: &Path,
names: &[OsString],
options: &ConfinedOptions,
expected_root: Option<crate::file_key::FileKey>,
) -> Result<std::fs::File, ContainError> {
use std::ffi::CString;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::MetadataExt;
options.check()?;
let cstr = |bytes: &[u8]| {
CString::new(bytes).map_err(|_| {
ContainError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
"path contains a NUL byte",
))
})
};
let (last, parents) = names.split_last().ok_or(ContainError::InvalidRelative)?;
let root_c = cstr(root.as_os_str().as_bytes())?;
let fd = unsafe {
libc::open(
root_c.as_ptr(),
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
)
};
if fd < 0 {
let e = io::Error::last_os_error();
return Err(if e.raw_os_error() == Some(libc::ELOOP) {
ContainError::Symlink
} else {
ContainError::Io(e)
});
}
let mut dir = unsafe { OwnedFd::from_raw_fd(fd) };
if let Some(expected) = expected_root {
let mut st: libc::stat = unsafe { std::mem::zeroed() };
if unsafe { libc::fstat(dir.as_raw_fd(), &mut st) } != 0 {
return Err(ContainError::Io(io::Error::last_os_error()));
}
if st.st_dev as u64 != expected.volume || st.st_ino as u64 != expected.index {
return Err(ContainError::Escapes);
}
}
let refuse = |dir: &OwnedFd, name: &std::ffi::CStr, err: io::Error| -> ContainError {
let mut st: libc::stat = unsafe { std::mem::zeroed() };
let rc = unsafe {
libc::fstatat(
dir.as_raw_fd(),
name.as_ptr(),
&mut st,
libc::AT_SYMLINK_NOFOLLOW,
)
};
if rc == 0 && (st.st_mode & libc::S_IFMT) == libc::S_IFLNK {
ContainError::Symlink
} else {
ContainError::Io(err)
}
};
for name in parents {
let c = cstr(name.as_bytes())?;
let fd = unsafe {
libc::openat(
dir.as_raw_fd(),
c.as_ptr(),
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
)
};
if fd < 0 {
return Err(refuse(&dir, c.as_c_str(), io::Error::last_os_error()));
}
dir = unsafe { OwnedFd::from_raw_fd(fd) };
}
let c = cstr(last.as_bytes())?;
let mut flags = match (options.read, options.write || options.append) {
(true, false) => libc::O_RDONLY,
(false, true) => libc::O_WRONLY,
_ => libc::O_RDWR,
};
flags |= libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK;
if options.append {
flags |= libc::O_APPEND;
}
if options.create_new {
flags |= libc::O_CREAT | libc::O_EXCL;
} else if options.create {
flags |= libc::O_CREAT;
}
let fd = unsafe { libc::openat(dir.as_raw_fd(), c.as_ptr(), flags, 0o600 as libc::c_uint) };
if fd < 0 {
return Err(refuse(&dir, c.as_c_str(), io::Error::last_os_error()));
}
let file = std::fs::File::from(unsafe { OwnedFd::from_raw_fd(fd) });
let meta = file.metadata()?;
if !meta.is_file() {
return Err(ContainError::NotAFile);
}
crate::handle::clear_nonblock(&file)?;
if options.require_single_link && meta.nlink() != 1 {
return Err(ContainError::HardLinked);
}
if options.truncate {
file.set_len(0)?;
}
Ok(file)
}
#[cfg(all(feature = "fs", windows))]
fn open_strict(
root: &Path,
names: &[OsString],
options: &ConfinedOptions,
_expected_root: Option<crate::file_key::FileKey>,
) -> Result<std::fs::File, ContainError> {
use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
const FILE_SHARE_READ: u32 = 0x1;
const FILE_SHARE_PIN: u32 = 0x3;
const FILE_SHARE_READ_WRITE_DELETE: u32 = 0x7;
options.check()?;
let (last, parents) = names.split_last().ok_or(ContainError::InvalidRelative)?;
let pin = |path: &Path| -> Result<std::fs::File, ContainError> {
let dir = std::fs::OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_PIN)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
.open(path)?;
let meta = dir.metadata()?;
if meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return Err(ContainError::Symlink);
}
if !meta.is_dir() {
return Err(ContainError::Io(io::Error::new(
io::ErrorKind::NotADirectory,
"a path component is not a directory",
)));
}
if !is_below(root, &crate::handle::final_path(&dir)?) {
return Err(ContainError::Escapes);
}
Ok(dir)
};
let mut pins = vec![pin(root)?];
let mut cur = root.to_path_buf();
for name in parents {
cur.push(name);
pins.push(pin(&cur)?);
}
cur.push(last);
let file = std::fs::OpenOptions::new()
.read(options.read)
.write(options.write)
.append(options.append)
.create(options.create)
.create_new(options.create_new)
.share_mode(if options.share_read_only {
FILE_SHARE_READ
} else {
FILE_SHARE_READ_WRITE_DELETE
})
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
.open(&cur)?;
let meta = file.metadata()?;
if meta.file_type().is_symlink() || meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return Err(ContainError::Symlink);
}
if !meta.is_file() {
return Err(ContainError::NotAFile);
}
let real = crate::handle::final_path(&file)?;
if !is_below(root, &real) {
return Err(ContainError::Escapes);
}
if options.require_single_link && crate::handle::link_count(&file)? != 1 {
return Err(ContainError::HardLinked);
}
if options.truncate {
file.set_len(0)?;
}
drop(pins);
Ok(file)
}
#[cfg(all(feature = "fs", not(any(unix, windows))))]
fn open_strict(
_root: &Path,
_names: &[OsString],
_options: &ConfinedOptions,
_expected_root: Option<crate::file_key::FileKey>,
) -> Result<std::fs::File, ContainError> {
Err(ContainError::Io(io::Error::new(
io::ErrorKind::Unsupported,
"a confined open is not available on this platform",
)))
}