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