use anyhow::{Result as AnyhowResult, bail};
use std::fs;
use std::path::{Path, PathBuf};
pub fn relative_path_from_dir(from_dir: &Path, to: &Path) -> Option<PathBuf> {
if !from_dir.is_absolute() || !to.is_absolute() {
return None;
}
let from_components: Vec<_> = from_dir.components().collect();
let to_components: Vec<_> = to.components().collect();
#[cfg(windows)]
{
use std::path::Component;
let from_prefix = match from_components.first() {
Some(Component::Prefix(prefix)) => Some(prefix.kind()),
_ => None,
};
let to_prefix = match to_components.first() {
Some(Component::Prefix(prefix)) => Some(prefix.kind()),
_ => None,
};
if from_prefix != to_prefix {
return None;
}
}
let mut common_len = 0usize;
while common_len < from_components.len()
&& common_len < to_components.len()
&& from_components[common_len] == to_components[common_len]
{
common_len += 1;
}
let mut out = PathBuf::new();
for _ in common_len..from_components.len() {
out.push("..");
}
for comp in &to_components[common_len..] {
match comp {
std::path::Component::Prefix(_) | std::path::Component::RootDir => return None,
_ => out.push(comp.as_os_str()),
}
}
if out.as_os_str().is_empty() {
out.push(".");
}
Some(out)
}
pub fn validate_path_str(path_str: &str) -> Result<(), String> {
let trimmed = path_str.trim();
if trimmed.is_empty() {
return Err("Path cannot be empty or contain only whitespace".to_string());
}
if trimmed.contains('\0') {
return Err("Path must not contain NUL".to_string());
}
for component in Path::new(trimmed).components() {
if matches!(component, std::path::Component::ParentDir) {
return Err("Path must not contain '..'".to_string());
}
}
Ok(())
}
pub fn create_validated_pathbuf(path_str: &str) -> Result<PathBuf, String> {
validate_path_str(path_str)?;
Ok(PathBuf::from(path_str.trim()))
}
pub fn safe_parent_for_creation(path: &Path) -> Option<&Path> {
path.parent().filter(|p| !p.as_os_str().is_empty())
}
pub fn is_symlink_dir(path: &Path) -> bool {
fs::symlink_metadata(path)
.map(|meta| meta.file_type().is_symlink())
.unwrap_or(false)
}
pub fn is_just_filename(path: &Path) -> bool {
path.parent().is_some_and(|p| p.as_os_str().is_empty())
}
pub fn validate_path_segment(segment: &str, what: &str) -> AnyhowResult<String> {
let trimmed = segment.trim();
if trimmed.is_empty() {
bail!("{what} is required");
}
if trimmed.chars().count() > 128 {
bail!("{what} is too long (max 128 characters)");
}
if trimmed == "." || trimmed == ".." {
bail!("{what} must not be '.' or '..'");
}
if trimmed.contains('\0') {
bail!("{what} must not contain NUL");
}
if trimmed.contains('/') || trimmed.contains('\\') {
bail!("{what} must not contain path separators");
}
#[cfg(windows)]
{
if trimmed.ends_with('.') {
bail!("{what} must not end with '.'");
}
const INVALID_CHARS: &[char] = &['<', '>', ':', '"', '|', '?', '*'];
if trimmed.chars().any(|ch| INVALID_CHARS.contains(&ch)) {
bail!("{what} contains invalid characters");
}
let stem = trimmed.split('.').next().unwrap_or(trimmed);
let stem_upper = stem.to_ascii_uppercase();
const RESERVED: &[&str] = &["CON", "PRN", "AUX", "NUL"];
if RESERVED.contains(&stem_upper.as_str()) {
bail!("{what} uses a reserved device name");
}
if let Some(num) = stem_upper.strip_prefix("COM") {
if matches!(num, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9") {
bail!("{what} uses a reserved device name");
}
}
if let Some(num) = stem_upper.strip_prefix("LPT") {
if matches!(num, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9") {
bail!("{what} uses a reserved device name");
}
}
}
Ok(trimmed.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_path_str() {
assert!(validate_path_str("").is_err());
assert!(validate_path_str(" ").is_err());
assert!(validate_path_str("\t").is_err());
assert!(validate_path_str("valid/path").is_ok());
assert!(validate_path_str("config.yaml").is_ok());
assert!(validate_path_str("a/../b").is_err());
assert!(validate_path_str("../escape").is_err());
assert!(validate_path_str("ok\0bad").is_err());
}
#[test]
fn test_create_validated_pathbuf() {
assert!(create_validated_pathbuf("").is_err());
assert!(create_validated_pathbuf(" ").is_err());
assert!(create_validated_pathbuf("valid/path").is_ok());
assert!(create_validated_pathbuf("../escape").is_err());
assert_eq!(
create_validated_pathbuf(" foo/bar ").unwrap(),
PathBuf::from("foo/bar")
);
}
#[test]
fn test_safe_parent_for_creation() {
use std::path::Path;
assert!(safe_parent_for_creation(Path::new("config.yaml")).is_none());
assert!(safe_parent_for_creation(Path::new("dir/config.yaml")).is_some());
assert!(safe_parent_for_creation(Path::new("/tmp/config.yaml")).is_some());
}
#[test]
fn test_is_just_filename() {
use std::path::Path;
assert!(is_just_filename(Path::new("config.yaml")));
assert!(!is_just_filename(Path::new("dir/config.yaml")));
assert!(!is_just_filename(Path::new("/tmp/config.yaml")));
}
#[test]
fn test_relative_path_from_dir_returns_none_for_relative_inputs() {
assert!(relative_path_from_dir(Path::new("a/b"), Path::new("/tmp/x")).is_none());
assert!(relative_path_from_dir(Path::new("/tmp/x"), Path::new("a/b")).is_none());
}
#[test]
fn test_relative_path_from_dir_basic() {
use tempfile::TempDir;
let temp = TempDir::new().expect("temp dir");
let root = temp.path();
let from_dir = root.join("a/b/c");
let to = root.join("a/d/e");
std::fs::create_dir_all(&from_dir).expect("create from");
std::fs::create_dir_all(&to).expect("create to");
let rel = relative_path_from_dir(&from_dir, &to).expect("relative path");
assert_eq!(rel, PathBuf::from("../../d/e"));
let same = relative_path_from_dir(&from_dir, &from_dir).expect("relative path");
assert_eq!(same, PathBuf::from("."));
}
#[test]
fn test_validate_path_segment_basic() {
assert!(validate_path_segment("", "name").is_err());
assert!(validate_path_segment(" ", "name").is_err());
assert!(validate_path_segment(".", "name").is_err());
assert!(validate_path_segment("..", "name").is_err());
assert!(validate_path_segment("a/b", "name").is_err());
assert!(validate_path_segment("a\\b", "name").is_err());
assert_eq!(validate_path_segment(" foo ", "name").unwrap(), "foo");
assert_eq!(validate_path_segment("foo-bar", "name").unwrap(), "foo-bar");
assert_eq!(validate_path_segment("draftpr", "name").unwrap(), "draftpr");
assert_eq!(validate_path_segment("中文", "name").unwrap(), "中文");
}
#[cfg(windows)]
#[test]
fn test_validate_path_segment_windows_reserved_names() {
assert!(validate_path_segment("con", "name").is_err());
assert!(validate_path_segment("con.txt", "name").is_err());
assert!(validate_path_segment("COM1", "name").is_err());
assert!(validate_path_segment("LPT9.log", "name").is_err());
assert!(validate_path_segment("bad:name", "name").is_err());
assert!(validate_path_segment("trailing.", "name").is_err());
}
}