Skip to main content

elastik_core/
path.rs

1//! Canonical world path validation.
2//!
3//! Pure string functions: decide whether a canonical world name is valid and
4//! attach a human-readable rejection reason. No `Core`, no I/O, no adapter
5//! wire-path normalization.
6//!
7//! Wire shorthand such as `/foo` -> `home/foo` lives in binary adapters, not
8//! in the Engine library.
9
10/// Canonical Engine world namespaces.
11pub const NAMESPACE_PREFIXES: &[&str] = &[
12    "home", "tmp", "dev", "sys", "etc", "lib", "boot", "usr", "var",
13];
14
15/// Test-only copy of the adapter-side path canonicalizer, kept for legacy
16/// white-box tests in `lib.rs`.
17#[cfg(test)]
18pub(crate) fn canonicalize_path(p: &str) -> String {
19    let stripped = p.trim_start_matches('/');
20    let first = stripped.split('/').next().unwrap_or("");
21    if NAMESPACE_PREFIXES.contains(&first) || first == "proc" {
22        stripped.to_owned()
23    } else {
24        format!("home/{stripped}")
25    }
26}
27
28/// Boolean wrapper for callers that only need the yes/no answer (CoAP
29/// surface, top-level reject path). Prefer `validate_world_name` when
30/// the rejection reason matters — the bool form is documented to elide
31/// the reason and a 400 with a generic message.
32#[cfg(test)]
33pub(crate) fn valid_world_name(world_name: &str) -> bool {
34    validate_world_name(world_name).is_ok()
35}
36
37/// Returns the specific rejection reason so adapters can surface precise
38/// diagnostics instead of a blanket invalid-path error.
39pub fn validate_world_name(world_name: &str) -> Result<(), &'static str> {
40    if world_name.is_empty() {
41        return Err("world path is empty");
42    }
43    if is_reserved_world_name(world_name) {
44        return Err("world path is a reserved namespace root");
45    }
46    if world_name.contains('\\') {
47        return Err("world path contains backslash");
48    }
49    if world_name.chars().any(char::is_control) {
50        return Err("world path contains control bytes");
51    }
52    for segment in world_name.split('/') {
53        if segment.is_empty() {
54            return Err("world path has empty segment");
55        }
56        if is_dot_segment(segment) {
57            return Err("world path contains dot or encoded-dot segment");
58        }
59    }
60    Ok(())
61}
62
63/// True if the segment is one of `.`, `..`, `%2e`, `%2e.`, `%2e%2e`,
64/// `.%2e`, `%2E%2E`, etc. — anything an attacker might use to walk out
65/// of a namespace through URL encoding tricks. Decoded paths AND raw
66/// percent-encoded paths are both rejected.
67///
68/// Module-private: only `validate_world_name` calls it. Keeping it private
69/// prevents sibling modules from acquiring an accidental dependency on this
70/// internal helper.
71fn is_dot_segment(segment: &str) -> bool {
72    let Some(rest) = strip_dot_token(segment) else {
73        return false;
74    };
75    rest.is_empty()
76        || strip_dot_token(rest)
77            .map(|tail| tail.is_empty())
78            .unwrap_or(false)
79}
80
81/// Strip a leading `.` or case-insensitive `%2e` from a segment.
82/// Helper for `is_dot_segment`; returns the remaining slice or None
83/// if the segment doesn't begin with a dot token. Module-private.
84fn strip_dot_token(segment: &str) -> Option<&str> {
85    if let Some(rest) = segment.strip_prefix('.') {
86        return Some(rest);
87    }
88    if segment
89        .as_bytes()
90        .get(..3)
91        .is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"%2e"))
92    {
93        return Some(&segment[3..]);
94    }
95    None
96}
97
98/// Reserved namespace roots (no world named exactly `home`, `tmp`,
99/// etc.) and the entire `/proc/*` subtree (which is read-only
100/// introspection, not a world). Module-private: only
101/// `validate_world_name` calls it.
102fn is_reserved_world_name(world_name: &str) -> bool {
103    NAMESPACE_PREFIXES.contains(&world_name)
104        || matches!(world_name, "proc" | "var/log")
105        || world_name.starts_with("proc/")
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn canonicalize_preserves_explicit_namespaces() {
114        assert_eq!(canonicalize_path("/home/tmp/foo"), "home/tmp/foo");
115        assert_eq!(canonicalize_path("/home/etc/foo"), "home/etc/foo");
116        assert_eq!(canonicalize_path("/tmp/foo"), "tmp/foo");
117        assert_eq!(canonicalize_path("/etc/foo"), "etc/foo");
118        assert_eq!(canonicalize_path("/foo"), "home/foo");
119    }
120
121    #[test]
122    fn control_bytes_are_not_valid_world_names() {
123        assert!(valid_world_name("home/ok"));
124        assert!(!valid_world_name("home/bad\nname"));
125        assert!(!valid_world_name(""));
126    }
127
128    #[test]
129    fn dot_segments_empty_segments_and_backslashes_are_not_valid_world_names() {
130        assert!(!valid_world_name("home/../etc/secret"));
131        assert!(!valid_world_name("home/%2E%2E/etc/secret"));
132        assert!(!valid_world_name("home/./x"));
133        assert!(!valid_world_name("home//x"));
134        assert!(!valid_world_name("home/x/"));
135        assert!(!valid_world_name("home\\x"));
136        assert_eq!(
137            validate_world_name("home/%2E%2E/etc/secret"),
138            Err("world path contains dot or encoded-dot segment")
139        );
140        assert_eq!(
141            validate_world_name("home//x"),
142            Err("world path has empty segment")
143        );
144        assert_eq!(
145            validate_world_name("home\\x"),
146            Err("world path contains backslash")
147        );
148    }
149
150    #[test]
151    fn namespace_roots_and_proc_subtree_are_not_world_names() {
152        for name in [
153            "home",
154            "tmp",
155            "dev",
156            "sys",
157            "proc",
158            "proc/anything",
159            "etc",
160            "lib",
161            "boot",
162            "usr",
163            "var",
164            "var/log",
165        ] {
166            assert!(!valid_world_name(name), "{name}");
167        }
168        assert!(valid_world_name("home/x"));
169        assert!(valid_world_name("var/log/deletes"));
170    }
171}