Skip to main content

vti_common/
context_path.rs

1//! Server-side re-export of the canonical context-path validators.
2//!
3//! Hierarchical trust-context paths are the security foundation for
4//! folder/sub-folder contexts (`docs/05-design-notes/hierarchical-contexts.md`).
5//! A context identifier **is** its materialized path: slash-separated segments,
6//! e.g. `acme/eng/team-a`. The authorization gate ([`crate::auth`]'s
7//! `has_context_access`) decides "admin of a parent → access to descendants"
8//! with [`is_ancestor_or_self`] — a **pure, store-free** segment comparison
9//! over data already in the verified JWT.
10//!
11//! ## Why this lives in the SDK now
12//! The pure construction/validation rules moved down into
13//! [`vta_sdk::context_path`] so **clients** can derive sub-context ids under
14//! the *same* rules the VTA enforces, without depending on this server-only
15//! crate (see issue #392). This module re-exports the pure helpers verbatim and
16//! wraps the two fallible constructors so they keep returning [`AppError`] for
17//! the server's `?`-ergonomics; the [`ValidationError`](vta_sdk::identifier::ValidationError)
18//! message is preserved verbatim.
19//!
20//! ## The one footgun, handled
21//! A raw `str::starts_with` is **wrong**: `acme` would "contain" `acme-evil`.
22//! Ancestry here is **segment-aware**, and `/` is the *only* separator — it
23//! cannot appear inside a segment (each segment is a
24//! [`validate_identifier`](crate::identifier::validate_identifier) value), so
25//! there is no `..` / slash-injection / empty-segment aliasing.
26
27use crate::error::AppError;
28
29// Pure helpers re-exported unchanged — clients make no authz decisions, but the
30// server's ACL gate and callers consume these by their existing paths.
31pub use vta_sdk::context_path::{
32    MAX_CONTEXT_DEPTH, SEPARATOR, depth, is_ancestor_or_self, parent_path,
33};
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.
38///
39/// Thin wrapper over [`vta_sdk::context_path::validate_context_path`] mapping
40/// the SDK error onto [`AppError::Validation`] (message preserved verbatim).
41pub fn validate_context_path(value: &str) -> Result<(), AppError> {
42    vta_sdk::context_path::validate_context_path(value).map_err(|e| AppError::Validation(e.0))
43}
44
45/// Build a child path under `parent` by appending a single `segment`. The
46/// `segment` must be one valid identifier — it cannot itself contain a separator
47/// (else it would silently add *several* levels) — and the resulting path must
48/// validate (depth included).
49///
50/// Thin wrapper over [`vta_sdk::context_path::child_path`] mapping the SDK error
51/// onto [`AppError::Validation`] (message preserved verbatim).
52pub fn child_path(parent: &str, segment: &str) -> Result<String, AppError> {
53    vta_sdk::context_path::child_path(parent, segment).map_err(|e| AppError::Validation(e.0))
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59
60    #[test]
61    fn validates_good_paths() {
62        for p in ["acme", "acme/eng", "acme/eng/team-a", "a.b_c/d-e", "x/y/z"] {
63            assert!(validate_context_path(p).is_ok(), "{p} should be valid");
64        }
65    }
66
67    #[test]
68    fn rejects_malformed_paths() {
69        assert!(validate_context_path("").is_err()); // empty
70        assert!(validate_context_path("/acme").is_err()); // leading separator
71        assert!(validate_context_path("acme/").is_err()); // trailing separator
72        assert!(validate_context_path("acme//eng").is_err()); // doubled separator
73        assert!(validate_context_path("acme/ev il").is_err()); // space in a segment
74        // `..` is a *legal* segment name (only alnum/`.`/`_`/`-`), but it can't
75        // escape anything: `/` is the sole separator and can't appear in a
76        // segment, so `..` is just a literal child named "..". The dangerous
77        // forms (slash / empty-segment injection) above are all rejected.
78        assert!(validate_context_path("acme/..").is_ok());
79    }
80
81    #[test]
82    fn enforces_max_depth() {
83        let deep = (0..=MAX_CONTEXT_DEPTH)
84            .map(|i| format!("s{i}"))
85            .collect::<Vec<_>>()
86            .join("/");
87        assert!(
88            validate_context_path(&deep).is_err(),
89            "{deep} exceeds max depth"
90        );
91        let ok = (0..MAX_CONTEXT_DEPTH)
92            .map(|i| format!("s{i}"))
93            .collect::<Vec<_>>()
94            .join("/");
95        assert!(validate_context_path(&ok).is_ok());
96    }
97
98    #[test]
99    fn ancestry_is_segment_aware_not_string_prefix() {
100        // Self.
101        assert!(is_ancestor_or_self("acme", "acme"));
102        assert!(is_ancestor_or_self("acme/eng", "acme/eng"));
103        // True ancestry.
104        assert!(is_ancestor_or_self("acme", "acme/eng"));
105        assert!(is_ancestor_or_self("acme", "acme/eng/team-a"));
106        assert!(is_ancestor_or_self("acme/eng", "acme/eng/team-a"));
107        // The prefix-confusion attack: string-prefix but NOT a segment ancestor.
108        assert!(!is_ancestor_or_self("acme", "acme-evil"));
109        assert!(!is_ancestor_or_self("acme/eng", "acme/engineering"));
110        assert!(!is_ancestor_or_self("ac", "acme"));
111        // Descendant is shorter / a sibling.
112        assert!(!is_ancestor_or_self("acme/eng", "acme"));
113        assert!(!is_ancestor_or_self("acme/eng", "acme/ops"));
114        // Empty never matches.
115        assert!(!is_ancestor_or_self("", "acme"));
116        assert!(!is_ancestor_or_self("acme", ""));
117    }
118
119    #[test]
120    fn parent_and_depth() {
121        assert_eq!(parent_path("acme"), None);
122        assert_eq!(parent_path("acme/eng"), Some("acme"));
123        assert_eq!(parent_path("acme/eng/team-a"), Some("acme/eng"));
124        assert_eq!(depth("acme"), 1);
125        assert_eq!(depth("acme/eng/team-a"), 3);
126        assert_eq!(depth(""), 0);
127    }
128
129    #[test]
130    fn child_path_builds_and_validates() {
131        assert_eq!(child_path("acme", "eng").unwrap(), "acme/eng");
132        assert!(child_path("acme", "ev/il").is_err()); // separator in the new segment
133        assert!(child_path("acme", "").is_err()); // empty segment
134    }
135}