1use anyhow::{bail, Result};
8
9const MAX_NAME_LEN: usize = 128;
11
12const MAX_TAG_LEN: usize = 64;
14
15pub fn validate_name(name: &str) -> Result<()> {
22 if name.is_empty() {
23 bail!("name cannot be empty");
24 }
25 if name.len() > MAX_NAME_LEN {
26 bail!("name exceeds maximum length of {MAX_NAME_LEN} characters");
27 }
28 if name.contains('\0') {
29 bail!("name contains null byte");
30 }
31 if name.contains('/') || name.contains('\\') {
32 bail!("name cannot contain path separators ('/' or '\\')");
33 }
34 if name.contains("..") {
35 bail!("name cannot contain '..'");
36 }
37 if name.starts_with('.') {
38 bail!("name cannot start with '.'");
39 }
40 if name.starts_with('-') {
41 bail!("name cannot start with '-'");
42 }
43
44 let first = name.chars().next().unwrap();
46 if !first.is_ascii_alphanumeric() {
47 bail!("name must start with a letter or digit");
48 }
49
50 for ch in name.chars() {
52 if !ch.is_ascii_alphanumeric() && ch != '.' && ch != '_' && ch != '-' {
53 bail!("name contains invalid character: '{ch}'. Only letters, digits, '.', '_', '-' are allowed");
54 }
55 }
56
57 let upper = name.to_uppercase();
59 let reserved = [
60 "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
61 "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
62 ];
63 let stem = upper.split('.').next().unwrap_or(&upper);
64 if reserved.contains(&stem) {
65 bail!("name uses a reserved system name: {name}");
66 }
67
68 Ok(())
69}
70
71pub fn validate_tag(tag: &str) -> Result<()> {
76 if tag.is_empty() {
77 bail!("tag cannot be empty");
78 }
79 if tag.len() > MAX_TAG_LEN {
80 bail!("tag exceeds maximum length of {MAX_TAG_LEN} characters");
81 }
82 if tag.contains(':') {
83 bail!("tag cannot contain ':'");
84 }
85 validate_name(tag)
87}
88
89pub fn validate_project_path(name: &str) -> Result<()> {
93 validate_name(name)?;
94
95 let path = std::path::Path::new(name);
97 if path.is_absolute() {
98 bail!("project name cannot be an absolute path");
99 }
100
101 Ok(())
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 #[test]
109 fn valid_names() {
110 assert!(validate_name("my-project").is_ok());
111 assert!(validate_name("model_v2").is_ok());
112 assert!(validate_name("llama3.1").is_ok());
113 assert!(validate_name("A").is_ok());
114 assert!(validate_name("test123").is_ok());
115 }
116
117 #[test]
118 fn rejects_empty() {
119 assert!(validate_name("").is_err());
120 }
121
122 #[test]
123 fn rejects_path_traversal() {
124 assert!(validate_name("../evil").is_err());
125 assert!(validate_name("../../etc/passwd").is_err());
126 assert!(validate_name("foo/../bar").is_err());
127 assert!(validate_name("..").is_err());
128 }
129
130 #[test]
131 fn rejects_path_separators() {
132 assert!(validate_name("foo/bar").is_err());
133 assert!(validate_name("foo\\bar").is_err());
134 assert!(validate_name("/etc/passwd").is_err());
135 }
136
137 #[test]
138 fn rejects_hidden_names() {
139 assert!(validate_name(".hidden").is_err());
140 assert!(validate_name(".git").is_err());
141 }
142
143 #[test]
144 fn rejects_null_bytes() {
145 assert!(validate_name("foo\0bar").is_err());
146 }
147
148 #[test]
149 fn rejects_too_long() {
150 let long = "a".repeat(129);
151 assert!(validate_name(&long).is_err());
152 let ok = "a".repeat(128);
153 assert!(validate_name(&ok).is_ok());
154 }
155
156 #[test]
157 fn rejects_windows_reserved() {
158 assert!(validate_name("CON").is_err());
159 assert!(validate_name("NUL").is_err());
160 assert!(validate_name("COM1").is_err());
161 assert!(validate_name("LPT1.txt").is_err());
162 }
163
164 #[test]
165 fn rejects_special_chars() {
166 assert!(validate_name("foo bar").is_err());
167 assert!(validate_name("foo@bar").is_err());
168 assert!(validate_name("foo$bar").is_err());
169 }
170
171 #[test]
172 fn rejects_leading_dash() {
173 assert!(validate_name("-flag").is_err());
174 }
175
176 #[test]
177 fn tag_rejects_colon() {
178 assert!(validate_tag("v1:latest").is_err());
179 assert!(validate_tag("production").is_ok());
180 }
181
182 #[test]
183 fn tag_max_length() {
184 let long = "a".repeat(65);
185 assert!(validate_tag(&long).is_err());
186 let ok = "a".repeat(64);
187 assert!(validate_tag(&ok).is_ok());
188 }
189
190 #[test]
191 fn project_path_rejects_absolute() {
192 assert!(validate_project_path("/tmp/evil").is_err());
193 }
194}