1use anyhow::{Result as AnyhowResult, bail};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7pub 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 _ in common_len..from_components.len() {
49 out.push("..");
50 }
51
52 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
67pub 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
87pub 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
93pub fn safe_parent_for_creation(path: &Path) -> Option<&Path> {
96 path.parent().filter(|p| !p.as_os_str().is_empty())
97}
98
99pub 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
106pub fn is_just_filename(path: &Path) -> bool {
108 path.parent().is_some_and(|p| p.as_os_str().is_empty())
109}
110
111pub 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 assert!(safe_parent_for_creation(Path::new("config.yaml")).is_none());
205
206 assert!(safe_parent_for_creation(Path::new("dir/config.yaml")).is_some());
208
209 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}