1use anyhow::{Result as AnyhowResult, bail};
4use std::path::{Path, PathBuf};
5
6pub 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 _ in common_len..from_components.len() {
48 out.push("..");
49 }
50
51 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
66pub 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
74pub fn create_validated_pathbuf(path_str: &str) -> Result<PathBuf, String> {
76 validate_path_str(path_str)?;
77 Ok(PathBuf::from(path_str))
78}
79
80pub fn safe_parent_for_creation(path: &Path) -> Option<&Path> {
83 path.parent().filter(|p| !p.as_os_str().is_empty())
84}
85
86pub fn is_just_filename(path: &Path) -> bool {
88 path.parent().is_some_and(|p| p.as_os_str().is_empty())
89}
90
91pub 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 assert!(safe_parent_for_creation(Path::new("config.yaml")).is_none());
177
178 assert!(safe_parent_for_creation(Path::new("dir/config.yaml")).is_some());
180
181 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}