1use 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#[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
71pub 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
128pub 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
149pub 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
185pub 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 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 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 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 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 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
286pub 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 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
344pub 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
411pub 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 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
449fn 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
503fn 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 checks.sort_by(|a, b| a.name.cmp(&b.name));
556
557 Ok(checks)
558}
559
560#[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 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, ®istry).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, ®istry).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", ®istry).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", ®istry).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 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 #[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 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, ®istry).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, ®istry).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, ®istry).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, ®istry).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 struct InlineSystemMock {
1120 configurator_name: String,
1121 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, ®istry).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, ®istry).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, ®istry).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, ®istry).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, ®istry).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}