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