Skip to main content

nodedb_types/
path_component.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Validation for names that become a single filesystem path component.
4//!
5//! Collection names, partition directory names, and segment file names all
6//! arrive from outside the process — SQL DDL, a Raft `InstallSnapshot` blob, a
7//! RESTORE payload — and are then joined onto a data directory. A name that is
8//! not a single plain component escapes that directory: `..` walks out of it,
9//! an embedded separator writes into a subtree the caller never named, and a
10//! leading `/` discards the base entirely (`Path::join` replaces the whole path
11//! on an absolute argument). Since the same names are also passed to
12//! `remove_dir_all`, an unchecked one deletes outside the data directory too.
13//!
14//! Every path builder that joins an externally supplied name calls
15//! [`is_plain_path_component`] first and raises its own domain error, so the
16//! rejection names the payload that carried the bad name.
17
18/// Whether `name` is safe to join onto a directory as exactly one component.
19///
20/// Accepts a non-empty name that is not `.` or `..`, carries no path separator
21/// (`/` on every platform, and `\` so a Unix-written name cannot escape when
22/// the same data directory is opened on Windows), no interior NUL, no drive
23/// prefix (`C:`), and no leading or trailing whitespace or dot that would make
24/// two distinct records collide in one directory entry.
25pub fn is_plain_path_component(name: &str) -> bool {
26    if name.is_empty() || name == "." || name == ".." {
27        return false;
28    }
29    if name.contains(['/', '\\', ':', '\0']) {
30        return false;
31    }
32    if name.starts_with(['.', ' ']) || name.ends_with(['.', ' ']) {
33        return false;
34    }
35    // A control character is never part of a legitimate name and turns log
36    // lines and error messages into terminal escape sequences.
37    !name.chars().any(char::is_control)
38}
39
40#[cfg(test)]
41mod tests {
42    use super::is_plain_path_component;
43
44    #[test]
45    fn accepts_ordinary_names() {
46        for name in [
47            "ts-20240101-000000_20240102-000000",
48            "partition.meta",
49            "users",
50            "col_1",
51            "a-b.c-d",
52        ] {
53            assert!(is_plain_path_component(name), "should accept {name:?}");
54        }
55    }
56
57    #[test]
58    fn rejects_traversal_and_separators() {
59        for name in [
60            "",
61            ".",
62            "..",
63            "../etc",
64            "..\\etc",
65            "a/b",
66            "a\\b",
67            "/abs",
68            "C:name",
69            ".hidden",
70            "trailing.",
71            " leading",
72            "trailing ",
73            "nul\0byte",
74            "esc\u{1b}[2J",
75            "new\nline",
76        ] {
77            assert!(!is_plain_path_component(name), "should reject {name:?}");
78        }
79    }
80}