Skip to main content

llman_core/
path_utils.rs

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