Skip to main content

cfgd_core/config/
source.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use super::origin::OriginSpec;
6use super::profile_spec::{EncryptionConstraint, ManagedFileSpec, PackagesSpec, SecretSpec};
7
8// --- Multi-source config management ---
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase", deny_unknown_fields)]
12pub struct SourceSpec {
13    pub name: String,
14    pub origin: OriginSpec,
15    #[serde(default)]
16    pub subscription: SubscriptionSpec,
17    #[serde(default)]
18    pub sync: SourceSyncSpec,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase", deny_unknown_fields)]
23pub struct SubscriptionSpec {
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub profile: Option<String>,
26    #[serde(default = "default_source_priority")]
27    pub priority: u32,
28    #[serde(default)]
29    pub accept_recommended: bool,
30    #[serde(default)]
31    pub opt_in: Vec<String>,
32    #[serde(default)]
33    pub overrides: serde_yaml::Value,
34    #[serde(default)]
35    pub reject: serde_yaml::Value,
36}
37
38impl Default for SubscriptionSpec {
39    fn default() -> Self {
40        Self {
41            profile: None,
42            priority: default_source_priority(),
43            accept_recommended: false,
44            opt_in: Vec::new(),
45            overrides: serde_yaml::Value::Null,
46            reject: serde_yaml::Value::Null,
47        }
48    }
49}
50
51fn default_source_priority() -> u32 {
52    500
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56#[serde(rename_all = "camelCase", deny_unknown_fields)]
57pub struct SourceSyncSpec {
58    #[serde(default = "default_sync_interval")]
59    pub interval: String,
60    #[serde(default)]
61    pub auto_apply: bool,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub pin_version: Option<String>,
64}
65
66impl Default for SourceSyncSpec {
67    fn default() -> Self {
68        Self {
69            interval: default_sync_interval(),
70            auto_apply: false,
71            pin_version: None,
72        }
73    }
74}
75
76pub(super) fn default_sync_interval() -> String {
77    "1h".to_string()
78}
79
80// --- ConfigSource manifest (published by team, lives in source repo as cfgd-source.yaml) ---
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(rename_all = "camelCase", deny_unknown_fields)]
84pub struct ConfigSourceDocument {
85    pub api_version: String,
86    pub kind: String,
87    pub metadata: ConfigSourceMetadata,
88    pub spec: ConfigSourceSpec,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase", deny_unknown_fields)]
93pub struct ConfigSourceMetadata {
94    pub name: String,
95    #[serde(default)]
96    pub version: Option<String>,
97    #[serde(default)]
98    pub description: Option<String>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase", deny_unknown_fields)]
103pub struct ConfigSourceSpec {
104    #[serde(default)]
105    pub provides: ConfigSourceProvides,
106    #[serde(default)]
107    pub policy: ConfigSourcePolicy,
108}
109
110#[derive(Debug, Clone, Default, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase", deny_unknown_fields)]
112pub struct ConfigSourceProvides {
113    #[serde(default)]
114    pub profiles: Vec<String>,
115    #[serde(default)]
116    pub profile_details: Vec<ConfigSourceProfileEntry>,
117    #[serde(default)]
118    pub platform_profiles: HashMap<String, String>,
119    #[serde(default)]
120    pub modules: Vec<String>,
121}
122
123/// Detailed profile entry in a ConfigSource manifest.
124/// When present, provides richer info than the flat `profiles` list.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126#[serde(rename_all = "camelCase", deny_unknown_fields)]
127pub struct ConfigSourceProfileEntry {
128    pub name: String,
129    #[serde(default)]
130    pub description: Option<String>,
131    #[serde(default)]
132    pub path: Option<String>,
133    #[serde(default)]
134    pub inherits: Vec<String>,
135}
136
137#[derive(Debug, Clone, Default, Serialize, Deserialize)]
138#[serde(rename_all = "camelCase", deny_unknown_fields)]
139pub struct ConfigSourcePolicy {
140    #[serde(default)]
141    pub required: PolicyItems,
142    #[serde(default)]
143    pub recommended: PolicyItems,
144    #[serde(default)]
145    pub optional: PolicyItems,
146    #[serde(default)]
147    pub locked: PolicyItems,
148    #[serde(default)]
149    pub constraints: SourceConstraints,
150}
151
152#[derive(Debug, Clone, Serialize, PartialEq)]
153#[serde(rename_all = "camelCase")]
154pub struct EnvVar {
155    pub name: String,
156    pub value: String,
157}
158
159impl<'de> Deserialize<'de> for EnvVar {
160    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
161    where
162        D: serde::Deserializer<'de>,
163    {
164        #[derive(Deserialize)]
165        #[serde(rename_all = "camelCase", deny_unknown_fields)]
166        struct Raw {
167            name: String,
168            value: String,
169        }
170        let raw = Raw::deserialize(deserializer)?;
171        crate::validate_env_var_user_name(&raw.name).map_err(serde::de::Error::custom)?;
172        Ok(EnvVar {
173            name: raw.name,
174            value: raw.value,
175        })
176    }
177}
178
179#[derive(Debug, Clone, Serialize, PartialEq)]
180#[serde(rename_all = "camelCase")]
181pub struct ShellAlias {
182    pub name: String,
183    pub command: String,
184}
185
186impl<'de> Deserialize<'de> for ShellAlias {
187    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
188    where
189        D: serde::Deserializer<'de>,
190    {
191        #[derive(Deserialize)]
192        #[serde(rename_all = "camelCase", deny_unknown_fields)]
193        struct Raw {
194            name: String,
195            command: String,
196        }
197        let raw = Raw::deserialize(deserializer)?;
198        crate::validate_alias_name(&raw.name).map_err(serde::de::Error::custom)?;
199        Ok(ShellAlias {
200            name: raw.name,
201            command: raw.command,
202        })
203    }
204}
205
206#[derive(Debug, Clone, Default, Serialize, Deserialize)]
207#[serde(rename_all = "camelCase", deny_unknown_fields)]
208pub struct PolicyItems {
209    #[serde(default)]
210    pub packages: Option<PackagesSpec>,
211    #[serde(default)]
212    pub files: Vec<ManagedFileSpec>,
213    #[serde(default)]
214    pub env: Vec<EnvVar>,
215    #[serde(default)]
216    pub aliases: Vec<ShellAlias>,
217    #[serde(default)]
218    pub system: HashMap<String, serde_yaml::Value>,
219    #[serde(default)]
220    pub profiles: Vec<String>,
221    #[serde(default)]
222    pub modules: Vec<String>,
223    #[serde(default)]
224    pub secrets: Vec<SecretSpec>,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
228#[serde(rename_all = "camelCase", deny_unknown_fields)]
229pub struct SourceConstraints {
230    #[serde(default = "default_true")]
231    pub no_scripts: bool,
232    #[serde(default = "default_true")]
233    pub no_secrets_read: bool,
234    #[serde(default)]
235    pub allowed_target_paths: Vec<String>,
236    #[serde(default)]
237    pub allow_system_changes: bool,
238    /// Require that the HEAD commit in this source's git repo has a valid
239    /// GPG or SSH signature. Subscribers can bypass with `security.allow-unsigned`.
240    #[serde(default)]
241    pub require_signed_commits: bool,
242    /// Encryption requirements imposed on files delivered by this source.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub encryption: Option<EncryptionConstraint>,
245}
246
247impl Default for SourceConstraints {
248    fn default() -> Self {
249        Self {
250            no_scripts: true,
251            no_secrets_read: true,
252            allowed_target_paths: Vec::new(),
253            allow_system_changes: false,
254            require_signed_commits: false,
255            encryption: None,
256        }
257    }
258}
259
260pub(super) fn default_true() -> bool {
261    true
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn source_spec_rejects_unknown_field() {
270        // `sourcees:`-style typos at the source level should error loudly.
271        let yaml = r#"name: team
272origin:
273  type: Git
274  url: https://example.com/x.git
275bogusField: 1
276"#;
277        let err = serde_yaml::from_str::<SourceSpec>(yaml)
278            .expect_err("expected deny_unknown_fields to reject bogusField");
279        let msg = format!("{}", err);
280        assert!(
281            msg.contains("unknown field") && msg.contains("bogusField"),
282            "expected unknown-field error mentioning bogusField, got: {msg}"
283        );
284    }
285
286    #[test]
287    fn subscription_spec_rejects_unknown_field() {
288        let yaml = "priority: 100\nautoApply: true\n";
289        let err = serde_yaml::from_str::<SubscriptionSpec>(yaml)
290            .expect_err("expected deny_unknown_fields to reject autoApply (belongs on sync)");
291        assert!(format!("{}", err).contains("unknown field"));
292    }
293
294    #[test]
295    fn env_var_rejects_cfgd_prefix_at_parse_time() {
296        let yaml = r#"
297- name: CFGD_FOO
298  value: bar
299"#;
300        let err = serde_yaml::from_str::<Vec<EnvVar>>(yaml)
301            .expect_err("CFGD_* env var names must be rejected");
302        let msg = format!("{err}");
303        assert!(
304            msg.contains("reserved"),
305            "error should mention 'reserved': {msg}"
306        );
307        assert!(
308            msg.contains("CFGD_FOO"),
309            "error should name the offending var: {msg}"
310        );
311    }
312
313    #[test]
314    fn env_var_accepts_normal_names() {
315        let yaml = r#"
316- name: MY_APP_KEY
317  value: hello
318- name: PATH
319  value: /usr/bin
320"#;
321        let vars: Vec<EnvVar> = serde_yaml::from_str(yaml).unwrap();
322        assert_eq!(vars.len(), 2);
323        assert_eq!(vars[0].name, "MY_APP_KEY");
324        assert_eq!(vars[1].name, "PATH");
325    }
326}