browser_automation_cli/
validation.rs1use std::path::{Component, Path};
5
6const 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
14pub 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
22pub 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
49pub 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
57pub 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
78pub fn reject_path_traversal_str(path: &str) -> Result<(), String> {
80 reject_path_traversal(Path::new(path))
81}
82
83pub 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 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}