Skip to main content

velesdb_core/
validation.rs

1//! Unified validation helpers.
2//!
3//! Centralizes dimension range checks, collection name validation, and
4//! mismatch validation used across collection creation, CRUD, and search paths.
5
6use crate::error::{Error, Result};
7
8/// Maximum allowed length for a collection name.
9pub const MAX_COLLECTION_NAME_LENGTH: usize = 128;
10
11/// Minimum valid vector dimension.
12pub const MIN_DIMENSION: usize = 1;
13
14/// Maximum valid vector dimension (65,536 — covers all known embedding models).
15pub const MAX_DIMENSION: usize = 65_536;
16
17/// Validates that a vector dimension is within the allowed range.
18///
19/// # Errors
20///
21/// Returns [`Error::InvalidDimension`] if `dimension` is outside
22/// [`MIN_DIMENSION`]`..=`[`MAX_DIMENSION`].
23pub 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
34/// Validates that a vector's actual dimension matches the expected dimension.
35///
36/// # Errors
37///
38/// Returns [`crate::error::Error::DimensionMismatch`] if `actual != expected`.
39pub 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
46/// Validates that a collection name is safe for use as a filesystem directory.
47///
48/// # Rules
49///
50/// - Must not be empty.
51/// - Must not exceed [`MAX_COLLECTION_NAME_LENGTH`] characters.
52/// - Must contain only ASCII alphanumeric characters, underscores, or hyphens
53///   (`[a-zA-Z0-9_-]`).
54/// - Must not be `.` or `..` (path traversal).
55/// - Must not start with a hyphen (avoids CLI flag confusion).
56/// - Must not be a Windows reserved device name (`CON`, `PRN`, `AUX`, `NUL`,
57///   `COM1`–`COM9`, `LPT1`–`LPT9`).
58///
59/// # Errors
60///
61/// Returns [`Error::InvalidCollectionName`] with a human-readable reason.
62///
63/// # Examples
64///
65/// ```
66/// use velesdb_core::validate_collection_name;
67///
68/// assert!(validate_collection_name("my_collection").is_ok());
69/// assert!(validate_collection_name("docs-v2").is_ok());
70/// assert!(validate_collection_name("").is_err());
71/// assert!(validate_collection_name("../evil").is_err());
72/// assert!(validate_collection_name("a/b").is_err());
73/// ```
74pub 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
111/// Returns `true` if `c` is allowed in a collection name.
112fn is_valid_name_char(c: char) -> bool {
113    c.is_ascii_alphanumeric() || c == '_' || c == '-'
114}
115
116/// Returns `true` if `name` matches a Windows reserved device name
117/// (case-insensitive).
118fn 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
147/// Convenience constructor for [`Error::InvalidCollectionName`].
148fn 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        // "connection" contains "con" but is not the reserved name "CON"
239        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}