Skip to main content

greentic_deploy_spec/
refs.rs

1//! URI-shaped reference newtypes.
2//!
3//! - [`SecretRef`] wraps a `secret://<env>/<...>` URI. The runtime resolves the
4//!   reference through the env's secrets env-pack; the actual material never
5//!   appears in the deployment object model.
6//! - [`RuntimeRef`] wraps a `runtime://<env>/discovered/<...>` URI. Values are
7//!   resolved through [`EnvironmentRuntime::discovered`](crate::EnvironmentRuntime).
8//! - [`ExtensionRef`] wraps an `ext://<descriptor-path>[/<instance>]` URI. It
9//!   carries **no env segment** (the env is implicit in the resolving
10//!   workload's context) and resolves through
11//!   [`Environment::extension_for_ref`](crate::Environment::extension_for_ref)
12//!   to the bound [`ExtensionBinding`](crate::ExtensionBinding).
13
14use crate::capability_slot::descriptor_path_char_ok;
15use serde::{Deserialize, Serialize};
16use std::fmt;
17use std::str::FromStr;
18use thiserror::Error;
19
20// `SecretRef` and its parse error now live in `greentic-secrets-spec` so the
21// whole ecosystem shares one definition of the `secret://` scheme and one
22// authoritative `secret://` <-> `secrets://` converter. Re-exported here so the
23// deploy-spec object model (and its public API) are unchanged.
24pub use greentic_secrets_spec::{SecretRef, SecretRefParseError};
25
26const RUNTIME_SCHEME: &str = "runtime://";
27const EXTENSION_SCHEME: &str = "ext://";
28
29macro_rules! uri_ref {
30    ($(#[$meta:meta])* $name:ident, $err:ident, $scheme:expr) => {
31        $(#[$meta])*
32        #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
33        #[serde(try_from = "String", into = "String")]
34        pub struct $name(String);
35
36        impl $name {
37            pub fn try_new(raw: impl Into<String>) -> Result<Self, $err> {
38                let raw = raw.into();
39                if !raw.starts_with($scheme) {
40                    return Err($err::MissingScheme);
41                }
42                if raw.len() == $scheme.len() {
43                    return Err($err::EmptyPath);
44                }
45                // First segment after the scheme is the env identifier; refs
46                // are documented as `<scheme>://<env>/<path...>`. The env
47                // segment must be present and non-empty so callers can scope
48                // a ref to its owning environment.
49                let after_scheme = &raw[$scheme.len()..];
50                let env_seg = match after_scheme.find('/') {
51                    Some(idx) => &after_scheme[..idx],
52                    None => after_scheme,
53                };
54                if env_seg.is_empty() {
55                    return Err($err::EmptyEnvSegment);
56                }
57                Ok(Self(raw))
58            }
59
60            pub fn as_str(&self) -> &str {
61                &self.0
62            }
63
64            /// First path segment after the scheme — the env id the ref is
65            /// scoped to. Returns `None` if the ref was constructed by a
66            /// future version of this crate that bypassed [`Self::try_new`]
67            /// (current invariant: `Self::try_new` always populates this).
68            pub fn env_segment(&self) -> &str {
69                let after_scheme = &self.0[$scheme.len()..];
70                match after_scheme.find('/') {
71                    Some(idx) => &after_scheme[..idx],
72                    None => after_scheme,
73                }
74            }
75        }
76
77        impl fmt::Display for $name {
78            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79                f.write_str(&self.0)
80            }
81        }
82
83        impl FromStr for $name {
84            type Err = $err;
85
86            fn from_str(s: &str) -> Result<Self, Self::Err> {
87                Self::try_new(s)
88            }
89        }
90
91        impl TryFrom<String> for $name {
92            type Error = $err;
93
94            fn try_from(value: String) -> Result<Self, Self::Error> {
95                Self::try_new(value)
96            }
97        }
98
99        impl From<$name> for String {
100            fn from(value: $name) -> Self {
101                value.0
102            }
103        }
104    };
105}
106
107uri_ref!(
108    /// Reference into [`EnvironmentRuntime::discovered`](crate::EnvironmentRuntime):
109    /// `runtime://<env>/discovered/<path>`.
110    RuntimeRef, RuntimeRefParseError, RUNTIME_SCHEME
111);
112
113/// Charset permitted in an [`ExtensionRef`] / [`ExtensionBinding`](crate::ExtensionBinding)
114/// instance id: ASCII lowercase, digits, `-`. Notably excludes `.` and `/` so
115/// an instance id can never be confused with a descriptor path segment or
116/// inject a second `ext://` path component.
117pub(crate) fn instance_id_char_ok(ch: char) -> bool {
118    ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-'
119}
120
121/// Reference to an env extension binding: `ext://<descriptor-path>[/<instance>]`.
122///
123/// Unlike [`SecretRef`] / [`RuntimeRef`], an extension ref carries **no env
124/// segment** — extensions are resolved within the already-known env context of
125/// the workload that names them. `<descriptor-path>` is a
126/// [`PackDescriptor`](crate::PackDescriptor) path (version-independent — the
127/// binding owns the concrete version); the optional `<instance>` selects one of
128/// N instances of the same extension type. Lookup is by `(path, instance_id)`
129/// against [`Environment::extensions`](crate::Environment), the same key its
130/// uniqueness invariant enforces.
131#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
132#[serde(try_from = "String", into = "String")]
133pub struct ExtensionRef {
134    raw: String,
135    path: String,
136    instance_id: Option<String>,
137}
138
139impl ExtensionRef {
140    pub fn try_new(raw: impl Into<String>) -> Result<Self, ExtensionRefParseError> {
141        let raw = raw.into();
142        let body = raw
143            .strip_prefix(EXTENSION_SCHEME)
144            .ok_or(ExtensionRefParseError::MissingScheme)?;
145        // Split on the FIRST `/` only: everything before is the descriptor
146        // path, everything after is the instance id. A second `/` therefore
147        // lands inside the instance id and is rejected by its charset, so an
148        // extension ref is always exactly two segments.
149        let (path, instance) = match body.split_once('/') {
150            Some((p, inst)) => (p, Some(inst)),
151            None => (body, None),
152        };
153        if path.is_empty() {
154            return Err(ExtensionRefParseError::EmptyPath);
155        }
156        if !path.contains('.') {
157            return Err(ExtensionRefParseError::PathMissingDot);
158        }
159        if let Some(ch) = path.chars().find(|c| !descriptor_path_char_ok(*c)) {
160            return Err(ExtensionRefParseError::InvalidPathChar(ch));
161        }
162        // Own everything borrowed from `raw` before moving `raw` into `Self`.
163        let path = path.to_string();
164        let instance_id = instance
165            .map(|inst| validate_instance_id(inst).map(str::to_string))
166            .transpose()?;
167        Ok(Self {
168            raw,
169            path,
170            instance_id,
171        })
172    }
173
174    pub fn as_str(&self) -> &str {
175        &self.raw
176    }
177
178    /// Version-independent descriptor path the ref selects.
179    pub fn path(&self) -> &str {
180        &self.path
181    }
182
183    /// Instance selector, or `None` for the default (unnamed) instance.
184    pub fn instance_id(&self) -> Option<&str> {
185        self.instance_id.as_deref()
186    }
187}
188
189/// Validate an extension instance id against [`instance_id_char_ok`], returning
190/// it unchanged on success. Shared by [`ExtensionRef`] parsing and
191/// [`ExtensionBinding`](crate::ExtensionBinding) validation so a stored binding
192/// and a ref that selects it agree on the legal charset.
193pub(crate) fn validate_instance_id(inst: &str) -> Result<&str, ExtensionRefParseError> {
194    if inst.is_empty() {
195        return Err(ExtensionRefParseError::EmptyInstance);
196    }
197    if let Some(ch) = inst.chars().find(|c| !instance_id_char_ok(*c)) {
198        return Err(ExtensionRefParseError::InvalidInstanceChar(ch));
199    }
200    Ok(inst)
201}
202
203impl fmt::Display for ExtensionRef {
204    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205        f.write_str(&self.raw)
206    }
207}
208
209impl FromStr for ExtensionRef {
210    type Err = ExtensionRefParseError;
211
212    fn from_str(s: &str) -> Result<Self, Self::Err> {
213        Self::try_new(s)
214    }
215}
216
217impl TryFrom<String> for ExtensionRef {
218    type Error = ExtensionRefParseError;
219
220    fn try_from(value: String) -> Result<Self, Self::Error> {
221        Self::try_new(value)
222    }
223}
224
225impl From<ExtensionRef> for String {
226    fn from(value: ExtensionRef) -> Self {
227        value.raw
228    }
229}
230
231#[derive(Debug, Error, PartialEq, Eq)]
232pub enum ExtensionRefParseError {
233    #[error("extension-ref must start with `ext://`")]
234    MissingScheme,
235    #[error("extension-ref path is empty")]
236    EmptyPath,
237    #[error("extension-ref path must contain at least one `.`")]
238    PathMissingDot,
239    #[error("extension-ref path contains invalid character `{0}`")]
240    InvalidPathChar(char),
241    #[error("extension-ref instance id is empty")]
242    EmptyInstance,
243    #[error("extension-ref instance id contains invalid character `{0}`")]
244    InvalidInstanceChar(char),
245}
246
247#[derive(Debug, Error, PartialEq, Eq)]
248pub enum RuntimeRefParseError {
249    #[error("runtime-ref must start with `runtime://`")]
250    MissingScheme,
251    #[error("runtime-ref path is empty")]
252    EmptyPath,
253    #[error("runtime-ref must carry an env segment: `runtime://<env>/<path>`")]
254    EmptyEnvSegment,
255}
256
257#[cfg(test)]
258mod extension_ref_tests {
259    use super::*;
260
261    #[test]
262    fn parses_path_only() {
263        let r = ExtensionRef::try_new("ext://acme.oauth.auth0").unwrap();
264        assert_eq!(r.path(), "acme.oauth.auth0");
265        assert_eq!(r.instance_id(), None);
266        assert_eq!(r.as_str(), "ext://acme.oauth.auth0");
267    }
268
269    #[test]
270    fn parses_path_with_instance() {
271        let r = ExtensionRef::try_new("ext://acme.oauth.auth0/primary").unwrap();
272        assert_eq!(r.path(), "acme.oauth.auth0");
273        assert_eq!(r.instance_id(), Some("primary"));
274    }
275
276    #[test]
277    fn rejects_missing_scheme() {
278        assert_eq!(
279            ExtensionRef::try_new("acme.oauth.auth0").unwrap_err(),
280            ExtensionRefParseError::MissingScheme
281        );
282    }
283
284    #[test]
285    fn rejects_empty_path() {
286        assert_eq!(
287            ExtensionRef::try_new("ext://").unwrap_err(),
288            ExtensionRefParseError::EmptyPath
289        );
290        assert_eq!(
291            ExtensionRef::try_new("ext:///primary").unwrap_err(),
292            ExtensionRefParseError::EmptyPath
293        );
294    }
295
296    #[test]
297    fn rejects_path_without_dot() {
298        assert_eq!(
299            ExtensionRef::try_new("ext://oauth").unwrap_err(),
300            ExtensionRefParseError::PathMissingDot
301        );
302    }
303
304    #[test]
305    fn rejects_invalid_path_char() {
306        assert_eq!(
307            ExtensionRef::try_new("ext://Acme.Oauth").unwrap_err(),
308            ExtensionRefParseError::InvalidPathChar('A')
309        );
310    }
311
312    #[test]
313    fn rejects_empty_instance() {
314        assert_eq!(
315            ExtensionRef::try_new("ext://acme.oauth/").unwrap_err(),
316            ExtensionRefParseError::EmptyInstance
317        );
318    }
319
320    #[test]
321    fn rejects_second_path_segment_via_instance_charset() {
322        // A second `/` lands inside the instance id and is rejected — an
323        // extension ref is always exactly two segments.
324        assert_eq!(
325            ExtensionRef::try_new("ext://acme.oauth/inst/extra").unwrap_err(),
326            ExtensionRefParseError::InvalidInstanceChar('/')
327        );
328    }
329
330    #[test]
331    fn rejects_dot_in_instance() {
332        assert_eq!(
333            ExtensionRef::try_new("ext://acme.oauth/inst.bad").unwrap_err(),
334            ExtensionRefParseError::InvalidInstanceChar('.')
335        );
336    }
337
338    #[test]
339    fn serde_round_trips_through_string() {
340        let r = ExtensionRef::try_new("ext://acme.oauth.auth0/primary").unwrap();
341        let json = serde_json::to_string(&r).unwrap();
342        assert_eq!(json, "\"ext://acme.oauth.auth0/primary\"");
343        let back: ExtensionRef = serde_json::from_str(&json).unwrap();
344        assert_eq!(back, r);
345    }
346}