use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::config::{ComplianceExport, ComplianceFormat, ComplianceScope, MergedProfile};
use crate::errors::Result;
use crate::platform::Platform;
use crate::providers::ProviderRegistry;
use crate::to_posix_string;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComplianceSnapshot {
pub timestamp: String,
pub machine: MachineInfo,
pub profile: String,
pub sources: Vec<String>,
pub checks: Vec<ComplianceCheck>,
pub summary: ComplianceSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MachineInfo {
pub hostname: String,
pub os: String,
pub arch: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ComplianceCheck {
pub category: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
pub status: ComplianceStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manager: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub enum ComplianceStatus {
#[default]
Compliant,
Warning,
Violation,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComplianceSummary {
pub compliant: usize,
pub warning: usize,
pub violation: usize,
}
pub fn collect_snapshot(
profile_name: &str,
profile: &MergedProfile,
registry: &ProviderRegistry,
scope: &ComplianceScope,
sources: &[String],
) -> Result<ComplianceSnapshot> {
let platform = Platform::detect();
let hostname = crate::hostname_string();
let machine = MachineInfo {
hostname,
os: platform.os.as_str().to_owned(),
arch: platform.arch.as_str().to_owned(),
};
let mut checks = Vec::new();
if scope.files {
checks.extend(collect_file_checks(profile));
}
if scope.packages {
checks.extend(collect_package_checks(profile, registry)?);
}
if scope.system {
checks.extend(collect_system_checks(profile, registry)?);
}
if scope.secrets {
checks.extend(collect_secret_checks(profile));
}
for watch_path in &scope.watch_paths {
checks.extend(collect_watch_path_checks(watch_path));
}
for manager_name in &scope.watch_package_managers {
checks.extend(collect_watched_package_manager_checks(
manager_name,
registry,
)?);
}
let summary = compute_summary(&checks);
Ok(ComplianceSnapshot {
timestamp: crate::utc_now_iso8601(),
machine,
profile: profile_name.to_owned(),
sources: sources.to_vec(),
checks,
summary,
})
}
pub fn compute_summary(checks: &[ComplianceCheck]) -> ComplianceSummary {
let mut compliant = 0usize;
let mut warning = 0usize;
let mut violation = 0usize;
for check in checks {
match check.status {
ComplianceStatus::Compliant => compliant += 1,
ComplianceStatus::Warning => warning += 1,
ComplianceStatus::Violation => violation += 1,
}
}
ComplianceSummary {
compliant,
warning,
violation,
}
}
pub fn export_snapshot_to_file(
snapshot: &ComplianceSnapshot,
export: &ComplianceExport,
) -> Result<PathBuf> {
let export_dir = crate::expand_tilde(Path::new(&export.path));
std::fs::create_dir_all(&export_dir)?;
let timestamp_safe = crate::iso8601_to_filename_safe(&snapshot.timestamp);
let ext = match export.format {
ComplianceFormat::Json => "json",
ComplianceFormat::Yaml => "yaml",
};
let filename = format!("compliance-{}.{}", timestamp_safe, ext);
let file_path = export_dir.join(&filename);
let content = match export.format {
ComplianceFormat::Json => serde_json::to_string_pretty(snapshot)
.map_err(|e| std::io::Error::other(format!("JSON serialization failed: {}", e)))?,
ComplianceFormat::Yaml => serde_yaml::to_string(snapshot)
.map_err(|e| std::io::Error::other(format!("YAML serialization failed: {}", e)))?,
};
crate::atomic_write_str(&file_path, &content)?;
Ok(file_path)
}
pub fn collect_file_checks(profile: &MergedProfile) -> Vec<ComplianceCheck> {
let mut checks = Vec::new();
for file in &profile.files.managed {
let target = crate::expand_tilde(&file.target);
let exists = target.exists();
if !exists {
checks.push(ComplianceCheck {
category: "file".into(),
target: Some(to_posix_string(&target)),
status: ComplianceStatus::Violation,
detail: Some("managed file missing".into()),
..Default::default()
});
continue;
}
if let Some(ref perm_str) = file.permissions {
if let Ok(desired_mode) = u32::from_str_radix(perm_str, 8)
&& desired_mode <= 0o7777
{
let actual_mode = target
.metadata()
.ok()
.and_then(|m| crate::file_permissions_mode(&m));
match actual_mode {
Some(mode) if mode == desired_mode => {
checks.push(ComplianceCheck {
category: "file".into(),
target: Some(to_posix_string(&target)),
status: ComplianceStatus::Compliant,
detail: Some(format!("permissions {:#o}", mode)),
..Default::default()
});
}
Some(mode) => {
checks.push(ComplianceCheck {
category: "file".into(),
target: Some(to_posix_string(&target)),
status: ComplianceStatus::Warning,
detail: Some(format!(
"permissions {:#o}, expected {:#o}",
mode, desired_mode
)),
..Default::default()
});
}
None => {
checks.push(ComplianceCheck {
category: "file".into(),
target: Some(to_posix_string(&target)),
status: ComplianceStatus::Compliant,
detail: Some("permissions not applicable on this platform".into()),
..Default::default()
});
}
}
} else {
checks.push(ComplianceCheck {
category: "file".into(),
target: Some(to_posix_string(&target)),
status: ComplianceStatus::Warning,
detail: Some(format!("invalid permission string: {}", perm_str)),
..Default::default()
});
}
} else {
checks.push(ComplianceCheck {
category: "file".into(),
target: Some(to_posix_string(&target)),
status: ComplianceStatus::Compliant,
detail: Some("present".into()),
..Default::default()
});
}
if let Some(ref enc) = file.encryption {
checks.push(ComplianceCheck {
category: "file-encryption".into(),
target: Some(to_posix_string(&target)),
status: ComplianceStatus::Compliant,
detail: Some(format!("encryption: backend={}", enc.backend)),
..Default::default()
});
}
}
checks
}
pub fn collect_package_checks(
profile: &MergedProfile,
registry: &ProviderRegistry,
) -> Result<Vec<ComplianceCheck>> {
let mut checks = Vec::new();
for pm in registry.available_package_managers() {
let desired = crate::config::desired_packages_for_spec(pm.name(), &profile.packages);
if desired.is_empty() {
continue;
}
let installed = match pm.installed_packages() {
Ok(set) => set,
Err(e) => {
checks.push(ComplianceCheck {
category: "package".into(),
manager: Some(pm.name().to_owned()),
status: ComplianceStatus::Warning,
detail: Some(format!("cannot query {}: {}", pm.name(), e)),
..Default::default()
});
continue;
}
};
for pkg in &desired {
if installed.contains(pkg) {
checks.push(ComplianceCheck {
category: "package".into(),
name: Some(pkg.clone()),
manager: Some(pm.name().to_owned()),
status: ComplianceStatus::Compliant,
detail: Some("installed".into()),
..Default::default()
});
} else {
checks.push(ComplianceCheck {
category: "package".into(),
name: Some(pkg.clone()),
manager: Some(pm.name().to_owned()),
status: ComplianceStatus::Violation,
detail: Some("not installed".into()),
..Default::default()
});
}
}
}
Ok(checks)
}
pub fn collect_system_checks(
profile: &MergedProfile,
registry: &ProviderRegistry,
) -> Result<Vec<ComplianceCheck>> {
let mut checks = Vec::new();
let available = registry.available_system_configurators();
for (key, desired) in &profile.system {
let configurator = available.iter().find(|c| c.name() == key);
let Some(configurator) = configurator else {
checks.push(ComplianceCheck {
category: "system".into(),
key: Some(key.clone()),
status: ComplianceStatus::Warning,
detail: Some(format!("no configurator available for '{}'", key)),
..Default::default()
});
continue;
};
match configurator.diff(desired) {
Ok(drifts) => {
if drifts.is_empty() {
checks.push(ComplianceCheck {
category: "system".into(),
key: Some(key.clone()),
status: ComplianceStatus::Compliant,
detail: Some("no drift".into()),
..Default::default()
});
} else {
for drift in &drifts {
checks.push(ComplianceCheck {
category: "system".into(),
key: Some(format!("{}.{}", key, drift.key)),
status: ComplianceStatus::Violation,
detail: Some(format!(
"expected {}, actual {}",
drift.expected, drift.actual
)),
value: Some(drift.actual.clone()),
..Default::default()
});
}
}
}
Err(e) => {
checks.push(ComplianceCheck {
category: "system".into(),
key: Some(key.clone()),
status: ComplianceStatus::Warning,
detail: Some(format!("diff failed: {}", e)),
..Default::default()
});
}
}
}
Ok(checks)
}
pub fn collect_secret_checks(profile: &MergedProfile) -> Vec<ComplianceCheck> {
let mut checks = Vec::new();
for secret in &profile.secrets {
let Some(ref target_path) = secret.target else {
continue;
};
let target = crate::expand_tilde(target_path);
if target.exists() {
checks.push(ComplianceCheck {
category: "secret".into(),
target: Some(to_posix_string(&target)),
status: ComplianceStatus::Compliant,
detail: Some("target file present".into()),
..Default::default()
});
} else {
checks.push(ComplianceCheck {
category: "secret".into(),
target: Some(to_posix_string(&target)),
status: ComplianceStatus::Violation,
detail: Some("target file missing".into()),
..Default::default()
});
}
}
checks
}
fn collect_watch_path_checks(path_str: &str) -> Vec<ComplianceCheck> {
let path = crate::expand_tilde(Path::new(path_str));
if !path.exists() {
return vec![ComplianceCheck {
category: "watchPath".into(),
path: Some(to_posix_string(path)),
status: ComplianceStatus::Warning,
detail: Some("path does not exist".into()),
..Default::default()
}];
}
let meta = match path.metadata() {
Ok(m) => m,
Err(e) => {
return vec![ComplianceCheck {
category: "watchPath".into(),
path: Some(to_posix_string(path)),
status: ComplianceStatus::Warning,
detail: Some(format!("cannot stat: {}", e)),
..Default::default()
}];
}
};
let perms = crate::file_permissions_mode(&meta);
let kind = if meta.is_dir() {
"directory"
} else if meta.is_file() {
"file"
} else {
"other"
};
let detail = match perms {
Some(mode) => format!("{}, permissions {:#o}", kind, mode),
None => kind.to_string(),
};
vec![ComplianceCheck {
category: "watchPath".into(),
path: Some(to_posix_string(path)),
status: ComplianceStatus::Compliant,
detail: Some(detail),
..Default::default()
}]
}
fn collect_watched_package_manager_checks(
manager_name: &str,
registry: &ProviderRegistry,
) -> Result<Vec<ComplianceCheck>> {
let pm = registry
.available_package_managers()
.into_iter()
.find(|pm| pm.name() == manager_name);
let Some(pm) = pm else {
return Ok(vec![ComplianceCheck {
category: "watchPackage".into(),
manager: Some(manager_name.to_owned()),
status: ComplianceStatus::Warning,
detail: Some(format!("package manager '{}' not available", manager_name)),
..Default::default()
}]);
};
let installed = match pm.installed_packages() {
Ok(set) => set,
Err(e) => {
return Ok(vec![ComplianceCheck {
category: "watchPackage".into(),
manager: Some(manager_name.to_owned()),
status: ComplianceStatus::Warning,
detail: Some(format!("cannot query {}: {}", manager_name, e)),
..Default::default()
}]);
}
};
let mut checks: Vec<ComplianceCheck> = installed
.into_iter()
.map(|pkg| ComplianceCheck {
category: "watchPackage".into(),
name: Some(pkg),
manager: Some(manager_name.to_owned()),
status: ComplianceStatus::Compliant,
detail: Some("installed".into()),
..Default::default()
})
.collect();
checks.sort_by(|a, b| a.name.cmp(&b.name));
Ok(checks)
}
#[cfg(test)]
mod tests;