Skip to main content

llman/
path_utils.rs

1//! Path validation and utility functions
2
3use anyhow::{Result as AnyhowResult, bail};
4use std::path::{Path, PathBuf};
5
6/// Compute a relative path from `from_dir` to `to`.
7///
8/// Returns `None` when either path is not absolute, or when a relative path
9/// cannot be expressed (for example, differing Windows drive letters).
10pub fn relative_path_from_dir(from_dir: &Path, to: &Path) -> Option<PathBuf> {
11    if !from_dir.is_absolute() || !to.is_absolute() {
12        return None;
13    }
14
15    let from_components: Vec<_> = from_dir.components().collect();
16    let to_components: Vec<_> = to.components().collect();
17
18    #[cfg(windows)]
19    {
20        use std::path::Component;
21
22        let from_prefix = match from_components.first() {
23            Some(Component::Prefix(prefix)) => Some(prefix.kind()),
24            _ => None,
25        };
26        let to_prefix = match to_components.first() {
27            Some(Component::Prefix(prefix)) => Some(prefix.kind()),
28            _ => None,
29        };
30
31        if from_prefix != to_prefix {
32            return None;
33        }
34    }
35
36    let mut common_len = 0usize;
37    while common_len < from_components.len()
38        && common_len < to_components.len()
39        && from_components[common_len] == to_components[common_len]
40    {
41        common_len += 1;
42    }
43
44    let mut out = PathBuf::new();
45
46    // For each remaining segment in `from_dir`, go up one level.
47    for _ in common_len..from_components.len() {
48        out.push("..");
49    }
50
51    // Then descend into the remaining segments of `to`.
52    for comp in &to_components[common_len..] {
53        match comp {
54            std::path::Component::Prefix(_) | std::path::Component::RootDir => return None,
55            _ => out.push(comp.as_os_str()),
56        }
57    }
58
59    if out.as_os_str().is_empty() {
60        out.push(".");
61    }
62
63    Some(out)
64}
65
66/// Validates that a path string is not empty or just whitespace
67pub fn validate_path_str(path_str: &str) -> Result<(), String> {
68    if path_str.trim().is_empty() {
69        return Err("Path cannot be empty or contain only whitespace".to_string());
70    }
71    Ok(())
72}
73
74/// Creates a PathBuf from a string, validating it's not empty or whitespace
75pub fn create_validated_pathbuf(path_str: &str) -> Result<PathBuf, String> {
76    validate_path_str(path_str)?;
77    Ok(PathBuf::from(path_str))
78}
79
80/// Safely gets the parent directory for creating directories.
81/// Returns None for paths that don't need directory creation (like "config.yaml" in current dir)
82pub fn safe_parent_for_creation(path: &Path) -> Option<&Path> {
83    path.parent().filter(|p| !p.as_os_str().is_empty())
84}
85
86/// Checks if a path looks like a filename (no directory components)
87pub fn is_just_filename(path: &Path) -> bool {
88    path.parent().is_some_and(|p| p.as_os_str().is_empty())
89}
90
91/// Validates that a string is safe to use as a single path segment (e.g. an id or file stem).
92///
93/// Returns the trimmed segment on success.
94pub fn validate_path_segment(segment: &str, what: &str) -> AnyhowResult<String> {
95    let trimmed = segment.trim();
96    if trimmed.is_empty() {
97        bail!("{what} is required");
98    }
99
100    if trimmed.chars().count() > 128 {
101        bail!("{what} is too long (max 128 characters)");
102    }
103
104    if trimmed == "." || trimmed == ".." {
105        bail!("{what} must not be '.' or '..'");
106    }
107
108    if trimmed.contains('\0') {
109        bail!("{what} must not contain NUL");
110    }
111
112    if trimmed.contains('/') || trimmed.contains('\\') {
113        bail!("{what} must not contain path separators");
114    }
115
116    #[cfg(windows)]
117    {
118        if trimmed.ends_with('.') {
119            bail!("{what} must not end with '.'");
120        }
121
122        const INVALID_CHARS: &[char] = &['<', '>', ':', '"', '|', '?', '*'];
123        if trimmed.chars().any(|ch| INVALID_CHARS.contains(&ch)) {
124            bail!("{what} contains invalid characters");
125        }
126
127        let stem = trimmed.split('.').next().unwrap_or(trimmed);
128        let stem_upper = stem.to_ascii_uppercase();
129
130        const RESERVED: &[&str] = &["CON", "PRN", "AUX", "NUL"];
131        if RESERVED.contains(&stem_upper.as_str()) {
132            bail!("{what} uses a reserved device name");
133        }
134
135        if let Some(num) = stem_upper.strip_prefix("COM") {
136            if matches!(num, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9") {
137                bail!("{what} uses a reserved device name");
138            }
139        }
140
141        if let Some(num) = stem_upper.strip_prefix("LPT") {
142            if matches!(num, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9") {
143                bail!("{what} uses a reserved device name");
144            }
145        }
146    }
147
148    Ok(trimmed.to_string())
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn test_validate_path_str() {
157        assert!(validate_path_str("").is_err());
158        assert!(validate_path_str("   ").is_err());
159        assert!(validate_path_str("\t").is_err());
160        assert!(validate_path_str("valid/path").is_ok());
161        assert!(validate_path_str("config.yaml").is_ok());
162    }
163
164    #[test]
165    fn test_create_validated_pathbuf() {
166        assert!(create_validated_pathbuf("").is_err());
167        assert!(create_validated_pathbuf("   ").is_err());
168        assert!(create_validated_pathbuf("valid/path").is_ok());
169    }
170
171    #[test]
172    fn test_safe_parent_for_creation() {
173        use std::path::Path;
174
175        // Should return None for just filename
176        assert!(safe_parent_for_creation(Path::new("config.yaml")).is_none());
177
178        // Should return Some for paths with directories
179        assert!(safe_parent_for_creation(Path::new("dir/config.yaml")).is_some());
180
181        // Should return Some for absolute paths
182        assert!(safe_parent_for_creation(Path::new("/tmp/config.yaml")).is_some());
183    }
184
185    #[test]
186    fn test_is_just_filename() {
187        use std::path::Path;
188
189        assert!(is_just_filename(Path::new("config.yaml")));
190        assert!(!is_just_filename(Path::new("dir/config.yaml")));
191        assert!(!is_just_filename(Path::new("/tmp/config.yaml")));
192    }
193
194    #[test]
195    fn test_relative_path_from_dir_returns_none_for_relative_inputs() {
196        assert!(relative_path_from_dir(Path::new("a/b"), Path::new("/tmp/x")).is_none());
197        assert!(relative_path_from_dir(Path::new("/tmp/x"), Path::new("a/b")).is_none());
198    }
199
200    #[test]
201    fn test_relative_path_from_dir_basic() {
202        use tempfile::TempDir;
203
204        let temp = TempDir::new().expect("temp dir");
205        let root = temp.path();
206        let from_dir = root.join("a/b/c");
207        let to = root.join("a/d/e");
208        std::fs::create_dir_all(&from_dir).expect("create from");
209        std::fs::create_dir_all(&to).expect("create to");
210
211        let rel = relative_path_from_dir(&from_dir, &to).expect("relative path");
212        assert_eq!(rel, PathBuf::from("../../d/e"));
213
214        let same = relative_path_from_dir(&from_dir, &from_dir).expect("relative path");
215        assert_eq!(same, PathBuf::from("."));
216    }
217
218    #[test]
219    fn test_validate_path_segment_basic() {
220        assert!(validate_path_segment("", "name").is_err());
221        assert!(validate_path_segment("   ", "name").is_err());
222        assert!(validate_path_segment(".", "name").is_err());
223        assert!(validate_path_segment("..", "name").is_err());
224        assert!(validate_path_segment("a/b", "name").is_err());
225        assert!(validate_path_segment("a\\b", "name").is_err());
226        assert_eq!(validate_path_segment(" foo ", "name").unwrap(), "foo");
227        assert_eq!(validate_path_segment("foo-bar", "name").unwrap(), "foo-bar");
228        assert_eq!(validate_path_segment("draftpr", "name").unwrap(), "draftpr");
229        assert_eq!(validate_path_segment("中文", "name").unwrap(), "中文");
230    }
231
232    #[cfg(windows)]
233    #[test]
234    fn test_validate_path_segment_windows_reserved_names() {
235        assert!(validate_path_segment("con", "name").is_err());
236        assert!(validate_path_segment("con.txt", "name").is_err());
237        assert!(validate_path_segment("COM1", "name").is_err());
238        assert!(validate_path_segment("LPT9.log", "name").is_err());
239        assert!(validate_path_segment("bad:name", "name").is_err());
240        assert!(validate_path_segment("trailing.", "name").is_err());
241    }
242}