Skip to main content

fnox_core/providers/
secret_ref.rs

1//! Types for provider configuration fields that can reference secrets.
2//!
3//! This module provides types that allow provider configuration properties to be either
4//! literal values or references to secrets defined elsewhere in the configuration.
5//!
6//! # Example
7//!
8//! ```toml
9//! [providers]
10//! age = { type = "age", recipients = ["age1..."] }
11//! vault = { type = "vault", address = "https://vault.example.com", token = { secret = "VAULT_TOKEN" } }
12//!
13//! [secrets]
14//! VAULT_TOKEN = { provider = "age", value = "encrypted-token..." }
15//! ```
16
17use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
18use serde::{Deserialize, Deserializer, Serialize, Serializer};
19use std::borrow::Cow;
20
21/// A value that can be either a literal string or a reference to a secret.
22///
23/// In TOML, this deserializes from either:
24/// - `field = "literal-value"`
25/// - `field = { secret = "SECRET_NAME" }`
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum StringOrSecretRef {
28    /// A literal string value
29    Literal(String),
30    /// A reference to a secret by name
31    SecretRef { secret: String },
32}
33
34impl JsonSchema for StringOrSecretRef {
35    fn schema_name() -> Cow<'static, str> {
36        Cow::Borrowed("StringOrSecretRef")
37    }
38
39    fn json_schema(generator: &mut SchemaGenerator) -> Schema {
40        // Get the string schema
41        let string_schema = generator.subschema_for::<String>();
42
43        // Create the oneOf schema: string or { secret: string }
44        json_schema!({
45            "description": "Either a literal string or a reference to a secret",
46            "oneOf": [
47                string_schema,
48                {
49                    "type": "object",
50                    "properties": {
51                        "secret": { "type": "string" }
52                    },
53                    "required": ["secret"],
54                    "additionalProperties": false
55                }
56            ]
57        })
58    }
59}
60
61impl StringOrSecretRef {
62    /// Returns true if this is a secret reference
63    #[cfg(test)]
64    pub fn is_secret_ref(&self) -> bool {
65        matches!(self, Self::SecretRef { .. })
66    }
67
68    /// Returns the secret name if this is a secret reference
69    #[cfg(test)]
70    pub fn secret_name(&self) -> Option<&str> {
71        match self {
72            Self::SecretRef { secret } => Some(secret),
73            Self::Literal(_) => None,
74        }
75    }
76
77    /// Returns the literal value if this is a literal
78    pub fn as_literal(&self) -> Option<&str> {
79        match self {
80            Self::Literal(s) => Some(s),
81            Self::SecretRef { .. } => None,
82        }
83    }
84}
85
86impl<'de> Deserialize<'de> for StringOrSecretRef {
87    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
88    where
89        D: Deserializer<'de>,
90    {
91        #[derive(Deserialize)]
92        #[serde(untagged)]
93        enum Helper {
94            Literal(String),
95            SecretRef { secret: String },
96        }
97
98        match Helper::deserialize(deserializer)? {
99            Helper::Literal(s) => Ok(StringOrSecretRef::Literal(s)),
100            Helper::SecretRef { secret } => Ok(StringOrSecretRef::SecretRef { secret }),
101        }
102    }
103}
104
105impl Serialize for StringOrSecretRef {
106    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
107    where
108        S: Serializer,
109    {
110        match self {
111            Self::Literal(s) => s.serialize(serializer),
112            Self::SecretRef { secret } => {
113                use serde::ser::SerializeMap;
114                let mut map = serializer.serialize_map(Some(1))?;
115                map.serialize_entry("secret", secret)?;
116                map.end()
117            }
118        }
119    }
120}
121
122impl From<String> for StringOrSecretRef {
123    fn from(s: String) -> Self {
124        Self::Literal(s)
125    }
126}
127
128impl From<&str> for StringOrSecretRef {
129    fn from(s: &str) -> Self {
130        Self::Literal(s.to_string())
131    }
132}
133
134/// An optional value that can be a literal string, a secret reference, or absent.
135///
136/// In TOML, this deserializes from:
137/// - Field absent: `None`
138/// - `field = "literal-value"`: `Some(Literal("literal-value"))`
139/// - `field = { secret = "SECRET_NAME" }`: `Some(SecretRef { secret: "SECRET_NAME" })`
140#[derive(Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
141#[schemars(transparent)]
142pub struct OptionStringOrSecretRef(pub Option<StringOrSecretRef>);
143
144impl OptionStringOrSecretRef {
145    /// Creates a new empty optional value
146    pub fn none() -> Self {
147        Self(None)
148    }
149
150    /// Creates a new optional value with a literal string
151    pub fn literal(s: impl Into<String>) -> Self {
152        Self(Some(StringOrSecretRef::Literal(s.into())))
153    }
154
155    /// Returns true if this is None
156    pub fn is_none(&self) -> bool {
157        self.0.is_none()
158    }
159
160    /// Returns true if this is Some
161    #[cfg(test)]
162    pub fn is_some(&self) -> bool {
163        self.0.is_some()
164    }
165
166    /// Returns the inner Option
167    pub fn as_ref(&self) -> Option<&StringOrSecretRef> {
168        self.0.as_ref()
169    }
170
171    /// Returns true if this contains a secret reference
172    #[cfg(test)]
173    pub fn has_secret_ref(&self) -> bool {
174        matches!(self.0, Some(StringOrSecretRef::SecretRef { .. }))
175    }
176
177    /// Returns the secret name if this is a secret reference
178    #[cfg(test)]
179    pub fn secret_name(&self) -> Option<&str> {
180        match &self.0 {
181            Some(StringOrSecretRef::SecretRef { secret }) => Some(secret),
182            _ => None,
183        }
184    }
185
186    /// Returns the literal value if this is a literal
187    #[cfg(test)]
188    pub fn as_literal(&self) -> Option<&str> {
189        self.0.as_ref().and_then(|v| v.as_literal())
190    }
191}
192
193impl<'de> Deserialize<'de> for OptionStringOrSecretRef {
194    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
195    where
196        D: Deserializer<'de>,
197    {
198        let opt: Option<StringOrSecretRef> = Option::deserialize(deserializer)?;
199        Ok(OptionStringOrSecretRef(opt))
200    }
201}
202
203impl Serialize for OptionStringOrSecretRef {
204    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
205    where
206        S: Serializer,
207    {
208        match &self.0 {
209            Some(v) => v.serialize(serializer),
210            None => serializer.serialize_none(),
211        }
212    }
213}
214
215impl From<Option<String>> for OptionStringOrSecretRef {
216    fn from(opt: Option<String>) -> Self {
217        Self(opt.map(StringOrSecretRef::Literal))
218    }
219}
220
221impl From<String> for OptionStringOrSecretRef {
222    fn from(s: String) -> Self {
223        Self(Some(StringOrSecretRef::Literal(s)))
224    }
225}
226
227impl From<&str> for OptionStringOrSecretRef {
228    fn from(s: &str) -> Self {
229        Self(Some(StringOrSecretRef::Literal(s.to_string())))
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn test_string_or_secret_ref_literal_deser() {
239        let toml_str = r#"field = "literal-value""#;
240        #[derive(Deserialize)]
241        struct Test {
242            field: StringOrSecretRef,
243        }
244        let parsed: Test = toml_edit::de::from_str(toml_str).unwrap();
245        assert_eq!(
246            parsed.field,
247            StringOrSecretRef::Literal("literal-value".to_string())
248        );
249    }
250
251    #[test]
252    fn test_string_or_secret_ref_secret_ref_deser() {
253        let toml_str = r#"field = { secret = "MY_SECRET" }"#;
254        #[derive(Deserialize)]
255        struct Test {
256            field: StringOrSecretRef,
257        }
258        let parsed: Test = toml_edit::de::from_str(toml_str).unwrap();
259        assert_eq!(
260            parsed.field,
261            StringOrSecretRef::SecretRef {
262                secret: "MY_SECRET".to_string()
263            }
264        );
265    }
266
267    #[test]
268    fn test_string_or_secret_ref_literal_ser() {
269        #[derive(Serialize)]
270        struct Test {
271            field: StringOrSecretRef,
272        }
273        let value = Test {
274            field: StringOrSecretRef::Literal("test".to_string()),
275        };
276        let serialized = toml_edit::ser::to_string(&value).unwrap();
277        assert_eq!(serialized.trim(), r#"field = "test""#);
278    }
279
280    #[test]
281    fn test_string_or_secret_ref_secret_ref_ser() {
282        #[derive(Serialize)]
283        struct Test {
284            field: StringOrSecretRef,
285        }
286        let value = Test {
287            field: StringOrSecretRef::SecretRef {
288                secret: "MY_SECRET".to_string(),
289            },
290        };
291        let serialized = toml_edit::ser::to_string(&value).unwrap();
292        assert!(serialized.contains("secret"));
293        assert!(serialized.contains("MY_SECRET"));
294    }
295
296    #[test]
297    fn test_option_string_or_secret_ref_none() {
298        let toml_str = r#""#;
299        #[derive(Deserialize)]
300        struct Test {
301            #[serde(default)]
302            field: OptionStringOrSecretRef,
303        }
304        let parsed: Test = toml_edit::de::from_str(toml_str).unwrap();
305        assert!(parsed.field.is_none());
306    }
307
308    #[test]
309    fn test_option_string_or_secret_ref_literal() {
310        let toml_str = r#"field = "value""#;
311        #[derive(Deserialize)]
312        struct Test {
313            #[serde(default)]
314            field: OptionStringOrSecretRef,
315        }
316        let parsed: Test = toml_edit::de::from_str(toml_str).unwrap();
317        assert!(parsed.field.is_some());
318        assert_eq!(parsed.field.as_literal(), Some("value"));
319    }
320
321    #[test]
322    fn test_option_string_or_secret_ref_secret() {
323        let toml_str = r#"field = { secret = "SECRET_NAME" }"#;
324        #[derive(Deserialize)]
325        struct Test {
326            #[serde(default)]
327            field: OptionStringOrSecretRef,
328        }
329        let parsed: Test = toml_edit::de::from_str(toml_str).unwrap();
330        assert!(parsed.field.is_some());
331        assert!(parsed.field.has_secret_ref());
332        assert_eq!(parsed.field.secret_name(), Some("SECRET_NAME"));
333    }
334
335    #[test]
336    fn test_helpers() {
337        let literal = StringOrSecretRef::Literal("test".to_string());
338        assert!(!literal.is_secret_ref());
339        assert_eq!(literal.as_literal(), Some("test"));
340        assert_eq!(literal.secret_name(), None);
341
342        let secret = StringOrSecretRef::SecretRef {
343            secret: "SECRET".to_string(),
344        };
345        assert!(secret.is_secret_ref());
346        assert_eq!(secret.as_literal(), None);
347        assert_eq!(secret.secret_name(), Some("SECRET"));
348    }
349}