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 — the single owner of the storage-scope grammar.
135///
136/// A secret is stored under the key `"{scope}:{name}"`, where `{scope}` is the
137/// string produced by [`SecretScope::to_storage_scope`]. This enum is the one
138/// place that knows how each logical scope maps to (and parses back from) that
139/// on-disk scope string, so the grammar is not re-implemented per crate.
140///
141/// ## Storage-scope strings
142/// - `Deployment(d)`                            => `d`
143/// - `Service { deployment, service }`          => `deployment/service`
144/// - `Environment { env_id }`                   => `env:{env_id}`
145/// - `ProjectEnvironment { project_id, env_id }`=> `project:{project_id}:env:{env_id}`
146/// - `Custom(s)`                                => `s` (opaque, verbatim)
147#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
148pub enum SecretScope {
149    /// Deployment-level secret, accessible by all services in the deployment.
150    Deployment(String),
151
152    /// Service-level secret, accessible only by the specified service.
153    Service {
154        /// The deployment this service belongs to.
155        deployment: String,
156        /// The specific service name.
157        service: String,
158    },
159
160    /// Global environment-scoped secret (no project).
161    ///
162    /// Storage scope: `env:{env_id}`.
163    Environment {
164        /// The environment identifier.
165        env_id: String,
166    },
167
168    /// Project-scoped environment secret.
169    ///
170    /// Storage scope: `project:{project_id}:env:{env_id}`.
171    ProjectEnvironment {
172        /// The project identifier.
173        project_id: String,
174        /// The environment identifier.
175        env_id: String,
176    },
177
178    /// Opaque fallback scope carrying a storage-scope string verbatim.
179    ///
180    /// Produced by [`SecretScope::from_storage_scope`] for any scope string
181    /// that does not match a recognized environment grammar (e.g. the
182    /// `"default"` scope, or bare/`/`-containing deployment and service forms).
183    Custom(String),
184}
185
186impl SecretScope {
187    /// Create a deployment-scoped secret scope.
188    pub fn deployment(name: impl Into<String>) -> Self {
189        Self::Deployment(name.into())
190    }
191
192    /// Create a service-scoped secret scope.
193    pub fn service(deployment: impl Into<String>, service: impl Into<String>) -> Self {
194        Self::Service {
195            deployment: deployment.into(),
196            service: service.into(),
197        }
198    }
199
200    /// Create a global environment-scoped secret scope (no project).
201    pub fn environment(env_id: impl Into<String>) -> Self {
202        Self::Environment {
203            env_id: env_id.into(),
204        }
205    }
206
207    /// Create a project-scoped environment secret scope.
208    pub fn project_environment(project_id: impl Into<String>, env_id: impl Into<String>) -> Self {
209        Self::ProjectEnvironment {
210            project_id: project_id.into(),
211            env_id: env_id.into(),
212        }
213    }
214
215    /// Build an environment scope from an optional project and an env id.
216    ///
217    /// Returns [`SecretScope::ProjectEnvironment`] when `project_id` is `Some`,
218    /// else [`SecretScope::Environment`]. Mirrors the `env_scope()` helper in
219    /// `zlayer-api` exactly.
220    #[must_use]
221    pub fn for_env(project_id: Option<&str>, env_id: &str) -> Self {
222        match project_id {
223            Some(pid) => Self::project_environment(pid, env_id),
224            None => Self::environment(env_id),
225        }
226    }
227
228    /// Render the storage-scope string for this scope.
229    ///
230    /// This is the `{scope}` half of the `"{scope}:{name}"` storage key. It is
231    /// the inverse of [`SecretScope::from_storage_scope`] for the environment
232    /// and custom forms.
233    #[must_use]
234    pub fn to_storage_scope(&self) -> String {
235        match self {
236            Self::Deployment(deployment) => deployment.clone(),
237            Self::Service {
238                deployment,
239                service,
240            } => format!("{deployment}/{service}"),
241            Self::Environment { env_id } => format!("env:{env_id}"),
242            Self::ProjectEnvironment { project_id, env_id } => {
243                format!("project:{project_id}:env:{env_id}")
244            }
245            Self::Custom(scope) => scope.clone(),
246        }
247    }
248
249    /// Parse a storage-scope string back into a [`SecretScope`].
250    ///
251    /// Total parser — never fails. The inverse of
252    /// [`SecretScope::to_storage_scope`] for the environment and custom forms:
253    /// - `env:{id}` (non-empty `id`)                       => [`SecretScope::Environment`]
254    /// - `project:{pid}:env:{id}` (both non-empty)         => [`SecretScope::ProjectEnvironment`]
255    /// - anything else                                     => [`SecretScope::Custom`]
256    ///
257    /// Bare deployment names and `deployment/service` forms are not
258    /// distinguished here; they round-trip as [`SecretScope::Custom`]. Only the
259    /// environment and custom forms are guaranteed to round-trip by equality.
260    #[must_use]
261    pub fn from_storage_scope(scope: &str) -> Self {
262        if let Some(env_id) = scope.strip_prefix("env:") {
263            if !env_id.is_empty() {
264                return Self::Environment {
265                    env_id: env_id.to_string(),
266                };
267            }
268        }
269        if let Some(rest) = scope.strip_prefix("project:") {
270            if let Some((project_id, env_id)) = rest.split_once(":env:") {
271                if !project_id.is_empty() && !env_id.is_empty() {
272                    return Self::ProjectEnvironment {
273                        project_id: project_id.to_string(),
274                        env_id: env_id.to_string(),
275                    };
276                }
277            }
278        }
279        Self::Custom(scope.to_string())
280    }
281
282    /// Get the deployment name for this scope.
283    ///
284    /// Returns the empty string for environment/project-environment/custom
285    /// scopes, which have no deployment component.
286    #[must_use]
287    pub fn deployment_name(&self) -> &str {
288        match self {
289            Self::Deployment(name) => name,
290            Self::Service { deployment, .. } => deployment,
291            Self::Environment { .. } | Self::ProjectEnvironment { .. } | Self::Custom(_) => "",
292        }
293    }
294
295    /// Get the service name if this is a service-scoped secret.
296    #[must_use]
297    pub fn service_name(&self) -> Option<&str> {
298        match self {
299            Self::Service { service, .. } => Some(service),
300            Self::Deployment(_)
301            | Self::Environment { .. }
302            | Self::ProjectEnvironment { .. }
303            | Self::Custom(_) => None,
304        }
305    }
306
307    /// Get the environment id if this is an environment-shaped scope.
308    ///
309    /// Returns `Some` for [`SecretScope::Environment`] and
310    /// [`SecretScope::ProjectEnvironment`], else `None`.
311    #[must_use]
312    pub fn environment_id(&self) -> Option<&str> {
313        match self {
314            Self::Environment { env_id } | Self::ProjectEnvironment { env_id, .. } => Some(env_id),
315            Self::Deployment(_) | Self::Service { .. } | Self::Custom(_) => None,
316        }
317    }
318
319    /// Whether this scope is one of the environment-shaped variants.
320    #[must_use]
321    pub fn is_environment_shaped(&self) -> bool {
322        matches!(
323            self,
324            Self::Environment { .. } | Self::ProjectEnvironment { .. }
325        )
326    }
327}
328
329/// A reference to a secret, parsed from the `$S:` prefix syntax.
330///
331/// ## Formats
332/// - `$S:name` - Deployment-level secret
333/// - `$S:name/field` - Deployment-level secret with JSON field extraction
334/// - `$S:@service/name[/field]` - Service-level secret
335/// - `$S::env/name[/field]` - Environment-scoped secret (global environment)
336/// - `$S:project:env/name[/field]` - Project-scoped environment secret
337///
338/// ## Environment-scope disambiguation
339/// Environment-scoped references require a colon in the scope segment so
340/// they cannot be confused with the legacy deployment-level `name/field`
341/// form. The leading colon in `$S::env/name` marks "no project, scope is
342/// environment `env`". The `$S:project:env/name` form is unambiguous
343/// because it already contains a colon.
344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
345pub struct SecretRef {
346    /// The name of the secret.
347    pub name: String,
348
349    /// Optional service name (for service-scoped secrets).
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub service: Option<String>,
352
353    /// Optional project name (for project-scoped environment secrets).
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub project: Option<String>,
356
357    /// Optional environment name (for environment-scoped secrets).
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub environment: Option<String>,
360
361    /// Optional field to extract from a structured secret (e.g., JSON).
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub field: Option<String>,
364}
365
366impl SecretRef {
367    /// The prefix used to identify secret references in configuration values.
368    pub const PREFIX: &'static str = "$S:";
369
370    /// Check if a string value is a secret reference.
371    #[must_use]
372    pub fn is_secret_ref(value: &str) -> bool {
373        value.starts_with(Self::PREFIX)
374    }
375
376    /// Parse a secret reference from a string.
377    ///
378    /// ## Formats
379    /// - `$S:name` - Deployment-level secret
380    /// - `$S:name/field` - Deployment-level secret with JSON field extraction
381    /// - `$S:@service/name[/field]` - Service-level secret
382    /// - `$S::env/name[/field]` - Environment-scoped secret (global environment)
383    /// - `$S:project:env/name[/field]` - Project-scoped environment secret
384    ///
385    /// Returns `None` if the string is not a valid secret reference.
386    #[must_use]
387    pub fn parse(value: &str) -> Option<Self> {
388        // Must start with the prefix
389        let rest = value.strip_prefix(Self::PREFIX)?;
390
391        // Empty reference is invalid
392        if rest.is_empty() {
393            return None;
394        }
395
396        // Service-scoped: $S:@service/name[/field]
397        if let Some(service_rest) = rest.strip_prefix('@') {
398            let mut parts = service_rest.splitn(3, '/');
399
400            let service = parts.next()?;
401            if service.is_empty() {
402                return None;
403            }
404
405            let name = parts.next()?;
406            if name.is_empty() {
407                return None;
408            }
409
410            let field = parts
411                .next()
412                .map(ToString::to_string)
413                .filter(|s| !s.is_empty());
414
415            return Some(Self {
416                name: name.to_string(),
417                service: Some(service.to_string()),
418                project: None,
419                environment: None,
420                field,
421            });
422        }
423
424        // Reject leading '/' (e.g. "$S:/name"): no scope/name segment.
425        if rest.starts_with('/') {
426            return None;
427        }
428
429        // Environment-scoped forms: the scope segment (everything before the
430        // first '/') must contain a ':'. This disambiguates against the
431        // legacy deployment-level `name/field` form, which contains no ':'.
432        // Supported:
433        //   $S::env/name[/field]        -> environment-only (empty project)
434        //   $S:project:env/name[/field] -> project-scoped environment
435        if let Some((scope, tail)) = rest.split_once('/') {
436            if scope.contains(':') {
437                return Self::parse_env_scoped(scope, tail);
438            }
439            // Legacy deployment-level with field: name/field
440            if scope.is_empty() {
441                return None;
442            }
443            // Reject stray ':' inside a plain name (e.g. "$S:foo:bar/baz")
444            // is handled above (scope contains ':'); also reject the "name
445            // only" form when it contains colons further down.
446            if tail.is_empty() {
447                return None;
448            }
449            // Tail itself must not contain further '/' after the field
450            // (legacy grammar is exactly `name/field`).
451            if tail.contains('/') {
452                return None;
453            }
454            return Some(Self {
455                name: scope.to_string(),
456                service: None,
457                project: None,
458                environment: None,
459                field: Some(tail.to_string()),
460            });
461        }
462
463        // No '/' in rest: it's either a plain deployment name or malformed.
464        // A name containing ':' is treated as malformed (e.g. "$S:::name").
465        if rest.contains(':') {
466            return None;
467        }
468        Some(Self {
469            name: rest.to_string(),
470            service: None,
471            project: None,
472            environment: None,
473            field: None,
474        })
475    }
476
477    /// Parse the environment-scoped forms (scope segment contains ':').
478    ///
479    /// `scope` is the pre-'/' segment, `tail` is everything after the first
480    /// '/', which must contain at least a non-empty name, optionally
481    /// followed by `/field`.
482    fn parse_env_scoped(scope: &str, tail: &str) -> Option<Self> {
483        // Extract the project/env split.
484        let (project_raw, environment) = scope.split_once(':')?;
485        // Reject environments containing further colons (e.g. "a:b:c").
486        if environment.is_empty() || environment.contains(':') {
487            return None;
488        }
489        let project = if project_raw.is_empty() {
490            None
491        } else {
492            Some(project_raw.to_string())
493        };
494
495        // Split tail into name and optional field.
496        let (name, field) = match tail.split_once('/') {
497            Some((name, field)) => {
498                if field.is_empty() || field.contains('/') {
499                    return None;
500                }
501                (name, Some(field.to_string()))
502            }
503            None => (tail, None),
504        };
505
506        if name.is_empty() {
507            return None;
508        }
509
510        Some(Self {
511            name: name.to_string(),
512            service: None,
513            project,
514            environment: Some(environment.to_string()),
515            field,
516        })
517    }
518
519    /// Convert this reference to a `SecretScope` for a given deployment.
520    ///
521    /// Environment/project scopes are not representable by `SecretScope`
522    /// (which only models deployment vs service); those references fall
523    /// back to a deployment-level scope and must be resolved by a higher
524    /// layer that understands environment and project scoping.
525    #[must_use]
526    pub fn to_scope(&self, deployment: &str) -> SecretScope {
527        match &self.service {
528            Some(service) => SecretScope::Service {
529                deployment: deployment.to_string(),
530                service: service.clone(),
531            },
532            None => SecretScope::Deployment(deployment.to_string()),
533        }
534    }
535
536    /// Check if this is a deployment-level secret reference.
537    #[must_use]
538    pub fn is_deployment_level(&self) -> bool {
539        self.service.is_none() && self.project.is_none() && self.environment.is_none()
540    }
541
542    /// Check if this is a service-level secret reference.
543    #[must_use]
544    pub fn is_service_level(&self) -> bool {
545        self.service.is_some()
546    }
547
548    /// Check if this reference is environment-scoped (with or without a project).
549    #[must_use]
550    pub fn is_environment_level(&self) -> bool {
551        self.environment.is_some()
552    }
553
554    /// Check if this reference is scoped to a project environment.
555    #[must_use]
556    pub fn is_project_environment_level(&self) -> bool {
557        self.project.is_some() && self.environment.is_some()
558    }
559
560    /// Check if this reference includes field extraction.
561    #[must_use]
562    pub fn has_field(&self) -> bool {
563        self.field.is_some()
564    }
565}
566
567impl std::fmt::Display for SecretRef {
568    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
569        f.write_str(Self::PREFIX)?;
570        if let Some(service) = &self.service {
571            write!(f, "@{service}/{}", self.name)?;
572            if let Some(field) = &self.field {
573                write!(f, "/{field}")?;
574            }
575        } else if let Some(environment) = &self.environment {
576            if let Some(project) = &self.project {
577                write!(f, "{project}:{environment}/{}", self.name)?;
578            } else {
579                write!(f, ":{environment}/{}", self.name)?;
580            }
581            if let Some(field) = &self.field {
582                write!(f, "/{field}")?;
583            }
584        } else {
585            // Deployment-level: `name` or `name/field`.
586            f.write_str(&self.name)?;
587            if let Some(field) = &self.field {
588                write!(f, "/{field}")?;
589            }
590        }
591        Ok(())
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    #[test]
600    fn test_secret_debug_redacted() {
601        let secret = Secret::new("super-secret-value");
602        let debug_output = format!("{secret:?}");
603        assert_eq!(debug_output, "[REDACTED]");
604        assert!(!debug_output.contains("super-secret-value"));
605    }
606
607    #[test]
608    fn test_secret_expose() {
609        let secret = Secret::new("my-secret");
610        assert_eq!(secret.expose(), "my-secret");
611    }
612
613    #[test]
614    fn test_secret_from_string() {
615        let secret: Secret = "test-secret".into();
616        assert_eq!(secret.expose(), "test-secret");
617
618        let secret: Secret = String::from("another-secret").into();
619        assert_eq!(secret.expose(), "another-secret");
620    }
621
622    #[test]
623    fn test_secret_zeroize() {
624        let mut secret = Secret::new("sensitive-data");
625        secret.zeroize();
626        // After zeroize, the secret should be empty
627        assert_eq!(secret.expose(), "");
628    }
629
630    #[test]
631    fn test_secret_metadata_new() {
632        let metadata = SecretMetadata::new("test-secret");
633        assert_eq!(metadata.name, "test-secret");
634        assert_eq!(metadata.version, 1);
635        assert!(metadata.created_at > 0);
636        assert_eq!(metadata.created_at, metadata.updated_at);
637    }
638
639    #[test]
640    fn test_secret_metadata_update() {
641        let mut metadata = SecretMetadata::new("test-secret");
642        let original_created = metadata.created_at;
643        let original_version = metadata.version;
644
645        // Simulate time passing
646        std::thread::sleep(std::time::Duration::from_millis(10));
647        metadata.update();
648
649        assert_eq!(metadata.created_at, original_created);
650        assert!(metadata.updated_at >= original_created);
651        assert_eq!(metadata.version, original_version + 1);
652    }
653
654    #[test]
655    fn test_secret_scope_deployment() {
656        let scope = SecretScope::deployment("my-deployment");
657        assert_eq!(scope.deployment_name(), "my-deployment");
658        assert!(scope.service_name().is_none());
659        assert_eq!(scope.to_storage_scope(), "my-deployment");
660    }
661
662    #[test]
663    fn test_secret_scope_service() {
664        let scope = SecretScope::service("my-deployment", "my-service");
665        assert_eq!(scope.deployment_name(), "my-deployment");
666        assert_eq!(scope.service_name(), Some("my-service"));
667        assert_eq!(scope.to_storage_scope(), "my-deployment/my-service");
668    }
669
670    #[test]
671    fn test_secret_scope_env_round_trip() {
672        let cases = [
673            SecretScope::Environment {
674                env_id: "e1".to_string(),
675            },
676            SecretScope::ProjectEnvironment {
677                project_id: "p1".to_string(),
678                env_id: "e1".to_string(),
679            },
680            SecretScope::Custom("default".to_string()),
681            SecretScope::Custom("weird:scope".to_string()),
682        ];
683        for scope in cases {
684            let rendered = scope.to_storage_scope();
685            assert_eq!(
686                SecretScope::from_storage_scope(&rendered),
687                scope,
688                "round-trip mismatch for {rendered}"
689            );
690        }
691    }
692
693    #[test]
694    fn test_secret_scope_for_env() {
695        assert_eq!(
696            SecretScope::for_env(Some("p1"), "e1").to_storage_scope(),
697            "project:p1:env:e1"
698        );
699        assert_eq!(
700            SecretScope::for_env(None, "e1").to_storage_scope(),
701            "env:e1"
702        );
703        assert_eq!(
704            SecretScope::for_env(Some("p1"), "e1"),
705            SecretScope::project_environment("p1", "e1")
706        );
707        assert_eq!(
708            SecretScope::for_env(None, "e1"),
709            SecretScope::environment("e1")
710        );
711    }
712
713    #[test]
714    fn test_secret_scope_environment_id() {
715        assert_eq!(
716            SecretScope::from_storage_scope("env:abc").environment_id(),
717            Some("abc")
718        );
719        assert_eq!(
720            SecretScope::from_storage_scope("project:p:env:q").environment_id(),
721            Some("q")
722        );
723        assert_eq!(
724            SecretScope::from_storage_scope("default").environment_id(),
725            None
726        );
727        assert!(SecretScope::from_storage_scope("env:abc").is_environment_shaped());
728        assert!(SecretScope::from_storage_scope("project:p:env:q").is_environment_shaped());
729        assert!(!SecretScope::from_storage_scope("default").is_environment_shaped());
730    }
731
732    #[test]
733    fn test_secret_scope_from_storage_scope_fallbacks() {
734        // Empty env id -> Custom, not Environment.
735        assert_eq!(
736            SecretScope::from_storage_scope("env:"),
737            SecretScope::Custom("env:".to_string())
738        );
739        // Empty project id -> Custom.
740        assert_eq!(
741            SecretScope::from_storage_scope("project::env:q"),
742            SecretScope::Custom("project::env:q".to_string())
743        );
744        // Empty env id with project -> Custom.
745        assert_eq!(
746            SecretScope::from_storage_scope("project:p:env:"),
747            SecretScope::Custom("project:p:env:".to_string())
748        );
749        // Bare/`/`-containing deployment forms round-trip as Custom.
750        assert_eq!(
751            SecretScope::from_storage_scope("my-deployment"),
752            SecretScope::Custom("my-deployment".to_string())
753        );
754        assert_eq!(
755            SecretScope::from_storage_scope("dep/svc"),
756            SecretScope::Custom("dep/svc".to_string())
757        );
758    }
759
760    #[test]
761    fn test_secret_ref_is_secret_ref() {
762        assert!(SecretRef::is_secret_ref("$S:my-secret"));
763        assert!(SecretRef::is_secret_ref("$S:@service/secret"));
764        assert!(!SecretRef::is_secret_ref("my-secret"));
765        assert!(!SecretRef::is_secret_ref("S:my-secret"));
766        assert!(!SecretRef::is_secret_ref("$:my-secret"));
767    }
768
769    #[test]
770    fn test_secret_ref_parse_deployment_level() {
771        let secret_ref = SecretRef::parse("$S:database-password").unwrap();
772        assert_eq!(secret_ref.name, "database-password");
773        assert!(secret_ref.service.is_none());
774        assert!(secret_ref.project.is_none());
775        assert!(secret_ref.environment.is_none());
776        assert!(secret_ref.field.is_none());
777        assert!(secret_ref.is_deployment_level());
778    }
779
780    #[test]
781    fn test_secret_ref_parse_service_level() {
782        let secret_ref = SecretRef::parse("$S:@api/database-password").unwrap();
783        assert_eq!(secret_ref.name, "database-password");
784        assert_eq!(secret_ref.service, Some("api".to_string()));
785        assert!(secret_ref.project.is_none());
786        assert!(secret_ref.environment.is_none());
787        assert!(secret_ref.field.is_none());
788        assert!(secret_ref.is_service_level());
789    }
790
791    #[test]
792    fn test_secret_ref_parse_service_level_with_field() {
793        let secret_ref = SecretRef::parse("$S:@api/database/password").unwrap();
794        assert_eq!(secret_ref.name, "database");
795        assert_eq!(secret_ref.service, Some("api".to_string()));
796        assert_eq!(secret_ref.field, Some("password".to_string()));
797        assert!(secret_ref.has_field());
798    }
799
800    #[test]
801    fn test_secret_ref_parse_deployment_with_field_legacy() {
802        // Legacy deployment-level with field: `name/field`.
803        let secret_ref = SecretRef::parse("$S:database/password").unwrap();
804        assert_eq!(secret_ref.name, "database");
805        assert!(secret_ref.service.is_none());
806        assert!(secret_ref.project.is_none());
807        assert!(secret_ref.environment.is_none());
808        assert_eq!(secret_ref.field, Some("password".to_string()));
809        assert!(secret_ref.has_field());
810    }
811
812    #[test]
813    fn test_secret_ref_parse_environment_level() {
814        let secret_ref = SecretRef::parse("$S::staging/db-password").unwrap();
815        assert_eq!(secret_ref.name, "db-password");
816        assert_eq!(secret_ref.environment, Some("staging".to_string()));
817        assert!(secret_ref.project.is_none());
818        assert!(secret_ref.service.is_none());
819        assert!(secret_ref.field.is_none());
820        assert!(secret_ref.is_environment_level());
821        assert!(!secret_ref.is_project_environment_level());
822        assert!(!secret_ref.is_deployment_level());
823    }
824
825    #[test]
826    fn test_secret_ref_parse_environment_level_with_field() {
827        let secret_ref = SecretRef::parse("$S::staging/db-creds/password").unwrap();
828        assert_eq!(secret_ref.name, "db-creds");
829        assert_eq!(secret_ref.environment, Some("staging".to_string()));
830        assert!(secret_ref.project.is_none());
831        assert_eq!(secret_ref.field, Some("password".to_string()));
832    }
833
834    #[test]
835    fn test_secret_ref_parse_project_environment_level() {
836        let secret_ref = SecretRef::parse("$S:myproj:staging/db-password").unwrap();
837        assert_eq!(secret_ref.name, "db-password");
838        assert_eq!(secret_ref.project, Some("myproj".to_string()));
839        assert_eq!(secret_ref.environment, Some("staging".to_string()));
840        assert!(secret_ref.service.is_none());
841        assert!(secret_ref.field.is_none());
842        assert!(secret_ref.is_environment_level());
843        assert!(secret_ref.is_project_environment_level());
844    }
845
846    #[test]
847    fn test_secret_ref_parse_project_environment_with_field() {
848        let secret_ref = SecretRef::parse("$S:myproj:prod/creds/api_key").unwrap();
849        assert_eq!(secret_ref.name, "creds");
850        assert_eq!(secret_ref.project, Some("myproj".to_string()));
851        assert_eq!(secret_ref.environment, Some("prod".to_string()));
852        assert_eq!(secret_ref.field, Some("api_key".to_string()));
853    }
854
855    #[test]
856    fn test_secret_ref_parse_invalid() {
857        // No prefix
858        assert!(SecretRef::parse("database-password").is_none());
859
860        // Empty after prefix
861        assert!(SecretRef::parse("$S:").is_none());
862
863        // Empty service name
864        assert!(SecretRef::parse("$S:@/secret").is_none());
865
866        // Empty secret name after service
867        assert!(SecretRef::parse("$S:@service/").is_none());
868
869        // Just @ with no service
870        assert!(SecretRef::parse("$S:@").is_none());
871
872        // Leading slash (empty scope/name)
873        assert!(SecretRef::parse("$S:/name").is_none());
874
875        // Empty name in legacy `name/field` form
876        assert!(SecretRef::parse("$S:database/").is_none());
877
878        // Triple-colon flat form (no '/'): colons disallowed in bare names
879        assert!(SecretRef::parse("$S:::name").is_none());
880
881        // Empty environment in env-only form ("$S::/name")
882        assert!(SecretRef::parse("$S::/name").is_none());
883
884        // Empty environment with project ("proj:/name")
885        assert!(SecretRef::parse("$S:proj:/name").is_none());
886
887        // Extra colon in scope ("a:b:c/name")
888        assert!(SecretRef::parse("$S:a:b:c/name").is_none());
889
890        // Env-scoped with empty name
891        assert!(SecretRef::parse("$S::env/").is_none());
892
893        // Extra trailing segment on legacy deployment form
894        assert!(SecretRef::parse("$S:name/field/extra").is_none());
895    }
896
897    #[test]
898    fn test_secret_ref_display_roundtrip() {
899        let cases = [
900            "$S:database-password",
901            "$S:database/password",
902            "$S:@api/database-password",
903            "$S:@api/database/password",
904            "$S::staging/db-password",
905            "$S::staging/db-creds/password",
906            "$S:myproj:staging/db-password",
907            "$S:myproj:prod/creds/api_key",
908        ];
909
910        for input in cases {
911            let parsed =
912                SecretRef::parse(input).unwrap_or_else(|| panic!("failed to parse {input}"));
913            let rendered = parsed.to_string();
914            assert_eq!(rendered, input, "round-trip mismatch for {input}");
915            let reparsed = SecretRef::parse(&rendered)
916                .unwrap_or_else(|| panic!("failed to re-parse {rendered}"));
917            assert_eq!(parsed, reparsed);
918        }
919    }
920
921    #[test]
922    fn test_secret_ref_serde_backcompat() {
923        // Pre-existing JSON without project/environment fields must still deserialize.
924        let json = r#"{"name":"db","service":"api","field":"password"}"#;
925        let parsed: SecretRef = serde_json::from_str(json).unwrap();
926        assert_eq!(parsed.name, "db");
927        assert_eq!(parsed.service, Some("api".to_string()));
928        assert_eq!(parsed.field, Some("password".to_string()));
929        assert!(parsed.project.is_none());
930        assert!(parsed.environment.is_none());
931
932        // Minimal form (just a name).
933        let minimal = r#"{"name":"db"}"#;
934        let parsed: SecretRef = serde_json::from_str(minimal).unwrap();
935        assert_eq!(parsed.name, "db");
936        assert!(parsed.service.is_none());
937        assert!(parsed.project.is_none());
938        assert!(parsed.environment.is_none());
939        assert!(parsed.field.is_none());
940    }
941
942    #[test]
943    fn test_secret_ref_to_scope() {
944        // Deployment-level
945        let secret_ref = SecretRef::parse("$S:my-secret").unwrap();
946        let scope = secret_ref.to_scope("prod");
947        assert_eq!(scope, SecretScope::Deployment("prod".to_string()));
948
949        // Service-level
950        let secret_ref = SecretRef::parse("$S:@api/my-secret").unwrap();
951        let scope = secret_ref.to_scope("prod");
952        assert_eq!(
953            scope,
954            SecretScope::Service {
955                deployment: "prod".to_string(),
956                service: "api".to_string(),
957            }
958        );
959    }
960
961    #[test]
962    fn test_secret_metadata_serialization() {
963        let metadata = SecretMetadata {
964            name: "test".to_string(),
965            created_at: 1_234_567_890,
966            updated_at: 1_234_567_900,
967            version: 5,
968        };
969
970        let json = serde_json::to_string(&metadata).unwrap();
971        let deserialized: SecretMetadata = serde_json::from_str(&json).unwrap();
972
973        assert_eq!(metadata, deserialized);
974    }
975
976    #[test]
977    fn test_secret_scope_serialization() {
978        let deployment_scope = SecretScope::deployment("my-deploy");
979        let json = serde_json::to_string(&deployment_scope).unwrap();
980        let deserialized: SecretScope = serde_json::from_str(&json).unwrap();
981        assert_eq!(deployment_scope, deserialized);
982
983        let service_scope = SecretScope::service("my-deploy", "my-service");
984        let json = serde_json::to_string(&service_scope).unwrap();
985        let deserialized: SecretScope = serde_json::from_str(&json).unwrap();
986        assert_eq!(service_scope, deserialized);
987    }
988}