greentic_secrets_spec/
refs.rs1use crate::error::Result;
15use crate::types::Scope;
16use crate::uri::{SECRET_STORE_SCHEME, SecretUri, normalize_team};
17use core::fmt;
18use core::str::FromStr;
19#[cfg(feature = "serde")]
20use serde::{Deserialize, Serialize};
21use thiserror::Error;
22
23pub const SECRET_SCHEME: &str = "secret://";
25
26#[derive(Clone, Debug, PartialEq, Eq, Hash)]
33#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
34#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
35pub struct SecretRef(String);
36
37impl SecretRef {
38 pub fn try_new(raw: impl Into<String>) -> core::result::Result<Self, SecretRefParseError> {
40 let raw = raw.into();
41 if !raw.starts_with(SECRET_SCHEME) {
42 return Err(SecretRefParseError::MissingScheme);
43 }
44 if raw.len() == SECRET_SCHEME.len() {
45 return Err(SecretRefParseError::EmptyPath);
46 }
47 if env_segment_of(&raw).is_empty() {
51 return Err(SecretRefParseError::EmptyEnvSegment);
52 }
53 Ok(Self(raw))
54 }
55
56 pub fn as_str(&self) -> &str {
58 &self.0
59 }
60
61 pub fn env_segment(&self) -> &str {
63 env_segment_of(&self.0)
64 }
65
66 pub fn to_store_uri(&self) -> Result<SecretUri> {
77 let mut flipped = String::with_capacity(self.0.len() + 1);
78 flipped.push_str(SECRET_STORE_SCHEME);
79 flipped.push_str(&self.0[SECRET_SCHEME.len()..]);
80 let parsed = SecretUri::parse(&flipped)?;
81 let scope = Scope::new(
84 parsed.scope().env(),
85 parsed.scope().tenant(),
86 normalize_team(parsed.scope().team()),
87 )?;
88 let mut uri = SecretUri::new(scope, parsed.category(), parsed.name())?;
89 if let Some(version) = parsed.version() {
90 uri = uri.with_version(Some(version))?;
91 }
92 Ok(uri)
93 }
94
95 pub fn from_store_uri(uri: &SecretUri) -> core::result::Result<Self, SecretRefParseError> {
98 let store = uri.to_string();
99 let body = store.strip_prefix(SECRET_STORE_SCHEME).unwrap_or(&store);
100 let mut raw = String::with_capacity(SECRET_SCHEME.len() + body.len());
101 raw.push_str(SECRET_SCHEME);
102 raw.push_str(body);
103 Self::try_new(raw)
104 }
105}
106
107fn env_segment_of(raw: &str) -> &str {
113 let after_scheme = &raw[SECRET_SCHEME.len()..];
114 match after_scheme.find('/') {
115 Some(idx) => &after_scheme[..idx],
116 None => after_scheme,
117 }
118}
119
120impl fmt::Display for SecretRef {
121 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122 f.write_str(&self.0)
123 }
124}
125
126impl FromStr for SecretRef {
127 type Err = SecretRefParseError;
128
129 fn from_str(s: &str) -> core::result::Result<Self, Self::Err> {
130 Self::try_new(s)
131 }
132}
133
134impl TryFrom<String> for SecretRef {
135 type Error = SecretRefParseError;
136
137 fn try_from(value: String) -> core::result::Result<Self, Self::Error> {
138 Self::try_new(value)
139 }
140}
141
142impl From<SecretRef> for String {
143 fn from(value: SecretRef) -> Self {
144 value.0
145 }
146}
147
148#[derive(Debug, Error, Clone, PartialEq, Eq)]
150pub enum SecretRefParseError {
151 #[error("secret-ref must start with `secret://`")]
153 MissingScheme,
154 #[error("secret-ref path is empty")]
156 EmptyPath,
157 #[error("secret-ref must carry an env segment: `secret://<env>/<path>`")]
159 EmptyEnvSegment,
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 #[test]
167 fn parses_store_aligned_ref() {
168 let r = SecretRef::try_new("secret://dev/demo/_/messaging-slack/api_key").unwrap();
169 assert_eq!(r.env_segment(), "dev");
170 assert_eq!(r.as_str(), "secret://dev/demo/_/messaging-slack/api_key");
171 }
172
173 #[test]
174 fn rejects_missing_scheme() {
175 assert_eq!(
176 SecretRef::try_new("dev/demo").unwrap_err(),
177 SecretRefParseError::MissingScheme
178 );
179 }
180
181 #[test]
182 fn rejects_empty_path() {
183 assert_eq!(
184 SecretRef::try_new("secret://").unwrap_err(),
185 SecretRefParseError::EmptyPath
186 );
187 }
188
189 #[test]
190 fn rejects_empty_env_segment() {
191 assert_eq!(
192 SecretRef::try_new("secret:///demo/_/c/n").unwrap_err(),
193 SecretRefParseError::EmptyEnvSegment
194 );
195 }
196
197 #[test]
198 fn to_store_uri_flips_scheme_and_normalizes_team() {
199 let underscore = SecretRef::try_new("secret://dev/demo/_/messaging-slack/api_key")
200 .unwrap()
201 .to_store_uri()
202 .unwrap();
203 let defaulted = SecretRef::try_new("secret://dev/demo/default/messaging-slack/api_key")
204 .unwrap()
205 .to_store_uri()
206 .unwrap();
207 assert_eq!(
208 underscore.to_string(),
209 "secrets://dev/demo/_/messaging-slack/api_key"
210 );
211 assert_eq!(underscore, defaulted);
213 }
214
215 #[test]
216 fn to_store_uri_preserves_real_team() {
217 let uri = SecretRef::try_new("secret://dev/demo/legal/configs/url")
218 .unwrap()
219 .to_store_uri()
220 .unwrap();
221 assert_eq!(uri.to_string(), "secrets://dev/demo/legal/configs/url");
222 }
223
224 #[test]
225 fn non_store_aligned_ref_errors_rather_than_misconverting() {
226 assert!(
229 SecretRef::try_new("secret://dev/my-bundle/my-pack/question")
230 .unwrap()
231 .to_store_uri()
232 .is_err()
233 );
234 }
235
236 #[test]
237 fn store_uri_round_trip() {
238 let store = SecretUri::parse("secrets://dev/demo/_/messaging-slack/api_key").unwrap();
239 let secret_ref = SecretRef::from_store_uri(&store).unwrap();
240 assert_eq!(
241 secret_ref.as_str(),
242 "secret://dev/demo/_/messaging-slack/api_key"
243 );
244 assert_eq!(secret_ref.to_store_uri().unwrap(), store);
245 }
246
247 #[cfg(feature = "serde")]
248 #[test]
249 fn serde_round_trips_through_string() {
250 let r = SecretRef::try_new("secret://dev/demo/_/messaging-slack/api_key").unwrap();
251 let json = serde_json::to_string(&r).unwrap();
252 assert_eq!(json, "\"secret://dev/demo/_/messaging-slack/api_key\"");
253 let back: SecretRef = serde_json::from_str(&json).unwrap();
254 assert_eq!(back, r);
255 }
256}