use crate::error::{Error, Result};
pub const MAX_COLLECTION_NAME_LENGTH: usize = 128;
pub const MIN_DIMENSION: usize = 1;
pub const MAX_DIMENSION: usize = 65_536;
pub fn validate_dimension(dimension: usize) -> Result<()> {
if !(MIN_DIMENSION..=MAX_DIMENSION).contains(&dimension) {
return Err(Error::InvalidDimension {
dimension,
min: MIN_DIMENSION,
max: MAX_DIMENSION,
});
}
Ok(())
}
pub fn validate_dimension_match(expected: usize, actual: usize) -> Result<()> {
if actual != expected {
return Err(Error::DimensionMismatch { expected, actual });
}
Ok(())
}
pub fn validate_collection_name(name: &str) -> Result<()> {
if name.is_empty() {
return Err(invalid_name(name, "must not be empty"));
}
if name.len() > MAX_COLLECTION_NAME_LENGTH {
return Err(invalid_name(
name,
&format!("exceeds maximum length of {MAX_COLLECTION_NAME_LENGTH} characters"),
));
}
if name == "." || name == ".." {
return Err(invalid_name(name, "path traversal is not allowed"));
}
if name.starts_with('-') {
return Err(invalid_name(name, "must not start with a hyphen"));
}
if let Some(bad) = name.chars().find(|c| !is_valid_name_char(*c)) {
return Err(invalid_name(
name,
&format!(
"contains forbidden character '{bad}'; \
only ASCII letters, digits, underscores, and hyphens are allowed"
),
));
}
if is_windows_reserved(name) {
return Err(invalid_name(name, "is a Windows reserved device name"));
}
Ok(())
}
fn is_valid_name_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_' || c == '-'
}
fn is_windows_reserved(name: &str) -> bool {
let upper = name.to_ascii_uppercase();
matches!(
upper.as_str(),
"CON"
| "PRN"
| "AUX"
| "NUL"
| "COM1"
| "COM2"
| "COM3"
| "COM4"
| "COM5"
| "COM6"
| "COM7"
| "COM8"
| "COM9"
| "LPT1"
| "LPT2"
| "LPT3"
| "LPT4"
| "LPT5"
| "LPT6"
| "LPT7"
| "LPT8"
| "LPT9"
)
}
fn invalid_name(name: &str, reason: &str) -> Error {
Error::InvalidCollectionName {
name: name.to_string(),
reason: reason.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_simple_ascii_names() {
for name in ["a", "abc", "my_coll", "docs-v2", "A1_b2-C3"] {
validate_collection_name(name).unwrap();
}
}
#[test]
fn accepts_max_length() {
let name = "x".repeat(MAX_COLLECTION_NAME_LENGTH);
validate_collection_name(&name).unwrap();
}
#[test]
fn rejects_empty() {
assert!(validate_collection_name("").is_err());
}
#[test]
fn rejects_over_max_length() {
let name = "x".repeat(MAX_COLLECTION_NAME_LENGTH + 1);
assert!(validate_collection_name(&name).is_err());
}
#[test]
fn rejects_dot_and_dotdot() {
assert!(validate_collection_name(".").is_err());
assert!(validate_collection_name("..").is_err());
}
#[test]
fn rejects_path_separators() {
assert!(validate_collection_name("a/b").is_err());
assert!(validate_collection_name("a\\b").is_err());
assert!(validate_collection_name("../x").is_err());
}
#[test]
fn rejects_leading_hyphen() {
assert!(validate_collection_name("-bad").is_err());
assert!(validate_collection_name("--bad").is_err());
}
#[test]
fn allows_interior_hyphens() {
validate_collection_name("a-b").unwrap();
validate_collection_name("a-b-c").unwrap();
}
#[test]
fn rejects_special_chars() {
for name in ["a b", "a@b", "a.b", "a#b", "a$b", "a:b", "a*b"] {
assert!(
validate_collection_name(name).is_err(),
"Should reject {:?}",
name
);
}
}
#[test]
fn rejects_unicode() {
assert!(validate_collection_name("café").is_err());
assert!(validate_collection_name("日本").is_err());
}
#[test]
fn rejects_windows_reserved_case_insensitive() {
for name in ["CON", "con", "Con", "PRN", "AUX", "NUL", "COM1", "LPT9"] {
assert!(
validate_collection_name(name).is_err(),
"Should reject {:?}",
name
);
}
}
#[test]
fn allows_names_containing_reserved_as_substring() {
validate_collection_name("connection").unwrap();
validate_collection_name("my_aux_data").unwrap();
validate_collection_name("com10").unwrap();
}
#[test]
fn error_code_is_veles_034() {
let err = validate_collection_name("").unwrap_err();
assert_eq!(err.code(), "VELES-034");
}
#[test]
fn error_is_recoverable() {
let err = validate_collection_name("").unwrap_err();
assert!(err.is_recoverable());
}
}