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]
135 fn every_character_of_the_alphabet_is_admitted() {
136 for (realm, name) in [
137 ("a", "b"),
138 ("7", "9"),
139 ("-", "-"),
140 ("_", "_"),
141 (".", "."),
142 ("my_realm.v2", "api-key_2.old"),
143 ] {
144 let built = SecretRef::new(realm, name).unwrap();
145 assert_eq!(
146 SecretRef::parse(&built.to_string()).unwrap(),
147 built,
148 "{realm}/{name}"
149 );
150 }
151 }
152
153 #[test]
154 fn secret_ref_grammar_is_fail_closed() {
155 let ok = SecretRef::new("anthropic", "api-key").unwrap();
156 assert_eq!(ok.realm(), "anthropic");
157 assert_eq!(ok.name(), "api-key");
158 assert_eq!(ok.to_string(), "secret:anthropic/api-key");
159 assert_eq!(SecretRef::parse("secret:anthropic/api-key").unwrap(), ok);
160 for (realm, name) in [
161 ("", "key"),
162 ("realm", ""),
163 ("a b", "key"),
164 ("realm", "a/b"),
165 ("realm", "key:"),
166 ("realm", "key "),
167 ] {
168 let text = format!("secret:{realm}/{name}");
169 let err = SecretRef::new(realm, name).unwrap_err();
170 assert_eq!(err.code(), &AxCode::ConfigInvalid, "{text}");
171 assert_eq!(err.subject(), text.as_str(), "the refusal names {text}");
172 }
173 for bad in [
174 "secret:",
175 "secret:/x",
176 "secret:a/",
177 "secret:a b/c",
178 "cas:b3-ab",
179 "secret:a/b/c",
180 "SECRET:a/b",
181 ] {
182 let err = SecretRef::parse(bad).unwrap_err();
183 assert_eq!(err.code(), &AxCode::ConfigInvalid, "{bad:?}");
184 assert_eq!(err.subject(), bad, "the refusal names {bad:?}");
185 }
186 }
187}