agent_first_http/sdk/profile/
paths.rs1use std::path::{Path, PathBuf};
8
9use crate::shared::error::{Error, ErrorCode};
10
11pub fn default_root() -> PathBuf {
13 if let Some(dir) = dirs::data_dir() {
14 return dir.join("afhttp").join("profiles");
15 }
16 PathBuf::from("./afhttp-profiles")
17}
18
19pub fn validate_name(name: &str) -> Result<(), Error> {
29 if name.is_empty() {
30 return Err(Error::new(
31 ErrorCode::ProfileInvalidName,
32 "profile name cannot be empty",
33 ));
34 }
35 if name.len() > 64 {
36 return Err(Error::new(
37 ErrorCode::ProfileInvalidName,
38 "profile name longer than 64 characters",
39 ));
40 }
41 if name.starts_with('.') {
42 return Err(Error::new(
43 ErrorCode::ProfileInvalidName,
44 "profile name cannot start with '.'",
45 ));
46 }
47 for c in name.chars() {
48 let ok = c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.';
49 if !ok {
50 return Err(Error::new(
51 ErrorCode::ProfileInvalidName,
52 format!("profile name contains forbidden character: {c:?}"),
53 ));
54 }
55 }
56 if is_windows_reserved(name) {
57 return Err(Error::new(
58 ErrorCode::ProfileInvalidName,
59 format!(
60 "profile name {name:?} matches a Windows reserved device \
61 name; pick another to keep profiles portable"
62 ),
63 ));
64 }
65 Ok(())
66}
67
68fn is_windows_reserved(name: &str) -> bool {
74 const RESERVED: &[&str] = &[
75 "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8",
76 "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
77 ];
78 let base = name.split_once('.').map(|(b, _)| b).unwrap_or(name);
79 let lower = base.to_ascii_lowercase();
80 RESERVED.iter().any(|r| *r == lower)
81}
82
83pub fn ensure_no_filesystem_collision(root: &Path, name: &str) -> Result<(), Error> {
88 let target = root.join(name);
89 let md = match std::fs::symlink_metadata(&target) {
90 Ok(m) => m,
91 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
92 Err(e) => {
93 return Err(Error::new(
94 ErrorCode::ProfileRootUnavailable,
95 format!("stat {}: {e}", target.display()),
96 ));
97 }
98 };
99 if md.is_dir() {
100 return Ok(());
101 }
102 let kind = if md.file_type().is_symlink() {
103 "symlink"
104 } else if md.is_file() {
105 "regular file"
106 } else {
107 "non-directory entry"
108 };
109 Err(Error::new(
110 ErrorCode::ProfileInvalidName,
111 format!(
112 "profile name {name:?} would collide with a {kind} at {} — \
113 remove or rename it before reusing the name",
114 target.display()
115 ),
116 ))
117}
118
119pub fn join_root_name(root: &Path, name: &str) -> Result<PathBuf, Error> {
123 validate_name(name)?;
124 Ok(root.join(name))
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130
131 #[test]
132 fn rejects_traversal() {
133 assert_eq!(
134 validate_name("..").err().map(|e| e.error_code),
135 Some(ErrorCode::ProfileInvalidName)
136 );
137 assert_eq!(
138 validate_name("a/b").err().map(|e| e.error_code),
139 Some(ErrorCode::ProfileInvalidName)
140 );
141 assert_eq!(
142 validate_name(".hidden").err().map(|e| e.error_code),
143 Some(ErrorCode::ProfileInvalidName)
144 );
145 }
146
147 #[test]
148 fn rejects_empty_and_long() {
149 assert!(validate_name("").is_err());
150 let big = "a".repeat(65);
151 assert!(validate_name(&big).is_err());
152 }
153
154 #[test]
155 fn accepts_reasonable_names() {
156 for n in ["work", "main_2026", "alpha-rc.1", "x"] {
157 validate_name(n).unwrap_or_else(|e| panic!("{n}: {e}"));
158 }
159 }
160
161 #[test]
162 fn rejects_windows_reserved_device_names() {
163 for n in ["con", "PRN", "Nul", "COM1", "lpt9", "AUX"] {
165 assert_eq!(
166 validate_name(n).err().map(|e| e.error_code),
167 Some(ErrorCode::ProfileInvalidName),
168 "{n} should be rejected",
169 );
170 }
171 assert!(validate_name("CON.log").is_err());
173 assert!(validate_name("nul.txt").is_err());
174 for n in ["console", "containers", "comet1", "nullify"] {
176 validate_name(n).unwrap_or_else(|e| panic!("{n}: {e}"));
177 }
178 }
179
180 #[test]
181 fn rejects_unicode_control_and_non_ascii() {
182 assert!(validate_name("work\u{0000}").is_err()); assert!(validate_name("work\u{0007}").is_err()); assert!(validate_name("работа").is_err()); assert!(validate_name("作業").is_err()); assert!(validate_name("café").is_err()); assert!(validate_name("a\\b").is_err());
191 }
192
193 #[test]
194 fn ensure_no_filesystem_collision_accepts_missing_and_dir_targets() {
195 let tmp = tempfile::tempdir().unwrap();
196 ensure_no_filesystem_collision(tmp.path(), "fresh").unwrap();
198 std::fs::create_dir(tmp.path().join("reused")).unwrap();
200 ensure_no_filesystem_collision(tmp.path(), "reused").unwrap();
201 }
202
203 #[test]
204 fn ensure_no_filesystem_collision_rejects_file_collision() {
205 let tmp = tempfile::tempdir().unwrap();
206 std::fs::write(tmp.path().join("squatter"), b"not a dir").unwrap();
207 let err = ensure_no_filesystem_collision(tmp.path(), "squatter")
208 .err()
209 .unwrap();
210 assert_eq!(err.error_code, ErrorCode::ProfileInvalidName);
211 assert!(
212 err.detail.contains("regular file"),
213 "detail should name the collision kind: {}",
214 err.detail
215 );
216 }
217
218 #[cfg(unix)]
219 #[test]
220 fn ensure_no_filesystem_collision_rejects_symlink_collision() {
221 let tmp = tempfile::tempdir().unwrap();
222 let target = tmp.path().join("real");
223 std::fs::create_dir(&target).unwrap();
224 std::os::unix::fs::symlink(&target, tmp.path().join("alias")).unwrap();
225 let err = ensure_no_filesystem_collision(tmp.path(), "alias")
226 .err()
227 .unwrap();
228 assert_eq!(err.error_code, ErrorCode::ProfileInvalidName);
229 assert!(err.detail.contains("symlink"));
230 }
231
232 #[test]
233 fn validate_name_round_trips_legal_inputs() {
234 let alphabet: &[u8] = b"abcdefghijklmnopqrstuvwxyz\
239 ABCDEFGHIJKLMNOPQRSTUVWXYZ\
240 0123456789-_.";
241 let root = std::path::Path::new("/tmp/afhttp");
242 for seed in 0u32..2000 {
243 let len = ((seed >> 8) % 12 + 1) as usize;
244 let mut buf = Vec::with_capacity(len);
245 let mut s = seed.wrapping_mul(2654435761);
246 for _ in 0..len {
247 s = s.wrapping_mul(2654435761).wrapping_add(1);
248 buf.push(alphabet[(s as usize) % alphabet.len()]);
249 }
250 let name = std::str::from_utf8(&buf).unwrap();
251 if validate_name(name).is_ok() {
252 let p = join_root_name(root, name).unwrap();
253 assert_eq!(p, root.join(name), "join_root_name drift for {name:?}");
254 }
255 }
256 }
257
258 #[test]
259 fn default_root_is_under_data_dir() {
260 let p = default_root();
261 let s = p.to_string_lossy().to_string();
262 assert!(s.contains("afhttp"));
263 assert!(s.ends_with("profiles") || s.contains("profiles"));
264 }
265}