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".to_string(),
meta: None,
entries: Vec::new(),
}
}
pub fn find_entry(&self, path: &str) -> Option<&AuditEntry> {
self.entries.iter().find(|e| e.path == path)
}
pub fn find_entry_mut(&mut self, path: &str) -> Option<&mut AuditEntry> {
self.entries.iter_mut().find(|e| 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);
}
}
}
#[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 note: Option<String>,
}
fn default_true() -> bool {
true
}
impl AuditEntry {
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 is performed.",
AuditStrategy::Checksum { .. } =>
"Verifies the file's SHA-256 digest. \
Suitable for binaries, images, and archives.",
AuditStrategy::LineMatch { .. } =>
"Verifies that specific lines were added or removed. \
The primary strategy for config-value changes.",
AuditStrategy::Regex { .. } =>
"Verifies that changed lines match a regular expression. \
Useful when the exact value is environment-dependent.",
AuditStrategy::Exact { .. } =>
"Verifies that the file's full content exactly matches \
the 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: expected_sha256 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"),
}
}
}