1use crate::error::{AxCode, AxError};
11
12#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
16pub struct SecretRef {
17 realm: String,
18 name: String,
19}
20
21fn segment_ok(segment: &str) -> bool {
25 !segment.is_empty()
26 && segment
27 .chars()
28 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
29}
30
31fn outside_the_grammar(raw: &str) -> AxError {
35 AxError::failure(AxCode::ConfigInvalid, "build a secret reference", raw).with_recovery(
36 "write secret:<realm>/<name>, where each segment is non-empty and uses only letters, \
37 digits, '-', '_' or '.'",
38 )
39}
40
41impl SecretRef {
42 pub fn new(realm: &str, name: &str) -> Result<SecretRef, AxError> {
52 if !segment_ok(realm) || !segment_ok(name) {
53 return Err(outside_the_grammar(&format!("secret:{realm}/{name}")));
54 }
55 Ok(SecretRef {
56 realm: realm.to_owned(),
57 name: name.to_owned(),
58 })
59 }
60
61 pub fn parse(raw: &str) -> Result<SecretRef, AxError> {
69 let Some(rest) = raw.strip_prefix("secret:") else {
70 return Err(outside_the_grammar(raw));
71 };
72 let Some((realm, name)) = rest.split_once('/') else {
73 return Err(outside_the_grammar(raw));
74 };
75 SecretRef::new(realm, name)
76 }
77
78 pub fn realm(&self) -> &str {
79 &self.realm
80 }
81
82 pub fn name(&self) -> &str {
83 &self.name
84 }
85}
86
87impl std::fmt::Display for SecretRef {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 write!(f, "secret:{}/{}", self.realm, self.name)
90 }
91}
92
93impl serde::Serialize for SecretRef {
94 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
95 serializer.collect_str(self)
96 }
97}
98
99impl<'de> serde::Deserialize<'de> for SecretRef {
100 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
101 let raw = String::deserialize(deserializer)?;
102 SecretRef::parse(&raw).map_err(serde::de::Error::custom)
103 }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct SecretSpan {
111 pub start: usize,
112 pub len: usize,
113 pub provider: Option<&'static str>,
114}
115
116#[cfg(test)]
117#[allow(
118 clippy::unwrap_used,
119 clippy::expect_used,
120 clippy::panic,
121 clippy::indexing_slicing,
122 reason = "test code"
123)]
124mod tests {
125 use super::*;
126
127 #[test]
133 fn secret_ref_grammar_is_fail_closed() {
134 let ok = SecretRef::new("anthropic", "api-key").unwrap();
135 assert_eq!(ok.realm(), "anthropic");
136 assert_eq!(ok.name(), "api-key");
137 assert_eq!(ok.to_string(), "secret:anthropic/api-key");
138 assert_eq!(SecretRef::parse("secret:anthropic/api-key").unwrap(), ok);
139 for (realm, name) in [
140 ("", "key"),
141 ("realm", ""),
142 ("a b", "key"),
143 ("realm", "a/b"),
144 ("realm", "key:"),
145 ("realm", "key "),
146 ] {
147 let text = format!("secret:{realm}/{name}");
148 let err = SecretRef::new(realm, name).unwrap_err();
149 assert_eq!(err.code(), &AxCode::ConfigInvalid, "{text}");
150 assert_eq!(err.subject(), text.as_str(), "the refusal names {text}");
151 }
152 for bad in [
153 "secret:",
154 "secret:/x",
155 "secret:a/",
156 "secret:a b/c",
157 "cas:b3-ab",
158 "secret:a/b/c",
159 "SECRET:a/b",
160 ] {
161 let err = SecretRef::parse(bad).unwrap_err();
162 assert_eq!(err.code(), &AxCode::ConfigInvalid, "{bad:?}");
163 assert_eq!(err.subject(), bad, "the refusal names {bad:?}");
164 }
165 }
166}