recall_wire/validate.rs
1//! The rules both halves apply to a request, so neither can drift from the
2//! other.
3
4/// Why a request was rejected.
5///
6/// Shared so the server (refusing a request) and the client (refusing to
7/// send one) act on the same reasons and produce the same wording.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
9pub enum ValidationError {
10 /// No `project_key`: there is nothing to file this under.
11 #[error("project_key is required")]
12 MissingProjectKey,
13 /// No `file_path`.
14 #[error("file_path is required")]
15 MissingFilePath,
16 /// An absolute path, including a Windows drive prefix.
17 #[error("file_path must be relative")]
18 FilePathAbsolute,
19 /// A `..` segment, which would escape the memory directory.
20 #[error("file_path must not contain a .. segment")]
21 FilePathTraversal,
22}
23
24/// Enforces that a `file_path` is safe to join onto a memory directory on
25/// any machine that later pulls it.
26///
27/// A pulled file is written to disk by whoever fetches it, so a bad path
28/// here is not merely invalid data — it is a write outside the memory
29/// directory on someone else's machine. Hence checking on the way in
30/// (server) as well as on the way out (client).
31///
32/// Rejection is per-segment, not by substring: a filename like `..config.md`
33/// is perfectly legitimate and must not be caught, while an `a/../../b`
34/// segment must be. (The Node server used a substring check and wrongly
35/// rejected the former.)
36///
37/// ```
38/// # use recall_wire::{validate_file_path, ValidationError};
39/// assert!(validate_file_path("topics/auth/tokens.md").is_ok());
40/// assert!(validate_file_path("..config.md").is_ok());
41/// assert_eq!(
42/// validate_file_path("../outside.md"),
43/// Err(ValidationError::FilePathTraversal),
44/// );
45/// ```
46pub fn validate_file_path(path: &str) -> Result<(), ValidationError> {
47 if path.is_empty() {
48 return Err(ValidationError::MissingFilePath);
49 }
50 if path.starts_with('/') || path.starts_with('\\') {
51 return Err(ValidationError::FilePathAbsolute);
52 }
53 // A Windows drive prefix ("C:...") is absolute too, and anything that
54 // later joins this path would treat it that way.
55 if path.len() >= 2 && path.as_bytes()[1] == b':' {
56 return Err(ValidationError::FilePathAbsolute);
57 }
58 if path.split(['/', '\\']).any(|segment| segment == "..") {
59 return Err(ValidationError::FilePathTraversal);
60 }
61 Ok(())
62}
63
64#[cfg(test)]
65mod tests {
66 use super::*;
67
68 #[test]
69 fn validates_file_paths() {
70 for ok in [
71 "MEMORY.md",
72 "debugging.md",
73 "topics/auth/tokens.md",
74 ".hidden.md",
75 // Leading dots in a filename are not traversal. The Node server's
76 // substring check wrongly rejected this.
77 "..config.md",
78 ] {
79 assert!(validate_file_path(ok).is_ok(), "{ok} should be accepted");
80 }
81
82 for (path, want) in [
83 ("", ValidationError::MissingFilePath),
84 ("/etc/passwd", ValidationError::FilePathAbsolute),
85 ("C:/Windows/system32", ValidationError::FilePathAbsolute),
86 (r"\etc\passwd", ValidationError::FilePathAbsolute),
87 ("../outside.md", ValidationError::FilePathTraversal),
88 (
89 "topics/../../outside.md",
90 ValidationError::FilePathTraversal,
91 ),
92 (
93 r"topics\..\..\outside.md",
94 ValidationError::FilePathTraversal,
95 ),
96 ("..", ValidationError::FilePathTraversal),
97 ] {
98 assert_eq!(validate_file_path(path), Err(want), "for {path:?}");
99 }
100 }
101}