1#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
9pub enum ValidationError {
10 #[error("project_key is required")]
12 MissingProjectKey,
13 #[error("file_path is required")]
15 MissingFilePath,
16 #[error("file_path must be relative")]
18 FilePathAbsolute,
19 #[error("file_path must not contain a .. segment")]
21 FilePathTraversal,
22 #[error("project_key must be at most 4096 bytes")]
24 ProjectKeyTooLong,
25 #[error("file_path must be at most 4096 bytes")]
27 FilePathTooLong,
28 #[error("base_sha256 must be 64 hexadecimal characters")]
30 BaseSha256,
31}
32
33pub const MAX_PROJECT_KEY_BYTES: usize = 4096;
37
38pub const MAX_FILE_PATH_BYTES: usize = 4096;
41
42pub fn validate_project_key(key: &str) -> Result<(), ValidationError> {
45 if key.is_empty() {
46 return Err(ValidationError::MissingProjectKey);
47 }
48 if key.len() > MAX_PROJECT_KEY_BYTES {
49 return Err(ValidationError::ProjectKeyTooLong);
50 }
51 Ok(())
52}
53
54pub fn validate_base_sha256(base: &str) -> Result<(), ValidationError> {
58 if base.len() == 64 && base.bytes().all(|b| b.is_ascii_hexdigit()) {
59 Ok(())
60 } else {
61 Err(ValidationError::BaseSha256)
62 }
63}
64
65pub fn validate_file_path(path: &str) -> Result<(), ValidationError> {
88 if path.is_empty() {
89 return Err(ValidationError::MissingFilePath);
90 }
91 if path.len() > MAX_FILE_PATH_BYTES {
92 return Err(ValidationError::FilePathTooLong);
93 }
94 if path.starts_with('/') || path.starts_with('\\') {
95 return Err(ValidationError::FilePathAbsolute);
96 }
97 if path.len() >= 2 && path.as_bytes()[1] == b':' {
100 return Err(ValidationError::FilePathAbsolute);
101 }
102 if path.split(['/', '\\']).any(|segment| segment == "..") {
103 return Err(ValidationError::FilePathTraversal);
104 }
105 Ok(())
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 #[test]
113 fn validates_file_paths() {
114 for ok in [
115 "MEMORY.md",
116 "debugging.md",
117 "topics/auth/tokens.md",
118 ".hidden.md",
119 "..config.md",
122 ] {
123 assert!(validate_file_path(ok).is_ok(), "{ok} should be accepted");
124 }
125
126 for (path, want) in [
127 ("", ValidationError::MissingFilePath),
128 ("/etc/passwd", ValidationError::FilePathAbsolute),
129 ("C:/Windows/system32", ValidationError::FilePathAbsolute),
130 (r"\etc\passwd", ValidationError::FilePathAbsolute),
131 ("../outside.md", ValidationError::FilePathTraversal),
132 (
133 "topics/../../outside.md",
134 ValidationError::FilePathTraversal,
135 ),
136 (
137 r"topics\..\..\outside.md",
138 ValidationError::FilePathTraversal,
139 ),
140 ("..", ValidationError::FilePathTraversal),
141 ] {
142 assert_eq!(validate_file_path(path), Err(want), "for {path:?}");
143 }
144 let longest = "a".repeat(MAX_FILE_PATH_BYTES);
145 assert!(validate_file_path(&longest).is_ok());
146 assert_eq!(
147 validate_file_path(&format!("{longest}b")),
148 Err(ValidationError::FilePathTooLong)
149 );
150 }
151
152 #[test]
153 fn validates_project_keys_and_bases() {
154 assert!(validate_project_key("acme/app").is_ok());
155 assert!(validate_project_key(&"k".repeat(MAX_PROJECT_KEY_BYTES)).is_ok());
156 assert_eq!(
157 validate_project_key(""),
158 Err(ValidationError::MissingProjectKey)
159 );
160 assert_eq!(
161 validate_project_key(&"k".repeat(MAX_PROJECT_KEY_BYTES + 1)),
162 Err(ValidationError::ProjectKeyTooLong)
163 );
164
165 let base = crate::content_sha256("hello");
166 assert!(validate_base_sha256(&base).is_ok());
167 assert!(validate_base_sha256(&base.to_uppercase()).is_ok());
168 for bad in [
169 "",
170 "abc",
171 &base[1..],
172 &format!("{base}0"),
173 &base.replacen('a', "g", 1),
174 ] {
175 assert_eq!(
176 validate_base_sha256(bad),
177 Err(ValidationError::BaseSha256),
178 "{bad:?}"
179 );
180 }
181 }
182}