greentic_deploy_spec/
refs.rs1use crate::capability_slot::descriptor_path_char_ok;
15use serde::{Deserialize, Serialize};
16use std::fmt;
17use std::str::FromStr;
18use thiserror::Error;
19
20pub 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 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 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 RuntimeRef, RuntimeRefParseError, RUNTIME_SCHEME
111);
112
113pub(crate) fn instance_id_char_ok(ch: char) -> bool {
118 ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-'
119}
120
121#[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 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 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 pub fn path(&self) -> &str {
180 &self.path
181 }
182
183 pub fn instance_id(&self) -> Option<&str> {
185 self.instance_id.as_deref()
186 }
187}
188
189pub(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 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}