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}