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;
11
12// ---------------------------------------------------------------------------
13// Types
14// ---------------------------------------------------------------------------
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct ComplianceSnapshot {
19    pub timestamp: String,
20    pub machine: MachineInfo,
21    pub profile: String,
22    pub sources: Vec<String>,
23    pub checks: Vec<ComplianceCheck>,
24    pub summary: ComplianceSummary,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct MachineInfo {
29    pub hostname: String,
30    pub os: String,
31    pub arch: String,
32}
33
34#[derive(Debug, Clone, Default, Serialize, Deserialize)]
35pub struct ComplianceCheck {
36    pub category: String,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub target: Option<String>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub name: Option<String>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub key: Option<String>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub path: Option<String>,
45    pub status: ComplianceStatus,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub detail: Option<String>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub version: Option<String>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub manager: Option<String>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub value: Option<String>,
54}
55
56#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
57pub enum ComplianceStatus {
58    #[default]
59    Compliant,
60    Warning,
61    Violation,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct ComplianceSummary {
66    pub compliant: usize,
67    pub warning: usize,
68    pub violation: usize,
69}
70
71// ---------------------------------------------------------------------------
72// Collection
73// ---------------------------------------------------------------------------
74
75/// Collect a full compliance snapshot for the current machine state.
76pub fn collect_snapshot(
77    profile_name: &str,
78    profile: &MergedProfile,
79    registry: &ProviderRegistry,
80    scope: &ComplianceScope,
81    sources: &[String],
82) -> Result<ComplianceSnapshot> {
83    let platform = Platform::detect();
84    let hostname = crate::hostname_string();
85
86    let machine = MachineInfo {
87        hostname,
88        os: platform.os.as_str().to_owned(),
89        arch: platform.arch.as_str().to_owned(),
90    };
91
92    let mut checks = Vec::new();
93
94    if scope.files {
95        checks.extend(collect_file_checks(profile));
96    }
97    if scope.packages {
98        checks.extend(collect_package_checks(profile, registry)?);
99    }
100    if scope.system {
101        checks.extend(collect_system_checks(profile, registry)?);
102    }
103    if scope.secrets {
104        checks.extend(collect_secret_checks(profile));
105    }
106    for watch_path in &scope.watch_paths {
107        checks.extend(collect_watch_path_checks(watch_path));
108    }
109    for manager_name in &scope.watch_package_managers {
110        checks.extend(collect_watched_package_manager_checks(
111            manager_name,
112            registry,
113        )?);
114    }
115
116    let summary = compute_summary(&checks);
117
118    Ok(ComplianceSnapshot {
119        timestamp: crate::utc_now_iso8601(),
120        machine,
121        profile: profile_name.to_owned(),
122        sources: sources.to_vec(),
123        checks,
124        summary,
125    })
126}
127
128/// Compute summary counts from a list of checks.
129pub fn compute_summary(checks: &[ComplianceCheck]) -> ComplianceSummary {
130    let mut compliant = 0usize;
131    let mut warning = 0usize;
132    let mut violation = 0usize;
133
134    for check in checks {
135        match check.status {
136            ComplianceStatus::Compliant => compliant += 1,
137            ComplianceStatus::Warning => warning += 1,
138            ComplianceStatus::Violation => violation += 1,
139        }
140    }
141
142    ComplianceSummary {
143        compliant,
144        warning,
145        violation,
146    }
147}
148
149// ---------------------------------------------------------------------------
150// Export
151// ---------------------------------------------------------------------------
152
153/// Export a compliance snapshot to a file based on the export configuration.
154///
155/// Steps: expand tilde in path, create directory, build timestamped filename,
156/// serialize to JSON or YAML, and atomically write.
157///
158/// Returns the path of the written file.
159pub fn export_snapshot_to_file(
160    snapshot: &ComplianceSnapshot,
161    export: &ComplianceExport,
162) -> Result<PathBuf> {
163    let export_dir = crate::expand_tilde(Path::new(&export.path));
164    std::fs::create_dir_all(&export_dir)?;
165
166    let timestamp_safe = snapshot.timestamp.replace(':', "-");
167    let ext = match export.format {
168        ComplianceFormat::Json => "json",
169        ComplianceFormat::Yaml => "yaml",
170    };
171    let filename = format!("compliance-{}.{}", timestamp_safe, ext);
172    let file_path = export_dir.join(&filename);
173
174    let content = match export.format {
175        ComplianceFormat::Json => serde_json::to_string_pretty(snapshot)
176            .map_err(|e| std::io::Error::other(format!("JSON serialization failed: {}", e)))?,
177        ComplianceFormat::Yaml => serde_yaml::to_string(snapshot)
178            .map_err(|e| std::io::Error::other(format!("YAML serialization failed: {}", e)))?,
179    };
180
181    crate::atomic_write_str(&file_path, &content)?;
182    Ok(file_path)
183}
184
185// ---------------------------------------------------------------------------
186// File checks
187// ---------------------------------------------------------------------------
188
189/// Check managed files: existence, permissions, encryption declaration.
190pub fn collect_file_checks(profile: &MergedProfile) -> Vec<ComplianceCheck> {
191    let mut checks = Vec::new();
192
193    for file in &profile.files.managed {
194        let target = crate::expand_tilde(&file.target);
195        let exists = target.exists();
196
197        if !exists {
198            checks.push(ComplianceCheck {
199                category: "file".into(),
200                target: Some(target.display().to_string()),
201                status: ComplianceStatus::Violation,
202                detail: Some("managed file missing".into()),
203                ..Default::default()
204            });
205            continue;
206        }
207
208        // Check permissions if declared
209        if let Some(ref perm_str) = file.permissions {
210            if let Ok(desired_mode) = u32::from_str_radix(perm_str, 8)
211                && desired_mode <= 0o7777
212            {
213                let actual_mode = target
214                    .metadata()
215                    .ok()
216                    .and_then(|m| crate::file_permissions_mode(&m));
217                match actual_mode {
218                    Some(mode) if mode == desired_mode => {
219                        checks.push(ComplianceCheck {
220                            category: "file".into(),
221                            target: Some(target.display().to_string()),
222                            status: ComplianceStatus::Compliant,
223                            detail: Some(format!("permissions {:#o}", mode)),
224                            ..Default::default()
225                        });
226                    }
227                    Some(mode) => {
228                        checks.push(ComplianceCheck {
229                            category: "file".into(),
230                            target: Some(target.display().to_string()),
231                            status: ComplianceStatus::Warning,
232                            detail: Some(format!(
233                                "permissions {:#o}, expected {:#o}",
234                                mode, desired_mode
235                            )),
236                            ..Default::default()
237                        });
238                    }
239                    None => {
240                        // Windows or metadata unavailable — compliant by default
241                        checks.push(ComplianceCheck {
242                            category: "file".into(),
243                            target: Some(target.display().to_string()),
244                            status: ComplianceStatus::Compliant,
245                            detail: Some("permissions not applicable on this platform".into()),
246                            ..Default::default()
247                        });
248                    }
249                }
250            } else {
251                // Malformed permission string
252                checks.push(ComplianceCheck {
253                    category: "file".into(),
254                    target: Some(target.display().to_string()),
255                    status: ComplianceStatus::Warning,
256                    detail: Some(format!("invalid permission string: {}", perm_str)),
257                    ..Default::default()
258                });
259            }
260        } else {
261            // No permissions declared — file exists, compliant
262            checks.push(ComplianceCheck {
263                category: "file".into(),
264                target: Some(target.display().to_string()),
265                status: ComplianceStatus::Compliant,
266                detail: Some("present".into()),
267                ..Default::default()
268            });
269        }
270
271        // Check encryption declaration (if encryption is specified, just verify it is declared)
272        if let Some(ref enc) = file.encryption {
273            checks.push(ComplianceCheck {
274                category: "file-encryption".into(),
275                target: Some(target.display().to_string()),
276                status: ComplianceStatus::Compliant,
277                detail: Some(format!("encryption: backend={}", enc.backend)),
278                ..Default::default()
279            });
280        }
281    }
282
283    checks
284}
285
286// ---------------------------------------------------------------------------
287// Package checks
288// ---------------------------------------------------------------------------
289
290/// Check that declared packages are installed via their respective managers.
291pub fn collect_package_checks(
292    profile: &MergedProfile,
293    registry: &ProviderRegistry,
294) -> Result<Vec<ComplianceCheck>> {
295    let mut checks = Vec::new();
296
297    for pm in registry.available_package_managers() {
298        let desired = crate::config::desired_packages_for_spec(pm.name(), &profile.packages);
299        if desired.is_empty() {
300            continue;
301        }
302
303        let installed = match pm.installed_packages() {
304            Ok(set) => set,
305            Err(e) => {
306                // Cannot query this manager — report as warning
307                checks.push(ComplianceCheck {
308                    category: "package".into(),
309                    manager: Some(pm.name().to_owned()),
310                    status: ComplianceStatus::Warning,
311                    detail: Some(format!("cannot query {}: {}", pm.name(), e)),
312                    ..Default::default()
313                });
314                continue;
315            }
316        };
317
318        for pkg in &desired {
319            if installed.contains(pkg) {
320                checks.push(ComplianceCheck {
321                    category: "package".into(),
322                    name: Some(pkg.clone()),
323                    manager: Some(pm.name().to_owned()),
324                    status: ComplianceStatus::Compliant,
325                    detail: Some("installed".into()),
326                    ..Default::default()
327                });
328            } else {
329                checks.push(ComplianceCheck {
330                    category: "package".into(),
331                    name: Some(pkg.clone()),
332                    manager: Some(pm.name().to_owned()),
333                    status: ComplianceStatus::Violation,
334                    detail: Some("not installed".into()),
335                    ..Default::default()
336                });
337            }
338        }
339    }
340
341    Ok(checks)
342}
343
344// ---------------------------------------------------------------------------
345// System checks
346// ---------------------------------------------------------------------------
347
348/// Check system configurator state for drift.
349pub fn collect_system_checks(
350    profile: &MergedProfile,
351    registry: &ProviderRegistry,
352) -> Result<Vec<ComplianceCheck>> {
353    let mut checks = Vec::new();
354    let available = registry.available_system_configurators();
355
356    for (key, desired) in &profile.system {
357        let configurator = available.iter().find(|c| c.name() == key);
358
359        let Some(configurator) = configurator else {
360            checks.push(ComplianceCheck {
361                category: "system".into(),
362                key: Some(key.clone()),
363                status: ComplianceStatus::Warning,
364                detail: Some(format!("no configurator available for '{}'", key)),
365                ..Default::default()
366            });
367            continue;
368        };
369
370        match configurator.diff(desired) {
371            Ok(drifts) => {
372                if drifts.is_empty() {
373                    checks.push(ComplianceCheck {
374                        category: "system".into(),
375                        key: Some(key.clone()),
376                        status: ComplianceStatus::Compliant,
377                        detail: Some("no drift".into()),
378                        ..Default::default()
379                    });
380                } else {
381                    for drift in &drifts {
382                        checks.push(ComplianceCheck {
383                            category: "system".into(),
384                            key: Some(format!("{}.{}", key, drift.key)),
385                            status: ComplianceStatus::Violation,
386                            detail: Some(format!(
387                                "expected {}, actual {}",
388                                drift.expected, drift.actual
389                            )),
390                            value: Some(drift.actual.clone()),
391                            ..Default::default()
392                        });
393                    }
394                }
395            }
396            Err(e) => {
397                checks.push(ComplianceCheck {
398                    category: "system".into(),
399                    key: Some(key.clone()),
400                    status: ComplianceStatus::Warning,
401                    detail: Some(format!("diff failed: {}", e)),
402                    ..Default::default()
403                });
404            }
405        }
406    }
407
408    Ok(checks)
409}
410
411// ---------------------------------------------------------------------------
412// Secret checks
413// ---------------------------------------------------------------------------
414
415/// Check secrets: for secrets with file targets, verify the target file exists
416/// and check its permissions. NEVER reads or logs secret values.
417pub fn collect_secret_checks(profile: &MergedProfile) -> Vec<ComplianceCheck> {
418    let mut checks = Vec::new();
419
420    for secret in &profile.secrets {
421        let Some(ref target_path) = secret.target else {
422            // Env-only secret — no file to check
423            continue;
424        };
425
426        let target = crate::expand_tilde(target_path);
427        if target.exists() {
428            checks.push(ComplianceCheck {
429                category: "secret".into(),
430                target: Some(target.display().to_string()),
431                status: ComplianceStatus::Compliant,
432                detail: Some("target file present".into()),
433                ..Default::default()
434            });
435        } else {
436            checks.push(ComplianceCheck {
437                category: "secret".into(),
438                target: Some(target.display().to_string()),
439                status: ComplianceStatus::Violation,
440                detail: Some("target file missing".into()),
441                ..Default::default()
442            });
443        }
444    }
445
446    checks
447}
448
449// ---------------------------------------------------------------------------
450// Watch path checks
451// ---------------------------------------------------------------------------
452
453/// Stat a watch path and report basic info.
454fn collect_watch_path_checks(path_str: &str) -> Vec<ComplianceCheck> {
455    let path = crate::expand_tilde(Path::new(path_str));
456
457    if !path.exists() {
458        return vec![ComplianceCheck {
459            category: "watchPath".into(),
460            path: Some(path.display().to_string()),
461            status: ComplianceStatus::Warning,
462            detail: Some("path does not exist".into()),
463            ..Default::default()
464        }];
465    }
466
467    let meta = match path.metadata() {
468        Ok(m) => m,
469        Err(e) => {
470            return vec![ComplianceCheck {
471                category: "watchPath".into(),
472                path: Some(path.display().to_string()),
473                status: ComplianceStatus::Warning,
474                detail: Some(format!("cannot stat: {}", e)),
475                ..Default::default()
476            }];
477        }
478    };
479
480    let perms = crate::file_permissions_mode(&meta);
481    let kind = if meta.is_dir() {
482        "directory"
483    } else if meta.is_file() {
484        "file"
485    } else {
486        "other"
487    };
488
489    let detail = match perms {
490        Some(mode) => format!("{}, permissions {:#o}", kind, mode),
491        None => kind.to_string(),
492    };
493
494    vec![ComplianceCheck {
495        category: "watchPath".into(),
496        path: Some(path.display().to_string()),
497        status: ComplianceStatus::Compliant,
498        detail: Some(detail),
499        ..Default::default()
500    }]
501}
502
503// ---------------------------------------------------------------------------
504// Watch package manager checks
505// ---------------------------------------------------------------------------
506
507/// Enumerate all installed packages from a named package manager.
508/// Each installed package is reported as a `watchPackage` category check,
509/// providing a full inventory of what is installed (not just managed packages).
510fn collect_watched_package_manager_checks(
511    manager_name: &str,
512    registry: &ProviderRegistry,
513) -> Result<Vec<ComplianceCheck>> {
514    let pm = registry
515        .available_package_managers()
516        .into_iter()
517        .find(|pm| pm.name() == manager_name);
518
519    let Some(pm) = pm else {
520        return Ok(vec![ComplianceCheck {
521            category: "watchPackage".into(),
522            manager: Some(manager_name.to_owned()),
523            status: ComplianceStatus::Warning,
524            detail: Some(format!("package manager '{}' not available", manager_name)),
525            ..Default::default()
526        }]);
527    };
528
529    let installed = match pm.installed_packages() {
530        Ok(set) => set,
531        Err(e) => {
532            return Ok(vec![ComplianceCheck {
533                category: "watchPackage".into(),
534                manager: Some(manager_name.to_owned()),
535                status: ComplianceStatus::Warning,
536                detail: Some(format!("cannot query {}: {}", manager_name, e)),
537                ..Default::default()
538            }]);
539        }
540    };
541
542    let mut checks: Vec<ComplianceCheck> = installed
543        .into_iter()
544        .map(|pkg| ComplianceCheck {
545            category: "watchPackage".into(),
546            name: Some(pkg),
547            manager: Some(manager_name.to_owned()),
548            status: ComplianceStatus::Compliant,
549            detail: Some("installed".into()),
550            ..Default::default()
551        })
552        .collect();
553
554    // Sort for deterministic output
555    checks.sort_by(|a, b| a.name.cmp(&b.name));
556
557    Ok(checks)
558}
559
560// ---------------------------------------------------------------------------
561// Tests
562// ---------------------------------------------------------------------------
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    use std::collections::HashMap;
569
570    #[test]
571    fn snapshot_serializes_to_json() {
572        let snapshot = ComplianceSnapshot {
573            timestamp: "2026-03-25T00:00:00Z".into(),
574            machine: MachineInfo {
575                hostname: "test-host".into(),
576                os: "linux".into(),
577                arch: "x86_64".into(),
578            },
579            profile: "default".into(),
580            sources: vec!["local".into()],
581            checks: vec![
582                ComplianceCheck {
583                    category: "file".into(),
584                    target: Some("/home/user/.zshrc".into()),
585                    status: ComplianceStatus::Compliant,
586                    detail: Some("present".into()),
587                    ..Default::default()
588                },
589                ComplianceCheck {
590                    category: "package".into(),
591                    name: Some("ripgrep".into()),
592                    manager: Some("apt".into()),
593                    status: ComplianceStatus::Violation,
594                    detail: Some("not installed".into()),
595                    ..Default::default()
596                },
597            ],
598            summary: ComplianceSummary {
599                compliant: 1,
600                warning: 0,
601                violation: 1,
602            },
603        };
604
605        let json = serde_json::to_string_pretty(&snapshot).unwrap();
606        assert!(json.contains("\"timestamp\""));
607        assert!(json.contains("\"machine\""));
608        assert!(json.contains("\"test-host\""));
609        assert!(json.contains("\"Compliant\""));
610        assert!(json.contains("\"Violation\""));
611
612        // Roundtrip
613        let parsed: ComplianceSnapshot = serde_json::from_str(&json).unwrap();
614        assert_eq!(parsed.profile, "default");
615        assert_eq!(parsed.checks.len(), 2);
616        assert_eq!(parsed.summary.compliant, 1);
617        assert_eq!(parsed.summary.violation, 1);
618    }
619
620    #[test]
621    fn summary_counts_match_check_statuses() {
622        let checks = vec![
623            ComplianceCheck {
624                category: "file".into(),
625                status: ComplianceStatus::Compliant,
626                ..Default::default()
627            },
628            ComplianceCheck {
629                category: "file".into(),
630                status: ComplianceStatus::Compliant,
631                ..Default::default()
632            },
633            ComplianceCheck {
634                category: "package".into(),
635                status: ComplianceStatus::Violation,
636                ..Default::default()
637            },
638            ComplianceCheck {
639                category: "system".into(),
640                status: ComplianceStatus::Warning,
641                ..Default::default()
642            },
643            ComplianceCheck {
644                category: "system".into(),
645                status: ComplianceStatus::Warning,
646                ..Default::default()
647            },
648            ComplianceCheck {
649                category: "file".into(),
650                status: ComplianceStatus::Violation,
651                ..Default::default()
652            },
653        ];
654
655        let summary = compute_summary(&checks);
656        assert_eq!(summary.compliant, 2);
657        assert_eq!(summary.warning, 2);
658        assert_eq!(summary.violation, 2);
659    }
660
661    #[test]
662    fn collect_file_checks_existing_file() {
663        let dir = tempfile::tempdir().unwrap();
664        let file_path = dir.path().join("test.conf");
665        std::fs::write(&file_path, "content").unwrap();
666
667        let profile = MergedProfile {
668            files: crate::config::FilesSpec {
669                managed: vec![crate::config::ManagedFileSpec {
670                    source: "test.conf".into(),
671                    target: file_path.clone(),
672                    strategy: None,
673                    private: false,
674                    origin: None,
675                    encryption: None,
676                    permissions: None,
677                }],
678                permissions: HashMap::new(),
679            },
680            ..Default::default()
681        };
682
683        let checks = collect_file_checks(&profile);
684        assert_eq!(checks.len(), 1);
685        assert_eq!(checks[0].status, ComplianceStatus::Compliant);
686        assert_eq!(checks[0].detail.as_deref(), Some("present"));
687    }
688
689    #[test]
690    fn collect_file_checks_missing_file() {
691        let profile = MergedProfile {
692            files: crate::config::FilesSpec {
693                managed: vec![crate::config::ManagedFileSpec {
694                    source: "test.conf".into(),
695                    target: "/tmp/cfgd-nonexistent-file-12345".into(),
696                    strategy: None,
697                    private: false,
698                    origin: None,
699                    encryption: None,
700                    permissions: None,
701                }],
702                permissions: HashMap::new(),
703            },
704            ..Default::default()
705        };
706
707        let checks = collect_file_checks(&profile);
708        assert_eq!(checks.len(), 1);
709        assert_eq!(checks[0].status, ComplianceStatus::Violation);
710        assert_eq!(checks[0].detail.as_deref(), Some("managed file missing"));
711    }
712
713    #[cfg(unix)]
714    #[test]
715    fn collect_file_checks_permissions_match() {
716        let dir = tempfile::tempdir().unwrap();
717        let file_path = dir.path().join("secret.key");
718        std::fs::write(&file_path, "key-data").unwrap();
719        crate::set_file_permissions(&file_path, 0o600).unwrap();
720
721        let profile = MergedProfile {
722            files: crate::config::FilesSpec {
723                managed: vec![crate::config::ManagedFileSpec {
724                    source: "secret.key".into(),
725                    target: file_path.clone(),
726                    strategy: None,
727                    private: false,
728                    origin: None,
729                    encryption: None,
730                    permissions: Some("600".into()),
731                }],
732                permissions: HashMap::new(),
733            },
734            ..Default::default()
735        };
736
737        let checks = collect_file_checks(&profile);
738        assert_eq!(checks.len(), 1);
739        assert_eq!(checks[0].status, ComplianceStatus::Compliant);
740        assert!(checks[0].detail.as_deref().unwrap().contains("0o600"));
741    }
742
743    #[cfg(unix)]
744    #[test]
745    fn collect_file_checks_permissions_mismatch() {
746        let dir = tempfile::tempdir().unwrap();
747        let file_path = dir.path().join("secret.key");
748        std::fs::write(&file_path, "key-data").unwrap();
749        crate::set_file_permissions(&file_path, 0o644).unwrap();
750
751        let profile = MergedProfile {
752            files: crate::config::FilesSpec {
753                managed: vec![crate::config::ManagedFileSpec {
754                    source: "secret.key".into(),
755                    target: file_path.clone(),
756                    strategy: None,
757                    private: false,
758                    origin: None,
759                    encryption: None,
760                    permissions: Some("600".into()),
761                }],
762                permissions: HashMap::new(),
763            },
764            ..Default::default()
765        };
766
767        let checks = collect_file_checks(&profile);
768        assert_eq!(checks.len(), 1);
769        assert_eq!(checks[0].status, ComplianceStatus::Warning);
770        assert!(checks[0].detail.as_deref().unwrap().contains("expected"));
771    }
772
773    #[test]
774    fn collect_system_checks_maps_drifts() {
775        use crate::providers::{ProviderRegistry, SystemDrift};
776        use crate::test_helpers::MockSystemConfigurator;
777
778        let mut registry = ProviderRegistry::new();
779        registry.system_configurators.push(Box::new(
780            MockSystemConfigurator::new("shell").with_drift(vec![SystemDrift {
781                key: "defaultShell".into(),
782                expected: "/bin/zsh".into(),
783                actual: "/bin/bash".into(),
784            }]),
785        ));
786
787        let mut system = HashMap::new();
788        system.insert(
789            "shell".to_owned(),
790            serde_yaml::Value::String("/bin/zsh".into()),
791        );
792
793        let profile = MergedProfile {
794            system,
795            ..Default::default()
796        };
797
798        let checks = collect_system_checks(&profile, &registry).unwrap();
799        assert_eq!(checks.len(), 1);
800        assert_eq!(checks[0].status, ComplianceStatus::Violation);
801        assert_eq!(checks[0].key.as_deref(), Some("shell.defaultShell"));
802        assert!(checks[0].detail.as_deref().unwrap().contains("/bin/bash"));
803    }
804
805    #[test]
806    fn collect_system_checks_compliant_when_no_drift() {
807        use crate::providers::ProviderRegistry;
808        use crate::test_helpers::MockSystemConfigurator;
809
810        let mut registry = ProviderRegistry::new();
811        registry
812            .system_configurators
813            .push(Box::new(MockSystemConfigurator::new("shell")));
814
815        let mut system = HashMap::new();
816        system.insert(
817            "shell".to_owned(),
818            serde_yaml::Value::String("/bin/zsh".into()),
819        );
820
821        let profile = MergedProfile {
822            system,
823            ..Default::default()
824        };
825
826        let checks = collect_system_checks(&profile, &registry).unwrap();
827        assert_eq!(checks.len(), 1);
828        assert_eq!(checks[0].status, ComplianceStatus::Compliant);
829    }
830
831    #[test]
832    fn collect_secret_checks_target_exists() {
833        let dir = tempfile::tempdir().unwrap();
834        let target = dir.path().join("token.txt");
835        std::fs::write(&target, "redacted").unwrap();
836
837        let profile = MergedProfile {
838            secrets: vec![crate::config::SecretSpec {
839                source: "vault://secret/token".into(),
840                target: Some(target.clone()),
841                template: None,
842                backend: None,
843                envs: None,
844            }],
845            ..Default::default()
846        };
847
848        let checks = collect_secret_checks(&profile);
849        assert_eq!(checks.len(), 1);
850        assert_eq!(checks[0].status, ComplianceStatus::Compliant);
851    }
852
853    #[test]
854    fn collect_secret_checks_target_missing() {
855        let profile = MergedProfile {
856            secrets: vec![crate::config::SecretSpec {
857                source: "vault://secret/token".into(),
858                target: Some("/tmp/cfgd-nonexistent-secret-12345".into()),
859                template: None,
860                backend: None,
861                envs: None,
862            }],
863            ..Default::default()
864        };
865
866        let checks = collect_secret_checks(&profile);
867        assert_eq!(checks.len(), 1);
868        assert_eq!(checks[0].status, ComplianceStatus::Violation);
869    }
870
871    #[test]
872    fn collect_secret_checks_env_only_skipped() {
873        let profile = MergedProfile {
874            secrets: vec![crate::config::SecretSpec {
875                source: "vault://secret/api-key".into(),
876                target: None,
877                template: None,
878                backend: None,
879                envs: Some(vec!["API_KEY=vault://secret/api-key".into()]),
880            }],
881            ..Default::default()
882        };
883
884        let checks = collect_secret_checks(&profile);
885        assert!(checks.is_empty());
886    }
887
888    #[test]
889    fn watch_path_existing_file() {
890        let dir = tempfile::tempdir().unwrap();
891        let file_path = dir.path().join("watched.conf");
892        std::fs::write(&file_path, "data").unwrap();
893
894        let checks = collect_watch_path_checks(&file_path.to_string_lossy());
895        assert_eq!(checks.len(), 1);
896        assert_eq!(checks[0].status, ComplianceStatus::Compliant);
897        assert!(checks[0].detail.as_deref().unwrap().contains("file"));
898    }
899
900    #[test]
901    fn watch_path_nonexistent() {
902        let checks = collect_watch_path_checks("/tmp/cfgd-nonexistent-watch-12345");
903        assert_eq!(checks.len(), 1);
904        assert_eq!(checks[0].status, ComplianceStatus::Warning);
905    }
906
907    #[test]
908    fn watch_package_manager_not_available() {
909        let registry = ProviderRegistry::new();
910        let checks = collect_watched_package_manager_checks("nonexistent-pm", &registry).unwrap();
911        assert_eq!(checks.len(), 1);
912        assert_eq!(checks[0].category, "watchPackage");
913        assert_eq!(checks[0].status, ComplianceStatus::Warning);
914        assert!(
915            checks[0]
916                .detail
917                .as_deref()
918                .unwrap()
919                .contains("not available")
920        );
921    }
922
923    #[test]
924    fn watch_package_manager_returns_installed() {
925        use crate::providers::StubPackageManager;
926
927        let mut registry = ProviderRegistry::new();
928        registry.package_managers.push(Box::new(
929            StubPackageManager::new("mock").with_installed(&["ripgrep", "fd"]),
930        ));
931
932        let checks = collect_watched_package_manager_checks("mock", &registry).unwrap();
933        assert_eq!(checks.len(), 2);
934        assert!(checks.iter().all(|c| c.category == "watchPackage"));
935        assert!(
936            checks
937                .iter()
938                .all(|c| c.status == ComplianceStatus::Compliant)
939        );
940        assert!(checks.iter().all(|c| c.manager.as_deref() == Some("mock")));
941        // Sorted by name
942        assert_eq!(checks[0].name.as_deref(), Some("fd"));
943        assert_eq!(checks[1].name.as_deref(), Some("ripgrep"));
944    }
945
946    #[test]
947    fn export_snapshot_to_file_json() {
948        let dir = tempfile::tempdir().unwrap();
949        let export = ComplianceExport {
950            format: ComplianceFormat::Json,
951            path: dir.path().display().to_string(),
952        };
953        let snapshot = ComplianceSnapshot {
954            timestamp: "2026-03-25T12:00:00Z".into(),
955            machine: MachineInfo {
956                hostname: "test".into(),
957                os: "linux".into(),
958                arch: "x86_64".into(),
959            },
960            profile: "default".into(),
961            sources: vec![],
962            checks: vec![],
963            summary: ComplianceSummary {
964                compliant: 0,
965                warning: 0,
966                violation: 0,
967            },
968        };
969
970        let path = export_snapshot_to_file(&snapshot, &export).unwrap();
971        assert!(path.exists());
972        assert!(
973            path.file_name()
974                .unwrap()
975                .to_string_lossy()
976                .ends_with(".json")
977        );
978
979        let content = std::fs::read_to_string(&path).unwrap();
980        let parsed: ComplianceSnapshot = serde_json::from_str(&content).unwrap();
981        assert_eq!(parsed.profile, "default");
982    }
983
984    #[test]
985    fn export_snapshot_to_file_yaml() {
986        let dir = tempfile::tempdir().unwrap();
987        let export = ComplianceExport {
988            format: ComplianceFormat::Yaml,
989            path: dir.path().display().to_string(),
990        };
991        let snapshot = ComplianceSnapshot {
992            timestamp: "2026-03-25T12:00:00Z".into(),
993            machine: MachineInfo {
994                hostname: "test".into(),
995                os: "linux".into(),
996                arch: "x86_64".into(),
997            },
998            profile: "default".into(),
999            sources: vec![],
1000            checks: vec![],
1001            summary: ComplianceSummary {
1002                compliant: 0,
1003                warning: 0,
1004                violation: 0,
1005            },
1006        };
1007
1008        let path = export_snapshot_to_file(&snapshot, &export).unwrap();
1009        assert!(path.exists());
1010        assert!(
1011            path.file_name()
1012                .unwrap()
1013                .to_string_lossy()
1014                .ends_with(".yaml")
1015        );
1016    }
1017
1018    // -----------------------------------------------------------------------
1019    // collect_package_checks
1020    // -----------------------------------------------------------------------
1021
1022    #[test]
1023    fn collect_package_checks_installed_package_compliant() {
1024        use crate::config::MergedProfile;
1025        use crate::providers::StubPackageManager;
1026
1027        let mut profile = MergedProfile::default();
1028        // Use pipx (Vec<String>) which is simpler to construct
1029        profile.packages.pipx = vec!["ripgrep".into()];
1030
1031        let mut registry = ProviderRegistry::new();
1032        registry.package_managers.push(Box::new(
1033            StubPackageManager::new("pipx").with_installed(&["ripgrep"]),
1034        ));
1035
1036        let checks = collect_package_checks(&profile, &registry).unwrap();
1037        assert_eq!(checks.len(), 1);
1038        assert_eq!(checks[0].status, ComplianceStatus::Compliant);
1039        assert_eq!(checks[0].name.as_deref(), Some("ripgrep"));
1040        assert_eq!(checks[0].manager.as_deref(), Some("pipx"));
1041    }
1042
1043    #[test]
1044    fn collect_package_checks_missing_package_violation() {
1045        use crate::config::MergedProfile;
1046        use crate::providers::StubPackageManager;
1047
1048        let mut profile = MergedProfile::default();
1049        profile.packages.pipx = vec!["missing-pkg".into()];
1050
1051        let mut registry = ProviderRegistry::new();
1052        registry.package_managers.push(Box::new(
1053            StubPackageManager::new("pipx").with_installed(&[]),
1054        ));
1055
1056        let checks = collect_package_checks(&profile, &registry).unwrap();
1057        assert_eq!(checks.len(), 1);
1058        assert_eq!(checks[0].status, ComplianceStatus::Violation);
1059        assert!(
1060            checks[0]
1061                .detail
1062                .as_deref()
1063                .unwrap()
1064                .contains("not installed")
1065        );
1066    }
1067
1068    #[test]
1069    fn collect_package_checks_empty_desired_skips_manager() {
1070        use crate::config::MergedProfile;
1071        use crate::providers::StubPackageManager;
1072
1073        let profile = MergedProfile::default();
1074        let mut registry = ProviderRegistry::new();
1075        registry.package_managers.push(Box::new(
1076            StubPackageManager::new("pipx").with_installed(&["curl"]),
1077        ));
1078
1079        let checks = collect_package_checks(&profile, &registry).unwrap();
1080        assert!(checks.is_empty(), "no desired packages = no checks");
1081    }
1082
1083    #[test]
1084    fn collect_package_checks_multiple_managers() {
1085        use crate::config::MergedProfile;
1086        use crate::providers::StubPackageManager;
1087
1088        let mut profile = MergedProfile::default();
1089        profile.packages.pipx = vec!["ripgrep".into()];
1090        profile.packages.dnf = vec!["fd-find".into()];
1091
1092        let mut registry = ProviderRegistry::new();
1093        registry.package_managers.push(Box::new(
1094            StubPackageManager::new("pipx").with_installed(&["ripgrep"]),
1095        ));
1096        registry
1097            .package_managers
1098            .push(Box::new(StubPackageManager::new("dnf").with_installed(&[])));
1099
1100        let checks = collect_package_checks(&profile, &registry).unwrap();
1101        assert_eq!(checks.len(), 2);
1102        let pipx_check = checks
1103            .iter()
1104            .find(|c| c.manager.as_deref() == Some("pipx"))
1105            .unwrap();
1106        assert_eq!(pipx_check.status, ComplianceStatus::Compliant);
1107        let dnf_check = checks
1108            .iter()
1109            .find(|c| c.manager.as_deref() == Some("dnf"))
1110            .unwrap();
1111        assert_eq!(dnf_check.status, ComplianceStatus::Violation);
1112    }
1113
1114    // -----------------------------------------------------------------------
1115    // collect_system_checks
1116    // -----------------------------------------------------------------------
1117
1118    // Inline mock for system configurator tests (test_helpers is feature-gated)
1119    struct InlineSystemMock {
1120        configurator_name: String,
1121        // Store as tuples to avoid Clone requirement on SystemDrift
1122        drift_tuples: Vec<(String, String, String)>,
1123        should_fail: bool,
1124    }
1125    impl crate::providers::SystemConfigurator for InlineSystemMock {
1126        fn name(&self) -> &str {
1127            &self.configurator_name
1128        }
1129        fn is_available(&self) -> bool {
1130            true
1131        }
1132        fn current_state(&self) -> crate::errors::Result<serde_yaml::Value> {
1133            Ok(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()))
1134        }
1135        fn diff(
1136            &self,
1137            _desired: &serde_yaml::Value,
1138        ) -> crate::errors::Result<Vec<crate::providers::SystemDrift>> {
1139            if self.should_fail {
1140                Err(crate::errors::CfgdError::Io(std::io::Error::other(
1141                    "mock diff failure",
1142                )))
1143            } else {
1144                Ok(self
1145                    .drift_tuples
1146                    .iter()
1147                    .map(|(k, e, a)| crate::providers::SystemDrift {
1148                        key: k.clone(),
1149                        expected: e.clone(),
1150                        actual: a.clone(),
1151                    })
1152                    .collect())
1153            }
1154        }
1155        fn apply(
1156            &self,
1157            _desired: &serde_yaml::Value,
1158            _printer: &crate::output::Printer,
1159        ) -> crate::errors::Result<()> {
1160            Ok(())
1161        }
1162    }
1163
1164    #[test]
1165    fn collect_system_checks_no_drift_compliant() {
1166        use crate::config::MergedProfile;
1167
1168        let mut profile = MergedProfile::default();
1169        profile.system.insert(
1170            "mock".to_string(),
1171            serde_yaml::Value::Mapping(serde_yaml::Mapping::new()),
1172        );
1173
1174        let mut registry = ProviderRegistry::new();
1175        registry
1176            .system_configurators
1177            .push(Box::new(InlineSystemMock {
1178                configurator_name: "mock".to_string(),
1179                drift_tuples: vec![],
1180                should_fail: false,
1181            }));
1182
1183        let checks = collect_system_checks(&profile, &registry).unwrap();
1184        assert_eq!(checks.len(), 1);
1185        assert_eq!(checks[0].status, ComplianceStatus::Compliant);
1186    }
1187
1188    #[test]
1189    fn collect_system_checks_with_drift_violation() {
1190        use crate::config::MergedProfile;
1191        let mut profile = MergedProfile::default();
1192        profile.system.insert(
1193            "mock".to_string(),
1194            serde_yaml::Value::String("desired".into()),
1195        );
1196
1197        let mut registry = ProviderRegistry::new();
1198        registry
1199            .system_configurators
1200            .push(Box::new(InlineSystemMock {
1201                configurator_name: "mock".to_string(),
1202                drift_tuples: vec![("net.ipv4.ip_forward".into(), "1".into(), "0".into())],
1203                should_fail: false,
1204            }));
1205
1206        let checks = collect_system_checks(&profile, &registry).unwrap();
1207        assert_eq!(checks.len(), 1);
1208        assert_eq!(checks[0].status, ComplianceStatus::Violation);
1209        assert!(checks[0].detail.as_deref().unwrap().contains("expected 1"));
1210        assert!(checks[0].detail.as_deref().unwrap().contains("actual 0"));
1211    }
1212
1213    #[test]
1214    fn collect_system_checks_missing_configurator_warning() {
1215        use crate::config::MergedProfile;
1216
1217        let mut profile = MergedProfile::default();
1218        profile.system.insert(
1219            "nonexistent".to_string(),
1220            serde_yaml::Value::Mapping(serde_yaml::Mapping::new()),
1221        );
1222
1223        let registry = ProviderRegistry::new();
1224        let checks = collect_system_checks(&profile, &registry).unwrap();
1225        assert_eq!(checks.len(), 1);
1226        assert_eq!(checks[0].status, ComplianceStatus::Warning);
1227        assert!(
1228            checks[0]
1229                .detail
1230                .as_deref()
1231                .unwrap()
1232                .contains("no configurator")
1233        );
1234    }
1235
1236    #[test]
1237    fn collect_system_checks_diff_error_warning() {
1238        use crate::config::MergedProfile;
1239
1240        let mut profile = MergedProfile::default();
1241        profile.system.insert(
1242            "mock".to_string(),
1243            serde_yaml::Value::String("desired".into()),
1244        );
1245
1246        let mut registry = ProviderRegistry::new();
1247        registry
1248            .system_configurators
1249            .push(Box::new(InlineSystemMock {
1250                configurator_name: "mock".to_string(),
1251                drift_tuples: vec![],
1252                should_fail: true,
1253            }));
1254
1255        let checks = collect_system_checks(&profile, &registry).unwrap();
1256        assert_eq!(checks.len(), 1);
1257        assert_eq!(checks[0].status, ComplianceStatus::Warning);
1258        assert!(checks[0].detail.as_deref().unwrap().contains("diff failed"));
1259    }
1260
1261    #[test]
1262    fn collect_system_checks_multiple_drifts_multiple_violations() {
1263        use crate::config::MergedProfile;
1264        let mut profile = MergedProfile::default();
1265        profile.system.insert(
1266            "mock".to_string(),
1267            serde_yaml::Value::String("desired".into()),
1268        );
1269
1270        let mut registry = ProviderRegistry::new();
1271        registry
1272            .system_configurators
1273            .push(Box::new(InlineSystemMock {
1274                configurator_name: "mock".to_string(),
1275                drift_tuples: vec![
1276                    ("a".into(), "1".into(), "0".into()),
1277                    ("b".into(), "true".into(), "false".into()),
1278                ],
1279                should_fail: false,
1280            }));
1281
1282        let checks = collect_system_checks(&profile, &registry).unwrap();
1283        assert_eq!(checks.len(), 2);
1284        assert!(
1285            checks
1286                .iter()
1287                .all(|c| c.status == ComplianceStatus::Violation)
1288        );
1289    }
1290
1291    #[test]
1292    fn watch_path_directory() {
1293        let dir = tempfile::tempdir().unwrap();
1294        let checks = collect_watch_path_checks(&dir.path().to_string_lossy());
1295        assert_eq!(checks.len(), 1);
1296        assert_eq!(checks[0].status, ComplianceStatus::Compliant);
1297        assert!(checks[0].detail.as_deref().unwrap().contains("directory"));
1298    }
1299
1300    #[test]
1301    fn export_snapshot_creates_parent_dir() {
1302        let dir = tempfile::tempdir().unwrap();
1303        let nested = dir.path().join("deep/nested/dir");
1304        let export = ComplianceExport {
1305            format: ComplianceFormat::Json,
1306            path: nested.display().to_string(),
1307        };
1308        let snapshot = ComplianceSnapshot {
1309            timestamp: "2026-03-25T12:00:00Z".into(),
1310            machine: MachineInfo {
1311                hostname: "test".into(),
1312                os: "linux".into(),
1313                arch: "x86_64".into(),
1314            },
1315            profile: "default".into(),
1316            sources: vec![],
1317            checks: vec![],
1318            summary: ComplianceSummary {
1319                compliant: 0,
1320                warning: 0,
1321                violation: 0,
1322            },
1323        };
1324
1325        let path = export_snapshot_to_file(&snapshot, &export).unwrap();
1326        assert!(path.exists());
1327        assert!(nested.exists());
1328    }
1329
1330    #[test]
1331    fn compute_summary_all_statuses() {
1332        let checks = vec![
1333            ComplianceCheck {
1334                status: ComplianceStatus::Compliant,
1335                ..Default::default()
1336            },
1337            ComplianceCheck {
1338                status: ComplianceStatus::Compliant,
1339                ..Default::default()
1340            },
1341            ComplianceCheck {
1342                status: ComplianceStatus::Warning,
1343                ..Default::default()
1344            },
1345            ComplianceCheck {
1346                status: ComplianceStatus::Violation,
1347                ..Default::default()
1348            },
1349            ComplianceCheck {
1350                status: ComplianceStatus::Violation,
1351                ..Default::default()
1352            },
1353            ComplianceCheck {
1354                status: ComplianceStatus::Violation,
1355                ..Default::default()
1356            },
1357        ];
1358        let summary = compute_summary(&checks);
1359        assert_eq!(summary.compliant, 2);
1360        assert_eq!(summary.warning, 1);
1361        assert_eq!(summary.violation, 3);
1362    }
1363}