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}