use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use crate::diff::entry::DiffType;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditDefinition {
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meta: Option<AuditMeta>,
#[serde(default)]
pub entries: Vec<AuditEntry>,
}
impl AuditDefinition {
pub fn new_empty() -> Self {
Self { version: "1".into(), meta: None, entries: Vec::new() }
}
pub fn find_entry(&self, path: &str) -> Option<&AuditEntry> {
self.entries.iter().find(|e| !e.is_glob() && e.path == path)
.or_else(|| self.entries.iter().find(|e| e.is_glob() && e.glob_matches(path)))
}
pub fn find_entry_mut(&mut self, path: &str) -> Option<&mut AuditEntry> {
self.entries.iter_mut().find(|e| !e.is_glob() && e.path == path)
}
pub fn upsert_entry(&mut self, entry: AuditEntry) {
if let Some(existing) = self.find_entry_mut(&entry.path.clone()) {
*existing = entry;
} else {
self.entries.push(entry);
}
}
pub fn expired_entries(&self) -> Vec<&AuditEntry> {
let today = Utc::now().date_naive();
self.entries.iter()
.filter(|e| e.expires_at.map_or(false, |d| d <= today))
.collect()
}
pub fn expiring_soon(&self, days: i64) -> Vec<&AuditEntry> {
let today = Utc::now().date_naive();
let threshold = today + chrono::Duration::days(days);
self.entries.iter()
.filter(|e| e.expires_at.map_or(false, |d| d > today && d <= threshold))
.collect()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AuditMeta {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub path: String,
pub diff_type: DiffType,
pub reason: String,
#[serde(default)]
pub strategy: AuditStrategy,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ticket: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approved_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approved_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<NaiveDate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_at: Option<DateTime<Utc>>,
}
fn default_true() -> bool { true }
impl AuditEntry {
pub fn is_glob(&self) -> bool {
self.path.contains('*') || self.path.contains('?') || self.path.contains('[')
}
pub fn glob_matches(&self, candidate: &str) -> bool {
if !self.is_glob() { return self.path == candidate; }
glob::Pattern::new(&self.path)
.map(|p| p.matches(candidate))
.unwrap_or(false)
}
pub fn is_expired(&self) -> bool {
self.expires_at.map_or(false, |d| d <= Utc::now().date_naive())
}
pub fn expires_soon(&self, days: i64) -> bool {
let today = Utc::now().date_naive();
let threshold = today + chrono::Duration::days(days);
self.expires_at.map_or(false, |d| d > today && d <= threshold)
}
pub fn stamp_now(&mut self) {
let now = Utc::now();
if self.created_at.is_none() {
self.created_at = Some(now);
}
self.updated_at = Some(now);
}
pub fn is_approvable(&self) -> Result<(), String> {
if self.path.trim().is_empty() {
return Err("Path must not be empty.".into());
}
if self.reason.trim().is_empty() {
return Err("Reason must not be empty.".into());
}
self.strategy.validate()?;
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum AuditStrategy {
None,
Checksum { expected_sha256: String },
LineMatch { rules: Vec<LineRule> },
Regex { pattern: String, #[serde(default)] target: RegexTarget },
Exact { expected_content: String },
}
impl Default for AuditStrategy {
fn default() -> Self { AuditStrategy::None }
}
impl AuditStrategy {
pub fn label(&self) -> &'static str {
match self {
AuditStrategy::None => "None",
AuditStrategy::Checksum { .. } => "Checksum",
AuditStrategy::LineMatch { .. } => "LineMatch",
AuditStrategy::Regex { .. } => "Regex",
AuditStrategy::Exact { .. } => "Exact",
}
}
pub fn description(&self) -> &'static str {
match self {
AuditStrategy::None =>
"Checks only that the expected change type occurred. No content inspection.",
AuditStrategy::Checksum { .. } =>
"Verifies the file's SHA-256 digest. Best for binaries, images, archives.",
AuditStrategy::LineMatch { .. } =>
"Verifies specific lines were added or removed. Primary strategy for config changes.",
AuditStrategy::Regex { .. } =>
"Verifies changed lines match a regular expression. Good for environment-dependent values.",
AuditStrategy::Exact { .. } =>
"Verifies the file's full content exactly matches expected text. Avoid for large files.",
}
}
pub fn validate(&self) -> Result<(), String> {
match self {
AuditStrategy::None => Ok(()),
AuditStrategy::Checksum { expected_sha256 } => {
if expected_sha256.trim().is_empty() {
return Err("Checksum: expected_sha256 must not be empty.".into());
}
if !expected_sha256.chars().all(|c| c.is_ascii_hexdigit()) {
return Err("Checksum: expected_sha256 must be a valid hex string.".into());
}
if expected_sha256.len() != 64 {
return Err("Checksum: must be a 64-character SHA-256 hex digest.".into());
}
Ok(())
}
AuditStrategy::LineMatch { rules } => {
if rules.is_empty() {
return Err("LineMatch: at least one rule is required.".into());
}
for (i, r) in rules.iter().enumerate() {
if r.line.trim().is_empty() {
return Err(format!("LineMatch rule {}: line must not be empty.", i + 1));
}
}
Ok(())
}
AuditStrategy::Regex { pattern, .. } => {
if pattern.trim().is_empty() {
return Err("Regex: pattern must not be empty.".into());
}
regex::Regex::new(pattern)
.map(|_| ())
.map_err(|e| format!("Regex: invalid pattern — {e}"))
}
AuditStrategy::Exact { expected_content } => {
if expected_content.is_empty() {
return Err("Exact: expected_content must not be empty.".into());
}
Ok(())
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineRule {
pub action: LineAction,
pub line: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LineAction { Added, Removed }
impl std::fmt::Display for LineAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LineAction::Added => write!(f, "Added"),
LineAction::Removed => write!(f, "Removed"),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum RegexTarget {
#[default] AddedLines,
RemovedLines,
AllChangedLines,
}
impl std::fmt::Display for RegexTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RegexTarget::AddedLines => write!(f, "Added lines"),
RegexTarget::RemovedLines => write!(f, "Removed lines"),
RegexTarget::AllChangedLines => write!(f, "All changed lines"),
}
}
}