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;
11use crate::to_posix_string;
12
13#[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
72pub 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
129pub 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
150pub 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
186pub 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 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 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 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 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 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
287pub 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 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
345pub 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
412pub 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 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
450fn 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
504fn 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 checks.sort_by(|a, b| a.name.cmp(&b.name));
557
558 Ok(checks)
559}
560
561#[cfg(test)]
566mod tests;