use std::io;
use std::path::{Component, Path, PathBuf};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum GuardType {
Path,
TmpPath,
CwdPath,
}
impl GuardType {
pub(crate) fn parse(name: &str) -> Option<Self> {
match name {
"path" => Some(Self::Path),
"tmp_path" => Some(Self::TmpPath),
"cwd_path" => Some(Self::CwdPath),
_ => None,
}
}
pub(crate) const fn name(self) -> &'static str {
match self {
Self::Path => "path",
Self::TmpPath => "tmp_path",
Self::CwdPath => "cwd_path",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Containment {
Type(GuardType),
Under,
}
impl Containment {
const fn label(self) -> &'static str {
match self {
Self::Type(guard_type) => guard_type.name(),
Self::Under => "--under",
}
}
}
#[derive(Debug)]
pub(crate) enum GuardError {
EmptyValue,
ContainsNewline,
InvalidRoot,
TraversalSegment,
Rejected {
normalized: PathBuf,
},
OutsideContainment {
containment: Containment,
normalized: PathBuf,
},
SymlinkEscapesContainment {
containment: Containment,
normalized: PathBuf,
target: PathBuf,
},
Io(io::Error),
}
impl GuardError {
pub(crate) fn code(&self) -> &'static str {
match self {
Self::EmptyValue => "guard_empty_value",
Self::ContainsNewline => "guard_contains_newline",
Self::InvalidRoot => "guard_invalid_root",
Self::TraversalSegment => "guard_traversal_segment",
Self::Rejected { .. } => "guard_rejected_target",
Self::OutsideContainment { .. } => "guard_outside_containment",
Self::SymlinkEscapesContainment { .. } => "guard_symlink_escapes_containment",
Self::Io(_) => "guard_target_unreadable",
}
}
}
impl std::fmt::Display for GuardError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EmptyValue => write!(f, "VALUE must not be empty or blank"),
Self::ContainsNewline => write!(f, "VALUE must not contain a newline"),
Self::InvalidRoot => write!(
f,
"--under ROOT must not be empty, blank, or contain a newline"
),
Self::TraversalSegment => write!(
f,
"VALUE must not contain a `..` segment; name the target directly, because \
resolving `..` would silently move the operand out of the directory it appears \
to sit in"
),
Self::Rejected { normalized } => write!(
f,
"refusing to guard `{}`: it is the filesystem root, the home directory, the \
current directory, or an ancestor of one of those",
normalized.display()
),
Self::OutsideContainment {
containment,
normalized,
} => write!(
f,
"refusing to guard `{}`: it is not strictly inside the {} containment root",
normalized.display(),
containment.label()
),
Self::SymlinkEscapesContainment {
containment,
normalized,
target,
} => write!(
f,
"refusing to guard `{}`: its final segment is a symlink to `{}`, outside the {} \
containment root, so a verb that follows symlinks would act outside it",
normalized.display(),
target.display(),
containment.label()
),
Self::Io(err) => write!(
f,
"could not resolve VALUE against the real filesystem: {err}"
),
}
}
}
pub(crate) fn guard_from_environment(
guard_type: GuardType,
value: &str,
under: Option<&str>,
) -> Result<PathBuf, GuardError> {
let cwd = std::env::current_dir().map_err(GuardError::Io)?;
let home = home_dir().map(|home| resolve_missing_ok(&home).unwrap_or(home));
let temp_roots = candidate_temp_roots();
evaluate(guard_type, value, under, &cwd, home.as_deref(), &temp_roots)
}
pub(crate) fn evaluate(
guard_type: GuardType,
value: &str,
under: Option<&str>,
cwd: &Path,
home: Option<&Path>,
temp_root_candidates: &[PathBuf],
) -> Result<PathBuf, GuardError> {
if value.is_empty() {
return Err(GuardError::EmptyValue);
}
if value.contains('\n') || value.contains('\r') {
return Err(GuardError::ContainsNewline);
}
if value.trim().is_empty() {
return Err(GuardError::EmptyValue);
}
if Path::new(value)
.components()
.any(|component| matches!(component, Component::ParentDir))
{
return Err(GuardError::TraversalSegment);
}
let normalized = normalize_target(value, cwd).map_err(GuardError::Io)?;
let canonical_cwd = resolve_missing_ok(cwd).map_err(GuardError::Io)?;
if is_rejected(&normalized, &canonical_cwd, home) {
return Err(GuardError::Rejected { normalized });
}
let requirements =
containment_requirements(guard_type, under, &canonical_cwd, temp_root_candidates, cwd)?;
if requirements.is_empty() {
return Ok(normalized);
}
let link_target = symlink_target(&normalized)?;
for (containment, roots) in &requirements {
if !roots
.iter()
.any(|root| is_strictly_under(&normalized, root))
{
return Err(GuardError::OutsideContainment {
containment: *containment,
normalized,
});
}
if let Some(target) = &link_target
&& !roots.iter().any(|root| is_strictly_under(target, root))
{
return Err(GuardError::SymlinkEscapesContainment {
containment: *containment,
normalized,
target: target.clone(),
});
}
}
Ok(normalized)
}
fn containment_requirements(
guard_type: GuardType,
under: Option<&str>,
canonical_cwd: &Path,
temp_root_candidates: &[PathBuf],
cwd: &Path,
) -> Result<Vec<(Containment, Vec<PathBuf>)>, GuardError> {
let mut requirements: Vec<(Containment, Vec<PathBuf>)> = Vec::new();
match guard_type {
GuardType::Path => {}
GuardType::CwdPath => requirements.push((
Containment::Type(guard_type),
vec![canonical_cwd.to_path_buf()],
)),
GuardType::TmpPath => requirements.push((
Containment::Type(guard_type),
temp_root_candidates
.iter()
.filter_map(|candidate| resolve_missing_ok(candidate).ok())
.collect(),
)),
}
if let Some(root) = under {
requirements.push((Containment::Under, vec![resolve_root(root, cwd)?]));
}
Ok(requirements)
}
fn resolve_root(root: &str, cwd: &Path) -> Result<PathBuf, GuardError> {
if root.trim().is_empty() || root.contains('\n') || root.contains('\r') {
return Err(GuardError::InvalidRoot);
}
let candidate = Path::new(root);
let absolute = if candidate.is_absolute() {
candidate.to_path_buf()
} else {
cwd.join(candidate)
};
resolve_missing_ok(&absolute).map_err(GuardError::Io)
}
fn symlink_target(path: &Path) -> Result<Option<PathBuf>, GuardError> {
let metadata = match std::fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(GuardError::Io(err)),
};
if !metadata.file_type().is_symlink() {
return Ok(None);
}
let link = std::fs::read_link(path).map_err(GuardError::Io)?;
let absolute = if link.is_absolute() {
link
} else {
match path.parent() {
Some(parent) => parent.join(link),
None => return Ok(None),
}
};
resolve_missing_ok(&absolute)
.map(Some)
.map_err(GuardError::Io)
}
fn is_rejected(normalized: &Path, canonical_cwd: &Path, home: Option<&Path>) -> bool {
if normalized.parent().is_none() {
return true;
}
if canonical_cwd.starts_with(normalized) {
return true;
}
if let Some(home) = home
&& home.starts_with(normalized)
{
return true;
}
false
}
fn is_strictly_under(target: &Path, root: &Path) -> bool {
target != root && target.starts_with(root)
}
fn normalize_target(value: &str, cwd: &Path) -> io::Result<PathBuf> {
let candidate = Path::new(value);
let absolute = if candidate.is_absolute() {
candidate.to_path_buf()
} else {
cwd.join(candidate)
};
let mut components: Vec<Component<'_>> = absolute.components().collect();
while matches!(components.last(), Some(Component::CurDir)) {
components.pop();
}
if matches!(components.last(), Some(Component::Normal(_))) {
let Some(Component::Normal(name)) = components.pop() else {
unreachable!("just matched Some(Component::Normal(_)) above");
};
let parent: PathBuf = components.iter().collect();
let canonical_parent = resolve_missing_ok(&parent)?;
Ok(canonical_parent.join(name))
} else {
let whole: PathBuf = components.iter().collect();
resolve_missing_ok(&whole)
}
}
fn resolve_missing_ok(path: &Path) -> io::Result<PathBuf> {
if let Ok(canonical) = std::fs::canonicalize(path) {
return Ok(plain_form(canonical));
}
let components: Vec<Component<'_>> = path.components().collect();
let root_len = components
.iter()
.take_while(|component| matches!(component, Component::Prefix(_) | Component::RootDir))
.count();
let root: PathBuf = components[..root_len].iter().collect();
let mut resolved = std::fs::canonicalize(&root)?;
let mut existing_len = root_len;
for extended_len in (root_len + 1)..=components.len() {
let candidate: PathBuf = components[..extended_len].iter().collect();
match std::fs::canonicalize(&candidate) {
Ok(canonical) => {
resolved = canonical;
existing_len = extended_len;
}
Err(_) => break,
}
}
for component in &components[existing_len..] {
match component {
Component::Normal(name) => resolved.push(name),
Component::CurDir => {}
Component::ParentDir => {
resolved.pop();
}
Component::RootDir | Component::Prefix(_) => {}
}
}
Ok(plain_form(resolved))
}
#[cfg(windows)]
fn plain_form(path: PathBuf) -> PathBuf {
use std::ffi::OsString;
use std::path::Prefix;
let mut components = path.components();
let Some(Component::Prefix(prefix)) = components.next() else {
return path;
};
let Prefix::VerbatimDisk(letter) = prefix.kind() else {
return path;
};
let mut plain = OsString::from(format!("{}:", letter as char));
plain.push(components.as_path().as_os_str());
PathBuf::from(plain)
}
#[cfg(not(windows))]
fn plain_form(path: PathBuf) -> PathBuf {
path
}
#[cfg(unix)]
fn home_dir() -> Option<PathBuf> {
non_empty_env("HOME")
}
#[cfg(windows)]
fn home_dir() -> Option<PathBuf> {
non_empty_env("USERPROFILE")
}
fn non_empty_env(name: &str) -> Option<PathBuf> {
std::env::var_os(name)
.map(PathBuf::from)
.filter(|value| !value.as_os_str().is_empty())
}
#[cfg(target_os = "macos")]
fn candidate_temp_roots() -> Vec<PathBuf> {
let mut roots = vec![
PathBuf::from("/private/tmp"),
PathBuf::from("/private/var/tmp"),
];
if let Some(per_user) = darwin_user_temp_dir() {
roots.push(per_user);
}
roots
}
#[cfg(all(target_os = "macos", feature = "libc"))]
fn darwin_user_temp_dir() -> Option<PathBuf> {
let needed = unsafe { libc::confstr(libc::_CS_DARWIN_USER_TEMP_DIR, std::ptr::null_mut(), 0) };
if needed == 0 {
return None;
}
let mut buffer = vec![0u8; needed];
let written = unsafe {
libc::confstr(
libc::_CS_DARWIN_USER_TEMP_DIR,
buffer.as_mut_ptr().cast(),
buffer.len(),
)
};
if written == 0 || written > buffer.len() {
return None;
}
buffer.truncate(written.saturating_sub(1));
String::from_utf8(buffer).ok().map(PathBuf::from)
}
#[cfg(all(target_os = "macos", not(feature = "libc")))]
fn darwin_user_temp_dir() -> Option<PathBuf> {
None
}
#[cfg(all(unix, not(target_os = "macos")))]
fn candidate_temp_roots() -> Vec<PathBuf> {
let mut roots = vec![PathBuf::from("/tmp"), PathBuf::from("/var/tmp")];
if let Some(uid) = current_uid() {
roots.push(PathBuf::from(format!("/run/user/{uid}")));
}
roots
}
#[cfg(all(unix, not(target_os = "macos"), feature = "libc"))]
fn current_uid() -> Option<u32> {
Some(unsafe { libc::getuid() })
}
#[cfg(all(unix, not(target_os = "macos"), not(feature = "libc")))]
fn current_uid() -> Option<u32> {
None
}
#[cfg(windows)]
fn candidate_temp_roots() -> Vec<PathBuf> {
let mut roots = vec![PathBuf::from(r"C:\Windows\Temp")];
if let Some(local_app_data) = windows_known_folder::local_app_data() {
roots.push(local_app_data.join("Temp"));
}
roots
}
#[cfg(windows)]
mod windows_known_folder {
use std::ffi::c_void;
use std::os::windows::ffi::OsStringExt;
use std::path::PathBuf;
#[repr(C)]
struct Guid {
data1: u32,
data2: u16,
data3: u16,
data4: [u8; 8],
}
const FOLDERID_LOCAL_APP_DATA: Guid = Guid {
data1: 0xF1B3_2785,
data2: 0x6FBA,
data3: 0x4FCF,
data4: [0x9D, 0x55, 0x7B, 0x8E, 0x7F, 0x15, 0x70, 0x91],
};
#[link(name = "shell32")]
unsafe extern "system" {
fn SHGetKnownFolderPath(
rfid: *const Guid,
flags: u32,
token: *mut c_void,
out_path: *mut *mut u16,
) -> i32;
}
#[link(name = "ole32")]
unsafe extern "system" {
fn CoTaskMemFree(pv: *mut c_void);
}
pub(super) fn local_app_data() -> Option<PathBuf> {
let mut raw: *mut u16 = std::ptr::null_mut();
let hresult = unsafe {
SHGetKnownFolderPath(&FOLDERID_LOCAL_APP_DATA, 0, std::ptr::null_mut(), &mut raw)
};
if hresult < 0 || raw.is_null() {
return None;
}
let len = unsafe {
let mut len = 0usize;
while *raw.add(len) != 0 {
len += 1;
}
len
};
let units = unsafe { std::slice::from_raw_parts(raw, len) };
let text = std::ffi::OsString::from_wide(units);
unsafe { CoTaskMemFree(raw.cast()) };
Some(PathBuf::from(text))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp() -> tempfile::TempDir {
tempfile::tempdir().expect("failed to create tempdir fixture")
}
fn evaluate_typed(
guard_type: GuardType,
value: &str,
cwd: &Path,
home: Option<&Path>,
temp_root_candidates: &[PathBuf],
) -> Result<PathBuf, GuardError> {
evaluate(guard_type, value, None, cwd, home, temp_root_candidates)
}
fn symlink_dir(target: &Path, link: &Path) {
#[cfg(unix)]
std::os::unix::fs::symlink(target, link).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_dir(target, link).unwrap();
}
#[test]
fn empty_and_newline_values_are_rejected_before_touching_the_filesystem() {
let cwd = temp();
assert!(matches!(
evaluate_typed(GuardType::Path, "", cwd.path(), None, &[]),
Err(GuardError::EmptyValue)
));
assert!(matches!(
evaluate_typed(GuardType::Path, "a\nb", cwd.path(), None, &[]),
Err(GuardError::ContainsNewline)
));
assert!(matches!(
evaluate_typed(GuardType::Path, "a\rb", cwd.path(), None, &[]),
Err(GuardError::ContainsNewline)
));
}
#[test]
fn filesystem_root_is_always_rejected() {
let cwd = temp();
#[cfg(unix)]
let root = "/";
#[cfg(windows)]
let root = "C:\\";
let result = evaluate_typed(GuardType::Path, root, cwd.path(), None, &[]);
assert!(
matches!(result, Err(GuardError::Rejected { .. })),
"{result:?}"
);
}
#[test]
fn home_itself_and_its_ancestors_are_rejected_for_every_type() {
let cwd = temp();
let home = temp();
let home_path = resolve_missing_ok(home.path()).unwrap();
let ancestor = home_path.parent().unwrap().to_path_buf();
for guard_type in [GuardType::Path, GuardType::TmpPath, GuardType::CwdPath] {
let home_result = evaluate_typed(
guard_type,
home_path.to_str().unwrap(),
cwd.path(),
Some(&home_path),
std::slice::from_ref(&home_path),
);
assert!(
matches!(home_result, Err(GuardError::Rejected { .. })),
"{guard_type:?}: {home_result:?}"
);
let ancestor_result = evaluate_typed(
guard_type,
ancestor.to_str().unwrap(),
cwd.path(),
Some(&home_path),
std::slice::from_ref(&ancestor),
);
assert!(
matches!(ancestor_result, Err(GuardError::Rejected { .. })),
"{guard_type:?}: {ancestor_result:?}"
);
}
}
#[test]
fn cwd_itself_and_its_ancestors_are_rejected_for_every_type() {
let root = temp();
let cwd = root.path().join("nested");
std::fs::create_dir(&cwd).unwrap();
for guard_type in [GuardType::Path, GuardType::TmpPath, GuardType::CwdPath] {
let cwd_result = evaluate_typed(guard_type, cwd.to_str().unwrap(), &cwd, None, &[]);
assert!(
matches!(cwd_result, Err(GuardError::Rejected { .. })),
"{cwd_result:?}"
);
let ancestor_result =
evaluate_typed(guard_type, root.path().to_str().unwrap(), &cwd, None, &[]);
assert!(
matches!(ancestor_result, Err(GuardError::Rejected { .. })),
"{ancestor_result:?}"
);
}
}
#[test]
fn plain_path_type_allows_any_real_directory_outside_the_reject_set() {
let cwd = temp();
let elsewhere = temp();
let result = evaluate_typed(
GuardType::Path,
elsewhere.path().to_str().unwrap(),
cwd.path(),
None,
&[],
);
let expected = resolve_missing_ok(elsewhere.path()).unwrap();
assert_eq!(result.unwrap(), expected);
}
#[test]
fn cwd_path_rejects_targets_outside_cwd_and_accepts_children() {
let cwd = temp();
let elsewhere = temp();
let outside = evaluate_typed(
GuardType::CwdPath,
elsewhere.path().to_str().unwrap(),
cwd.path(),
None,
&[],
);
assert!(
matches!(outside, Err(GuardError::OutsideContainment { .. })),
"{outside:?}"
);
let child = cwd.path().join("child");
std::fs::create_dir(&child).unwrap();
let inside = evaluate_typed(GuardType::CwdPath, "child", cwd.path(), None, &[]);
assert_eq!(inside.unwrap(), resolve_missing_ok(&child).unwrap());
}
#[test]
fn tmp_path_rejects_real_directories_outside_every_candidate_root() {
let cwd = temp();
let tmp_root = temp();
let elsewhere = temp();
let outside = evaluate_typed(
GuardType::TmpPath,
elsewhere.path().to_str().unwrap(),
cwd.path(),
None,
&[tmp_root.path().to_path_buf()],
);
assert!(
matches!(outside, Err(GuardError::OutsideContainment { .. })),
"{outside:?}"
);
let child = tmp_root.path().join("work");
std::fs::create_dir(&child).unwrap();
let inside = evaluate_typed(
GuardType::TmpPath,
child.to_str().unwrap(),
cwd.path(),
None,
&[tmp_root.path().to_path_buf()],
);
assert_eq!(inside.unwrap(), resolve_missing_ok(&child).unwrap());
}
#[test]
fn tmp_path_containment_root_itself_is_rejected_not_just_ancestors() {
let cwd = temp();
let tmp_root = temp();
let result = evaluate_typed(
GuardType::TmpPath,
tmp_root.path().to_str().unwrap(),
cwd.path(),
None,
&[tmp_root.path().to_path_buf()],
);
assert!(
matches!(result, Err(GuardError::OutsideContainment { .. })),
"{result:?}"
);
}
#[test]
fn nonexistent_target_passes_lexically_when_otherwise_lawful() {
let cwd = temp();
let missing = cwd.path().join("does-not-exist-yet");
let result = evaluate_typed(
GuardType::CwdPath,
missing.to_str().unwrap(),
cwd.path(),
None,
&[],
);
let canonical_cwd = resolve_missing_ok(cwd.path()).unwrap();
assert_eq!(result.unwrap(), canonical_cwd.join("does-not-exist-yet"));
}
#[test]
fn nonexistent_target_under_a_nonexistent_parent_still_passes() {
let cwd = temp();
let missing = cwd.path().join("a").join("b").join("c");
let result = evaluate_typed(
GuardType::CwdPath,
missing.to_str().unwrap(),
cwd.path(),
None,
&[],
);
let canonical_cwd = resolve_missing_ok(cwd.path()).unwrap();
assert_eq!(result.unwrap(), canonical_cwd.join("a").join("b").join("c"));
}
#[cfg(windows)]
#[test]
fn windows_output_is_the_drive_form_not_the_verbatim_one() {
let cwd = temp();
let nested = cwd.path().join("out");
std::fs::create_dir_all(&nested).unwrap();
let resolved = resolve_missing_ok(&nested).unwrap();
let printed = resolved.to_string_lossy();
assert!(
!printed.starts_with(r"\\?\"),
"guard printed the verbatim form: {printed}"
);
let mut characters = printed.chars();
assert!(characters.next().is_some_and(|c| c.is_ascii_alphabetic()));
assert_eq!(characters.next(), Some(':'));
assert_eq!(
std::fs::canonicalize(&resolved).unwrap(),
std::fs::canonicalize(&nested).unwrap()
);
}
#[test]
fn dot_dot_lexically_cancels_a_nonexistent_component() {
let cwd = temp();
let value = cwd.path().join("missing-dir").join("..").join("sibling");
let normalized = normalize_target(value.to_str().unwrap(), cwd.path()).unwrap();
let canonical_cwd = resolve_missing_ok(cwd.path()).unwrap();
assert_eq!(normalized, canonical_cwd.join("sibling"));
let refused = evaluate_typed(
GuardType::CwdPath,
value.to_str().unwrap(),
cwd.path(),
None,
&[],
);
assert!(
matches!(refused, Err(GuardError::TraversalSegment)),
"{refused:?}"
);
}
#[test]
fn embedded_dot_dot_through_a_real_symlink_resolves_the_true_parent() {
let root = temp();
let real_parent = root.path().join("real_parent");
std::fs::create_dir(&real_parent).unwrap();
let child = real_parent.join("child");
std::fs::create_dir(&child).unwrap();
let link = root.path().join("link");
symlink_dir(&child, &link);
let value = link.join("..").join("sibling");
let cwd = temp();
let normalized = normalize_target(value.to_str().unwrap(), cwd.path()).unwrap();
#[cfg(unix)]
let expected = resolve_missing_ok(&real_parent).unwrap().join("sibling");
#[cfg(windows)]
let expected = resolve_missing_ok(root.path()).unwrap().join("sibling");
assert_eq!(normalized, expected);
let refused = evaluate_typed(
GuardType::Path,
value.to_str().unwrap(),
cwd.path(),
None,
&[],
);
assert!(
matches!(refused, Err(GuardError::TraversalSegment)),
"{refused:?}"
);
}
#[test]
fn final_symlink_segment_is_not_resolved_to_its_target() {
let root = temp();
let target_dir = root.path().join("target_dir");
std::fs::create_dir(&target_dir).unwrap();
let link = root.path().join("link_name");
symlink_dir(&target_dir, &link);
let cwd = temp();
let result = evaluate_typed(
GuardType::Path,
link.to_str().unwrap(),
cwd.path(),
None,
&[],
)
.unwrap();
let canonical_root = resolve_missing_ok(root.path()).unwrap();
assert_eq!(result, canonical_root.join("link_name"));
assert_ne!(result, resolve_missing_ok(&target_dir).unwrap());
}
#[test]
fn trailing_dot_behaves_exactly_like_the_bare_form() {
let root = temp();
let target_dir = root.path().join("target_dir");
std::fs::create_dir(&target_dir).unwrap();
let link = root.path().join("link_name");
symlink_dir(&target_dir, &link);
let cwd = temp();
let bare = evaluate_typed(
GuardType::Path,
link.to_str().unwrap(),
cwd.path(),
None,
&[],
)
.unwrap();
let with_dot = evaluate_typed(
GuardType::Path,
link.join(".").to_str().unwrap(),
cwd.path(),
None,
&[],
)
.unwrap();
assert_eq!(bare, with_dot);
}
#[test]
fn containment_root_symlink_is_resolved_before_comparison() {
let root = temp();
let real_root = root.path().join("real_root");
std::fs::create_dir(&real_root).unwrap();
let root_link = root.path().join("root_link");
symlink_dir(&real_root, &root_link);
let child = root_link.join("work");
std::fs::create_dir(root.path().join("real_root").join("work")).unwrap();
let cwd = temp();
let result = evaluate_typed(
GuardType::TmpPath,
child.to_str().unwrap(),
cwd.path(),
None,
std::slice::from_ref(&root_link),
);
let expected = resolve_missing_ok(&real_root).unwrap().join("work");
assert_eq!(result.unwrap(), expected);
}
#[test]
fn missing_temp_root_candidate_is_skipped_not_an_error() {
let cwd = temp();
let tmp_root = temp();
let missing_root = tmp_root.path().join("does-not-exist-root");
let child = tmp_root.path().join("work");
std::fs::create_dir(&child).unwrap();
let result = evaluate_typed(
GuardType::TmpPath,
child.to_str().unwrap(),
cwd.path(),
None,
&[missing_root, tmp_root.path().to_path_buf()],
);
assert_eq!(result.unwrap(), resolve_missing_ok(&child).unwrap());
}
#[test]
fn relative_value_resolves_against_the_supplied_cwd() {
let cwd = temp();
let child = cwd.path().join("child");
std::fs::create_dir(&child).unwrap();
let result = evaluate_typed(GuardType::CwdPath, "child", cwd.path(), None, &[]).unwrap();
assert_eq!(result, resolve_missing_ok(&child).unwrap());
}
#[test]
fn all_whitespace_value_is_rejected_like_an_empty_one() {
let cwd = temp();
for blank in [" ", "\t", " \t "] {
let result = evaluate_typed(GuardType::Path, blank, cwd.path(), None, &[]);
assert!(matches!(result, Err(GuardError::EmptyValue)), "{result:?}");
}
}
#[test]
fn final_symlink_pointing_outside_the_containment_root_is_rejected() {
let cwd = temp();
let tmp_root = temp();
let elsewhere = temp();
let escape = elsewhere.path().join("real-target");
std::fs::create_dir(&escape).unwrap();
let link = tmp_root.path().join("escape_link");
symlink_dir(&escape, &link);
let result = evaluate_typed(
GuardType::TmpPath,
link.to_str().unwrap(),
cwd.path(),
None,
&[tmp_root.path().to_path_buf()],
);
assert!(
matches!(result, Err(GuardError::SymlinkEscapesContainment { .. })),
"{result:?}"
);
}
#[test]
fn final_symlink_staying_inside_the_containment_root_still_passes_unresolved() {
let cwd = temp();
let tmp_root = temp();
let sibling = tmp_root.path().join("sibling");
std::fs::create_dir(&sibling).unwrap();
let link = tmp_root.path().join("inside_link");
symlink_dir(&sibling, &link);
let result = evaluate_typed(
GuardType::TmpPath,
link.to_str().unwrap(),
cwd.path(),
None,
&[tmp_root.path().to_path_buf()],
)
.unwrap();
assert_eq!(
result,
resolve_missing_ok(tmp_root.path())
.unwrap()
.join("inside_link")
);
}
#[test]
fn a_dangling_final_symlink_is_judged_by_where_it_would_create() {
let cwd = temp();
let tmp_root = temp();
let elsewhere = temp();
let link = tmp_root.path().join("dangling");
symlink_dir(&elsewhere.path().join("not-created-yet"), &link);
let result = evaluate_typed(
GuardType::TmpPath,
link.to_str().unwrap(),
cwd.path(),
None,
&[tmp_root.path().to_path_buf()],
);
assert!(
matches!(result, Err(GuardError::SymlinkEscapesContainment { .. })),
"{result:?}"
);
}
#[test]
fn plain_path_type_has_no_root_so_a_symlink_is_never_an_escape() {
let cwd = temp();
let root = temp();
let elsewhere = temp();
let link = root.path().join("escape_link");
symlink_dir(elsewhere.path(), &link);
let result = evaluate_typed(
GuardType::Path,
link.to_str().unwrap(),
cwd.path(),
None,
&[],
);
assert_eq!(
result.unwrap(),
resolve_missing_ok(root.path()).unwrap().join("escape_link")
);
}
#[test]
fn under_root_anchors_a_target_the_type_vocabulary_cannot_name() {
let cwd = temp();
let anchor = temp();
let child = anchor.path().join("build");
std::fs::create_dir(&child).unwrap();
let inside = evaluate(
GuardType::Path,
child.to_str().unwrap(),
Some(anchor.path().to_str().unwrap()),
cwd.path(),
None,
&[],
);
assert_eq!(inside.unwrap(), resolve_missing_ok(&child).unwrap());
let elsewhere = temp();
let outside = evaluate(
GuardType::Path,
elsewhere.path().to_str().unwrap(),
Some(anchor.path().to_str().unwrap()),
cwd.path(),
None,
&[],
);
assert!(
matches!(
outside,
Err(GuardError::OutsideContainment {
containment: Containment::Under,
..
})
),
"{outside:?}"
);
}
#[test]
fn under_root_itself_is_rejected_like_every_other_containment_root() {
let cwd = temp();
let anchor = temp();
let result = evaluate(
GuardType::Path,
anchor.path().to_str().unwrap(),
Some(anchor.path().to_str().unwrap()),
cwd.path(),
None,
&[],
);
assert!(
matches!(result, Err(GuardError::OutsideContainment { .. })),
"{result:?}"
);
}
#[test]
fn a_blank_under_root_is_rejected_rather_than_meaning_the_current_directory() {
let cwd = temp();
let child = cwd.path().join("child");
std::fs::create_dir(&child).unwrap();
for blank in ["", " ", "\n"] {
let result = evaluate(
GuardType::Path,
child.to_str().unwrap(),
Some(blank),
cwd.path(),
None,
&[],
);
assert!(
matches!(result, Err(GuardError::InvalidRoot)),
"{blank:?}: {result:?}"
);
}
}
#[test]
fn type_and_under_must_both_be_satisfied() {
let cwd = temp();
let tmp_root = temp();
let inside_tmp = tmp_root.path().join("work");
std::fs::create_dir(&inside_tmp).unwrap();
let unrelated_anchor = temp();
let result = evaluate(
GuardType::TmpPath,
inside_tmp.to_str().unwrap(),
Some(unrelated_anchor.path().to_str().unwrap()),
cwd.path(),
None,
&[tmp_root.path().to_path_buf()],
);
assert!(
matches!(
result,
Err(GuardError::OutsideContainment {
containment: Containment::Under,
..
})
),
"{result:?}"
);
let outside_tmp = unrelated_anchor.path().join("work");
std::fs::create_dir(&outside_tmp).unwrap();
let result = evaluate(
GuardType::TmpPath,
outside_tmp.to_str().unwrap(),
Some(unrelated_anchor.path().to_str().unwrap()),
cwd.path(),
None,
&[tmp_root.path().to_path_buf()],
);
assert!(
matches!(
result,
Err(GuardError::OutsideContainment {
containment: Containment::Type(GuardType::TmpPath),
..
})
),
"{result:?}"
);
}
#[test]
fn under_root_is_resolved_through_its_own_symlink() {
let cwd = temp();
let root = temp();
let real_anchor = root.path().join("real_anchor");
std::fs::create_dir(&real_anchor).unwrap();
let anchor_link = root.path().join("anchor_link");
symlink_dir(&real_anchor, &anchor_link);
let child = real_anchor.join("build");
std::fs::create_dir(&child).unwrap();
let result = evaluate(
GuardType::Path,
child.to_str().unwrap(),
Some(anchor_link.to_str().unwrap()),
cwd.path(),
None,
&[],
);
assert_eq!(result.unwrap(), resolve_missing_ok(&child).unwrap());
}
#[test]
fn guard_type_parse_round_trips_the_closed_vocabulary() {
assert_eq!(GuardType::parse("path"), Some(GuardType::Path));
assert_eq!(GuardType::parse("tmp_path"), Some(GuardType::TmpPath));
assert_eq!(GuardType::parse("cwd_path"), Some(GuardType::CwdPath));
assert_eq!(GuardType::parse("bogus"), None);
}
}