agent_first_http/sdk/profile/
paths.rs1use std::path::{Path, PathBuf};
10
11use crate::shared::error::{Error, ErrorCode};
12
13pub const KNOWN_BACKENDS: &[&str] = &[
14 "chromium",
15 "chrome",
16 "chrome-headless-shell",
17 "fingerprint-chromium",
18 "edge",
19 "brave",
20 "lightpanda",
21 "camoufox",
22];
23
24pub fn default_root() -> PathBuf {
26 if let Some(dir) = dirs::data_dir() {
27 return dir.join("afhttp").join("profiles");
28 }
29 PathBuf::from("./afhttp-profiles")
30}
31
32pub fn validate_name(name: &str) -> Result<(), Error> {
42 if name.is_empty() {
43 return Err(Error::new(
44 ErrorCode::ProfileInvalidName,
45 "profile name cannot be empty",
46 ));
47 }
48 if name.len() > 64 {
49 return Err(Error::new(
50 ErrorCode::ProfileInvalidName,
51 "profile name longer than 64 characters",
52 ));
53 }
54 if name.starts_with('.') {
55 return Err(Error::new(
56 ErrorCode::ProfileInvalidName,
57 "profile name cannot start with '.'",
58 ));
59 }
60 for c in name.chars() {
61 let ok = c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.';
62 if !ok {
63 return Err(Error::new(
64 ErrorCode::ProfileInvalidName,
65 format!("profile name contains forbidden character: {c:?}"),
66 ));
67 }
68 }
69 if is_windows_reserved(name) {
70 return Err(Error::new(
71 ErrorCode::ProfileInvalidName,
72 format!(
73 "profile name {name:?} matches a Windows reserved device \
74 name; pick another to keep profiles portable"
75 ),
76 ));
77 }
78 Ok(())
79}
80
81pub fn validate_backend_key(backend: &str) -> Result<(), Error> {
82 if KNOWN_BACKENDS.contains(&backend) {
83 Ok(())
84 } else {
85 Err(Error::new(
86 ErrorCode::InvalidArgument,
87 format!(
88 "unknown profile backend {backend:?}; expected one of {}",
89 KNOWN_BACKENDS.join(", ")
90 ),
91 ))
92 }
93}
94
95fn is_windows_reserved(name: &str) -> bool {
101 const RESERVED: &[&str] = &[
102 "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8",
103 "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
104 ];
105 let base = name.split_once('.').map(|(b, _)| b).unwrap_or(name);
106 let lower = base.to_ascii_lowercase();
107 RESERVED.iter().any(|r| *r == lower)
108}
109
110pub fn ensure_no_filesystem_collision(root: &Path, name: &str) -> Result<(), Error> {
115 let target = root.join(name);
116 let md = match std::fs::symlink_metadata(&target) {
117 Ok(m) => m,
118 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
119 Err(e) => {
120 return Err(Error::new(
121 ErrorCode::ProfileRootUnavailable,
122 format!("stat {}: {e}", target.display()),
123 ));
124 }
125 };
126 if md.is_dir() {
127 return Ok(());
128 }
129 let kind = if md.file_type().is_symlink() {
130 "symlink"
131 } else if md.is_file() {
132 "regular file"
133 } else {
134 "non-directory entry"
135 };
136 Err(Error::new(
137 ErrorCode::ProfileInvalidName,
138 format!(
139 "profile name {name:?} would collide with a {kind} at {} — \
140 remove or rename it before reusing the name",
141 target.display()
142 ),
143 ))
144}
145
146pub fn ensure_backend_dir(root: &Path, backend: &str) -> Result<PathBuf, Error> {
147 validate_backend_key(backend)?;
148 let dir = root.join(backend);
149 match std::fs::symlink_metadata(&dir) {
150 Ok(md) if md.is_dir() => Ok(dir),
151 Ok(md) => {
152 let kind = if md.file_type().is_symlink() {
153 "symlink"
154 } else if md.is_file() {
155 "regular file"
156 } else {
157 "non-directory entry"
158 };
159 Err(Error::new(
160 ErrorCode::ProfileRootUnavailable,
161 format!(
162 "profile backend {backend:?} would collide with a {kind} at {}",
163 dir.display()
164 ),
165 ))
166 }
167 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
168 std::fs::create_dir_all(&dir).map_err(|e| {
169 Error::new(
170 ErrorCode::ProfileRootUnavailable,
171 format!("create profile backend dir {}: {e}", dir.display()),
172 )
173 })?;
174 Ok(dir)
175 }
176 Err(e) => Err(Error::new(
177 ErrorCode::ProfileRootUnavailable,
178 format!("stat {}: {e}", dir.display()),
179 )),
180 }
181}
182
183pub fn join_root_name(root: &Path, name: &str) -> Result<PathBuf, Error> {
187 validate_name(name)?;
188 Ok(root.join(name))
189}
190
191pub fn join_root_backend_name(root: &Path, backend: &str, name: &str) -> Result<PathBuf, Error> {
192 validate_backend_key(backend)?;
193 validate_name(name)?;
194 Ok(root.join(backend).join(name))
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn rejects_traversal() {
203 assert_eq!(
204 validate_name("..").err().map(|e| e.error_code),
205 Some(ErrorCode::ProfileInvalidName)
206 );
207 assert_eq!(
208 validate_name("a/b").err().map(|e| e.error_code),
209 Some(ErrorCode::ProfileInvalidName)
210 );
211 assert_eq!(
212 validate_name(".hidden").err().map(|e| e.error_code),
213 Some(ErrorCode::ProfileInvalidName)
214 );
215 }
216
217 #[test]
218 fn rejects_empty_and_long() {
219 assert!(validate_name("").is_err());
220 let big = "a".repeat(65);
221 assert!(validate_name(&big).is_err());
222 }
223
224 #[test]
225 fn accepts_reasonable_names() {
226 for n in ["work", "main_2026", "alpha-rc.1", "x"] {
227 validate_name(n).unwrap_or_else(|e| panic!("{n}: {e}"));
228 }
229 }
230
231 #[test]
232 fn rejects_windows_reserved_device_names() {
233 for n in ["con", "PRN", "Nul", "COM1", "lpt9", "AUX"] {
235 assert_eq!(
236 validate_name(n).err().map(|e| e.error_code),
237 Some(ErrorCode::ProfileInvalidName),
238 "{n} should be rejected",
239 );
240 }
241 assert!(validate_name("CON.log").is_err());
243 assert!(validate_name("nul.txt").is_err());
244 for n in ["console", "containers", "comet1", "nullify"] {
246 validate_name(n).unwrap_or_else(|e| panic!("{n}: {e}"));
247 }
248 }
249
250 #[test]
251 fn rejects_unicode_control_and_non_ascii() {
252 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());
261 }
262
263 #[test]
264 fn ensure_no_filesystem_collision_accepts_missing_and_dir_targets() {
265 let tmp = tempfile::tempdir().unwrap();
266 ensure_no_filesystem_collision(tmp.path(), "fresh").unwrap();
268 std::fs::create_dir(tmp.path().join("reused")).unwrap();
270 ensure_no_filesystem_collision(tmp.path(), "reused").unwrap();
271 }
272
273 #[test]
274 fn ensure_no_filesystem_collision_rejects_file_collision() {
275 let tmp = tempfile::tempdir().unwrap();
276 std::fs::write(tmp.path().join("squatter"), b"not a dir").unwrap();
277 let err = ensure_no_filesystem_collision(tmp.path(), "squatter")
278 .err()
279 .unwrap();
280 assert_eq!(err.error_code, ErrorCode::ProfileInvalidName);
281 assert!(
282 err.detail.contains("regular file"),
283 "detail should name the collision kind: {}",
284 err.detail
285 );
286 }
287
288 #[cfg(unix)]
289 #[test]
290 fn ensure_no_filesystem_collision_rejects_symlink_collision() {
291 let tmp = tempfile::tempdir().unwrap();
292 let target = tmp.path().join("real");
293 std::fs::create_dir(&target).unwrap();
294 std::os::unix::fs::symlink(&target, tmp.path().join("alias")).unwrap();
295 let err = ensure_no_filesystem_collision(tmp.path(), "alias")
296 .err()
297 .unwrap();
298 assert_eq!(err.error_code, ErrorCode::ProfileInvalidName);
299 assert!(err.detail.contains("symlink"));
300 }
301
302 #[test]
303 fn validate_name_round_trips_legal_inputs() {
304 let alphabet: &[u8] = b"abcdefghijklmnopqrstuvwxyz\
309 ABCDEFGHIJKLMNOPQRSTUVWXYZ\
310 0123456789-_.";
311 let root = std::path::Path::new("/tmp/afhttp");
312 for seed in 0u32..2000 {
313 let len = ((seed >> 8) % 12 + 1) as usize;
314 let mut buf = Vec::with_capacity(len);
315 let mut s = seed.wrapping_mul(2654435761);
316 for _ in 0..len {
317 s = s.wrapping_mul(2654435761).wrapping_add(1);
318 buf.push(alphabet[(s as usize) % alphabet.len()]);
319 }
320 let name = std::str::from_utf8(&buf).unwrap();
321 if validate_name(name).is_ok() {
322 let p = join_root_name(root, name).unwrap();
323 assert_eq!(p, root.join(name), "join_root_name drift for {name:?}");
324 }
325 }
326 }
327
328 #[test]
329 fn backend_scoped_join_uses_backend_then_name() {
330 let root = std::path::Path::new("/tmp/afhttp/profiles");
331 assert_eq!(
332 join_root_backend_name(root, "brave", "work").unwrap(),
333 root.join("brave").join("work")
334 );
335 assert_eq!(
336 join_root_backend_name(root, "chromium", "work").unwrap(),
337 root.join("chromium").join("work")
338 );
339 assert!(join_root_backend_name(root, "firefox", "work").is_err());
340 }
341
342 #[test]
343 fn default_root_is_under_data_dir() {
344 let p = default_root();
345 let s = p.to_string_lossy().to_string();
346 assert!(s.contains("afhttp"));
347 assert!(s.ends_with("profiles") || s.contains("profiles"));
348 }
349}