Skip to main content

cfgd_core/config/
sync_secrets.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use super::source::default_sync_interval;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9#[serde(rename_all = "camelCase", deny_unknown_fields)]
10pub struct SyncConfig {
11    #[serde(default)]
12    pub auto_push: bool,
13    #[serde(default)]
14    pub auto_pull: bool,
15    #[serde(default = "default_sync_interval")]
16    pub interval: String,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase", deny_unknown_fields)]
21pub struct NotifyConfig {
22    #[serde(default)]
23    pub drift: bool,
24    #[serde(default)]
25    pub method: NotifyMethod,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub webhook_url: Option<String>,
28}
29
30#[derive(Debug, Clone, Default, Serialize, Deserialize)]
31pub enum NotifyMethod {
32    #[default]
33    Desktop,
34    Stdout,
35    Webhook,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(rename_all = "camelCase", deny_unknown_fields)]
40pub struct SecretsConfig {
41    #[serde(default = "default_secrets_backend")]
42    pub backend: String,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub sops: Option<SopsConfig>,
45    #[serde(default)]
46    pub integrations: Vec<SecretIntegration>,
47}
48
49fn default_secrets_backend() -> String {
50    "sops".to_string()
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase", deny_unknown_fields)]
55pub struct SopsConfig {
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub age_key: Option<PathBuf>,
58}
59
60// no deny_unknown_fields — incompatible with serde(flatten) on `extra`; the
61// flattened map intentionally captures arbitrary per-backend keys (vault, item,
62// etc.) and `deny_unknown_fields` would short-circuit that routing.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct SecretIntegration {
66    pub name: String,
67    #[serde(flatten)]
68    pub extra: HashMap<String, serde_yaml::Value>,
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use crate::config::source::default_sync_interval;
75
76    #[test]
77    fn sync_config_defaults_auto_push_to_false_when_omitted() {
78        let parsed: SyncConfig = serde_yaml::from_str("interval: 1h").unwrap();
79        assert!(!parsed.auto_push);
80        assert!(!parsed.auto_pull);
81        assert_eq!(parsed.interval, "1h");
82    }
83
84    #[test]
85    fn sync_config_uses_default_interval_when_all_omitted() {
86        let parsed: SyncConfig = serde_yaml::from_str("{}").unwrap();
87        assert_eq!(parsed.interval, default_sync_interval());
88    }
89
90    #[test]
91    fn sync_config_yaml_uses_camelcase_field_names() {
92        let v = SyncConfig {
93            auto_push: true,
94            auto_pull: false,
95            interval: "5m".into(),
96        };
97        let yaml = serde_yaml::to_string(&v).unwrap();
98        assert!(
99            yaml.contains("autoPush: true"),
100            "yaml missing autoPush: {yaml}"
101        );
102        assert!(
103            yaml.contains("autoPull: false"),
104            "yaml missing autoPull: {yaml}"
105        );
106        assert!(
107            yaml.contains("interval: 5m"),
108            "yaml missing interval: {yaml}"
109        );
110        assert!(
111            !yaml.contains("auto_push"),
112            "yaml leaked snake_case: {yaml}"
113        );
114        assert!(
115            !yaml.contains("auto_pull"),
116            "yaml leaked snake_case: {yaml}"
117        );
118
119        let parsed: SyncConfig = serde_yaml::from_str(&yaml).unwrap();
120        assert!(parsed.auto_push);
121        assert!(!parsed.auto_pull);
122        assert_eq!(parsed.interval, "5m");
123    }
124
125    #[test]
126    fn notify_config_defaults_drift_to_false() {
127        let parsed: NotifyConfig = serde_yaml::from_str("{}").unwrap();
128        assert!(!parsed.drift);
129    }
130
131    #[test]
132    fn notify_config_defaults_method_to_desktop() {
133        let parsed: NotifyConfig = serde_yaml::from_str("{}").unwrap();
134        assert!(matches!(parsed.method, NotifyMethod::Desktop));
135    }
136
137    #[test]
138    fn notify_config_omits_webhook_url() {
139        let parsed: NotifyConfig = serde_yaml::from_str("{}").unwrap();
140        assert!(parsed.webhook_url.is_none());
141
142        let v = NotifyConfig {
143            drift: false,
144            method: NotifyMethod::Desktop,
145            webhook_url: None,
146        };
147        let yaml = serde_yaml::to_string(&v).unwrap();
148        assert!(
149            !yaml.contains("webhookUrl"),
150            "yaml should skip None webhookUrl: {yaml}"
151        );
152        assert!(
153            !yaml.contains("webhook_url"),
154            "yaml should skip None webhook_url: {yaml}"
155        );
156    }
157
158    #[test]
159    fn notify_config_yaml_uses_camelcase_field_names() {
160        let v = NotifyConfig {
161            drift: true,
162            method: NotifyMethod::Webhook,
163            webhook_url: Some("https://example.com/hook".into()),
164        };
165        let yaml = serde_yaml::to_string(&v).unwrap();
166        assert!(yaml.contains("drift: true"), "yaml missing drift: {yaml}");
167        assert!(
168            yaml.contains("method: Webhook"),
169            "yaml missing method: {yaml}"
170        );
171        assert!(
172            yaml.contains("webhookUrl: https://example.com/hook"),
173            "yaml missing webhookUrl: {yaml}"
174        );
175        assert!(
176            !yaml.contains("webhook_url"),
177            "yaml leaked snake_case: {yaml}"
178        );
179
180        let parsed: NotifyConfig = serde_yaml::from_str(&yaml).unwrap();
181        assert!(parsed.drift);
182        assert!(matches!(parsed.method, NotifyMethod::Webhook));
183        assert_eq!(
184            parsed.webhook_url.as_deref(),
185            Some("https://example.com/hook")
186        );
187    }
188
189    #[test]
190    fn notify_method_default_is_desktop() {
191        assert!(matches!(NotifyMethod::default(), NotifyMethod::Desktop));
192    }
193
194    #[test]
195    fn notify_method_serializes_as_pascalcase_default() {
196        let yaml = serde_yaml::to_string(&NotifyMethod::Desktop).unwrap();
197        assert_eq!(yaml.trim(), "Desktop");
198    }
199
200    #[test]
201    fn notify_method_deserializes_each_variant() {
202        let cases = [
203            ("Desktop", NotifyMethod::Desktop),
204            ("Stdout", NotifyMethod::Stdout),
205            ("Webhook", NotifyMethod::Webhook),
206        ];
207        for (input, expected) in cases {
208            let parsed: NotifyMethod = serde_yaml::from_str(input).unwrap();
209            match (&parsed, &expected) {
210                (NotifyMethod::Desktop, NotifyMethod::Desktop)
211                | (NotifyMethod::Stdout, NotifyMethod::Stdout)
212                | (NotifyMethod::Webhook, NotifyMethod::Webhook) => {}
213                _ => panic!("expected {expected:?} for input {input}, got {parsed:?}"),
214            }
215        }
216    }
217
218    #[test]
219    fn secrets_config_defaults_backend_to_sops() {
220        let parsed: SecretsConfig = serde_yaml::from_str("{}").unwrap();
221        assert_eq!(parsed.backend, "sops");
222    }
223
224    #[test]
225    fn secrets_config_defaults_integrations_to_empty() {
226        let parsed: SecretsConfig = serde_yaml::from_str("{}").unwrap();
227        assert!(parsed.integrations.is_empty());
228        assert!(parsed.sops.is_none());
229    }
230
231    #[test]
232    fn sops_config_yaml_uses_camelcase_field_names() {
233        let v = SopsConfig {
234            age_key: Some(PathBuf::from("/etc/cfgd/age.key")),
235        };
236        let yaml = serde_yaml::to_string(&v).unwrap();
237        assert!(
238            yaml.contains("ageKey: /etc/cfgd/age.key"),
239            "yaml missing ageKey: {yaml}"
240        );
241        assert!(!yaml.contains("age_key"), "yaml leaked snake_case: {yaml}");
242
243        let parsed: SopsConfig = serde_yaml::from_str(&yaml).unwrap();
244        assert_eq!(
245            parsed.age_key.as_deref(),
246            Some(PathBuf::from("/etc/cfgd/age.key").as_path())
247        );
248    }
249
250    #[test]
251    fn sops_config_omits_age_key_when_none() {
252        let v = SopsConfig { age_key: None };
253        let yaml = serde_yaml::to_string(&v).unwrap();
254        assert!(
255            !yaml.contains("ageKey"),
256            "yaml should skip None ageKey: {yaml}"
257        );
258        assert!(
259            !yaml.contains("age_key"),
260            "yaml should skip None age_key: {yaml}"
261        );
262    }
263
264    #[test]
265    fn secret_integration_flattens_extra_fields_into_top_level_yaml() {
266        let mut extra: HashMap<String, serde_yaml::Value> = HashMap::new();
267        extra.insert(
268            "vault".to_string(),
269            serde_yaml::Value::String("Personal".to_string()),
270        );
271        extra.insert(
272            "item".to_string(),
273            serde_yaml::Value::String("GitHub Token".to_string()),
274        );
275        let v = SecretIntegration {
276            name: "1password".to_string(),
277            extra,
278        };
279
280        let yaml = serde_yaml::to_string(&v).unwrap();
281        assert!(
282            yaml.contains("name: 1password"),
283            "yaml missing name: {yaml}"
284        );
285        assert!(
286            yaml.contains("vault: Personal"),
287            "yaml missing vault: {yaml}"
288        );
289        assert!(
290            yaml.contains("item: GitHub Token"),
291            "yaml missing item: {yaml}"
292        );
293        assert!(
294            !yaml.contains("extra:"),
295            "yaml should not have nested extra block: {yaml}"
296        );
297
298        let parsed: SecretIntegration = serde_yaml::from_str(&yaml).unwrap();
299        assert_eq!(parsed.name, "1password");
300        assert_eq!(parsed.extra.len(), 2);
301        assert_eq!(
302            parsed.extra.get("vault").and_then(|v| v.as_str()),
303            Some("Personal")
304        );
305        assert_eq!(
306            parsed.extra.get("item").and_then(|v| v.as_str()),
307            Some("GitHub Token")
308        );
309    }
310
311    #[test]
312    fn secret_integration_collects_unknown_fields_into_extra() {
313        let yaml = "name: foo\narbitrary: bar\nanother: 123\n";
314        let parsed: SecretIntegration = serde_yaml::from_str(yaml).unwrap();
315        assert_eq!(parsed.name, "foo");
316        assert_eq!(parsed.extra.len(), 2);
317        assert_eq!(
318            parsed.extra.get("arbitrary").and_then(|v| v.as_str()),
319            Some("bar")
320        );
321        assert_eq!(
322            parsed.extra.get("another").and_then(|v| v.as_i64()),
323            Some(123)
324        );
325    }
326}