Skip to main content

gloves_core/
secret_ref.rs

1use std::{fmt, str::FromStr};
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4
5use crate::{types::SecretId, ValidationError};
6
7const SECRET_REF_SCHEME: &str = "gloves://";
8
9/// Stable runtime-neutral reference to a stored secret.
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11pub struct SecretRef {
12    secret_id: SecretId,
13}
14
15impl SecretRef {
16    /// Builds a secret reference from an existing secret identifier.
17    pub fn new(secret_id: SecretId) -> Self {
18        Self { secret_id }
19    }
20
21    /// Returns the referenced secret identifier.
22    pub fn secret_id(&self) -> &SecretId {
23        &self.secret_id
24    }
25
26    /// Returns the canonical ref string.
27    pub fn as_str(&self) -> String {
28        format!("{SECRET_REF_SCHEME}{}", self.secret_id.as_str())
29    }
30}
31
32impl fmt::Display for SecretRef {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        f.write_str(&self.as_str())
35    }
36}
37
38impl From<SecretId> for SecretRef {
39    fn from(value: SecretId) -> Self {
40        Self::new(value)
41    }
42}
43
44impl From<SecretRef> for String {
45    fn from(value: SecretRef) -> Self {
46        value.to_string()
47    }
48}
49
50impl FromStr for SecretRef {
51    type Err = SecretRefParseError;
52
53    fn from_str(value: &str) -> Result<Self, Self::Err> {
54        let raw_path = value
55            .strip_prefix(SECRET_REF_SCHEME)
56            .ok_or(SecretRefParseError::InvalidScheme)?;
57        if raw_path.is_empty() {
58            return Err(SecretRefParseError::MissingPath);
59        }
60
61        let mut segments = Vec::new();
62        for segment in raw_path.split('/') {
63            if segment.is_empty() {
64                return Err(SecretRefParseError::EmptyPathSegment);
65            }
66            segments.push(segment);
67        }
68
69        let secret_path = segments.join("/");
70        let secret_id =
71            SecretId::new(&secret_path).map_err(SecretRefParseError::InvalidSecretId)?;
72        Ok(Self::new(secret_id))
73    }
74}
75
76impl Serialize for SecretRef {
77    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
78    where
79        S: Serializer,
80    {
81        serializer.serialize_str(&self.to_string())
82    }
83}
84
85impl<'de> Deserialize<'de> for SecretRef {
86    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
87    where
88        D: Deserializer<'de>,
89    {
90        let value = String::deserialize(deserializer)?;
91        value.parse().map_err(serde::de::Error::custom)
92    }
93}
94
95/// Validation errors for portable secret references.
96#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
97pub enum SecretRefParseError {
98    /// The ref did not use the `gloves://` scheme.
99    #[error("invalid secret ref scheme: expected `gloves://`")]
100    InvalidScheme,
101    /// The ref omitted the secret path.
102    #[error("invalid secret ref: missing secret path")]
103    MissingPath,
104    /// The ref contained an empty path segment.
105    #[error("invalid secret ref: empty path segment")]
106    EmptyPathSegment,
107    /// The embedded secret id was invalid.
108    #[error(transparent)]
109    InvalidSecretId(#[from] ValidationError),
110}
111
112#[cfg(test)]
113mod tests {
114    use super::{SecretRef, SecretRefParseError};
115    use crate::types::SecretId;
116
117    #[test]
118    fn secret_ref_roundtrips_agent_paths() {
119        let secret_ref: SecretRef = "gloves://agents/devy/api-keys/openai".parse().unwrap();
120
121        assert_eq!(
122            secret_ref.secret_id().as_str(),
123            "agents/devy/api-keys/openai"
124        );
125        assert_eq!(
126            secret_ref.to_string(),
127            "gloves://agents/devy/api-keys/openai"
128        );
129    }
130
131    #[test]
132    fn secret_ref_from_secret_id_uses_canonical_format() {
133        let secret_ref = SecretRef::from(SecretId::new("shared/database-url").unwrap());
134
135        assert_eq!(secret_ref.to_string(), "gloves://shared/database-url");
136    }
137
138    #[test]
139    fn secret_ref_rejects_invalid_shapes() {
140        assert!(matches!(
141            "https://agents/devy/api-key".parse::<SecretRef>(),
142            Err(SecretRefParseError::InvalidScheme)
143        ));
144        assert!(matches!(
145            "gloves://".parse::<SecretRef>(),
146            Err(SecretRefParseError::MissingPath)
147        ));
148        assert!(matches!(
149            "gloves://agents//api-key".parse::<SecretRef>(),
150            Err(SecretRefParseError::EmptyPathSegment)
151        ));
152        assert!("gloves://agents/devy/../api-key"
153            .parse::<SecretRef>()
154            .is_err());
155    }
156}