Skip to main content

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}