use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use thiserror::Error;
const RESERVED_INTERNAL_PREFIX: &str = "__";
const RESERVED_TEST_PREFIX: &str = "_test";
#[derive(Debug, Error, PartialEq, Eq, Clone)]
pub enum PathError {
#[error(
"secret path '{path}' has {found} segment(s); minimum 3 required (`<scope>/<provider>/<purpose>`)"
)]
TooFewSegments {
path: String,
found: usize,
},
#[error("secret path '{path}' contains an empty segment at position {index}")]
EmptySegment {
path: String,
index: usize,
},
#[error(
"secret path '{path}' segment '{segment}' (position {index}) is not lowercase kebab-case (`[a-z][a-z0-9-]*`)"
)]
BadSegment {
path: String,
segment: String,
index: usize,
},
#[error(
"secret path '{path}' uses reserved prefix '{prefix}'; that namespace is for framework-internal use"
)]
ReservedPrefix {
path: String,
prefix: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct SecretPath(String);
impl SecretPath {
pub fn parse(s: &str) -> Result<Self, PathError> {
validate(s)?;
Ok(Self(s.to_owned()))
}
pub fn parse_internal(s: &str) -> Result<Self, PathError> {
validate_with(s, true)?;
Ok(Self(s.to_owned()))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn scope(&self) -> &str {
self.segment(0)
}
pub fn provider(&self) -> &str {
self.segment(1)
}
pub fn purpose(&self) -> &str {
let mut byte_offset = 0;
for (i, seg) in self.0.split('/').enumerate() {
if i < 2 {
byte_offset += seg.len() + 1; } else {
return &self.0[byte_offset..];
}
}
""
}
pub fn segments(&self) -> impl Iterator<Item = &str> {
self.0.split('/')
}
pub fn is_internal(&self) -> bool {
is_reserved_prefix(self.scope())
}
fn segment(&self, idx: usize) -> &str {
self.0.split('/').nth(idx).unwrap_or("")
}
}
impl fmt::Display for SecretPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl FromStr for SecretPath {
type Err = PathError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl AsRef<str> for SecretPath {
fn as_ref(&self) -> &str {
&self.0
}
}
impl Serialize for SecretPath {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.0)
}
}
impl<'de> Deserialize<'de> for SecretPath {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
SecretPath::parse(&s).map_err(serde::de::Error::custom)
}
}
fn validate(s: &str) -> Result<(), PathError> {
validate_with(s, false)
}
fn validate_with(s: &str, allow_reserved: bool) -> Result<(), PathError> {
let segments: Vec<&str> = s.split('/').collect();
if segments.len() < 3 {
return Err(PathError::TooFewSegments {
path: s.to_owned(),
found: segments.len(),
});
}
for (idx, seg) in segments.iter().enumerate() {
if seg.is_empty() {
return Err(PathError::EmptySegment {
path: s.to_owned(),
index: idx,
});
}
}
let scope = segments[0];
if is_reserved_segment(scope) {
if !allow_reserved {
return Err(PathError::ReservedPrefix {
path: s.to_owned(),
prefix: scope.to_owned(),
});
}
} else if !is_kebab_segment(scope) {
return Err(PathError::BadSegment {
path: s.to_owned(),
segment: scope.to_owned(),
index: 0,
});
}
for (idx, seg) in segments.iter().enumerate().skip(1) {
if !is_kebab_segment(seg) {
return Err(PathError::BadSegment {
path: s.to_owned(),
segment: (*seg).to_owned(),
index: idx,
});
}
}
Ok(())
}
fn is_kebab_segment(seg: &str) -> bool {
let bytes = seg.as_bytes();
if bytes.is_empty() {
return false;
}
if !bytes[0].is_ascii_lowercase() {
return false;
}
bytes
.iter()
.skip(1)
.all(|&b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
fn is_reserved_prefix(scope: &str) -> bool {
scope.starts_with(RESERVED_INTERNAL_PREFIX) || scope == RESERVED_TEST_PREFIX
}
fn is_reserved_segment(seg: &str) -> bool {
if seg == RESERVED_TEST_PREFIX {
return true;
}
if let Some(rest) = seg.strip_prefix(RESERVED_INTERNAL_PREFIX) {
return is_kebab_segment(rest);
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_three_segment_path() {
let p = SecretPath::parse("team/gitlab/token-deploy").unwrap();
assert_eq!(p.as_str(), "team/gitlab/token-deploy");
assert_eq!(p.scope(), "team");
assert_eq!(p.provider(), "gitlab");
assert_eq!(p.purpose(), "token-deploy");
assert!(!p.is_internal());
}
#[test]
fn accepts_canonical_examples_from_adr_020() {
for s in [
"team/gitlab/token-deploy",
"team/openai/api-key",
"personal/github/pat",
"personal/anthropic/api-key",
"client-acme/jira/api-key",
"sandbox/example-provider/token",
] {
assert!(SecretPath::parse(s).is_ok(), "{s} should parse");
}
}
#[test]
fn accepts_paths_longer_than_three_segments_in_purpose() {
let p = SecretPath::parse("team/gitlab/ci/deploy/token").unwrap();
assert_eq!(p.scope(), "team");
assert_eq!(p.provider(), "gitlab");
assert_eq!(p.purpose(), "ci/deploy/token");
assert_eq!(p.segments().count(), 5);
}
#[test]
fn parse_via_fromstr_trait() {
let p: SecretPath = "personal/github/pat".parse().unwrap();
assert_eq!(p.as_str(), "personal/github/pat");
}
#[test]
fn rejects_single_segment() {
match SecretPath::parse("token") {
Err(PathError::TooFewSegments { found, .. }) => assert_eq!(found, 1),
other => panic!("expected TooFewSegments, got {other:?}"),
}
}
#[test]
fn rejects_two_segments() {
let err = SecretPath::parse("team/gitlab").unwrap_err();
assert!(matches!(err, PathError::TooFewSegments { found: 2, .. }));
}
#[test]
fn rejects_dot_separator_as_too_few_segments() {
let err = SecretPath::parse("gitlab.token").unwrap_err();
assert!(matches!(err, PathError::TooFewSegments { found: 1, .. }));
}
#[test]
fn rejects_empty_middle_segment() {
let err = SecretPath::parse("team//gitlab/token").unwrap_err();
match err {
PathError::EmptySegment { index, .. } => assert_eq!(index, 1),
other => panic!("expected EmptySegment, got {other:?}"),
}
}
#[test]
fn rejects_empty_leading_segment() {
let err = SecretPath::parse("/gitlab/token/deploy").unwrap_err();
assert!(matches!(err, PathError::EmptySegment { index: 0, .. }));
}
#[test]
fn rejects_empty_trailing_segment() {
let err = SecretPath::parse("team/gitlab/token/").unwrap_err();
assert!(matches!(err, PathError::EmptySegment { index: 3, .. }));
}
#[test]
fn rejects_uppercase_segment() {
let err = SecretPath::parse("Team/gitlab/token").unwrap_err();
match err {
PathError::BadSegment { segment, index, .. } => {
assert_eq!(segment, "Team");
assert_eq!(index, 0);
}
other => panic!("expected BadSegment, got {other:?}"),
}
}
#[test]
fn rejects_kebab_starting_with_digit() {
let err = SecretPath::parse("team/9gitlab/token").unwrap_err();
assert!(matches!(err, PathError::BadSegment { index: 1, .. }));
}
#[test]
fn rejects_kebab_starting_with_dash() {
let err = SecretPath::parse("team/-gitlab/token").unwrap_err();
assert!(matches!(err, PathError::BadSegment { index: 1, .. }));
}
#[test]
fn rejects_segment_with_underscore() {
let err = SecretPath::parse("team/gitlab/token_deploy").unwrap_err();
assert!(matches!(err, PathError::BadSegment { index: 2, .. }));
}
#[test]
fn rejects_segment_with_dot() {
let err = SecretPath::parse("team/gitlab.token/deploy").unwrap_err();
assert!(matches!(err, PathError::BadSegment { index: 1, .. }));
}
#[test]
fn rejects_double_underscore_prefix() {
let err = SecretPath::parse("__sources/vault-a/token").unwrap_err();
match err {
PathError::ReservedPrefix { prefix, .. } => {
assert_eq!(prefix, "__sources");
}
other => panic!("expected ReservedPrefix, got {other:?}"),
}
}
#[test]
fn rejects_underscore_test_prefix() {
let err = SecretPath::parse("_test/foo/bar").unwrap_err();
assert!(matches!(err, PathError::ReservedPrefix { .. }));
}
#[test]
fn parse_internal_accepts_double_underscore_prefix() {
let p = SecretPath::parse_internal("__sources/vault-team/deploy").unwrap();
assert_eq!(p.scope(), "__sources");
assert!(p.is_internal());
}
#[test]
fn parse_internal_accepts_test_prefix() {
let p = SecretPath::parse_internal("_test/example/secret").unwrap();
assert!(p.is_internal());
}
#[test]
fn parse_internal_still_rejects_bad_segments() {
let err = SecretPath::parse_internal("__sources/Vault/token").unwrap_err();
assert!(matches!(err, PathError::BadSegment { index: 1, .. }));
}
#[test]
fn display_returns_full_path() {
let p = SecretPath::parse("team/gitlab/token-deploy").unwrap();
assert_eq!(format!("{p}"), "team/gitlab/token-deploy");
}
#[test]
fn segments_iter_returns_each_part() {
let p = SecretPath::parse("team/gitlab/token-deploy").unwrap();
let segs: Vec<_> = p.segments().collect();
assert_eq!(segs, vec!["team", "gitlab", "token-deploy"]);
}
#[test]
fn as_ref_str_works() {
let p = SecretPath::parse("team/gitlab/token-deploy").unwrap();
let s: &str = p.as_ref();
assert_eq!(s, "team/gitlab/token-deploy");
}
}