velesdb_core/
validation.rs1use crate::error::{Error, Result};
7
8pub const MAX_COLLECTION_NAME_LENGTH: usize = 128;
10
11pub const MIN_DIMENSION: usize = 1;
13
14pub const MAX_DIMENSION: usize = 65_536;
16
17pub fn validate_dimension(dimension: usize) -> Result<()> {
24 if !(MIN_DIMENSION..=MAX_DIMENSION).contains(&dimension) {
25 return Err(Error::InvalidDimension {
26 dimension,
27 min: MIN_DIMENSION,
28 max: MAX_DIMENSION,
29 });
30 }
31 Ok(())
32}
33
34pub fn validate_dimension_match(expected: usize, actual: usize) -> Result<()> {
40 if actual != expected {
41 return Err(Error::DimensionMismatch { expected, actual });
42 }
43 Ok(())
44}
45
46pub fn validate_collection_name(name: &str) -> Result<()> {
75 if name.is_empty() {
76 return Err(invalid_name(name, "must not be empty"));
77 }
78
79 if name.len() > MAX_COLLECTION_NAME_LENGTH {
80 return Err(invalid_name(
81 name,
82 &format!("exceeds maximum length of {MAX_COLLECTION_NAME_LENGTH} characters"),
83 ));
84 }
85
86 if name == "." || name == ".." {
87 return Err(invalid_name(name, "path traversal is not allowed"));
88 }
89
90 if name.starts_with('-') {
91 return Err(invalid_name(name, "must not start with a hyphen"));
92 }
93
94 if let Some(bad) = name.chars().find(|c| !is_valid_name_char(*c)) {
95 return Err(invalid_name(
96 name,
97 &format!(
98 "contains forbidden character '{bad}'; \
99 only ASCII letters, digits, underscores, and hyphens are allowed"
100 ),
101 ));
102 }
103
104 if is_windows_reserved(name) {
105 return Err(invalid_name(name, "is a Windows reserved device name"));
106 }
107
108 Ok(())
109}
110
111fn is_valid_name_char(c: char) -> bool {
113 c.is_ascii_alphanumeric() || c == '_' || c == '-'
114}
115
116fn is_windows_reserved(name: &str) -> bool {
119 let upper = name.to_ascii_uppercase();
120 matches!(
121 upper.as_str(),
122 "CON"
123 | "PRN"
124 | "AUX"
125 | "NUL"
126 | "COM1"
127 | "COM2"
128 | "COM3"
129 | "COM4"
130 | "COM5"
131 | "COM6"
132 | "COM7"
133 | "COM8"
134 | "COM9"
135 | "LPT1"
136 | "LPT2"
137 | "LPT3"
138 | "LPT4"
139 | "LPT5"
140 | "LPT6"
141 | "LPT7"
142 | "LPT8"
143 | "LPT9"
144 )
145}
146
147fn invalid_name(name: &str, reason: &str) -> Error {
149 Error::InvalidCollectionName {
150 name: name.to_string(),
151 reason: reason.to_string(),
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 #[test]
160 fn accepts_simple_ascii_names() {
161 for name in ["a", "abc", "my_coll", "docs-v2", "A1_b2-C3"] {
162 validate_collection_name(name).unwrap();
163 }
164 }
165
166 #[test]
167 fn accepts_max_length() {
168 let name = "x".repeat(MAX_COLLECTION_NAME_LENGTH);
169 validate_collection_name(&name).unwrap();
170 }
171
172 #[test]
173 fn rejects_empty() {
174 assert!(validate_collection_name("").is_err());
175 }
176
177 #[test]
178 fn rejects_over_max_length() {
179 let name = "x".repeat(MAX_COLLECTION_NAME_LENGTH + 1);
180 assert!(validate_collection_name(&name).is_err());
181 }
182
183 #[test]
184 fn rejects_dot_and_dotdot() {
185 assert!(validate_collection_name(".").is_err());
186 assert!(validate_collection_name("..").is_err());
187 }
188
189 #[test]
190 fn rejects_path_separators() {
191 assert!(validate_collection_name("a/b").is_err());
192 assert!(validate_collection_name("a\\b").is_err());
193 assert!(validate_collection_name("../x").is_err());
194 }
195
196 #[test]
197 fn rejects_leading_hyphen() {
198 assert!(validate_collection_name("-bad").is_err());
199 assert!(validate_collection_name("--bad").is_err());
200 }
201
202 #[test]
203 fn allows_interior_hyphens() {
204 validate_collection_name("a-b").unwrap();
205 validate_collection_name("a-b-c").unwrap();
206 }
207
208 #[test]
209 fn rejects_special_chars() {
210 for name in ["a b", "a@b", "a.b", "a#b", "a$b", "a:b", "a*b"] {
211 assert!(
212 validate_collection_name(name).is_err(),
213 "Should reject {:?}",
214 name
215 );
216 }
217 }
218
219 #[test]
220 fn rejects_unicode() {
221 assert!(validate_collection_name("café").is_err());
222 assert!(validate_collection_name("日本").is_err());
223 }
224
225 #[test]
226 fn rejects_windows_reserved_case_insensitive() {
227 for name in ["CON", "con", "Con", "PRN", "AUX", "NUL", "COM1", "LPT9"] {
228 assert!(
229 validate_collection_name(name).is_err(),
230 "Should reject {:?}",
231 name
232 );
233 }
234 }
235
236 #[test]
237 fn allows_names_containing_reserved_as_substring() {
238 validate_collection_name("connection").unwrap();
240 validate_collection_name("my_aux_data").unwrap();
241 validate_collection_name("com10").unwrap();
242 }
243
244 #[test]
245 fn error_code_is_veles_034() {
246 let err = validate_collection_name("").unwrap_err();
247 assert_eq!(err.code(), "VELES-034");
248 }
249
250 #[test]
251 fn error_is_recoverable() {
252 let err = validate_collection_name("").unwrap_err();
253 assert!(err.is_recoverable());
254 }
255}