#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum ValidationError {
#[error("project_key is required")]
MissingProjectKey,
#[error("file_path is required")]
MissingFilePath,
#[error("file_path must be relative")]
FilePathAbsolute,
#[error("file_path must not contain a .. segment")]
FilePathTraversal,
#[error("project_key must be at most 4096 bytes")]
ProjectKeyTooLong,
#[error("file_path must be at most 4096 bytes")]
FilePathTooLong,
#[error("base_sha256 must be 64 hexadecimal characters")]
BaseSha256,
}
pub const MAX_PROJECT_KEY_BYTES: usize = 4096;
pub const MAX_FILE_PATH_BYTES: usize = 4096;
pub fn validate_project_key(key: &str) -> Result<(), ValidationError> {
if key.is_empty() {
return Err(ValidationError::MissingProjectKey);
}
if key.len() > MAX_PROJECT_KEY_BYTES {
return Err(ValidationError::ProjectKeyTooLong);
}
Ok(())
}
pub fn validate_base_sha256(base: &str) -> Result<(), ValidationError> {
if base.len() == 64 && base.bytes().all(|b| b.is_ascii_hexdigit()) {
Ok(())
} else {
Err(ValidationError::BaseSha256)
}
}
pub fn validate_file_path(path: &str) -> Result<(), ValidationError> {
if path.is_empty() {
return Err(ValidationError::MissingFilePath);
}
if path.len() > MAX_FILE_PATH_BYTES {
return Err(ValidationError::FilePathTooLong);
}
if path.starts_with('/') || path.starts_with('\\') {
return Err(ValidationError::FilePathAbsolute);
}
if path.len() >= 2 && path.as_bytes()[1] == b':' {
return Err(ValidationError::FilePathAbsolute);
}
if path.split(['/', '\\']).any(|segment| segment == "..") {
return Err(ValidationError::FilePathTraversal);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_file_paths() {
for ok in [
"MEMORY.md",
"debugging.md",
"topics/auth/tokens.md",
".hidden.md",
"..config.md",
] {
assert!(validate_file_path(ok).is_ok(), "{ok} should be accepted");
}
for (path, want) in [
("", ValidationError::MissingFilePath),
("/etc/passwd", ValidationError::FilePathAbsolute),
("C:/Windows/system32", ValidationError::FilePathAbsolute),
(r"\etc\passwd", ValidationError::FilePathAbsolute),
("../outside.md", ValidationError::FilePathTraversal),
(
"topics/../../outside.md",
ValidationError::FilePathTraversal,
),
(
r"topics\..\..\outside.md",
ValidationError::FilePathTraversal,
),
("..", ValidationError::FilePathTraversal),
] {
assert_eq!(validate_file_path(path), Err(want), "for {path:?}");
}
let longest = "a".repeat(MAX_FILE_PATH_BYTES);
assert!(validate_file_path(&longest).is_ok());
assert_eq!(
validate_file_path(&format!("{longest}b")),
Err(ValidationError::FilePathTooLong)
);
}
#[test]
fn validates_project_keys_and_bases() {
assert!(validate_project_key("acme/app").is_ok());
assert!(validate_project_key(&"k".repeat(MAX_PROJECT_KEY_BYTES)).is_ok());
assert_eq!(
validate_project_key(""),
Err(ValidationError::MissingProjectKey)
);
assert_eq!(
validate_project_key(&"k".repeat(MAX_PROJECT_KEY_BYTES + 1)),
Err(ValidationError::ProjectKeyTooLong)
);
let base = crate::content_sha256("hello");
assert!(validate_base_sha256(&base).is_ok());
assert!(validate_base_sha256(&base.to_uppercase()).is_ok());
for bad in [
"",
"abc",
&base[1..],
&format!("{base}0"),
&base.replacen('a', "g", 1),
] {
assert_eq!(
validate_base_sha256(bad),
Err(ValidationError::BaseSha256),
"{bad:?}"
);
}
}
}