Skip to main content

greentic_secrets_spec/
uri.rs

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
8/// Scheme prefix for runtime secret store URIs (`secrets://`).
9pub const SECRET_STORE_SCHEME: &str = "secrets://";
10/// Placeholder segment used when a secret is not scoped to a specific team.
11///
12/// The default/empty team is always rendered as this `_` placeholder; see
13/// [`normalize_team`].
14pub 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
208// No schema integration in this crate; downstream can wrap as needed.
209
210/// Returns `true` when `team` represents the canonical "no specific team" value.
211///
212/// `None`, an empty/whitespace string, the literal [`TEAM_PLACEHOLDER`] (`_`),
213/// and a literal `default` (any case) all denote the team-less scope. They are
214/// deliberately treated as equivalent: the store renders them all as the
215/// `_` placeholder so a secret written under one form is always found under the
216/// others. Treating the placeholder itself as team-less keeps the canonicalizer
217/// a true round-trip — a URI *constructed* with team `Some("_")` and the same
218/// URI *parsed* back (where `_` becomes `None`) compare equal.
219pub 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
231/// Canonicalize a team value for secret scoping.
232///
233/// Maps the default/empty team (see [`is_default_team`]) to `None` — which
234/// renders as the `_` placeholder — and otherwise returns the trimmed team. This
235/// is the single source of truth for the "`_` everywhere" rule: every secret URI
236/// / [`crate::SecretRef`] construction must route its team segment through here
237/// so `default` and `_` can never diverge across producers.
238pub 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
246/// Build a canonical secret store URI
247/// (`secrets://<env>/<tenant>/<team|_>/<category>/<name>`), applying
248/// [`normalize_team`] so the team segment is always canonical.
249///
250/// This is the one helper all consumers (setup/start/deployer) should call
251/// instead of formatting the URI by hand.
252pub 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
263/// Canonicalize a raw secret name into the store-safe slug used in the trailing
264/// segment of every `secrets://.../<name>` URI.
265///
266/// Lowercases ASCII, keeps `[a-z0-9_]`, maps `-`/`.`/`/`/space to `_`, drops any
267/// other character, collapses runs of `_`, and trims leading/trailing `_`; an
268/// input that reduces to nothing yields `"secret"`. This is the single
269/// definition the whole ecosystem (setup/start/deployer) shares so a producer
270/// and a reader can never derive a name differently — a secret written under
271/// one normalization is always found under the other.
272pub 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
308/// Derive the environment-variable lookup key for a 5-segment `secrets://` store
309/// URI.
310///
311/// `secrets://<env>/<tenant>/<team>/<category>/<name>` becomes
312/// `GREENTIC_SECRET__<ENV>__<TENANT>__<TEAM>__<CATEGORY>__<NAME>`, with each
313/// segment uppercased and every non-alphanumeric byte mapped to `_`. Returns
314/// `None` when `uri` is not a 5-segment `secrets://` URI. The runtime reader and
315/// the deployer's resolver share this so a secret exported as an env var is
316/// found under exactly the key it was written as.
317pub 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        // A URI constructed with the literal `_` placeholder must be identical
390        // (value, equality, and hash) to a team-less one and to the same URI
391        // parsed back — otherwise the same rendered string carries two scopes.
392        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        // Already-canonical names are unchanged.
413        assert_eq!(
414            canonical_secret_name("telegram_bot_token"),
415            "telegram_bot_token"
416        );
417        assert_eq!(canonical_secret_name("a1"), "a1");
418        // Uppercase and separators normalize.
419        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        // Runs collapse and edges trim.
426        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        // Empty / all-dropped input falls back to a stable placeholder.
433        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        // Hyphenated category segments fold to `_`.
444        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        // Wrong scheme or wrong segment count yields None.
450        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}