use std::{
io,
path::{Component, Path, PathBuf},
};
use thiserror::Error;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum PathSafetyError {
#[error("path contains a NUL byte: {0}")]
NulByte(PathBuf),
#[error("path escape: `{candidate}` resolves outside `{root}`")]
Escape {
candidate: PathBuf,
root: PathBuf,
},
#[error("path is a symlink and follow_symlinks=false: {0}")]
UnexpectedSymlink(PathBuf),
#[error("i/o error resolving {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: io::Error,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SymlinkPolicy {
Reject,
Follow,
}
pub fn reject_nul(candidate: &Path) -> Result<(), PathSafetyError> {
let bytes = candidate.as_os_str().as_encoded_bytes();
if bytes.contains(&0) {
return Err(PathSafetyError::NulByte(candidate.to_path_buf()));
}
Ok(())
}
pub fn canonicalize_inside(
candidate: &Path,
root: &Path,
policy: SymlinkPolicy,
) -> Result<PathBuf, PathSafetyError> {
reject_nul(candidate)?;
reject_nul(root)?;
let joined = if candidate.is_absolute() {
candidate.to_path_buf()
} else {
root.join(candidate)
};
let resolved = match policy {
SymlinkPolicy::Follow => {
std::fs::canonicalize(&joined).map_err(|source| PathSafetyError::Io {
path: joined.clone(),
source,
})?
}
SymlinkPolicy::Reject => {
check_no_symlink_ancestors(&joined)?;
lexically_normalize(&joined)
}
};
if !is_descendant(&resolved, root) {
return Err(PathSafetyError::Escape {
candidate: candidate.to_path_buf(),
root: root.to_path_buf(),
});
}
Ok(resolved)
}
#[must_use]
pub fn is_descendant(path: &Path, root: &Path) -> bool {
let mut path_components = path.components();
for root_component in root.components() {
match path_components.next() {
Some(c) if c == root_component => {}
_ => return false,
}
}
true
}
fn check_no_symlink_ancestors(candidate: &Path) -> Result<(), PathSafetyError> {
let mut probe: PathBuf = PathBuf::new();
for component in candidate.components() {
probe.push(component.as_os_str());
if matches!(component, Component::RootDir | Component::Prefix(_)) {
continue;
}
match std::fs::symlink_metadata(&probe) {
Ok(meta) if meta.file_type().is_symlink() => {
return Err(PathSafetyError::UnexpectedSymlink(probe));
}
Ok(_) | Err(_) => {
}
}
}
Ok(())
}
fn lexically_normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
out.push(component.as_os_str());
}
Component::CurDir => {}
Component::ParentDir => {
if !out.pop() {
}
}
}
}
out
}
#[cfg(test)]
#[must_use]
pub fn relative_to(path: &Path, root: &Path) -> std::sync::Arc<Path> {
use std::sync::Arc;
path.strip_prefix(root)
.map_or_else(|_| Arc::from(path), Arc::from)
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use std::{ffi::OsString, os::unix::ffi::OsStringExt};
use tempfile::tempdir;
use super::*;
#[test]
fn test_should_reject_nul_in_path() {
let raw = OsString::from_vec(vec![b'/', b't', 0, b'p']);
let p = PathBuf::from(raw);
let err = reject_nul(&p).unwrap_err();
assert!(matches!(err, PathSafetyError::NulByte(_)));
}
#[test]
fn test_should_accept_descendant_path() {
let root = tempdir().unwrap();
let canonical_root = std::fs::canonicalize(root.path()).unwrap();
let child = canonical_root.join("a/b");
std::fs::create_dir_all(&child).unwrap();
let resolved = canonicalize_inside(&child, &canonical_root, SymlinkPolicy::Reject).unwrap();
assert!(is_descendant(&resolved, &canonical_root));
}
#[test]
fn test_should_reject_path_escape() {
let root = tempdir().unwrap();
let canonical_root = std::fs::canonicalize(root.path()).unwrap();
let escape = canonical_root.join("../escape");
let err = canonicalize_inside(&escape, &canonical_root, SymlinkPolicy::Reject).unwrap_err();
assert!(matches!(err, PathSafetyError::Escape { .. }));
}
#[test]
fn test_should_reject_symlink_with_reject_policy() {
let root = tempdir().unwrap();
let canonical_root = std::fs::canonicalize(root.path()).unwrap();
let real = canonical_root.join("real");
std::fs::create_dir(&real).unwrap();
let link = canonical_root.join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
let err = canonicalize_inside(&link, &canonical_root, SymlinkPolicy::Reject).unwrap_err();
assert!(matches!(err, PathSafetyError::UnexpectedSymlink(_)));
}
#[test]
fn test_should_resolve_symlink_with_follow_policy_inside_root() {
let root = tempdir().unwrap();
let canonical_root = std::fs::canonicalize(root.path()).unwrap();
let real = canonical_root.join("real");
std::fs::create_dir(&real).unwrap();
let link = canonical_root.join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
let resolved = canonicalize_inside(&link, &canonical_root, SymlinkPolicy::Follow).unwrap();
assert_eq!(resolved, real);
}
#[test]
fn test_should_reject_symlink_pointing_outside_root_with_follow_policy() {
let outside = tempdir().unwrap();
let outside_canonical = std::fs::canonicalize(outside.path()).unwrap();
let outside_target = outside_canonical.join("hostage");
std::fs::create_dir(&outside_target).unwrap();
let root = tempdir().unwrap();
let canonical_root = std::fs::canonicalize(root.path()).unwrap();
let link = canonical_root.join("escape");
std::os::unix::fs::symlink(&outside_target, &link).unwrap();
let err = canonicalize_inside(&link, &canonical_root, SymlinkPolicy::Follow).unwrap_err();
assert!(matches!(err, PathSafetyError::Escape { .. }));
}
#[test]
fn test_is_descendant_does_not_match_string_prefixes() {
let root = Path::new("/tmp/repo");
let sibling = Path::new("/tmp/repo-2/x");
assert!(!is_descendant(sibling, root));
let inside = Path::new("/tmp/repo/x");
assert!(is_descendant(inside, root));
}
#[test]
fn test_lexically_normalize_collapses_dot_dot() {
let p = Path::new("/a/b/../c/./d");
assert_eq!(lexically_normalize(p), PathBuf::from("/a/c/d"));
}
#[test]
fn test_lexically_normalize_does_not_escape_root() {
let p = Path::new("/../../etc/passwd");
let n = lexically_normalize(p);
assert!(!n.to_string_lossy().contains(".."));
}
#[test]
fn test_relative_to_strips_root() {
let root = Path::new("/tmp/repo");
let path = Path::new("/tmp/repo/a/b");
let rel = relative_to(path, root);
assert_eq!(&*rel, Path::new("a/b"));
}
}