Skip to main content

devboy_storage/
secret_path.rs

1//! Secret path validation per [ADR-020] §2.
2//!
3//! A *secret path* is the canonical name of a secret in the
4//! `devboy-tools` namespace. It is a `/`-separated sequence of
5//! lowercase kebab-case segments shaped as
6//! `<scope>/<provider>/<purpose>` (minimum three segments).
7//!
8//! The validator is the entry gate for every layer above the
9//! credential store — the manifest loader, the alias resolver, the
10//! source router. Drift in the namespace silently degrades into
11//! "every project invents its own pattern", so this module rejects
12//! non-conforming paths as a *hard error*.
13//!
14//! # Examples
15//!
16//! ```
17//! use devboy_storage::SecretPath;
18//!
19//! let p: SecretPath = "team/gitlab/token-deploy".parse().unwrap();
20//! assert_eq!(p.scope(), "team");
21//! assert_eq!(p.provider(), "gitlab");
22//! assert_eq!(p.purpose(), "token-deploy");
23//! assert!(!p.is_internal());
24//!
25//! assert!("gitlab.token".parse::<SecretPath>().is_err());
26//! assert!("team/gitlab".parse::<SecretPath>().is_err());
27//! assert!("__sources/vault/token".parse::<SecretPath>().is_err());
28//! ```
29//!
30//! [ADR-020]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-020-secret-manifest-and-alias-resolution.md
31
32use std::fmt;
33use std::str::FromStr;
34
35use serde::{Deserialize, Deserializer, Serialize, Serializer};
36use thiserror::Error;
37
38/// Reserved path prefixes that are rejected at user-facing entry points
39/// per ADR-020 §2.
40const RESERVED_INTERNAL_PREFIX: &str = "__";
41const RESERVED_TEST_PREFIX: &str = "_test";
42
43/// Failure modes when parsing a [`SecretPath`].
44///
45/// Each variant maps 1:1 onto a rule from ADR-020 §2 so callers can
46/// surface a precise message to the user (or to `doctor`).
47#[derive(Debug, Error, PartialEq, Eq, Clone)]
48pub enum PathError {
49    /// Fewer than three `/`-separated segments.
50    ///
51    /// Two-segment paths like `gitlab/token` are intentionally not
52    /// allowed: they have no scope and silently encourage cross-context
53    /// reuse of credentials that should be distinct.
54    #[error(
55        "secret path '{path}' has {found} segment(s); minimum 3 required (`<scope>/<provider>/<purpose>`)"
56    )]
57    TooFewSegments {
58        /// The path the caller offered.
59        path: String,
60        /// Number of segments found.
61        found: usize,
62    },
63
64    /// A segment was empty (e.g. `team//gitlab/token`).
65    #[error("secret path '{path}' contains an empty segment at position {index}")]
66    EmptySegment {
67        /// The path the caller offered.
68        path: String,
69        /// Zero-based segment index that was empty.
70        index: usize,
71    },
72
73    /// A segment did not match `[a-z][a-z0-9-]*`.
74    #[error(
75        "secret path '{path}' segment '{segment}' (position {index}) is not lowercase kebab-case (`[a-z][a-z0-9-]*`)"
76    )]
77    BadSegment {
78        /// The path the caller offered.
79        path: String,
80        /// The offending segment.
81        segment: String,
82        /// Zero-based index of the bad segment.
83        index: usize,
84    },
85
86    /// The first segment is one of the reserved framework prefixes
87    /// (`__*` or `_test`) and the path was offered through a
88    /// user-facing entry point.
89    #[error(
90        "secret path '{path}' uses reserved prefix '{prefix}'; that namespace is for framework-internal use"
91    )]
92    ReservedPrefix {
93        /// The path the caller offered.
94        path: String,
95        /// The reserved scope segment.
96        prefix: String,
97    },
98}
99
100/// A validated secret path.
101///
102/// Construct one through [`SecretPath::parse`] (or `"...".parse()` via
103/// [`FromStr`]). The wrapped string is guaranteed to satisfy every rule
104/// in ADR-020 §2 at construction time, and the type is the only way
105/// the rest of the framework consumes a path.
106#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
107pub struct SecretPath(String);
108
109impl SecretPath {
110    /// Validate `s` and return an owned [`SecretPath`].
111    ///
112    /// Equivalent to `s.parse::<SecretPath>()`.
113    pub fn parse(s: &str) -> Result<Self, PathError> {
114        validate(s)?;
115        Ok(Self(s.to_owned()))
116    }
117
118    /// Like [`SecretPath::parse`] but accepts internally-reserved
119    /// prefixes (`__*`, `_test`).
120    ///
121    /// This entry point is **only** for the framework's own
122    /// consumers — for example, the source-credential resolver in
123    /// ADR-021 §5 reading paths under `__sources/...`. Production
124    /// code that takes a path from the user must call
125    /// [`SecretPath::parse`] instead.
126    pub fn parse_internal(s: &str) -> Result<Self, PathError> {
127        validate_with(s, /* allow_reserved = */ true)?;
128        Ok(Self(s.to_owned()))
129    }
130
131    /// The full path, e.g. `"team/gitlab/token-deploy"`.
132    pub fn as_str(&self) -> &str {
133        &self.0
134    }
135
136    /// The first segment — the scope (e.g. `team`, `personal`,
137    /// `client-acme`, `sandbox`, or one of the reserved prefixes when
138    /// the path was constructed via [`SecretPath::parse_internal`]).
139    pub fn scope(&self) -> &str {
140        self.segment(0)
141    }
142
143    /// The second segment — usually a provider name
144    /// (`gitlab`, `github`, `openai`, ...).
145    pub fn provider(&self) -> &str {
146        self.segment(1)
147    }
148
149    /// The third segment — usually a per-credential purpose
150    /// (`token-deploy`, `pat`, `api-key`, ...).
151    ///
152    /// Paths longer than three segments preserve the rest as part of
153    /// the purpose string returned here (joined with `/`).
154    pub fn purpose(&self) -> &str {
155        let mut byte_offset = 0;
156        for (i, seg) in self.0.split('/').enumerate() {
157            if i < 2 {
158                byte_offset += seg.len() + 1; // segment + '/'
159            } else {
160                return &self.0[byte_offset..];
161            }
162        }
163        // Unreachable: validator guarantees ≥3 segments.
164        ""
165    }
166
167    /// Iterate over the path's `/`-separated segments.
168    pub fn segments(&self) -> impl Iterator<Item = &str> {
169        self.0.split('/')
170    }
171
172    /// `true` if the path is in one of the reserved framework
173    /// namespaces (`__*` or `_test/*`). User-facing surfaces hide
174    /// such paths by default; see ADR-020 §2 and ADR-021 §5.
175    pub fn is_internal(&self) -> bool {
176        is_reserved_prefix(self.scope())
177    }
178
179    fn segment(&self, idx: usize) -> &str {
180        self.0.split('/').nth(idx).unwrap_or("")
181    }
182}
183
184impl fmt::Display for SecretPath {
185    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186        f.write_str(&self.0)
187    }
188}
189
190impl FromStr for SecretPath {
191    type Err = PathError;
192
193    fn from_str(s: &str) -> Result<Self, Self::Err> {
194        Self::parse(s)
195    }
196}
197
198impl AsRef<str> for SecretPath {
199    fn as_ref(&self) -> &str {
200        &self.0
201    }
202}
203
204impl Serialize for SecretPath {
205    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
206        serializer.serialize_str(&self.0)
207    }
208}
209
210impl<'de> Deserialize<'de> for SecretPath {
211    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
212        let s = String::deserialize(deserializer)?;
213        SecretPath::parse(&s).map_err(serde::de::Error::custom)
214    }
215}
216
217// =============================================================================
218// Internal validation
219// =============================================================================
220
221fn validate(s: &str) -> Result<(), PathError> {
222    validate_with(s, /* allow_reserved = */ false)
223}
224
225fn validate_with(s: &str, allow_reserved: bool) -> Result<(), PathError> {
226    let segments: Vec<&str> = s.split('/').collect();
227
228    if segments.len() < 3 {
229        return Err(PathError::TooFewSegments {
230            path: s.to_owned(),
231            found: segments.len(),
232        });
233    }
234
235    // Empty-segment check runs first so the rest of the loop can rely on
236    // non-empty bytes when classifying each position.
237    for (idx, seg) in segments.iter().enumerate() {
238        if seg.is_empty() {
239            return Err(PathError::EmptySegment {
240                path: s.to_owned(),
241                index: idx,
242            });
243        }
244    }
245
246    // Position 0 is the scope. It is either one of the two well-formed
247    // reserved prefixes (`__<kebab>` or exactly `_test`) — admitted only
248    // through the `parse_internal` entry point — or a regular kebab
249    // segment. Reserved-classification has to run *before* the kebab
250    // check because reserved segments begin with `_`, which the kebab
251    // alphabet rejects.
252    let scope = segments[0];
253    if is_reserved_segment(scope) {
254        if !allow_reserved {
255            return Err(PathError::ReservedPrefix {
256                path: s.to_owned(),
257                prefix: scope.to_owned(),
258            });
259        }
260        // else: reserved scope is permitted; fall through to validate
261        // the remaining segments.
262    } else if !is_kebab_segment(scope) {
263        return Err(PathError::BadSegment {
264            path: s.to_owned(),
265            segment: scope.to_owned(),
266            index: 0,
267        });
268    }
269
270    for (idx, seg) in segments.iter().enumerate().skip(1) {
271        if !is_kebab_segment(seg) {
272            return Err(PathError::BadSegment {
273                path: s.to_owned(),
274                segment: (*seg).to_owned(),
275                index: idx,
276            });
277        }
278    }
279
280    Ok(())
281}
282
283/// Check `[a-z][a-z0-9-]*` without pulling in the `regex` crate. Hot
284/// path on every secret resolution; explicit ASCII byte loop is the
285/// cheapest implementation and keeps this crate dependency-light.
286fn is_kebab_segment(seg: &str) -> bool {
287    let bytes = seg.as_bytes();
288    if bytes.is_empty() {
289        return false;
290    }
291    if !bytes[0].is_ascii_lowercase() {
292        return false;
293    }
294    bytes
295        .iter()
296        .skip(1)
297        .all(|&b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
298}
299
300/// Loose check used by [`SecretPath::is_internal`] on already-validated
301/// paths. The strict per-form classifier is [`is_reserved_segment`].
302fn is_reserved_prefix(scope: &str) -> bool {
303    scope.starts_with(RESERVED_INTERNAL_PREFIX) || scope == RESERVED_TEST_PREFIX
304}
305
306/// Strict classifier for a *well-formed* reserved scope segment. Used at
307/// validation time to decide whether to apply [`PathError::ReservedPrefix`]
308/// (instead of the generic kebab-rejection) and whether the path is
309/// admissible through [`SecretPath::parse_internal`].
310fn is_reserved_segment(seg: &str) -> bool {
311    if seg == RESERVED_TEST_PREFIX {
312        return true;
313    }
314    if let Some(rest) = seg.strip_prefix(RESERVED_INTERNAL_PREFIX) {
315        return is_kebab_segment(rest);
316    }
317    false
318}
319
320// =============================================================================
321// Tests
322// =============================================================================
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    // -- Valid paths -----------------------------------------------------------
329
330    #[test]
331    fn accepts_three_segment_path() {
332        let p = SecretPath::parse("team/gitlab/token-deploy").unwrap();
333        assert_eq!(p.as_str(), "team/gitlab/token-deploy");
334        assert_eq!(p.scope(), "team");
335        assert_eq!(p.provider(), "gitlab");
336        assert_eq!(p.purpose(), "token-deploy");
337        assert!(!p.is_internal());
338    }
339
340    #[test]
341    fn accepts_canonical_examples_from_adr_020() {
342        for s in [
343            "team/gitlab/token-deploy",
344            "team/openai/api-key",
345            "personal/github/pat",
346            "personal/anthropic/api-key",
347            "client-acme/jira/api-key",
348            "sandbox/example-provider/token",
349        ] {
350            assert!(SecretPath::parse(s).is_ok(), "{s} should parse");
351        }
352    }
353
354    #[test]
355    fn accepts_paths_longer_than_three_segments_in_purpose() {
356        let p = SecretPath::parse("team/gitlab/ci/deploy/token").unwrap();
357        assert_eq!(p.scope(), "team");
358        assert_eq!(p.provider(), "gitlab");
359        assert_eq!(p.purpose(), "ci/deploy/token");
360        assert_eq!(p.segments().count(), 5);
361    }
362
363    #[test]
364    fn parse_via_fromstr_trait() {
365        let p: SecretPath = "personal/github/pat".parse().unwrap();
366        assert_eq!(p.as_str(), "personal/github/pat");
367    }
368
369    // -- TooFewSegments --------------------------------------------------------
370
371    #[test]
372    fn rejects_single_segment() {
373        match SecretPath::parse("token") {
374            Err(PathError::TooFewSegments { found, .. }) => assert_eq!(found, 1),
375            other => panic!("expected TooFewSegments, got {other:?}"),
376        }
377    }
378
379    #[test]
380    fn rejects_two_segments() {
381        let err = SecretPath::parse("team/gitlab").unwrap_err();
382        assert!(matches!(err, PathError::TooFewSegments { found: 2, .. }));
383    }
384
385    #[test]
386    fn rejects_dot_separator_as_too_few_segments() {
387        // `gitlab.token` has no `/`, so it parses as a single segment.
388        let err = SecretPath::parse("gitlab.token").unwrap_err();
389        assert!(matches!(err, PathError::TooFewSegments { found: 1, .. }));
390    }
391
392    // -- EmptySegment ----------------------------------------------------------
393
394    #[test]
395    fn rejects_empty_middle_segment() {
396        let err = SecretPath::parse("team//gitlab/token").unwrap_err();
397        match err {
398            PathError::EmptySegment { index, .. } => assert_eq!(index, 1),
399            other => panic!("expected EmptySegment, got {other:?}"),
400        }
401    }
402
403    #[test]
404    fn rejects_empty_leading_segment() {
405        let err = SecretPath::parse("/gitlab/token/deploy").unwrap_err();
406        assert!(matches!(err, PathError::EmptySegment { index: 0, .. }));
407    }
408
409    #[test]
410    fn rejects_empty_trailing_segment() {
411        let err = SecretPath::parse("team/gitlab/token/").unwrap_err();
412        // 4 segments, last empty (index 3).
413        assert!(matches!(err, PathError::EmptySegment { index: 3, .. }));
414    }
415
416    // -- BadSegment ------------------------------------------------------------
417
418    #[test]
419    fn rejects_uppercase_segment() {
420        let err = SecretPath::parse("Team/gitlab/token").unwrap_err();
421        match err {
422            PathError::BadSegment { segment, index, .. } => {
423                assert_eq!(segment, "Team");
424                assert_eq!(index, 0);
425            }
426            other => panic!("expected BadSegment, got {other:?}"),
427        }
428    }
429
430    #[test]
431    fn rejects_kebab_starting_with_digit() {
432        let err = SecretPath::parse("team/9gitlab/token").unwrap_err();
433        assert!(matches!(err, PathError::BadSegment { index: 1, .. }));
434    }
435
436    #[test]
437    fn rejects_kebab_starting_with_dash() {
438        let err = SecretPath::parse("team/-gitlab/token").unwrap_err();
439        assert!(matches!(err, PathError::BadSegment { index: 1, .. }));
440    }
441
442    #[test]
443    fn rejects_segment_with_underscore() {
444        let err = SecretPath::parse("team/gitlab/token_deploy").unwrap_err();
445        assert!(matches!(err, PathError::BadSegment { index: 2, .. }));
446    }
447
448    #[test]
449    fn rejects_segment_with_dot() {
450        let err = SecretPath::parse("team/gitlab.token/deploy").unwrap_err();
451        assert!(matches!(err, PathError::BadSegment { index: 1, .. }));
452    }
453
454    // -- ReservedPrefix --------------------------------------------------------
455
456    #[test]
457    fn rejects_double_underscore_prefix() {
458        let err = SecretPath::parse("__sources/vault-a/token").unwrap_err();
459        match err {
460            PathError::ReservedPrefix { prefix, .. } => {
461                assert_eq!(prefix, "__sources");
462            }
463            other => panic!("expected ReservedPrefix, got {other:?}"),
464        }
465    }
466
467    #[test]
468    fn rejects_underscore_test_prefix() {
469        let err = SecretPath::parse("_test/foo/bar").unwrap_err();
470        assert!(matches!(err, PathError::ReservedPrefix { .. }));
471    }
472
473    #[test]
474    fn parse_internal_accepts_double_underscore_prefix() {
475        let p = SecretPath::parse_internal("__sources/vault-team/deploy").unwrap();
476        assert_eq!(p.scope(), "__sources");
477        assert!(p.is_internal());
478    }
479
480    #[test]
481    fn parse_internal_accepts_test_prefix() {
482        let p = SecretPath::parse_internal("_test/example/secret").unwrap();
483        assert!(p.is_internal());
484    }
485
486    #[test]
487    fn parse_internal_still_rejects_bad_segments() {
488        // Reserved prefix is allowed, but the rest of the path still has to be
489        // valid kebab-case.
490        let err = SecretPath::parse_internal("__sources/Vault/token").unwrap_err();
491        assert!(matches!(err, PathError::BadSegment { index: 1, .. }));
492    }
493
494    // -- Display / accessors ---------------------------------------------------
495
496    #[test]
497    fn display_returns_full_path() {
498        let p = SecretPath::parse("team/gitlab/token-deploy").unwrap();
499        assert_eq!(format!("{p}"), "team/gitlab/token-deploy");
500    }
501
502    #[test]
503    fn segments_iter_returns_each_part() {
504        let p = SecretPath::parse("team/gitlab/token-deploy").unwrap();
505        let segs: Vec<_> = p.segments().collect();
506        assert_eq!(segs, vec!["team", "gitlab", "token-deploy"]);
507    }
508
509    #[test]
510    fn as_ref_str_works() {
511        let p = SecretPath::parse("team/gitlab/token-deploy").unwrap();
512        let s: &str = p.as_ref();
513        assert_eq!(s, "team/gitlab/token-deploy");
514    }
515}