Skip to main content

greentic_secrets_spec/
refs.rs

1//! `secret://` deployment reference newtype.
2//!
3//! [`SecretRef`] wraps a `secret://<env>/<...>` URI — the *deployment-artifact*
4//! pointer into an environment's secrets. It is distinct from the *runtime
5//! store* URI ([`crate::SecretUri`], `secrets://`): the ref appears in
6//! deploy-spec objects and persisted artifacts, while the store URI is what
7//! backends read and write.
8//!
9//! This type was moved here from `greentic-deployer`'s `greentic-deploy-spec`
10//! crate so the whole ecosystem shares one definition of the `secret://` scheme
11//! and one authoritative `secret://` <-> `secrets://` converter (replacing the
12//! `replacen`-based copies that previously lived in start/setup/deployer).
13
14use 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
23/// Scheme prefix for `secret://` deployment references.
24pub const SECRET_SCHEME: &str = "secret://";
25
26/// Reference into an environment's secrets: `secret://<env>/<path>`.
27///
28/// The first path segment after the scheme is the env id the ref is scoped to
29/// and must be present and non-empty (see [`SecretRef::env_segment`]). The
30/// concrete secret material never appears in the deployment object model — it is
31/// resolved at runtime via [`SecretRef::to_store_uri`].
32#[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    /// Construct and validate a `secret://` reference.
39    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        // First segment after the scheme is the env identifier; refs are
48        // documented as `secret://<env>/<path...>`. The env segment must be
49        // present and non-empty so callers can scope a ref to its env.
50        if env_segment_of(&raw).is_empty() {
51            return Err(SecretRefParseError::EmptyEnvSegment);
52        }
53        Ok(Self(raw))
54    }
55
56    /// The raw `secret://...` string.
57    pub fn as_str(&self) -> &str {
58        &self.0
59    }
60
61    /// First path segment after the scheme — the env id the ref is scoped to.
62    pub fn env_segment(&self) -> &str {
63        env_segment_of(&self.0)
64    }
65
66    /// Convert this deployment ref into the canonical runtime store URI
67    /// (`secrets://`), applying [`normalize_team`] to the team segment.
68    ///
69    /// This is the single authoritative replacement for the `replacen`-based
70    /// `secret_ref_to_store_uri` helpers previously duplicated across
71    /// start/setup/deployer. It is only valid for *store-aligned* refs — those
72    /// whose path is exactly `<env>/<tenant>/<team>/<category>/<name>`. Refs
73    /// with a different shape (e.g. pack-config `secret://<env>/<bundle>/<pack>/<question>`)
74    /// are resolved through their own mapping and return a parse error here
75    /// rather than silently producing a wrong URI.
76    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        // Re-canonicalize the team segment so a ref carrying `default` resolves
82        // to the same `_` store location as a ref carrying `_`.
83        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    /// Build a `secret://` deployment ref from a runtime store URI (the inverse
96    /// prefix flip of [`SecretRef::to_store_uri`]).
97    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
107/// The env segment — the first path component after the `secret://` scheme — of
108/// a raw ref string. Callers guarantee the `secret://` prefix is present; the
109/// segment is everything up to the first `/` (or the whole tail if there is
110/// none). Shared by [`SecretRef::try_new`]'s validation and
111/// [`SecretRef::env_segment`] so the slicing rule lives in one place.
112fn 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/// Errors produced when parsing a [`SecretRef`].
149#[derive(Debug, Error, Clone, PartialEq, Eq)]
150pub enum SecretRefParseError {
151    /// The string did not start with `secret://`.
152    #[error("secret-ref must start with `secret://`")]
153    MissingScheme,
154    /// The string was exactly `secret://` with no path.
155    #[error("secret-ref path is empty")]
156    EmptyPath,
157    /// The first path segment (the env id) was empty.
158    #[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        // The `default` team collapses to the same `_` store location.
212        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        // pack-config shape `secret://<env>/<bundle>/<pack>/<question>` is only
227        // four path segments — it has no canonical store-URI flip.
228        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}