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 {
#[allow(dead_code)]
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"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, Utc};
fn entry(path: &str) -> AuditEntry {
AuditEntry {
path: path.to_string(),
diff_type: crate::DiffType::Modified,
reason: "test reason".into(),
strategy: AuditStrategy::None,
enabled: true,
ticket: None,
approved_by: None,
approved_at: None,
expires_at: None,
note: None,
created_at: None,
updated_at: None,
}
}
#[test]
fn find_entry_exact_match() {
let mut def = AuditDefinition::new_empty();
def.upsert_entry(entry("src/main.rs"));
assert!(def.find_entry("src/main.rs").is_some());
assert!(def.find_entry("src/lib.rs").is_none());
}
#[test]
fn find_entry_glob_fallback() {
let mut def = AuditDefinition::new_empty();
def.upsert_entry(entry("node_modules/**"));
assert!(def.find_entry("node_modules/lodash/index.js").is_some(),
"glob entry should match concrete path");
assert!(def.find_entry("src/main.rs").is_none(),
"glob should not match unrelated path");
}
#[test]
fn find_entry_exact_wins_over_glob() {
let mut def = AuditDefinition::new_empty();
def.upsert_entry(entry("node_modules/**"));
let mut specific = entry("node_modules/lodash/index.js");
specific.reason = "specific override".into();
def.upsert_entry(specific);
let found = def.find_entry("node_modules/lodash/index.js").unwrap();
assert_eq!(found.reason, "specific override",
"exact path should take precedence over glob");
}
#[test]
fn is_glob_detection() {
assert!(entry("src/**").is_glob());
assert!(entry("**/*.rs").is_glob());
assert!(entry("src/?.rs").is_glob());
assert!(entry("[ab].rs").is_glob());
assert!(!entry("src/main.rs").is_glob());
assert!(!entry("a/b/c.txt").is_glob());
}
#[test]
fn glob_matches_depth() {
let e = entry("dist/**");
assert!(e.glob_matches("dist/bundle.js"));
assert!(e.glob_matches("dist/css/style.css"));
assert!(!e.glob_matches("src/main.rs"));
}
#[test]
fn glob_matches_extension_pattern() {
let e = entry("**/*.lock");
assert!(e.glob_matches("Cargo.lock"));
assert!(e.glob_matches("package-lock.json") == false,
"*.lock should not match .json");
assert!(e.glob_matches("sub/dir/yarn.lock"));
}
#[test]
fn upsert_entry_updates_existing() {
let mut def = AuditDefinition::new_empty();
def.upsert_entry(entry("a.txt"));
assert_eq!(def.entries.len(), 1);
let mut updated = entry("a.txt");
updated.reason = "updated".into();
def.upsert_entry(updated);
assert_eq!(def.entries.len(), 1, "should not duplicate");
assert_eq!(def.entries[0].reason, "updated");
}
#[test]
fn expired_entries_detects_past_date() {
let mut def = AuditDefinition::new_empty();
let mut e = entry("old.txt");
e.expires_at = Some((Utc::now() - Duration::days(1)).date_naive());
def.upsert_entry(e);
def.upsert_entry(entry("current.txt"));
let expired = def.expired_entries();
assert_eq!(expired.len(), 1);
assert_eq!(expired[0].path, "old.txt");
}
#[test]
fn expiring_soon_within_window() {
let mut def = AuditDefinition::new_empty();
let mut e = entry("soon.txt");
e.expires_at = Some((Utc::now() + Duration::days(10)).date_naive());
def.upsert_entry(e);
let mut far = entry("far.txt");
far.expires_at = Some((Utc::now() + Duration::days(60)).date_naive());
def.upsert_entry(far);
let soon = def.expiring_soon(30);
assert_eq!(soon.len(), 1);
assert_eq!(soon[0].path, "soon.txt");
}
#[test]
fn is_approvable_requires_non_empty_reason() {
let mut e = entry("a.txt");
assert!(e.is_approvable().is_ok());
e.reason = " ".into(); assert!(e.is_approvable().is_err(), "whitespace-only reason should fail");
e.reason = String::new();
assert!(e.is_approvable().is_err(), "empty reason should fail");
}
}