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
20const SECRET_SCHEME: &str = "secret://";
21const RUNTIME_SCHEME: &str = "runtime://";
22const EXTENSION_SCHEME: &str = "ext://";
23
24macro_rules! uri_ref {
25    ($(#[$meta:meta])* $name:ident, $err:ident, $scheme:expr) => {
26        $(#[$meta])*
27        #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
28        #[serde(try_from = "String", into = "String")]
29        pub struct $name(String);
30
31        impl $name {
32            pub fn try_new(raw: impl Into<String>) -> Result<Self, $err> {
33                let raw = raw.into();
34                if !raw.starts_with($scheme) {
35                    return Err($err::MissingScheme);
36                }
37                if raw.len() == $scheme.len() {
38                    return Err($err::EmptyPath);
39                }
40                // First segment after the scheme is the env identifier; refs
41                // are documented as `<scheme>://<env>/<path...>`. The env
42                // segment must be present and non-empty so callers can scope
43                // a ref to its owning environment.
44                let after_scheme = &raw[$scheme.len()..];
45                let env_seg = match after_scheme.find('/') {
46                    Some(idx) => &after_scheme[..idx],
47                    None => after_scheme,
48                };
49                if env_seg.is_empty() {
50                    return Err($err::EmptyEnvSegment);
51                }
52                Ok(Self(raw))
53            }
54
55            pub fn as_str(&self) -> &str {
56                &self.0
57            }
58
59            /// First path segment after the scheme — the env id the ref is
60            /// scoped to. Returns `None` if the ref was constructed by a
61            /// future version of this crate that bypassed [`Self::try_new`]
62            /// (current invariant: `Self::try_new` always populates this).
63            pub fn env_segment(&self) -> &str {
64                let after_scheme = &self.0[$scheme.len()..];
65                match after_scheme.find('/') {
66                    Some(idx) => &after_scheme[..idx],
67                    None => after_scheme,
68                }
69            }
70        }
71
72        impl fmt::Display for $name {
73            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74                f.write_str(&self.0)
75            }
76        }
77
78        impl FromStr for $name {
79            type Err = $err;
80
81            fn from_str(s: &str) -> Result<Self, Self::Err> {
82                Self::try_new(s)
83            }
84        }
85
86        impl TryFrom<String> for $name {
87            type Error = $err;
88
89            fn try_from(value: String) -> Result<Self, Self::Error> {
90                Self::try_new(value)
91            }
92        }
93
94        impl From<$name> for String {
95            fn from(value: $name) -> Self {
96                value.0
97            }
98        }
99    };
100}
101
102uri_ref!(
103    /// Reference into the env's secrets env-pack: `secret://<env>/<path>`.
104    SecretRef, SecretRefParseError, SECRET_SCHEME
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 SecretRefParseError {
249    #[error("secret-ref must start with `secret://`")]
250    MissingScheme,
251    #[error("secret-ref path is empty")]
252    EmptyPath,
253    #[error("secret-ref must carry an env segment: `secret://<env>/<path>`")]
254    EmptyEnvSegment,
255}
256
257#[derive(Debug, Error, PartialEq, Eq)]
258pub enum RuntimeRefParseError {
259    #[error("runtime-ref must start with `runtime://`")]
260    MissingScheme,
261    #[error("runtime-ref path is empty")]
262    EmptyPath,
263    #[error("runtime-ref must carry an env segment: `runtime://<env>/<path>`")]
264    EmptyEnvSegment,
265}
266
267#[cfg(test)]
268mod extension_ref_tests {
269    use super::*;
270
271    #[test]
272    fn parses_path_only() {
273        let r = ExtensionRef::try_new("ext://acme.oauth.auth0").unwrap();
274        assert_eq!(r.path(), "acme.oauth.auth0");
275        assert_eq!(r.instance_id(), None);
276        assert_eq!(r.as_str(), "ext://acme.oauth.auth0");
277    }
278
279    #[test]
280    fn parses_path_with_instance() {
281        let r = ExtensionRef::try_new("ext://acme.oauth.auth0/primary").unwrap();
282        assert_eq!(r.path(), "acme.oauth.auth0");
283        assert_eq!(r.instance_id(), Some("primary"));
284    }
285
286    #[test]
287    fn rejects_missing_scheme() {
288        assert_eq!(
289            ExtensionRef::try_new("acme.oauth.auth0").unwrap_err(),
290            ExtensionRefParseError::MissingScheme
291        );
292    }
293
294    #[test]
295    fn rejects_empty_path() {
296        assert_eq!(
297            ExtensionRef::try_new("ext://").unwrap_err(),
298            ExtensionRefParseError::EmptyPath
299        );
300        assert_eq!(
301            ExtensionRef::try_new("ext:///primary").unwrap_err(),
302            ExtensionRefParseError::EmptyPath
303        );
304    }
305
306    #[test]
307    fn rejects_path_without_dot() {
308        assert_eq!(
309            ExtensionRef::try_new("ext://oauth").unwrap_err(),
310            ExtensionRefParseError::PathMissingDot
311        );
312    }
313
314    #[test]
315    fn rejects_invalid_path_char() {
316        assert_eq!(
317            ExtensionRef::try_new("ext://Acme.Oauth").unwrap_err(),
318            ExtensionRefParseError::InvalidPathChar('A')
319        );
320    }
321
322    #[test]
323    fn rejects_empty_instance() {
324        assert_eq!(
325            ExtensionRef::try_new("ext://acme.oauth/").unwrap_err(),
326            ExtensionRefParseError::EmptyInstance
327        );
328    }
329
330    #[test]
331    fn rejects_second_path_segment_via_instance_charset() {
332        // A second `/` lands inside the instance id and is rejected — an
333        // extension ref is always exactly two segments.
334        assert_eq!(
335            ExtensionRef::try_new("ext://acme.oauth/inst/extra").unwrap_err(),
336            ExtensionRefParseError::InvalidInstanceChar('/')
337        );
338    }
339
340    #[test]
341    fn rejects_dot_in_instance() {
342        assert_eq!(
343            ExtensionRef::try_new("ext://acme.oauth/inst.bad").unwrap_err(),
344            ExtensionRefParseError::InvalidInstanceChar('.')
345        );
346    }
347
348    #[test]
349    fn serde_round_trips_through_string() {
350        let r = ExtensionRef::try_new("ext://acme.oauth.auth0/primary").unwrap();
351        let json = serde_json::to_string(&r).unwrap();
352        assert_eq!(json, "\"ext://acme.oauth.auth0/primary\"");
353        let back: ExtensionRef = serde_json::from_str(&json).unwrap();
354        assert_eq!(back, r);
355    }
356}