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//! Name validation rejects anything that would escape the root or break
5//! filesystem traversal.
6
7use std::path::{Path, PathBuf};
8
9use crate::shared::error::{Error, ErrorCode};
10
11/// Resolve the default profile root.
12pub 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
19/// Validate a profile name. Allowed: ASCII alphanumerics plus `-`, `_`,
20/// `.` (but never starting with `.`), max 64 chars. Rejects Windows
21/// reserved device names so a profile created on Linux cannot become a
22/// poisoned path when the directory is read on Windows.
23///
24/// This is the structural check only — it does not touch the filesystem.
25/// Callers that need to ensure the target directory doesn't collide with
26/// an existing non-directory entry should chain
27/// [`ensure_no_filesystem_collision`] after this.
28pub 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
68/// Windows reserved device names per [MSDN naming
69/// conventions](https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file).
70/// A reserved match is the bare name *or* the bare name followed by
71/// `.<anything>` (e.g. `con.log` is also reserved on Windows). Case
72/// insensitive.
73fn 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
83/// Ensure that `<root>/<name>` either does not exist or is already a
84/// directory. Rejects the case where a regular file or symlink occupies
85/// the slot — `afhttp host --profile foo` should not silently start over
86/// a non-directory entry the user (or another tool) put there.
87pub 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
119/// Join the root + name into a concrete profile directory path. Performs
120/// structural validation only; callers that touch the filesystem should
121/// also run [`ensure_no_filesystem_collision`].
122pub 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        // Bare names.
164        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        // Reserved-with-extension forms (`con.log` is still a device on Windows).
172        assert!(validate_name("CON.log").is_err());
173        assert!(validate_name("nul.txt").is_err());
174        // Lookalikes that share a prefix must still be accepted.
175        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        // The ASCII-alphanumeric allowlist already catches these; this is
183        // a load-bearing sanity test that documents the contract.
184        assert!(validate_name("work\u{0000}").is_err()); // NUL byte
185        assert!(validate_name("work\u{0007}").is_err()); // BEL
186        assert!(validate_name("работа").is_err()); // Cyrillic
187        assert!(validate_name("作業").is_err()); // CJK
188        assert!(validate_name("café").is_err()); // accented Latin
189                                                 // Windows-style separator.
190        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        // Missing target — fine, will be mkdir'd later.
197        ensure_no_filesystem_collision(tmp.path(), "fresh").unwrap();
198        // Existing directory — also fine, this is the reuse case.
199        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        // Deterministic fuzz over the legal alphabet. Every accepted name
235        // must round-trip through `join_root_name` to the exact filesystem
236        // path we expect — no NFC drift, no character substitution, no
237        // surprises in path joining.
238        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}