Skip to main content

cfgd_core/compliance/
mod.rs

1// Compliance snapshot — types, collection logic, summary computation, export
2
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7use crate::config::{ComplianceExport, ComplianceFormat, ComplianceScope, MergedProfile};
8use crate::errors::Result;
9use crate::platform::Platform;
10use crate::providers::ProviderRegistry;
11use crate::to_posix_string;
12
13// ---------------------------------------------------------------------------
14// Types
15// ---------------------------------------------------------------------------
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct ComplianceSnapshot {
20    pub timestamp: String,
21    pub machine: MachineInfo,
22    pub profile: String,
23    pub sources: Vec<String>,
24    pub checks: Vec<ComplianceCheck>,
25    pub summary: ComplianceSummary,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct MachineInfo {
30    pub hostname: String,
31    pub os: String,
32    pub arch: String,
33}
34
35#[derive(Debug, Clone, Default, Serialize, Deserialize)]
36pub struct ComplianceCheck {
37    pub category: String,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub target: Option<String>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub name: Option<String>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub key: Option<String>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub path: Option<String>,
46    pub status: ComplianceStatus,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub detail: Option<String>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub version: Option<String>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub manager: Option<String>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub value: Option<String>,
55}
56
57#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
58pub enum ComplianceStatus {
59    #[default]
60    Compliant,
61    Warning,
62    Violation,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ComplianceSummary {
67    pub compliant: usize,
68    pub warning: usize,
69    pub violation: usize,
70}
71
72// ---------------------------------------------------------------------------
73// Collection
74// ---------------------------------------------------------------------------
75
76/// Collect a full compliance snapshot for the current machine state.
77pub fn collect_snapshot(
78    profile_name: &str,
79    profile: &MergedProfile,
80    registry: &ProviderRegistry,
81    scope: &ComplianceScope,
82    sources: &[String],
83) -> Result<ComplianceSnapshot> {
84    let platform = Platform::detect();
85    let hostname = crate::hostname_string();
86
87    let machine = MachineInfo {
88        hostname,
89        os: platform.os.as_str().to_owned(),
90        arch: platform.arch.as_str().to_owned(),
91    };
92
93    let mut checks = Vec::new();
94
95    if scope.files {
96        checks.extend(collect_file_checks(profile));
97    }
98    if scope.packages {
99        checks.extend(collect_package_checks(profile, registry)?);
100    }
101    if scope.system {
102        checks.extend(collect_system_checks(profile, registry)?);
103    }
104    if scope.secrets {
105        checks.extend(collect_secret_checks(profile));
106    }
107    for watch_path in &scope.watch_paths {
108        checks.extend(collect_watch_path_checks(watch_path));
109    }
110    for manager_name in &scope.watch_package_managers {
111        checks.extend(collect_watched_package_manager_checks(
112            manager_name,
113            registry,
114        )?);
115    }
116
117    let summary = compute_summary(&checks);
118
119    Ok(ComplianceSnapshot {
120        timestamp: crate::utc_now_iso8601(),
121        machine,
122        profile: profile_name.to_owned(),
123        sources: sources.to_vec(),
124        checks,
125        summary,
126    })
127}
128
129/// Compute summary counts from a list of checks.
130pub fn compute_summary(checks: &[ComplianceCheck]) -> ComplianceSummary {
131    let mut compliant = 0usize;
132    let mut warning = 0usize;
133    let mut violation = 0usize;
134
135    for check in checks {
136        match check.status {
137            ComplianceStatus::Compliant => compliant += 1,
138            ComplianceStatus::Warning => warning += 1,
139            ComplianceStatus::Violation => violation += 1,
140        }
141    }
142
143    ComplianceSummary {
144        compliant,
145        warning,
146        violation,
147    }
148}
149
150// ---------------------------------------------------------------------------
151// Export
152// ---------------------------------------------------------------------------
153
154/// Export a compliance snapshot to a file based on the export configuration.
155///
156/// Steps: expand tilde in path, create directory, build timestamped filename,
157/// serialize to JSON or YAML, and atomically write.
158///
159/// Returns the path of the written file.
160pub fn export_snapshot_to_file(
161    snapshot: &ComplianceSnapshot,
162    export: &ComplianceExport,
163) -> Result<PathBuf> {
164    let export_dir = crate::expand_tilde(Path::new(&export.path));
165    std::fs::create_dir_all(&export_dir)?;
166
167    let timestamp_safe = crate::iso8601_to_filename_safe(&snapshot.timestamp);
168    let ext = match export.format {
169        ComplianceFormat::Json => "json",
170        ComplianceFormat::Yaml => "yaml",
171    };
172    let filename = format!("compliance-{}.{}", timestamp_safe, ext);
173    let file_path = export_dir.join(&filename);
174
175    let content = match export.format {
176        ComplianceFormat::Json => serde_json::to_string_pretty(snapshot)
177            .map_err(|e| std::io::Error::other(format!("JSON serialization failed: {}", e)))?,
178        ComplianceFormat::Yaml => serde_yaml::to_string(snapshot)
179            .map_err(|e| std::io::Error::other(format!("YAML serialization failed: {}", e)))?,
180    };
181
182    crate::atomic_write_str(&file_path, &content)?;
183    Ok(file_path)
184}
185
186// ---------------------------------------------------------------------------
187// File checks
188// ---------------------------------------------------------------------------
189
190/// Check managed files: existence, permissions, encryption declaration.
191pub fn collect_file_checks(profile: &MergedProfile) -> Vec<ComplianceCheck> {
192    let mut checks = Vec::new();
193
194    for file in &profile.files.managed {
195        let target = crate::expand_tilde(&file.target);
196        let exists = target.exists();
197
198        if !exists {
199            checks.push(ComplianceCheck {
200                category: "file".into(),
201                target: Some(to_posix_string(&target)),
202                status: ComplianceStatus::Violation,
203                detail: Some("managed file missing".into()),
204                ..Default::default()
205            });
206            continue;
207        }
208
209        // Check permissions if declared
210        if let Some(ref perm_str) = file.permissions {
211            if let Ok(desired_mode) = u32::from_str_radix(perm_str, 8)
212                && desired_mode <= 0o7777
213            {
214                let actual_mode = target
215                    .metadata()
216                    .ok()
217                    .and_then(|m| crate::file_permissions_mode(&m));
218                match actual_mode {
219                    Some(mode) if mode == desired_mode => {
220                        checks.push(ComplianceCheck {
221                            category: "file".into(),
222                            target: Some(to_posix_string(&target)),
223                            status: ComplianceStatus::Compliant,
224                            detail: Some(format!("permissions {:#o}", mode)),
225                            ..Default::default()
226                        });
227                    }
228                    Some(mode) => {
229                        checks.push(ComplianceCheck {
230                            category: "file".into(),
231                            target: Some(to_posix_string(&target)),
232                            status: ComplianceStatus::Warning,
233                            detail: Some(format!(
234                                "permissions {:#o}, expected {:#o}",
235                                mode, desired_mode
236                            )),
237                            ..Default::default()
238                        });
239                    }
240                    None => {
241                        // Windows or metadata unavailable — compliant by default
242                        checks.push(ComplianceCheck {
243                            category: "file".into(),
244                            target: Some(to_posix_string(&target)),
245                            status: ComplianceStatus::Compliant,
246                            detail: Some("permissions not applicable on this platform".into()),
247                            ..Default::default()
248                        });
249                    }
250                }
251            } else {
252                // Malformed permission string
253                checks.push(ComplianceCheck {
254                    category: "file".into(),
255                    target: Some(to_posix_string(&target)),
256                    status: ComplianceStatus::Warning,
257                    detail: Some(format!("invalid permission string: {}", perm_str)),
258                    ..Default::default()
259                });
260            }
261        } else {
262            // No permissions declared — file exists, compliant
263            checks.push(ComplianceCheck {
264                category: "file".into(),
265                target: Some(to_posix_string(&target)),
266                status: ComplianceStatus::Compliant,
267                detail: Some("present".into()),
268                ..Default::default()
269            });
270        }
271
272        // Check encryption declaration (if encryption is specified, just verify it is declared)
273        if let Some(ref enc) = file.encryption {
274            checks.push(ComplianceCheck {
275                category: "file-encryption".into(),
276                target: Some(to_posix_string(&target)),
277                status: ComplianceStatus::Compliant,
278                detail: Some(format!("encryption: backend={}", enc.backend)),
279                ..Default::default()
280            });
281        }
282    }
283
284    checks
285}
286
287// ---------------------------------------------------------------------------
288// Package checks
289// ---------------------------------------------------------------------------
290
291/// Check that declared packages are installed via their respective managers.
292pub fn collect_package_checks(
293    profile: &MergedProfile,
294    registry: &ProviderRegistry,
295) -> Result<Vec<ComplianceCheck>> {
296    let mut checks = Vec::new();
297
298    for pm in registry.available_package_managers() {
299        let desired = crate::config::desired_packages_for_spec(pm.name(), &profile.packages);
300        if desired.is_empty() {
301            continue;
302        }
303
304        let installed = match pm.installed_packages() {
305            Ok(set) => set,
306            Err(e) => {
307                // Cannot query this manager — report as warning
308                checks.push(ComplianceCheck {
309                    category: "package".into(),
310                    manager: Some(pm.name().to_owned()),
311                    status: ComplianceStatus::Warning,
312                    detail: Some(format!("cannot query {}: {}", pm.name(), e)),
313                    ..Default::default()
314                });
315                continue;
316            }
317        };
318
319        for pkg in &desired {
320            if installed.contains(pkg) {
321                checks.push(ComplianceCheck {
322                    category: "package".into(),
323                    name: Some(pkg.clone()),
324                    manager: Some(pm.name().to_owned()),
325                    status: ComplianceStatus::Compliant,
326                    detail: Some("installed".into()),
327                    ..Default::default()
328                });
329            } else {
330                checks.push(ComplianceCheck {
331                    category: "package".into(),
332                    name: Some(pkg.clone()),
333                    manager: Some(pm.name().to_owned()),
334                    status: ComplianceStatus::Violation,
335                    detail: Some("not installed".into()),
336                    ..Default::default()
337                });
338            }
339        }
340    }
341
342    Ok(checks)
343}
344
345// ---------------------------------------------------------------------------
346// System checks
347// ---------------------------------------------------------------------------
348
349/// Check system configurator state for drift.
350pub fn collect_system_checks(
351    profile: &MergedProfile,
352    registry: &ProviderRegistry,
353) -> Result<Vec<ComplianceCheck>> {
354    let mut checks = Vec::new();
355    let available = registry.available_system_configurators();
356
357    for (key, desired) in &profile.system {
358        let configurator = available.iter().find(|c| c.name() == key);
359
360        let Some(configurator) = configurator else {
361            checks.push(ComplianceCheck {
362                category: "system".into(),
363                key: Some(key.clone()),
364                status: ComplianceStatus::Warning,
365                detail: Some(format!("no configurator available for '{}'", key)),
366                ..Default::default()
367            });
368            continue;
369        };
370
371        match configurator.diff(desired) {
372            Ok(drifts) => {
373                if drifts.is_empty() {
374                    checks.push(ComplianceCheck {
375                        category: "system".into(),
376                        key: Some(key.clone()),
377                        status: ComplianceStatus::Compliant,
378                        detail: Some("no drift".into()),
379                        ..Default::default()
380                    });
381                } else {
382                    for drift in &drifts {
383                        checks.push(ComplianceCheck {
384                            category: "system".into(),
385                            key: Some(format!("{}.{}", key, drift.key)),
386                            status: ComplianceStatus::Violation,
387                            detail: Some(format!(
388                                "expected {}, actual {}",
389                                drift.expected, drift.actual
390                            )),
391                            value: Some(drift.actual.clone()),
392                            ..Default::default()
393                        });
394                    }
395                }
396            }
397            Err(e) => {
398                checks.push(ComplianceCheck {
399                    category: "system".into(),
400                    key: Some(key.clone()),
401                    status: ComplianceStatus::Warning,
402                    detail: Some(format!("diff failed: {}", e)),
403                    ..Default::default()
404                });
405            }
406        }
407    }
408
409    Ok(checks)
410}
411
412// ---------------------------------------------------------------------------
413// Secret checks
414// ---------------------------------------------------------------------------
415
416/// Check secrets: for secrets with file targets, verify the target file exists
417/// and check its permissions. NEVER reads or logs secret values.
418pub fn collect_secret_checks(profile: &MergedProfile) -> Vec<ComplianceCheck> {
419    let mut checks = Vec::new();
420
421    for secret in &profile.secrets {
422        let Some(ref target_path) = secret.target else {
423            // Env-only secret — no file to check
424            continue;
425        };
426
427        let target = crate::expand_tilde(target_path);
428        if target.exists() {
429            checks.push(ComplianceCheck {
430                category: "secret".into(),
431                target: Some(to_posix_string(&target)),
432                status: ComplianceStatus::Compliant,
433                detail: Some("target file present".into()),
434                ..Default::default()
435            });
436        } else {
437            checks.push(ComplianceCheck {
438                category: "secret".into(),
439                target: Some(to_posix_string(&target)),
440                status: ComplianceStatus::Violation,
441                detail: Some("target file missing".into()),
442                ..Default::default()
443            });
444        }
445    }
446
447    checks
448}
449
450// ---------------------------------------------------------------------------
451// Watch path checks
452// ---------------------------------------------------------------------------
453
454/// Stat a watch path and report basic info.
455fn collect_watch_path_checks(path_str: &str) -> Vec<ComplianceCheck> {
456    let path = crate::expand_tilde(Path::new(path_str));
457
458    if !path.exists() {
459        return vec![ComplianceCheck {
460            category: "watchPath".into(),
461            path: Some(to_posix_string(path)),
462            status: ComplianceStatus::Warning,
463            detail: Some("path does not exist".into()),
464            ..Default::default()
465        }];
466    }
467
468    let meta = match path.metadata() {
469        Ok(m) => m,
470        Err(e) => {
471            return vec![ComplianceCheck {
472                category: "watchPath".into(),
473                path: Some(to_posix_string(path)),
474                status: ComplianceStatus::Warning,
475                detail: Some(format!("cannot stat: {}", e)),
476                ..Default::default()
477            }];
478        }
479    };
480
481    let perms = crate::file_permissions_mode(&meta);
482    let kind = if meta.is_dir() {
483        "directory"
484    } else if meta.is_file() {
485        "file"
486    } else {
487        "other"
488    };
489
490    let detail = match perms {
491        Some(mode) => format!("{}, permissions {:#o}", kind, mode),
492        None => kind.to_string(),
493    };
494
495    vec![ComplianceCheck {
496        category: "watchPath".into(),
497        path: Some(to_posix_string(path)),
498        status: ComplianceStatus::Compliant,
499        detail: Some(detail),
500        ..Default::default()
501    }]
502}
503
504// ---------------------------------------------------------------------------
505// Watch package manager checks
506// ---------------------------------------------------------------------------
507
508/// Enumerate all installed packages from a named package manager.
509/// Each installed package is reported as a `watchPackage` category check,
510/// providing a full inventory of what is installed (not just managed packages).
511fn collect_watched_package_manager_checks(
512    manager_name: &str,
513    registry: &ProviderRegistry,
514) -> Result<Vec<ComplianceCheck>> {
515    let pm = registry
516        .available_package_managers()
517        .into_iter()
518        .find(|pm| pm.name() == manager_name);
519
520    let Some(pm) = pm else {
521        return Ok(vec![ComplianceCheck {
522            category: "watchPackage".into(),
523            manager: Some(manager_name.to_owned()),
524            status: ComplianceStatus::Warning,
525            detail: Some(format!("package manager '{}' not available", manager_name)),
526            ..Default::default()
527        }]);
528    };
529
530    let installed = match pm.installed_packages() {
531        Ok(set) => set,
532        Err(e) => {
533            return Ok(vec![ComplianceCheck {
534                category: "watchPackage".into(),
535                manager: Some(manager_name.to_owned()),
536                status: ComplianceStatus::Warning,
537                detail: Some(format!("cannot query {}: {}", manager_name, e)),
538                ..Default::default()
539            }]);
540        }
541    };
542
543    let mut checks: Vec<ComplianceCheck> = installed
544        .into_iter()
545        .map(|pkg| ComplianceCheck {
546            category: "watchPackage".into(),
547            name: Some(pkg),
548            manager: Some(manager_name.to_owned()),
549            status: ComplianceStatus::Compliant,
550            detail: Some("installed".into()),
551            ..Default::default()
552        })
553        .collect();
554
555    // Sort for deterministic output
556    checks.sort_by(|a, b| a.name.cmp(&b.name));
557
558    Ok(checks)
559}
560
561// ---------------------------------------------------------------------------
562// Tests
563// ---------------------------------------------------------------------------
564
565#[cfg(test)]
566mod tests;