Skip to main content

zlayer_types/secrets/
types.rs

1//! Data types for the secrets domain.
2//!
3//! Lifted into `zlayer-types` so cross-crate consumers (`zlayer-api`,
4//! `zlayer-agent`, the CLI) can name secrets shapes without depending on
5//! `zlayer-secrets`. The `zlayer-secrets` crate re-exports these for
6//! backward compatibility.
7
8use secrecy::{ExposeSecret, SecretString};
9use serde::{Deserialize, Serialize};
10use zeroize::Zeroize;
11
12/// A secure secret wrapper that provides memory safety guarantees.
13///
14/// - Implements `Zeroize` for secure memory cleanup on drop
15/// - Debug output shows `[REDACTED]` instead of the actual value
16/// - Uses `SecretString` from the secrecy crate for the underlying storage
17#[derive(Clone)]
18pub struct Secret {
19    inner: SecretString,
20}
21
22impl Secret {
23    /// Create a new secret from a string value.
24    pub fn new(value: impl Into<String>) -> Self {
25        Self {
26            inner: SecretString::from(value.into()),
27        }
28    }
29
30    /// Expose the secret value for use.
31    ///
32    /// This should only be called when the secret value is actually needed,
33    /// such as when passing to an external service or writing to an encrypted store.
34    #[must_use]
35    pub fn expose(&self) -> &str {
36        self.inner.expose_secret()
37    }
38
39    /// Get the underlying `SecretString` reference.
40    #[must_use]
41    pub fn as_secret_string(&self) -> &SecretString {
42        &self.inner
43    }
44}
45
46impl std::fmt::Debug for Secret {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        f.write_str("[REDACTED]")
49    }
50}
51
52impl Zeroize for Secret {
53    fn zeroize(&mut self) {
54        // SecretString handles its own zeroization on drop,
55        // but we implement the trait for explicit zeroize calls.
56        // We replace with an empty secret to trigger cleanup.
57        self.inner = SecretString::from(String::new());
58    }
59}
60
61impl From<String> for Secret {
62    fn from(value: String) -> Self {
63        Self::new(value)
64    }
65}
66
67impl From<&str> for Secret {
68    fn from(value: &str) -> Self {
69        Self::new(value)
70    }
71}
72
73impl From<SecretString> for Secret {
74    fn from(value: SecretString) -> Self {
75        Self { inner: value }
76    }
77}
78
79/// Metadata associated with a stored secret.
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
81pub struct SecretMetadata {
82    /// The name/identifier of the secret.
83    pub name: String,
84
85    /// Unix timestamp when the secret was created.
86    pub created_at: i64,
87
88    /// Unix timestamp when the secret was last updated.
89    pub updated_at: i64,
90
91    /// Version number of the secret (incremented on each update).
92    pub version: u32,
93}
94
95impl SecretMetadata {
96    /// Create new metadata for a secret.
97    #[allow(clippy::cast_possible_wrap)]
98    pub fn new(name: impl Into<String>) -> Self {
99        let now = std::time::SystemTime::now()
100            .duration_since(std::time::UNIX_EPOCH)
101            .unwrap_or_default()
102            .as_secs() as i64;
103
104        Self {
105            name: name.into(),
106            created_at: now,
107            updated_at: now,
108            version: 1,
109        }
110    }
111
112    /// Update the metadata for a secret modification.
113    #[allow(clippy::cast_possible_wrap)]
114    pub fn update(&mut self) {
115        let now = std::time::SystemTime::now()
116            .duration_since(std::time::UNIX_EPOCH)
117            .unwrap_or_default()
118            .as_secs() as i64;
119
120        self.updated_at = now;
121        self.version = self.version.saturating_add(1);
122    }
123}
124
125/// Result of a secret rotation — records the version before and after the rotate call.
126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
127pub struct RotationResult {
128    /// Version number prior to rotation. `None` if the secret did not exist before.
129    pub previous_version: Option<u32>,
130    /// Version number after rotation (always set — it's a fresh write).
131    pub new_version: u32,
132}
133
134/// The scope of a secret - determines visibility and access.
135#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
136pub enum SecretScope {
137    /// Deployment-level secret, accessible by all services in the deployment.
138    Deployment(String),
139
140    /// Service-level secret, accessible only by the specified service.
141    Service {
142        /// The deployment this service belongs to.
143        deployment: String,
144        /// The specific service name.
145        service: String,
146    },
147}
148
149impl SecretScope {
150    /// Create a deployment-scoped secret scope.
151    pub fn deployment(name: impl Into<String>) -> Self {
152        Self::Deployment(name.into())
153    }
154
155    /// Create a service-scoped secret scope.
156    pub fn service(deployment: impl Into<String>, service: impl Into<String>) -> Self {
157        Self::Service {
158            deployment: deployment.into(),
159            service: service.into(),
160        }
161    }
162
163    /// Generate a key prefix for storage lookups.
164    ///
165    /// Returns a path-like prefix that can be used to organize secrets
166    /// in a hierarchical store.
167    #[must_use]
168    pub fn to_key_prefix(&self) -> String {
169        match self {
170            Self::Deployment(deployment) => format!("deployments/{deployment}/secrets"),
171            Self::Service {
172                deployment,
173                service,
174            } => format!("deployments/{deployment}/services/{service}/secrets"),
175        }
176    }
177
178    /// Get the deployment name for this scope.
179    #[must_use]
180    pub fn deployment_name(&self) -> &str {
181        match self {
182            Self::Deployment(name) => name,
183            Self::Service { deployment, .. } => deployment,
184        }
185    }
186
187    /// Get the service name if this is a service-scoped secret.
188    #[must_use]
189    pub fn service_name(&self) -> Option<&str> {
190        match self {
191            Self::Deployment(_) => None,
192            Self::Service { service, .. } => Some(service),
193        }
194    }
195}
196
197/// A reference to a secret, parsed from the `$S:` prefix syntax.
198///
199/// ## Formats
200/// - `$S:name` - Deployment-level secret
201/// - `$S:name/field` - Deployment-level secret with JSON field extraction
202/// - `$S:@service/name[/field]` - Service-level secret
203/// - `$S::env/name[/field]` - Environment-scoped secret (global environment)
204/// - `$S:project:env/name[/field]` - Project-scoped environment secret
205///
206/// ## Environment-scope disambiguation
207/// Environment-scoped references require a colon in the scope segment so
208/// they cannot be confused with the legacy deployment-level `name/field`
209/// form. The leading colon in `$S::env/name` marks "no project, scope is
210/// environment `env`". The `$S:project:env/name` form is unambiguous
211/// because it already contains a colon.
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213pub struct SecretRef {
214    /// The name of the secret.
215    pub name: String,
216
217    /// Optional service name (for service-scoped secrets).
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub service: Option<String>,
220
221    /// Optional project name (for project-scoped environment secrets).
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub project: Option<String>,
224
225    /// Optional environment name (for environment-scoped secrets).
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub environment: Option<String>,
228
229    /// Optional field to extract from a structured secret (e.g., JSON).
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub field: Option<String>,
232}
233
234impl SecretRef {
235    /// The prefix used to identify secret references in configuration values.
236    pub const PREFIX: &'static str = "$S:";
237
238    /// Check if a string value is a secret reference.
239    #[must_use]
240    pub fn is_secret_ref(value: &str) -> bool {
241        value.starts_with(Self::PREFIX)
242    }
243
244    /// Parse a secret reference from a string.
245    ///
246    /// ## Formats
247    /// - `$S:name` - Deployment-level secret
248    /// - `$S:name/field` - Deployment-level secret with JSON field extraction
249    /// - `$S:@service/name[/field]` - Service-level secret
250    /// - `$S::env/name[/field]` - Environment-scoped secret (global environment)
251    /// - `$S:project:env/name[/field]` - Project-scoped environment secret
252    ///
253    /// Returns `None` if the string is not a valid secret reference.
254    #[must_use]
255    pub fn parse(value: &str) -> Option<Self> {
256        // Must start with the prefix
257        let rest = value.strip_prefix(Self::PREFIX)?;
258
259        // Empty reference is invalid
260        if rest.is_empty() {
261            return None;
262        }
263
264        // Service-scoped: $S:@service/name[/field]
265        if let Some(service_rest) = rest.strip_prefix('@') {
266            let mut parts = service_rest.splitn(3, '/');
267
268            let service = parts.next()?;
269            if service.is_empty() {
270                return None;
271            }
272
273            let name = parts.next()?;
274            if name.is_empty() {
275                return None;
276            }
277
278            let field = parts
279                .next()
280                .map(ToString::to_string)
281                .filter(|s| !s.is_empty());
282
283            return Some(Self {
284                name: name.to_string(),
285                service: Some(service.to_string()),
286                project: None,
287                environment: None,
288                field,
289            });
290        }
291
292        // Reject leading '/' (e.g. "$S:/name"): no scope/name segment.
293        if rest.starts_with('/') {
294            return None;
295        }
296
297        // Environment-scoped forms: the scope segment (everything before the
298        // first '/') must contain a ':'. This disambiguates against the
299        // legacy deployment-level `name/field` form, which contains no ':'.
300        // Supported:
301        //   $S::env/name[/field]        -> environment-only (empty project)
302        //   $S:project:env/name[/field] -> project-scoped environment
303        if let Some((scope, tail)) = rest.split_once('/') {
304            if scope.contains(':') {
305                return Self::parse_env_scoped(scope, tail);
306            }
307            // Legacy deployment-level with field: name/field
308            if scope.is_empty() {
309                return None;
310            }
311            // Reject stray ':' inside a plain name (e.g. "$S:foo:bar/baz")
312            // is handled above (scope contains ':'); also reject the "name
313            // only" form when it contains colons further down.
314            if tail.is_empty() {
315                return None;
316            }
317            // Tail itself must not contain further '/' after the field
318            // (legacy grammar is exactly `name/field`).
319            if tail.contains('/') {
320                return None;
321            }
322            return Some(Self {
323                name: scope.to_string(),
324                service: None,
325                project: None,
326                environment: None,
327                field: Some(tail.to_string()),
328            });
329        }
330
331        // No '/' in rest: it's either a plain deployment name or malformed.
332        // A name containing ':' is treated as malformed (e.g. "$S:::name").
333        if rest.contains(':') {
334            return None;
335        }
336        Some(Self {
337            name: rest.to_string(),
338            service: None,
339            project: None,
340            environment: None,
341            field: None,
342        })
343    }
344
345    /// Parse the environment-scoped forms (scope segment contains ':').
346    ///
347    /// `scope` is the pre-'/' segment, `tail` is everything after the first
348    /// '/', which must contain at least a non-empty name, optionally
349    /// followed by `/field`.
350    fn parse_env_scoped(scope: &str, tail: &str) -> Option<Self> {
351        // Extract the project/env split.
352        let (project_raw, environment) = scope.split_once(':')?;
353        // Reject environments containing further colons (e.g. "a:b:c").
354        if environment.is_empty() || environment.contains(':') {
355            return None;
356        }
357        let project = if project_raw.is_empty() {
358            None
359        } else {
360            Some(project_raw.to_string())
361        };
362
363        // Split tail into name and optional field.
364        let (name, field) = match tail.split_once('/') {
365            Some((name, field)) => {
366                if field.is_empty() || field.contains('/') {
367                    return None;
368                }
369                (name, Some(field.to_string()))
370            }
371            None => (tail, None),
372        };
373
374        if name.is_empty() {
375            return None;
376        }
377
378        Some(Self {
379            name: name.to_string(),
380            service: None,
381            project,
382            environment: Some(environment.to_string()),
383            field,
384        })
385    }
386
387    /// Convert this reference to a `SecretScope` for a given deployment.
388    ///
389    /// Environment/project scopes are not representable by `SecretScope`
390    /// (which only models deployment vs service); those references fall
391    /// back to a deployment-level scope and must be resolved by a higher
392    /// layer that understands environment and project scoping.
393    #[must_use]
394    pub fn to_scope(&self, deployment: &str) -> SecretScope {
395        match &self.service {
396            Some(service) => SecretScope::Service {
397                deployment: deployment.to_string(),
398                service: service.clone(),
399            },
400            None => SecretScope::Deployment(deployment.to_string()),
401        }
402    }
403
404    /// Check if this is a deployment-level secret reference.
405    #[must_use]
406    pub fn is_deployment_level(&self) -> bool {
407        self.service.is_none() && self.project.is_none() && self.environment.is_none()
408    }
409
410    /// Check if this is a service-level secret reference.
411    #[must_use]
412    pub fn is_service_level(&self) -> bool {
413        self.service.is_some()
414    }
415
416    /// Check if this reference is environment-scoped (with or without a project).
417    #[must_use]
418    pub fn is_environment_level(&self) -> bool {
419        self.environment.is_some()
420    }
421
422    /// Check if this reference is scoped to a project environment.
423    #[must_use]
424    pub fn is_project_environment_level(&self) -> bool {
425        self.project.is_some() && self.environment.is_some()
426    }
427
428    /// Check if this reference includes field extraction.
429    #[must_use]
430    pub fn has_field(&self) -> bool {
431        self.field.is_some()
432    }
433}
434
435impl std::fmt::Display for SecretRef {
436    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
437        f.write_str(Self::PREFIX)?;
438        if let Some(service) = &self.service {
439            write!(f, "@{service}/{}", self.name)?;
440            if let Some(field) = &self.field {
441                write!(f, "/{field}")?;
442            }
443        } else if let Some(environment) = &self.environment {
444            if let Some(project) = &self.project {
445                write!(f, "{project}:{environment}/{}", self.name)?;
446            } else {
447                write!(f, ":{environment}/{}", self.name)?;
448            }
449            if let Some(field) = &self.field {
450                write!(f, "/{field}")?;
451            }
452        } else {
453            // Deployment-level: `name` or `name/field`.
454            f.write_str(&self.name)?;
455            if let Some(field) = &self.field {
456                write!(f, "/{field}")?;
457            }
458        }
459        Ok(())
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn test_secret_debug_redacted() {
469        let secret = Secret::new("super-secret-value");
470        let debug_output = format!("{secret:?}");
471        assert_eq!(debug_output, "[REDACTED]");
472        assert!(!debug_output.contains("super-secret-value"));
473    }
474
475    #[test]
476    fn test_secret_expose() {
477        let secret = Secret::new("my-secret");
478        assert_eq!(secret.expose(), "my-secret");
479    }
480
481    #[test]
482    fn test_secret_from_string() {
483        let secret: Secret = "test-secret".into();
484        assert_eq!(secret.expose(), "test-secret");
485
486        let secret: Secret = String::from("another-secret").into();
487        assert_eq!(secret.expose(), "another-secret");
488    }
489
490    #[test]
491    fn test_secret_zeroize() {
492        let mut secret = Secret::new("sensitive-data");
493        secret.zeroize();
494        // After zeroize, the secret should be empty
495        assert_eq!(secret.expose(), "");
496    }
497
498    #[test]
499    fn test_secret_metadata_new() {
500        let metadata = SecretMetadata::new("test-secret");
501        assert_eq!(metadata.name, "test-secret");
502        assert_eq!(metadata.version, 1);
503        assert!(metadata.created_at > 0);
504        assert_eq!(metadata.created_at, metadata.updated_at);
505    }
506
507    #[test]
508    fn test_secret_metadata_update() {
509        let mut metadata = SecretMetadata::new("test-secret");
510        let original_created = metadata.created_at;
511        let original_version = metadata.version;
512
513        // Simulate time passing
514        std::thread::sleep(std::time::Duration::from_millis(10));
515        metadata.update();
516
517        assert_eq!(metadata.created_at, original_created);
518        assert!(metadata.updated_at >= original_created);
519        assert_eq!(metadata.version, original_version + 1);
520    }
521
522    #[test]
523    fn test_secret_scope_deployment() {
524        let scope = SecretScope::deployment("my-deployment");
525        assert_eq!(scope.deployment_name(), "my-deployment");
526        assert!(scope.service_name().is_none());
527        assert_eq!(scope.to_key_prefix(), "deployments/my-deployment/secrets");
528    }
529
530    #[test]
531    fn test_secret_scope_service() {
532        let scope = SecretScope::service("my-deployment", "my-service");
533        assert_eq!(scope.deployment_name(), "my-deployment");
534        assert_eq!(scope.service_name(), Some("my-service"));
535        assert_eq!(
536            scope.to_key_prefix(),
537            "deployments/my-deployment/services/my-service/secrets"
538        );
539    }
540
541    #[test]
542    fn test_secret_ref_is_secret_ref() {
543        assert!(SecretRef::is_secret_ref("$S:my-secret"));
544        assert!(SecretRef::is_secret_ref("$S:@service/secret"));
545        assert!(!SecretRef::is_secret_ref("my-secret"));
546        assert!(!SecretRef::is_secret_ref("S:my-secret"));
547        assert!(!SecretRef::is_secret_ref("$:my-secret"));
548    }
549
550    #[test]
551    fn test_secret_ref_parse_deployment_level() {
552        let secret_ref = SecretRef::parse("$S:database-password").unwrap();
553        assert_eq!(secret_ref.name, "database-password");
554        assert!(secret_ref.service.is_none());
555        assert!(secret_ref.project.is_none());
556        assert!(secret_ref.environment.is_none());
557        assert!(secret_ref.field.is_none());
558        assert!(secret_ref.is_deployment_level());
559    }
560
561    #[test]
562    fn test_secret_ref_parse_service_level() {
563        let secret_ref = SecretRef::parse("$S:@api/database-password").unwrap();
564        assert_eq!(secret_ref.name, "database-password");
565        assert_eq!(secret_ref.service, Some("api".to_string()));
566        assert!(secret_ref.project.is_none());
567        assert!(secret_ref.environment.is_none());
568        assert!(secret_ref.field.is_none());
569        assert!(secret_ref.is_service_level());
570    }
571
572    #[test]
573    fn test_secret_ref_parse_service_level_with_field() {
574        let secret_ref = SecretRef::parse("$S:@api/database/password").unwrap();
575        assert_eq!(secret_ref.name, "database");
576        assert_eq!(secret_ref.service, Some("api".to_string()));
577        assert_eq!(secret_ref.field, Some("password".to_string()));
578        assert!(secret_ref.has_field());
579    }
580
581    #[test]
582    fn test_secret_ref_parse_deployment_with_field_legacy() {
583        // Legacy deployment-level with field: `name/field`.
584        let secret_ref = SecretRef::parse("$S:database/password").unwrap();
585        assert_eq!(secret_ref.name, "database");
586        assert!(secret_ref.service.is_none());
587        assert!(secret_ref.project.is_none());
588        assert!(secret_ref.environment.is_none());
589        assert_eq!(secret_ref.field, Some("password".to_string()));
590        assert!(secret_ref.has_field());
591    }
592
593    #[test]
594    fn test_secret_ref_parse_environment_level() {
595        let secret_ref = SecretRef::parse("$S::staging/db-password").unwrap();
596        assert_eq!(secret_ref.name, "db-password");
597        assert_eq!(secret_ref.environment, Some("staging".to_string()));
598        assert!(secret_ref.project.is_none());
599        assert!(secret_ref.service.is_none());
600        assert!(secret_ref.field.is_none());
601        assert!(secret_ref.is_environment_level());
602        assert!(!secret_ref.is_project_environment_level());
603        assert!(!secret_ref.is_deployment_level());
604    }
605
606    #[test]
607    fn test_secret_ref_parse_environment_level_with_field() {
608        let secret_ref = SecretRef::parse("$S::staging/db-creds/password").unwrap();
609        assert_eq!(secret_ref.name, "db-creds");
610        assert_eq!(secret_ref.environment, Some("staging".to_string()));
611        assert!(secret_ref.project.is_none());
612        assert_eq!(secret_ref.field, Some("password".to_string()));
613    }
614
615    #[test]
616    fn test_secret_ref_parse_project_environment_level() {
617        let secret_ref = SecretRef::parse("$S:myproj:staging/db-password").unwrap();
618        assert_eq!(secret_ref.name, "db-password");
619        assert_eq!(secret_ref.project, Some("myproj".to_string()));
620        assert_eq!(secret_ref.environment, Some("staging".to_string()));
621        assert!(secret_ref.service.is_none());
622        assert!(secret_ref.field.is_none());
623        assert!(secret_ref.is_environment_level());
624        assert!(secret_ref.is_project_environment_level());
625    }
626
627    #[test]
628    fn test_secret_ref_parse_project_environment_with_field() {
629        let secret_ref = SecretRef::parse("$S:myproj:prod/creds/api_key").unwrap();
630        assert_eq!(secret_ref.name, "creds");
631        assert_eq!(secret_ref.project, Some("myproj".to_string()));
632        assert_eq!(secret_ref.environment, Some("prod".to_string()));
633        assert_eq!(secret_ref.field, Some("api_key".to_string()));
634    }
635
636    #[test]
637    fn test_secret_ref_parse_invalid() {
638        // No prefix
639        assert!(SecretRef::parse("database-password").is_none());
640
641        // Empty after prefix
642        assert!(SecretRef::parse("$S:").is_none());
643
644        // Empty service name
645        assert!(SecretRef::parse("$S:@/secret").is_none());
646
647        // Empty secret name after service
648        assert!(SecretRef::parse("$S:@service/").is_none());
649
650        // Just @ with no service
651        assert!(SecretRef::parse("$S:@").is_none());
652
653        // Leading slash (empty scope/name)
654        assert!(SecretRef::parse("$S:/name").is_none());
655
656        // Empty name in legacy `name/field` form
657        assert!(SecretRef::parse("$S:database/").is_none());
658
659        // Triple-colon flat form (no '/'): colons disallowed in bare names
660        assert!(SecretRef::parse("$S:::name").is_none());
661
662        // Empty environment in env-only form ("$S::/name")
663        assert!(SecretRef::parse("$S::/name").is_none());
664
665        // Empty environment with project ("proj:/name")
666        assert!(SecretRef::parse("$S:proj:/name").is_none());
667
668        // Extra colon in scope ("a:b:c/name")
669        assert!(SecretRef::parse("$S:a:b:c/name").is_none());
670
671        // Env-scoped with empty name
672        assert!(SecretRef::parse("$S::env/").is_none());
673
674        // Extra trailing segment on legacy deployment form
675        assert!(SecretRef::parse("$S:name/field/extra").is_none());
676    }
677
678    #[test]
679    fn test_secret_ref_display_roundtrip() {
680        let cases = [
681            "$S:database-password",
682            "$S:database/password",
683            "$S:@api/database-password",
684            "$S:@api/database/password",
685            "$S::staging/db-password",
686            "$S::staging/db-creds/password",
687            "$S:myproj:staging/db-password",
688            "$S:myproj:prod/creds/api_key",
689        ];
690
691        for input in cases {
692            let parsed =
693                SecretRef::parse(input).unwrap_or_else(|| panic!("failed to parse {input}"));
694            let rendered = parsed.to_string();
695            assert_eq!(rendered, input, "round-trip mismatch for {input}");
696            let reparsed = SecretRef::parse(&rendered)
697                .unwrap_or_else(|| panic!("failed to re-parse {rendered}"));
698            assert_eq!(parsed, reparsed);
699        }
700    }
701
702    #[test]
703    fn test_secret_ref_serde_backcompat() {
704        // Pre-existing JSON without project/environment fields must still deserialize.
705        let json = r#"{"name":"db","service":"api","field":"password"}"#;
706        let parsed: SecretRef = serde_json::from_str(json).unwrap();
707        assert_eq!(parsed.name, "db");
708        assert_eq!(parsed.service, Some("api".to_string()));
709        assert_eq!(parsed.field, Some("password".to_string()));
710        assert!(parsed.project.is_none());
711        assert!(parsed.environment.is_none());
712
713        // Minimal form (just a name).
714        let minimal = r#"{"name":"db"}"#;
715        let parsed: SecretRef = serde_json::from_str(minimal).unwrap();
716        assert_eq!(parsed.name, "db");
717        assert!(parsed.service.is_none());
718        assert!(parsed.project.is_none());
719        assert!(parsed.environment.is_none());
720        assert!(parsed.field.is_none());
721    }
722
723    #[test]
724    fn test_secret_ref_to_scope() {
725        // Deployment-level
726        let secret_ref = SecretRef::parse("$S:my-secret").unwrap();
727        let scope = secret_ref.to_scope("prod");
728        assert_eq!(scope, SecretScope::Deployment("prod".to_string()));
729
730        // Service-level
731        let secret_ref = SecretRef::parse("$S:@api/my-secret").unwrap();
732        let scope = secret_ref.to_scope("prod");
733        assert_eq!(
734            scope,
735            SecretScope::Service {
736                deployment: "prod".to_string(),
737                service: "api".to_string(),
738            }
739        );
740    }
741
742    #[test]
743    fn test_secret_metadata_serialization() {
744        let metadata = SecretMetadata {
745            name: "test".to_string(),
746            created_at: 1_234_567_890,
747            updated_at: 1_234_567_900,
748            version: 5,
749        };
750
751        let json = serde_json::to_string(&metadata).unwrap();
752        let deserialized: SecretMetadata = serde_json::from_str(&json).unwrap();
753
754        assert_eq!(metadata, deserialized);
755    }
756
757    #[test]
758    fn test_secret_scope_serialization() {
759        let deployment_scope = SecretScope::deployment("my-deploy");
760        let json = serde_json::to_string(&deployment_scope).unwrap();
761        let deserialized: SecretScope = serde_json::from_str(&json).unwrap();
762        assert_eq!(deployment_scope, deserialized);
763
764        let service_scope = SecretScope::service("my-deploy", "my-service");
765        let json = serde_json::to_string(&service_scope).unwrap();
766        let deserialized: SecretScope = serde_json::from_str(&json).unwrap();
767        assert_eq!(service_scope, deserialized);
768    }
769}