use std::ffi::OsString;
use std::fmt;
use std::io::ErrorKind;
use std::path::{Component, Path, PathBuf};
use rskit_errors::{AppError, AppResult, ErrorCode};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum SafePathError {
Absolute,
ParentDir,
Prefix,
}
impl fmt::Display for SafePathError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Absolute => f.write_str("path must be relative, not absolute"),
Self::ParentDir => f.write_str("path must not contain '..' segments"),
Self::Prefix => f.write_str("path must not contain a platform path prefix"),
}
}
}
impl std::error::Error for SafePathError {}
pub fn validate_relative_path(path: &Path) -> Result<(), SafePathError> {
for component in path.components() {
match component {
Component::RootDir => return Err(SafePathError::Absolute),
Component::ParentDir => return Err(SafePathError::ParentDir),
#[cfg(windows)]
Component::Prefix(_) => return Err(SafePathError::Prefix),
#[cfg(not(windows))]
Component::Prefix(_) | Component::CurDir | Component::Normal(_) => {}
#[cfg(windows)]
Component::CurDir | Component::Normal(_) => {}
}
}
Ok(())
}
pub fn safe_join(root: &Path, rel_path: impl AsRef<Path>) -> Result<PathBuf, SafePathError> {
let rel_path = rel_path.as_ref();
validate_relative_path(rel_path)?;
Ok(root.join(rel_path))
}
pub fn absolute(path: &Path) -> AppResult<PathBuf> {
if path.is_absolute() {
return Ok(path.to_path_buf());
}
std::env::current_dir()
.map(|cwd| cwd.join(path))
.map_err(|error| AppError::new(ErrorCode::Internal, format!("failed to read cwd: {error}")))
}
pub fn canonicalize(path: &Path) -> AppResult<PathBuf> {
std::fs::canonicalize(path).map_err(|error| {
AppError::new(
ErrorCode::Internal,
format!("failed to canonicalize '{}': {error}", path.display()),
)
})
}
pub fn resolve_root_relative_to(
field: &str,
base_dir: &Path,
root: Option<&Path>,
) -> AppResult<PathBuf> {
let root = root.unwrap_or_else(|| Path::new("."));
let resolved = if root.is_absolute() {
root.to_path_buf()
} else {
base_dir.join(root)
};
canonicalize(&resolved).map_err(|error| {
AppError::invalid_input(
field,
format!("failed to resolve {field} '{}'", resolved.display()),
)
.with_cause(error)
})
}
pub fn confine_existing_path(root: &Path, path: &Path) -> AppResult<PathBuf> {
let root = canonicalize_directory_root(root)?;
let candidate = if path.is_absolute() {
path.to_path_buf()
} else {
root.join(path)
};
let candidate = canonicalize_confined_input(&candidate, "confined path")?;
ensure_confined(&root, &candidate)?;
Ok(candidate)
}
pub fn confine_path(root: &Path, path: &Path) -> AppResult<PathBuf> {
let root = canonicalize_directory_root(root)?;
let candidate = if path.is_absolute() {
path.to_path_buf()
} else {
root.join(path)
};
let (existing, missing) = existing_ancestor_and_missing_suffix(&candidate)?;
let existing = canonicalize_existing_ancestor(&existing)?;
ensure_confined(&root, &existing)?;
ensure_directory_for_missing_suffix(&existing, &missing)?;
let resolved = append_safe_missing_suffix(existing, missing)?;
ensure_confined(&root, &resolved)?;
Ok(resolved)
}
fn existing_ancestor_and_missing_suffix(path: &Path) -> AppResult<(PathBuf, Vec<OsString>)> {
let mut missing = Vec::new();
let mut current = path.to_path_buf();
while !exists_without_following_symlinks(¤t)? {
let Some(name) = current.file_name().map(OsString::from) else {
return Err(AppError::new(
ErrorCode::NotFound,
format!("no existing ancestor for '{}'", path.display()),
));
};
missing.push(name);
let Some(parent) = current.parent() else {
return Err(AppError::new(
ErrorCode::NotFound,
format!("no existing ancestor for '{}'", path.display()),
));
};
current = parent.to_path_buf();
}
missing.reverse();
Ok((current, missing))
}
fn canonicalize_directory_root(root: &Path) -> AppResult<PathBuf> {
let root = canonicalize_confined_input(root, "confined root")?;
let metadata = std::fs::metadata(&root).map_err(|error| {
AppError::new(
ErrorCode::Internal,
format!(
"failed to inspect confined root '{}': {error}",
root.display()
),
)
})?;
if metadata.is_dir() {
return Ok(root);
}
Err(AppError::new(
ErrorCode::InvalidInput,
format!("confined root '{}' is not a directory", root.display()),
))
}
fn canonicalize_confined_input(path: &Path, label: &str) -> AppResult<PathBuf> {
std::fs::canonicalize(path).map_err(|error| {
AppError::new(
confined_canonicalize_error_code(error.kind()),
format!(
"failed to canonicalize {label} '{}': {error}",
path.display()
),
)
})
}
const fn confined_canonicalize_error_code(kind: ErrorKind) -> ErrorCode {
match kind {
ErrorKind::NotFound => ErrorCode::NotFound,
ErrorKind::InvalidInput | ErrorKind::NotADirectory => ErrorCode::InvalidInput,
_ => ErrorCode::Internal,
}
}
fn exists_without_following_symlinks(path: &Path) -> AppResult<bool> {
match std::fs::symlink_metadata(path) {
Ok(_) => Ok(true),
Err(error) if matches!(error.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) => {
Ok(false)
}
Err(error) => Err(AppError::new(
ErrorCode::Internal,
format!("failed to inspect '{}': {error}", path.display()),
)),
}
}
fn canonicalize_existing_ancestor(path: &Path) -> AppResult<PathBuf> {
canonicalize_confined_input(path, "existing path ancestor").map_err(|error| {
AppError::new(
error.code(),
format!(
"existing path ancestor '{}' cannot be resolved: {}",
path.display(),
error.message()
),
)
})
}
fn ensure_directory_for_missing_suffix(existing: &Path, missing: &[OsString]) -> AppResult<()> {
if missing.is_empty() {
return Ok(());
}
let metadata = std::fs::metadata(existing).map_err(|error| {
AppError::new(
ErrorCode::Internal,
format!(
"failed to inspect existing path ancestor '{}': {error}",
existing.display()
),
)
})?;
if metadata.is_dir() {
return Ok(());
}
Err(AppError::new(
ErrorCode::InvalidInput,
format!(
"existing path ancestor '{}' is not a directory",
existing.display()
),
))
}
fn append_safe_missing_suffix(mut base: PathBuf, missing: Vec<OsString>) -> AppResult<PathBuf> {
for segment in missing {
let segment_path = Path::new(&segment);
validate_relative_path(segment_path).map_err(|error| {
AppError::new(
ErrorCode::InvalidInput,
format!(
"path segment '{}' is not safe: {error}",
segment_path.display()
),
)
})?;
let mut components = segment_path.components();
if !matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some() {
return Err(AppError::new(
ErrorCode::InvalidInput,
format!("path segment '{}' is not safe", segment_path.display()),
));
}
base.push(segment);
}
Ok(base)
}
fn ensure_confined(root: &Path, path: &Path) -> AppResult<()> {
if path.starts_with(root) {
return Ok(());
}
Err(AppError::new(
ErrorCode::InvalidInput,
format!(
"path '{}' resolves outside confined root '{}'",
path.display(),
root.display()
),
))
}
#[must_use]
pub fn parent_dir(path: &Path) -> Option<&Path> {
path.parent()
.filter(|parent| !parent.as_os_str().is_empty())
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use std::path::Path;
use rskit_errors::ErrorCode;
use super::{
SafePathError, absolute, append_safe_missing_suffix, canonicalize, confine_existing_path,
confine_path, resolve_root_relative_to, safe_join, validate_relative_path,
};
#[test]
fn validates_safe_relative_paths() {
assert!(validate_relative_path(Path::new("a/b.txt")).is_ok());
assert!(validate_relative_path(Path::new("./a/b.txt")).is_ok());
}
#[test]
fn rejects_absolute_paths() {
assert_eq!(
validate_relative_path(Path::new("/etc/passwd")).unwrap_err(),
SafePathError::Absolute
);
}
#[test]
fn rejects_parent_dir_paths() {
assert_eq!(
validate_relative_path(Path::new("../escape")).unwrap_err(),
SafePathError::ParentDir
);
}
#[test]
fn displays_safe_path_errors() {
assert_eq!(
SafePathError::Absolute.to_string(),
"path must be relative, not absolute"
);
assert_eq!(
SafePathError::ParentDir.to_string(),
"path must not contain '..' segments"
);
assert_eq!(
SafePathError::Prefix.to_string(),
"path must not contain a platform path prefix"
);
}
#[test]
fn safe_join_keeps_paths_under_root() {
assert_eq!(
safe_join(Path::new("/root"), "a/b.txt").unwrap(),
Path::new("/root").join("a/b.txt")
);
}
#[test]
fn absolute_resolves_relative_paths() {
let path = absolute(Path::new("a/b.txt")).unwrap();
assert!(path.is_absolute());
assert!(path.ends_with("a/b.txt"));
}
#[test]
fn absolute_returns_absolute_paths_unchanged() {
let path = Path::new("/tmp/a.txt");
assert_eq!(absolute(path).unwrap(), path);
}
#[test]
fn canonicalize_resolves_existing_paths_and_reports_missing() {
let dir = crate::TempDir::new().unwrap();
let file = dir.write_file("file.txt", b"hello").unwrap();
assert_eq!(
canonicalize(&file).unwrap(),
std::fs::canonicalize(&file).unwrap()
);
assert!(canonicalize(&dir.child("missing.txt").unwrap()).is_err());
}
#[test]
fn confines_existing_paths_under_root() {
let dir = crate::TempDir::new().unwrap();
let file = dir.write_file("nested/file.txt", b"hello").unwrap();
let confined = confine_existing_path(dir.path(), Path::new("nested/file.txt")).unwrap();
assert_eq!(confined, std::fs::canonicalize(file).unwrap());
}
#[test]
fn rejects_existing_paths_outside_root() {
let root = crate::TempDir::new().unwrap();
let outside = crate::TempDir::new().unwrap();
let file = outside.write_file("file.txt", b"hello").unwrap();
let error = confine_existing_path(root.path(), &file).unwrap_err();
assert_eq!(error.code(), ErrorCode::InvalidInput);
}
#[test]
fn rejects_missing_existing_paths_as_not_found() {
let root = crate::TempDir::new().unwrap();
let error = confine_existing_path(root.path(), Path::new("missing.txt")).unwrap_err();
assert_eq!(error.code(), ErrorCode::NotFound);
}
#[test]
fn rejects_missing_confined_roots_as_not_found() {
let dir = crate::TempDir::new().unwrap();
let missing_root = dir.child("missing-root").unwrap();
let error = confine_existing_path(&missing_root, Path::new("file.txt")).unwrap_err();
assert_eq!(error.code(), ErrorCode::NotFound);
}
#[test]
fn rejects_file_root_for_existing_paths() {
let dir = crate::TempDir::new().unwrap();
let root_file = dir.write_file("root.txt", b"not a dir").unwrap();
let error = confine_existing_path(&root_file, Path::new("child.txt")).unwrap_err();
assert_eq!(error.code(), ErrorCode::InvalidInput);
}
#[test]
fn confines_missing_output_paths_under_existing_parent() {
let dir = crate::TempDir::new().unwrap();
let confined = confine_path(dir.path(), Path::new("nested/output.txt")).unwrap();
assert!(confined.starts_with(std::fs::canonicalize(dir.path()).unwrap()));
assert!(confined.ends_with("nested/output.txt"));
}
#[test]
fn rejects_file_root_for_output_paths() {
let dir = crate::TempDir::new().unwrap();
let root_file = dir.write_file("root.txt", b"not a dir").unwrap();
let error = confine_path(&root_file, Path::new("output.txt")).unwrap_err();
assert_eq!(error.code(), ErrorCode::InvalidInput);
}
#[test]
fn rejects_missing_output_paths_below_existing_file() {
let dir = crate::TempDir::new().unwrap();
dir.write_file("file.txt", b"not a dir").unwrap();
let error = confine_path(dir.path(), Path::new("file.txt/output.txt")).unwrap_err();
assert_eq!(error.code(), ErrorCode::InvalidInput);
}
#[test]
fn rejects_curdir_missing_path_segments() {
let dir = crate::TempDir::new().unwrap();
let error = append_safe_missing_suffix(dir.path().to_path_buf(), vec![OsString::from(".")])
.unwrap_err();
assert_eq!(error.code(), ErrorCode::InvalidInput);
}
#[cfg(unix)]
#[test]
fn rejects_missing_paths_below_symlink_escape() {
let root = crate::TempDir::new().unwrap();
let outside = crate::TempDir::new().unwrap();
let link = root.child("link").unwrap();
std::os::unix::fs::symlink(outside.path(), &link).unwrap();
let error = confine_path(root.path(), Path::new("link/output.txt")).unwrap_err();
assert_eq!(error.code(), ErrorCode::InvalidInput);
}
#[cfg(unix)]
#[test]
fn rejects_missing_paths_below_broken_symlink() {
let root = crate::TempDir::new().unwrap();
let link = root.child("broken-link").unwrap();
let target = root.child("missing-target").unwrap();
std::os::unix::fs::symlink(target, &link).unwrap();
let error = confine_path(root.path(), Path::new("broken-link/output.txt")).unwrap_err();
assert_eq!(error.code(), ErrorCode::NotFound);
}
#[test]
fn resolve_root_defaults_to_base_dir() {
let dir = crate::TempDir::new().unwrap();
let root = resolve_root_relative_to("root", dir.path(), None).unwrap();
assert_eq!(root, canonicalize(dir.path()).unwrap());
}
#[test]
fn resolve_root_joins_relative_against_base_dir() {
let dir = crate::TempDir::new().unwrap();
let workspace = dir.path().join("workspace");
std::fs::create_dir(&workspace).unwrap();
let root =
resolve_root_relative_to("root", dir.path(), Some(Path::new("workspace"))).unwrap();
assert_eq!(root, canonicalize(&workspace).unwrap());
}
#[test]
fn resolve_root_accepts_absolute_root() {
let base = crate::TempDir::new().unwrap();
let target = crate::TempDir::new().unwrap();
let root = resolve_root_relative_to("root", base.path(), Some(target.path())).unwrap();
assert_eq!(root, canonicalize(target.path()).unwrap());
}
#[test]
fn resolve_root_surfaces_canonicalization_failure() {
let dir = crate::TempDir::new().unwrap();
let error =
resolve_root_relative_to("root", dir.path(), Some(Path::new("missing"))).unwrap_err();
assert_eq!(error.code(), ErrorCode::InvalidInput);
assert!(error.message().contains("failed to resolve root"));
}
}