Skip to main content

vta_sdk/
context_path.rs

1//! Hierarchical trust-context paths — the security foundation for
2//! folder/sub-folder contexts.
3//!
4//! A context identifier **is** its materialized path: slash-separated segments,
5//! e.g. `acme/eng/team-a`. The server's authorization gate decides "admin of a
6//! parent → access to descendants" with [`is_ancestor_or_self`] — a **pure,
7//! store-free** segment comparison over data already in the verified JWT.
8//!
9//! ## Why this lives in the SDK
10//! Path construction (`child_path`) and validation (`validate_context_path`)
11//! are the canonical rules for what a context path may contain. A **client**
12//! that derives a sub-context id (e.g. a community's `<top>/<slug>`) must apply
13//! the *same* rules so it only ever sends paths the VTA will accept. Keeping
14//! them here lets clients share the one source of truth instead of mirroring it
15//! and drifting on security-relevant logic. The server re-exports these through
16//! `vti_common::context_path`.
17//!
18//! ## The one footgun, handled
19//! A raw `str::starts_with` is **wrong**: `acme` would "contain" `acme-evil`.
20//! Ancestry here is **segment-aware**, and `/` is the *only* separator — it
21//! cannot appear inside a segment (each segment is a
22//! [`validate_identifier`](crate::identifier::validate_identifier) value), so
23//! there is no `..` / slash-injection / empty-segment aliasing.
24
25use crate::identifier::{ValidationError, validate_identifier};
26
27/// Maximum nesting depth (number of path segments). Bounds derivation depth and
28/// keeps ancestry checks cheap; deep trees are an anti-pattern, not a need.
29pub const MAX_CONTEXT_DEPTH: usize = 8;
30
31/// The path separator. A context identifier is segments joined by this; it never
32/// appears inside a segment.
33pub const SEPARATOR: char = '/';
34
35/// Validate a context path: non-empty, ≤ [`MAX_CONTEXT_DEPTH`] segments, every
36/// segment a valid identifier, and no empty / leading / trailing / doubled
37/// separators.
38pub fn validate_context_path(value: &str) -> Result<(), ValidationError> {
39    if value.is_empty() {
40        return Err(ValidationError("context path must not be empty".into()));
41    }
42    if value.starts_with(SEPARATOR) || value.ends_with(SEPARATOR) {
43        return Err(ValidationError(format!(
44            "context path must not start or end with '{SEPARATOR}'"
45        )));
46    }
47
48    let segments: Vec<&str> = value.split(SEPARATOR).collect();
49    if segments.len() > MAX_CONTEXT_DEPTH {
50        return Err(ValidationError(format!(
51            "context path is {} levels deep; maximum is {MAX_CONTEXT_DEPTH}",
52            segments.len()
53        )));
54    }
55    for segment in &segments {
56        // An empty segment means a leading/trailing/doubled separator — `split`
57        // yields `""` for each. (The leading/trailing case is caught above; this
58        // catches `a//b`.)
59        if segment.is_empty() {
60            return Err(ValidationError(
61                "context path must not contain an empty segment ('//')".into(),
62            ));
63        }
64        validate_identifier("context path segment", segment)?;
65    }
66    Ok(())
67}
68
69/// Split a path into its segments. The path is assumed
70/// [validated](validate_context_path); for an arbitrary string this still
71/// returns the slash-split parts.
72fn segments(path: &str) -> impl Iterator<Item = &str> {
73    path.split(SEPARATOR)
74}
75
76/// Whether `ancestor` is `descendant` itself or an ancestor of it — the test the
77/// ACL gate uses for "admin of a parent context covers the subtree".
78///
79/// **Segment-aware:** `descendant`'s segments must *begin with* `ancestor`'s
80/// segments, segment-for-segment. So `acme` is an ancestor of `acme/eng` but
81/// **not** of `acme-evil`, and `acme/eng` is not an ancestor of `acme/engineering`.
82///
83/// Inputs are compared as-is; callers gate creation through
84/// [`validate_context_path`], so malformed paths simply fail to match.
85pub fn is_ancestor_or_self(ancestor: &str, descendant: &str) -> bool {
86    // Empty strings never participate (an empty `allowed_contexts` entry must
87    // not grant access; super-admin is handled separately by the gate).
88    if ancestor.is_empty() || descendant.is_empty() {
89        return false;
90    }
91    let mut anc = segments(ancestor);
92    let mut desc = segments(descendant);
93    loop {
94        match anc.next() {
95            // Ancestor exhausted: descendant began with all of it → ancestor-or-self.
96            None => return true,
97            Some(a) => match desc.next() {
98                // Descendant ran out first, or a segment differs → not an ancestor.
99                None => return false,
100                Some(d) if a != d => return false,
101                Some(_) => continue,
102            },
103        }
104    }
105}
106
107/// The parent path (one segment shorter), or `None` for a top-level (single
108/// segment) path.
109pub fn parent_path(path: &str) -> Option<&str> {
110    path.rsplit_once(SEPARATOR).map(|(parent, _)| parent)
111}
112
113/// The depth (segment count) of a path. A top-level context is depth 1.
114pub fn depth(path: &str) -> usize {
115    if path.is_empty() {
116        return 0;
117    }
118    path.split(SEPARATOR).count()
119}
120
121/// Build a child path under `parent` by appending a single `segment`. The
122/// `segment` must be one valid identifier — it cannot itself contain a separator
123/// (else it would silently add *several* levels) — and the resulting path must
124/// validate (depth included).
125pub fn child_path(parent: &str, segment: &str) -> Result<String, ValidationError> {
126    // Reject a `segment` that is empty or contains the separator: `child_path`
127    // adds exactly one level.
128    validate_identifier("context path segment", segment)?;
129    let candidate = format!("{parent}{SEPARATOR}{segment}");
130    validate_context_path(&candidate)?;
131    Ok(candidate)
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn validates_good_paths() {
140        for p in ["acme", "acme/eng", "acme/eng/team-a", "a.b_c/d-e", "x/y/z"] {
141            assert!(validate_context_path(p).is_ok(), "{p} should be valid");
142        }
143    }
144
145    #[test]
146    fn rejects_malformed_paths() {
147        assert!(validate_context_path("").is_err()); // empty
148        assert!(validate_context_path("/acme").is_err()); // leading separator
149        assert!(validate_context_path("acme/").is_err()); // trailing separator
150        assert!(validate_context_path("acme//eng").is_err()); // doubled separator
151        assert!(validate_context_path("acme/ev il").is_err()); // space in a segment
152        // `..` is a *legal* segment name (only alnum/`.`/`_`/`-`), but it can't
153        // escape anything: `/` is the sole separator and can't appear in a
154        // segment, so `..` is just a literal child named "..". The dangerous
155        // forms (slash / empty-segment injection) above are all rejected.
156        assert!(validate_context_path("acme/..").is_ok());
157    }
158
159    #[test]
160    fn enforces_max_depth() {
161        let deep = (0..=MAX_CONTEXT_DEPTH)
162            .map(|i| format!("s{i}"))
163            .collect::<Vec<_>>()
164            .join("/");
165        assert!(
166            validate_context_path(&deep).is_err(),
167            "{deep} exceeds max depth"
168        );
169        let ok = (0..MAX_CONTEXT_DEPTH)
170            .map(|i| format!("s{i}"))
171            .collect::<Vec<_>>()
172            .join("/");
173        assert!(validate_context_path(&ok).is_ok());
174    }
175
176    #[test]
177    fn ancestry_is_segment_aware_not_string_prefix() {
178        // Self.
179        assert!(is_ancestor_or_self("acme", "acme"));
180        assert!(is_ancestor_or_self("acme/eng", "acme/eng"));
181        // True ancestry.
182        assert!(is_ancestor_or_self("acme", "acme/eng"));
183        assert!(is_ancestor_or_self("acme", "acme/eng/team-a"));
184        assert!(is_ancestor_or_self("acme/eng", "acme/eng/team-a"));
185        // The prefix-confusion attack: string-prefix but NOT a segment ancestor.
186        assert!(!is_ancestor_or_self("acme", "acme-evil"));
187        assert!(!is_ancestor_or_self("acme/eng", "acme/engineering"));
188        assert!(!is_ancestor_or_self("ac", "acme"));
189        // Descendant is shorter / a sibling.
190        assert!(!is_ancestor_or_self("acme/eng", "acme"));
191        assert!(!is_ancestor_or_self("acme/eng", "acme/ops"));
192        // Empty never matches.
193        assert!(!is_ancestor_or_self("", "acme"));
194        assert!(!is_ancestor_or_self("acme", ""));
195    }
196
197    #[test]
198    fn parent_and_depth() {
199        assert_eq!(parent_path("acme"), None);
200        assert_eq!(parent_path("acme/eng"), Some("acme"));
201        assert_eq!(parent_path("acme/eng/team-a"), Some("acme/eng"));
202        assert_eq!(depth("acme"), 1);
203        assert_eq!(depth("acme/eng/team-a"), 3);
204        assert_eq!(depth(""), 0);
205    }
206
207    #[test]
208    fn child_path_builds_and_validates() {
209        assert_eq!(child_path("acme", "eng").unwrap(), "acme/eng");
210        assert!(child_path("acme", "ev/il").is_err()); // separator in the new segment
211        assert!(child_path("acme", "").is_err()); // empty segment
212    }
213}