1use std::fmt;
33use std::str::FromStr;
34
35use serde::{Deserialize, Deserializer, Serialize, Serializer};
36use thiserror::Error;
37
38const RESERVED_INTERNAL_PREFIX: &str = "__";
41const RESERVED_TEST_PREFIX: &str = "_test";
42
43#[derive(Debug, Error, PartialEq, Eq, Clone)]
48pub enum PathError {
49 #[error(
55 "secret path '{path}' has {found} segment(s); minimum 3 required (`<scope>/<provider>/<purpose>`)"
56 )]
57 TooFewSegments {
58 path: String,
60 found: usize,
62 },
63
64 #[error("secret path '{path}' contains an empty segment at position {index}")]
66 EmptySegment {
67 path: String,
69 index: usize,
71 },
72
73 #[error(
75 "secret path '{path}' segment '{segment}' (position {index}) is not lowercase kebab-case (`[a-z][a-z0-9-]*`)"
76 )]
77 BadSegment {
78 path: String,
80 segment: String,
82 index: usize,
84 },
85
86 #[error(
90 "secret path '{path}' uses reserved prefix '{prefix}'; that namespace is for framework-internal use"
91 )]
92 ReservedPrefix {
93 path: String,
95 prefix: String,
97 },
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
107pub struct SecretPath(String);
108
109impl SecretPath {
110 pub fn parse(s: &str) -> Result<Self, PathError> {
114 validate(s)?;
115 Ok(Self(s.to_owned()))
116 }
117
118 pub fn parse_internal(s: &str) -> Result<Self, PathError> {
127 validate_with(s, true)?;
128 Ok(Self(s.to_owned()))
129 }
130
131 pub fn as_str(&self) -> &str {
133 &self.0
134 }
135
136 pub fn scope(&self) -> &str {
140 self.segment(0)
141 }
142
143 pub fn provider(&self) -> &str {
146 self.segment(1)
147 }
148
149 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; } else {
160 return &self.0[byte_offset..];
161 }
162 }
163 ""
165 }
166
167 pub fn segments(&self) -> impl Iterator<Item = &str> {
169 self.0.split('/')
170 }
171
172 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
217fn validate(s: &str) -> Result<(), PathError> {
222 validate_with(s, 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 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 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 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
283fn 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
300fn is_reserved_prefix(scope: &str) -> bool {
303 scope.starts_with(RESERVED_INTERNAL_PREFIX) || scope == RESERVED_TEST_PREFIX
304}
305
306fn 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#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[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 #[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 let err = SecretPath::parse("gitlab.token").unwrap_err();
389 assert!(matches!(err, PathError::TooFewSegments { found: 1, .. }));
390 }
391
392 #[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 assert!(matches!(err, PathError::EmptySegment { index: 3, .. }));
414 }
415
416 #[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 #[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 let err = SecretPath::parse_internal("__sources/Vault/token").unwrap_err();
491 assert!(matches!(err, PathError::BadSegment { index: 1, .. }));
492 }
493
494 #[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}