1use crate::error::{Error, Result};
2use crate::types::{Scope, validate_component, validate_version};
3#[cfg(feature = "serde")]
4use serde::{Deserialize, Serialize};
5use std::fmt;
6use std::str::FromStr;
7
8pub const SECRET_STORE_SCHEME: &str = "secrets://";
10pub const TEAM_PLACEHOLDER: &str = "_";
15
16const SCHEME: &str = SECRET_STORE_SCHEME;
17
18#[derive(Debug, Clone, PartialEq, Eq, Hash)]
19pub struct SecretUri {
20 scope: Scope,
21 category: String,
22 name: String,
23 version: Option<String>,
24}
25
26impl SecretUri {
27 pub fn new(scope: Scope, category: impl Into<String>, name: impl Into<String>) -> Result<Self> {
28 let category = category.into();
29 let name = name.into();
30
31 validate_component(&category, "category")?;
32 validate_component(&name, "name")?;
33
34 Ok(Self {
35 scope,
36 category,
37 name,
38 version: None,
39 })
40 }
41
42 pub fn scope(&self) -> &Scope {
43 &self.scope
44 }
45
46 pub fn category(&self) -> &str {
47 &self.category
48 }
49
50 pub fn name(&self) -> &str {
51 &self.name
52 }
53
54 pub fn version(&self) -> Option<&str> {
55 self.version.as_deref()
56 }
57
58 pub fn with_version(mut self, version: Option<&str>) -> Result<Self> {
59 if let Some(value) = version {
60 validate_version(value)?;
61 self.version = Some(value.to_string());
62 } else {
63 self.version = None;
64 }
65 Ok(self)
66 }
67
68 pub fn parse(input: &str) -> Result<Self> {
69 let raw = input.trim();
70 if !raw.starts_with(SCHEME) {
71 return Err(Error::InvalidScheme);
72 }
73
74 let path = &raw[SCHEME.len()..];
75 let mut segments = path.split('/');
76
77 let env = segments.next().ok_or(Error::MissingSegment {
78 field: "environment",
79 })?;
80 let tenant = segments
81 .next()
82 .ok_or(Error::MissingSegment { field: "tenant" })?;
83 let team_segment = segments
84 .next()
85 .ok_or(Error::MissingSegment { field: "team" })?;
86 let category = segments
87 .next()
88 .ok_or(Error::MissingSegment { field: "category" })?;
89 let name_segment = segments
90 .next()
91 .ok_or(Error::MissingSegment { field: "name" })?;
92
93 if segments.next().is_some() {
94 return Err(Error::ExtraSegments);
95 }
96
97 let team = if team_segment == TEAM_PLACEHOLDER {
98 None
99 } else {
100 Some(team_segment.to_string())
101 };
102
103 let (name, version) = split_name_version(name_segment)?;
104
105 let scope = Scope::new(env.to_string(), tenant.to_string(), team)?;
106 let mut uri = SecretUri::new(scope, category, name)?;
107 if let Some(version) = version {
108 uri = uri.with_version(Some(&version))?;
109 }
110
111 Ok(uri)
112 }
113
114 fn format_team(team: Option<&str>) -> &str {
115 team.unwrap_or(TEAM_PLACEHOLDER)
116 }
117}
118
119fn split_name_version(segment: &str) -> Result<(&str, Option<String>)> {
120 let mut parts = segment.split('@');
121 let name = parts.next().unwrap_or_default();
122 let version = parts.next();
123
124 if parts.next().is_some() {
125 return Err(Error::InvalidVersion {
126 value: segment.to_string(),
127 });
128 }
129
130 if let Some(v) = version {
131 validate_version(v)?;
132 Ok((name, Some(v.to_string())))
133 } else {
134 Ok((name, None))
135 }
136}
137
138impl fmt::Display for SecretUri {
139 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140 write!(
141 f,
142 "{SCHEME}{}/{}/{}/{}/{}",
143 self.scope.env(),
144 self.scope.tenant(),
145 Self::format_team(self.scope.team()),
146 self.category,
147 self.name
148 )?;
149
150 if let Some(version) = &self.version {
151 write!(f, "@{version}")?;
152 }
153 Ok(())
154 }
155}
156
157impl FromStr for SecretUri {
158 type Err = Error;
159
160 fn from_str(s: &str) -> Result<Self> {
161 SecretUri::parse(s)
162 }
163}
164
165impl SecretUri {
166 pub fn into_string(self) -> String {
167 self.to_string()
168 }
169}
170
171impl TryFrom<&str> for SecretUri {
172 type Error = Error;
173
174 fn try_from(value: &str) -> Result<Self> {
175 SecretUri::parse(value)
176 }
177}
178
179impl TryFrom<String> for SecretUri {
180 type Error = Error;
181
182 fn try_from(value: String) -> Result<Self> {
183 SecretUri::parse(&value)
184 }
185}
186
187#[cfg(feature = "serde")]
188impl Serialize for SecretUri {
189 fn serialize<S>(&self, serializer: S) -> core::result::Result<S::Ok, S::Error>
190 where
191 S: serde::Serializer,
192 {
193 serializer.serialize_str(&self.to_string())
194 }
195}
196
197#[cfg(feature = "serde")]
198impl<'de> Deserialize<'de> for SecretUri {
199 fn deserialize<D>(deserializer: D) -> core::result::Result<Self, D::Error>
200 where
201 D: serde::Deserializer<'de>,
202 {
203 let value = String::deserialize(deserializer)?;
204 SecretUri::parse(&value).map_err(serde::de::Error::custom)
205 }
206}
207
208pub fn is_default_team(team: Option<&str>) -> bool {
220 match team {
221 None => true,
222 Some(value) => {
223 let trimmed = value.trim();
224 trimmed.is_empty()
225 || trimmed == TEAM_PLACEHOLDER
226 || trimmed.eq_ignore_ascii_case("default")
227 }
228 }
229}
230
231pub fn normalize_team(team: Option<&str>) -> Option<String> {
239 if is_default_team(team) {
240 None
241 } else {
242 team.map(|value| value.trim().to_string())
243 }
244}
245
246pub fn canonical_secret_uri(
253 env: &str,
254 tenant: &str,
255 team: Option<&str>,
256 category: &str,
257 name: &str,
258) -> Result<SecretUri> {
259 let scope = Scope::new(env, tenant, normalize_team(team))?;
260 SecretUri::new(scope, category, name)
261}
262
263pub fn canonical_secret_name(raw: &str) -> String {
273 let mut result = String::with_capacity(raw.len());
274 let mut prev_underscore = false;
275
276 for ch in raw.chars() {
277 let Some(normalized) = normalize_secret_name_char(ch) else {
278 continue;
279 };
280 if normalized == '_' {
281 if prev_underscore {
282 continue;
283 }
284 prev_underscore = true;
285 } else {
286 prev_underscore = false;
287 }
288 result.push(normalized);
289 }
290
291 let trimmed = result.trim_matches('_');
292 if trimmed.is_empty() {
293 "secret".to_string()
294 } else {
295 trimmed.to_string()
296 }
297}
298
299fn normalize_secret_name_char(ch: char) -> Option<char> {
300 match ch {
301 'A'..='Z' => Some(ch.to_ascii_lowercase()),
302 'a'..='z' | '0'..='9' | '_' => Some(ch),
303 '-' | '.' | ' ' | '/' => Some('_'),
304 _ => None,
305 }
306}
307
308pub fn canonical_secret_store_key(uri: &str) -> Option<String> {
318 let trimmed = uri.strip_prefix(SECRET_STORE_SCHEME)?;
319 let segments: Vec<&str> = trimmed.split('/').collect();
320 if segments.len() != 5 {
321 return None;
322 }
323 let mut parts = Vec::with_capacity(segments.len() + 1);
324 parts.push("GREENTIC_SECRET".to_string());
325 parts.extend(segments.into_iter().map(normalize_store_segment));
326 Some(parts.join("__"))
327}
328
329fn normalize_store_segment(segment: &str) -> String {
330 segment
331 .chars()
332 .map(|ch| match ch {
333 'A'..='Z' | '0'..='9' => ch,
334 'a'..='z' => ch.to_ascii_uppercase(),
335 _ => '_',
336 })
337 .collect()
338}
339
340#[cfg(test)]
341mod canonical_tests {
342 use super::*;
343
344 #[test]
345 fn default_team_variants_collapse_to_none() {
346 for value in [
347 None,
348 Some(""),
349 Some(" "),
350 Some("_"),
351 Some("default"),
352 Some("Default"),
353 Some("DEFAULT"),
354 ] {
355 assert!(
356 is_default_team(value),
357 "expected {value:?} to be the default team"
358 );
359 assert_eq!(
360 normalize_team(value),
361 None,
362 "expected {value:?} to normalize to None"
363 );
364 }
365 }
366
367 #[test]
368 fn real_team_is_preserved() {
369 assert!(!is_default_team(Some("legal")));
370 assert_eq!(normalize_team(Some("legal")), Some("legal".to_string()));
371 assert_eq!(normalize_team(Some(" legal ")), Some("legal".to_string()));
372 }
373
374 #[test]
375 fn canonical_uri_renders_underscore_for_default_team() {
376 let none = canonical_secret_uri("dev", "demo", None, "messaging-slack", "api_key").unwrap();
377 let explicit_default =
378 canonical_secret_uri("dev", "demo", Some("default"), "messaging-slack", "api_key")
379 .unwrap();
380 assert_eq!(
381 none.to_string(),
382 "secrets://dev/demo/_/messaging-slack/api_key"
383 );
384 assert_eq!(none, explicit_default);
385 }
386
387 #[test]
388 fn placeholder_team_round_trips_and_equals_teamless() {
389 let placeholder =
393 canonical_secret_uri("dev", "demo", Some("_"), "messaging-slack", "api_key").unwrap();
394 let teamless =
395 canonical_secret_uri("dev", "demo", None, "messaging-slack", "api_key").unwrap();
396 assert_eq!(placeholder, teamless);
397 assert_eq!(
398 placeholder,
399 SecretUri::parse(&placeholder.to_string()).unwrap()
400 );
401 assert_eq!(placeholder.scope().team(), None);
402 }
403
404 #[test]
405 fn canonical_uri_keeps_real_team() {
406 let uri = canonical_secret_uri("dev", "demo", Some("legal"), "configs", "url").unwrap();
407 assert_eq!(uri.to_string(), "secrets://dev/demo/legal/configs/url");
408 }
409
410 #[test]
411 fn canonical_secret_name_fixed_points_and_normalization() {
412 assert_eq!(
414 canonical_secret_name("telegram_bot_token"),
415 "telegram_bot_token"
416 );
417 assert_eq!(canonical_secret_name("a1"), "a1");
418 assert_eq!(
420 canonical_secret_name("TELEGRAM_BOT_TOKEN"),
421 "telegram_bot_token"
422 );
423 assert_eq!(canonical_secret_name("bot-token"), "bot_token");
424 assert_eq!(canonical_secret_name("a.b c/d"), "a_b_c_d");
425 assert_eq!(
427 canonical_secret_name("double__underscore"),
428 "double_underscore"
429 );
430 assert_eq!(canonical_secret_name("_leading"), "leading");
431 assert_eq!(canonical_secret_name("trailing_"), "trailing");
432 assert_eq!(canonical_secret_name(""), "secret");
434 assert_eq!(canonical_secret_name("***"), "secret");
435 }
436
437 #[test]
438 fn canonical_secret_store_key_matches_runtime_shape() {
439 assert_eq!(
440 canonical_secret_store_key("secrets://dev/demo/_/openai/api_key").as_deref(),
441 Some("GREENTIC_SECRET__DEV__DEMO_____OPENAI__API_KEY")
442 );
443 assert_eq!(
445 canonical_secret_store_key("secrets://dev/demo/legal/messaging-slack/bot_token")
446 .as_deref(),
447 Some("GREENTIC_SECRET__DEV__DEMO__LEGAL__MESSAGING_SLACK__BOT_TOKEN")
448 );
449 assert_eq!(canonical_secret_store_key("secret://dev/demo/_/p/n"), None);
451 assert_eq!(canonical_secret_store_key("secrets://dev/demo/_/p"), None);
452 assert_eq!(
453 canonical_secret_store_key("secrets://dev/demo/_/p/n/extra"),
454 None
455 );
456 }
457}