zernel/
validation.rs

1// Copyright (C) 2026 Dyber, Inc. — Proprietary
2
3//! Input validation for user-supplied names, tags, and paths.
4//!
5//! Prevents path traversal attacks and ensures safe filesystem operations.
6
7use anyhow::{bail, Result};
8
9/// Maximum length for project/model names.
10const MAX_NAME_LEN: usize = 128;
11
12/// Maximum length for tags.
13const MAX_TAG_LEN: usize = 64;
14
15/// Validate a project or model name.
16///
17/// Rejects names containing path separators, parent directory references,
18/// null bytes, or other characters that could escape the intended directory.
19///
20/// Valid pattern: `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`
21pub 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    // Must start with alphanumeric
45    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    // Only allow safe characters
51    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    // Reject Windows reserved names
58    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
71/// Validate a model tag.
72///
73/// Same rules as `validate_name` plus rejects ':' (used as name:tag separator)
74/// and has a shorter maximum length.
75pub 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    // Delegate the rest to validate_name (same character rules)
86    validate_name(tag)
87}
88
89/// Validate that a path does not escape the current working directory.
90///
91/// Used for `zernel init` to prevent creating projects in arbitrary locations.
92pub fn validate_project_path(name: &str) -> Result<()> {
93    validate_name(name)?;
94
95    // Additional check: the resolved path must not be absolute
96    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}