Skip to main content

browser_automation_cli/
validation.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Input validation helpers for CLI flags and paths.
3
4use std::path::{Component, Path};
5
6/// Windows reserved device names (case-insensitive, with or without extension).
7///
8/// See: https://learn.microsoft.com/windows/win32/fileio/naming-a-file
9const WINDOWS_RESERVED_NAMES: &[&str] = &[
10    "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
11    "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
12];
13
14/// Check if a session name is valid (alphanumeric, hyphens, and underscores only).
15pub fn is_valid_session_name(name: &str) -> bool {
16    !name.is_empty()
17        && name
18            .chars()
19            .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
20}
21
22/// Convert arbitrary caller-provided text into a valid session-name component.
23pub fn sanitize_session_component(value: &str) -> String {
24    let mut out = String::new();
25    let mut last_was_sep = false;
26
27    for c in value.chars() {
28        if c.is_alphanumeric() {
29            out.extend(c.to_lowercase());
30            last_was_sep = false;
31        } else if c == '-' || c == '_' {
32            if !out.is_empty() && !last_was_sep {
33                out.push(c);
34                last_was_sep = true;
35            }
36        } else if !out.is_empty() && !last_was_sep {
37            out.push('-');
38            last_was_sep = true;
39        }
40    }
41
42    while out.ends_with(['-', '_']) {
43        out.pop();
44    }
45
46    out
47}
48
49/// Generate error message for invalid session name
50pub fn session_name_error(name: &str) -> String {
51    format!(
52        "Invalid session name '{}'. Only alphanumeric characters, hyphens, and underscores are allowed.",
53        name
54    )
55}
56
57/// Reject path strings that attempt directory traversal via `..` components.
58///
59/// Absolute paths are allowed (caller decides root policy). Empty paths fail.
60/// On all targets, also rejects Windows reserved device basenames (`CON`, `NUL`, …)
61/// so agents cannot create unportable artifact names when sharing scripts cross-OS.
62pub fn reject_path_traversal(path: &Path) -> Result<(), String> {
63    if path.as_os_str().is_empty() {
64        return Err("path must not be empty".into());
65    }
66    for c in path.components() {
67        if matches!(c, Component::ParentDir) {
68            return Err(format!(
69                "path traversal rejected (`..` component): {}",
70                path.display()
71            ));
72        }
73    }
74    reject_windows_reserved_basename(path)?;
75    Ok(())
76}
77
78/// Same as [`reject_path_traversal`] for stringy CLI inputs.
79pub fn reject_path_traversal_str(path: &str) -> Result<(), String> {
80    reject_path_traversal(Path::new(path))
81}
82
83/// Reject basenames that are Windows reserved device names (`NUL`, `COM1`, …).
84///
85/// Applied on every OS so path contracts stay portable (rules multiplataforma).
86pub fn reject_windows_reserved_basename(path: &Path) -> Result<(), String> {
87    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
88        return Ok(());
89    };
90    // Strip one extension: `nul.txt` and `CON` both reserved.
91    let stem = name
92        .split_once('.')
93        .map(|(s, _)| s)
94        .unwrap_or(name);
95    let upper = stem.to_ascii_uppercase();
96    if WINDOWS_RESERVED_NAMES.iter().any(|r| *r == upper) {
97        return Err(format!(
98            "Windows reserved device name rejected in path basename: {}",
99            path.display()
100        ));
101    }
102    Ok(())
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use std::path::PathBuf;
109
110    #[test]
111    fn sanitize_session_component_produces_valid_component() {
112        let value = sanitize_session_component("Next Dev Loop: /Users/me/worktree!");
113
114        assert_eq!(value, "next-dev-loop-users-me-worktree");
115        assert!(is_valid_session_name(&value));
116    }
117
118    #[test]
119    fn sanitize_session_component_trims_separators() {
120        assert_eq!(sanitize_session_component(" --Agent__ "), "agent");
121    }
122
123    #[test]
124    fn path_traversal_rejected() {
125        assert!(reject_path_traversal(Path::new("../etc/passwd")).is_err());
126        assert!(reject_path_traversal(Path::new("foo/../../bar")).is_err());
127        assert!(reject_path_traversal(Path::new("safe/out.png")).is_ok());
128        assert!(reject_path_traversal(Path::new("/abs/safe")).is_ok());
129        assert!(reject_path_traversal_str("").is_err());
130        let p = PathBuf::from("a/b/c");
131        assert!(reject_path_traversal(&p).is_ok());
132    }
133
134    #[test]
135    fn windows_reserved_basenames_rejected() {
136        assert!(reject_path_traversal(Path::new("NUL")).is_err());
137        assert!(reject_path_traversal(Path::new("nul.txt")).is_err());
138        assert!(reject_path_traversal(Path::new("out/CON")).is_err());
139        assert!(reject_path_traversal(Path::new("com1")).is_err());
140        assert!(reject_path_traversal(Path::new("report.png")).is_ok());
141        assert!(reject_windows_reserved_basename(Path::new("aux.log")).is_err());
142    }
143}