Skip to main content

wire/
scope_match.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Segment-aware scope containment for namespace-tree access checks.
3//!
4//! This is the single authorization primitive for "is `candidate` inside the
5//! namespace tree rooted at `scope`?". Callers MUST NOT reimplement this with
6//! string prefix matching: `candidate.starts_with(&format!("{}/", scope))`
7//! treats `..` segments literally, so `a/b/../c` passes a check against scope
8//! `a/b` and then normalizes to the *sibling* `a/c` — a check-then-normalize
9//! bypass (see heddle#631).
10
11/// Whether `candidate` is the scope namespace itself or a descendant of it,
12/// compared whole-segment by whole-segment.
13///
14/// Hardening rules (deny-by-default):
15/// - Any `.` or `..` segment in either string is rejected outright. We never
16///   normalize traversal segments — a path that needs normalizing is denied.
17/// - Empty segments (`a//b`, leading/trailing `/`, or the empty string) are
18///   rejected outright, so `/`-boundary tricks cannot smuggle segments past
19///   the comparison.
20/// - Comparison is per-segment, so scope `a/b` does NOT match `a/bc` (the
21///   classic non-boundary prefix bug) and never grants upward access (scope
22///   `a/b` does not match `a`).
23pub fn scope_contains(scope: &str, candidate: &str) -> bool {
24    let Some(scope_segments) = well_formed_segments(scope) else {
25        return false;
26    };
27    let Some(candidate_segments) = well_formed_segments(candidate) else {
28        return false;
29    };
30
31    candidate_segments.len() >= scope_segments.len()
32        && scope_segments == candidate_segments[..scope_segments.len()]
33}
34
35/// Splits `path` on `/`, returning `None` if any segment is empty, `.`,
36/// or `..` (including the empty string itself).
37fn well_formed_segments(path: &str) -> Option<Vec<&str>> {
38    let segments: Vec<&str> = path.split('/').collect();
39    if segments
40        .iter()
41        .any(|segment| segment.is_empty() || *segment == "." || *segment == "..")
42    {
43        return None;
44    }
45    Some(segments)
46}
47
48#[cfg(test)]
49mod tests {
50    use super::scope_contains;
51
52    #[test]
53    fn exact_match_allowed() {
54        assert!(scope_contains("a/b", "a/b"));
55    }
56
57    #[test]
58    fn descendant_allowed() {
59        assert!(scope_contains("a/b", "a/b/c"));
60        assert!(scope_contains("a/b", "a/b/c/d"));
61    }
62
63    #[test]
64    fn dotdot_traversal_denied() {
65        // The heddle#631 bypass: matches the old prefix check, normalizes to
66        // the sibling a/c after it.
67        assert!(!scope_contains("a/b", "a/b/../c"));
68        assert!(!scope_contains("a/b", "a/b/.."));
69        assert!(!scope_contains("a/b", "a/b/c/../../b/c"));
70    }
71
72    #[test]
73    fn single_dot_denied() {
74        // We deny rather than normalize: no traversal-ish input is trusted.
75        assert!(!scope_contains("a/b", "a/b/./c"));
76        assert!(!scope_contains("a/b", "a/b/."));
77    }
78
79    #[test]
80    fn empty_segments_denied() {
81        assert!(!scope_contains("a/b", "a//b"));
82        assert!(!scope_contains("a/b", "a/b//c"));
83        assert!(!scope_contains("a/b", "a/b/"));
84        assert!(!scope_contains("a/b", "/a/b"));
85        assert!(!scope_contains("a/b", ""));
86    }
87
88    #[test]
89    fn sibling_denied() {
90        assert!(!scope_contains("a/b", "a/c"));
91    }
92
93    #[test]
94    fn upward_access_denied() {
95        assert!(!scope_contains("a/b", "a"));
96        assert!(!scope_contains("a/b/c", "a/b"));
97    }
98
99    #[test]
100    fn non_boundary_prefix_denied() {
101        // scope "a/b" must not match "a/bc" in either direction.
102        assert!(!scope_contains("a/b", "a/bc"));
103        assert!(!scope_contains("a/bc", "a/b"));
104    }
105
106    #[test]
107    fn malformed_scope_denies_everything() {
108        // A scope containing traversal/empty segments is misconfigured;
109        // deny rather than guess.
110        assert!(!scope_contains("a/..", "a"));
111        assert!(!scope_contains("a/../b", "a/../b"));
112        assert!(!scope_contains("a//b", "a/b/c"));
113        assert!(!scope_contains("", "a"));
114        assert!(!scope_contains("", ""));
115    }
116}