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
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 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 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 SecretRef, SecretRefParseError, SECRET_SCHEME
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 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 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}