Skip to main content

agent_first_http/sdk/profile/
paths.rs

1//! Profile-root resolution + name validation.
2//!
3//! Root: `$XDG_DATA_HOME/afhttp/profiles/` (or `$HOME/.local/share/afhttp/profiles/`).
4//! Persistent profiles are scoped by backend family under
5//! `<root>/<backend>/<name>`.
6//! Name validation rejects anything that would escape the root or break
7//! filesystem traversal.
8
9use 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
23/// Resolve the default profile root.
24pub 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
31/// Validate a profile name. Allowed: ASCII alphanumerics plus `-`, `_`,
32/// `.` (but never starting with `.`), max 64 chars. Rejects Windows
33/// reserved device names so a profile created on Linux cannot become a
34/// poisoned path when the directory is read on Windows.
35///
36/// This is the structural check only — it does not touch the filesystem.
37/// Callers that need to ensure the target directory doesn't collide with
38/// an existing non-directory entry should chain
39/// [`ensure_no_filesystem_collision`] after this.
40pub 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
94/// Windows reserved device names per [MSDN naming
95/// conventions](https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file).
96/// A reserved match is the bare name *or* the bare name followed by
97/// `.<anything>` (e.g. `con.log` is also reserved on Windows). Case
98/// insensitive.
99fn 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
109/// Ensure that `<root>/<name>` either does not exist or is already a
110/// directory. Rejects the case where a regular file or symlink occupies
111/// the slot — `afhttp host --profile foo` should not silently start over
112/// a non-directory entry the user (or another tool) put there.
113pub 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
182/// Join the root + name into a concrete profile directory path. Performs
183/// structural validation only; callers that touch the filesystem should
184/// also run [`ensure_no_filesystem_collision`].
185pub 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        // Bare names.
233        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        // Reserved-with-extension forms (`con.log` is still a device on Windows).
241        assert!(validate_name("CON.log").is_err());
242        assert!(validate_name("nul.txt").is_err());
243        // Lookalikes that share a prefix must still be accepted.
244        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        // The ASCII-alphanumeric allowlist already catches these; this is
252        // a load-bearing sanity test that documents the contract.
253        assert!(validate_name("work\u{0000}").is_err()); // NUL byte
254        assert!(validate_name("work\u{0007}").is_err()); // BEL
255        assert!(validate_name("работа").is_err()); // Cyrillic
256        assert!(validate_name("作業").is_err()); // CJK
257        assert!(validate_name("café").is_err()); // accented Latin
258        // Windows-style separator.
259        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        // Missing target — fine, will be mkdir'd later.
266        ensure_no_filesystem_collision(tmp.path(), "fresh").unwrap();
267        // Existing directory — also fine, this is the reuse case.
268        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        // Deterministic fuzz over the legal alphabet. Every accepted name
304        // must round-trip through `join_root_name` to the exact filesystem
305        // path we expect — no NFC drift, no character substitution, no
306        // surprises in path joining.
307        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}