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    /// A `project_key` longer than [`MAX_PROJECT_KEY_BYTES`].
23    #[error("project_key must be at most 4096 bytes")]
24    ProjectKeyTooLong,
25    /// A `file_path` longer than [`MAX_FILE_PATH_BYTES`].
26    #[error("file_path must be at most 4096 bytes")]
27    FilePathTooLong,
28    /// A `base_sha256` that is not a SHA-256 in hex.
29    #[error("base_sha256 must be 64 hexadecimal characters")]
30    BaseSha256,
31}
32
33/// The longest `project_key` either side accepts, in bytes: `PATH_MAX`, so
34/// a key derived from a checkout's path always fits, while every push and
35/// pull's audit leaf, which names its project, stays small.
36pub const MAX_PROJECT_KEY_BYTES: usize = 4096;
37
38/// The longest `file_path` either side accepts, in bytes: `PATH_MAX`, for
39/// the same two reasons.
40pub const MAX_FILE_PATH_BYTES: usize = 4096;
41
42/// Enforces that a `project_key` is present and no longer than
43/// [`MAX_PROJECT_KEY_BYTES`].
44pub 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
54/// Enforces that a `base_sha256` is what [`crate::content_sha256`] writes: 64
55/// hex digits. Either case is accepted, as the server compares them without
56/// regard to case.
57pub 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
65/// Enforces that a `file_path` is safe to join onto a memory directory on
66/// any machine that later pulls it.
67///
68/// A pulled file is written to disk by whoever fetches it, so a bad path
69/// here is not merely invalid data — it is a write outside the memory
70/// directory on someone else's machine. Hence checking on the way in
71/// (server) as well as on the way out (client).
72///
73/// Rejection is per-segment, not by substring: a filename like `..config.md`
74/// is perfectly legitimate and must not be caught, while an `a/../../b`
75/// segment must be. (The Node server used a substring check and wrongly
76/// rejected the former.)
77///
78/// ```
79/// # use recall_wire::{validate_file_path, ValidationError};
80/// assert!(validate_file_path("topics/auth/tokens.md").is_ok());
81/// assert!(validate_file_path("..config.md").is_ok());
82/// assert_eq!(
83///     validate_file_path("../outside.md"),
84///     Err(ValidationError::FilePathTraversal),
85/// );
86/// ```
87pub 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    // A Windows drive prefix ("C:...") is absolute too, and anything that
98    // later joins this path would treat it that way.
99    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            // Leading dots in a filename are not traversal. The Node server's
120            // substring check wrongly rejected this.
121            "..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}