#[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,
}
pub fn validate_file_path(path: &str) -> Result<(), ValidationError> {
if path.is_empty() {
return Err(ValidationError::MissingFilePath);
}
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:?}");
}
}
}