1use chrono::{DateTime, NaiveDate, Utc};
26use serde::{Deserialize, Serialize};
27
28use crate::diff::entry::DiffType;
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct AuditDefinition {
34 pub version: String,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub meta: Option<AuditMeta>,
37 #[serde(default)]
38 pub entries: Vec<AuditEntry>,
39}
40
41impl AuditDefinition {
42 pub fn new_empty() -> Self {
43 Self { version: "1".into(), meta: None, entries: Vec::new() }
44 }
45
46 pub fn find_entry(&self, path: &str) -> Option<&AuditEntry> {
48 self.entries.iter().find(|e| !e.is_glob() && e.path == path)
49 .or_else(|| self.entries.iter().find(|e| e.is_glob() && e.glob_matches(path)))
50 }
51
52 pub fn find_entry_mut(&mut self, path: &str) -> Option<&mut AuditEntry> {
53 self.entries.iter_mut().find(|e| !e.is_glob() && e.path == path)
54 }
55
56 pub fn upsert_entry(&mut self, entry: AuditEntry) {
57 if let Some(existing) = self.find_entry_mut(&entry.path.clone()) {
58 *existing = entry;
59 } else {
60 self.entries.push(entry);
61 }
62 }
63
64 pub fn expired_entries(&self) -> Vec<&AuditEntry> {
66 let today = Utc::now().date_naive();
67 self.entries.iter()
68 .filter(|e| e.expires_at.map_or(false, |d| d <= today))
69 .collect()
70 }
71
72 pub fn expiring_soon(&self, days: i64) -> Vec<&AuditEntry> {
74 let today = Utc::now().date_naive();
75 let threshold = today + chrono::Duration::days(days);
76 self.entries.iter()
77 .filter(|e| e.expires_at.map_or(false, |d| d > today && d <= threshold))
78 .collect()
79 }
80}
81
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
85pub struct AuditMeta {
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub description: Option<String>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct AuditEntry {
94 pub path: String,
96 pub diff_type: DiffType,
97 pub reason: String,
99 #[serde(default)]
100 pub strategy: AuditStrategy,
101 #[serde(default = "default_true")]
102 pub enabled: bool,
103
104 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub ticket: Option<String>,
108
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub approved_by: Option<String>,
112
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub approved_at: Option<DateTime<Utc>>,
116
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub expires_at: Option<NaiveDate>,
120
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub note: Option<String>,
124
125 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub created_at: Option<DateTime<Utc>>,
129
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub updated_at: Option<DateTime<Utc>>,
133}
134
135fn default_true() -> bool { true }
136
137impl AuditEntry {
138 pub fn is_glob(&self) -> bool {
139 self.path.contains('*') || self.path.contains('?') || self.path.contains('[')
140 }
141
142 pub fn glob_matches(&self, candidate: &str) -> bool {
143 if !self.is_glob() { return self.path == candidate; }
144 glob::Pattern::new(&self.path)
145 .map(|p| p.matches(candidate))
146 .unwrap_or(false)
147 }
148
149 pub fn is_expired(&self) -> bool {
151 self.expires_at.map_or(false, |d| d <= Utc::now().date_naive())
152 }
153
154 pub fn expires_soon(&self, days: i64) -> bool {
156 let today = Utc::now().date_naive();
157 let threshold = today + chrono::Duration::days(days);
158 self.expires_at.map_or(false, |d| d > today && d <= threshold)
159 }
160
161 pub fn stamp_now(&mut self) {
163 let now = Utc::now();
164 if self.created_at.is_none() {
165 self.created_at = Some(now);
166 }
167 self.updated_at = Some(now);
168 }
169
170 pub fn is_approvable(&self) -> Result<(), String> {
171 if self.path.trim().is_empty() {
172 return Err("Path must not be empty.".into());
173 }
174 if self.reason.trim().is_empty() {
175 return Err("Reason must not be empty.".into());
176 }
177 self.strategy.validate()?;
178 Ok(())
179 }
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
185#[serde(tag = "type")]
186pub enum AuditStrategy {
187 None,
188 Checksum { expected_sha256: String },
189 LineMatch { rules: Vec<LineRule> },
190 Regex { pattern: String, #[serde(default)] target: RegexTarget },
191 Exact { expected_content: String },
192}
193
194impl Default for AuditStrategy {
195 fn default() -> Self { AuditStrategy::None }
196}
197
198impl AuditStrategy {
199 pub fn label(&self) -> &'static str {
200 match self {
201 AuditStrategy::None => "None",
202 AuditStrategy::Checksum { .. } => "Checksum",
203 AuditStrategy::LineMatch { .. } => "LineMatch",
204 AuditStrategy::Regex { .. } => "Regex",
205 AuditStrategy::Exact { .. } => "Exact",
206 }
207 }
208
209 pub fn description(&self) -> &'static str {
210 match self {
211 AuditStrategy::None =>
212 "Checks only that the expected change type occurred. No content inspection.",
213 AuditStrategy::Checksum { .. } =>
214 "Verifies the file's SHA-256 digest. Best for binaries, images, archives.",
215 AuditStrategy::LineMatch { .. } =>
216 "Verifies specific lines were added or removed. Primary strategy for config changes.",
217 AuditStrategy::Regex { .. } =>
218 "Verifies changed lines match a regular expression. Good for environment-dependent values.",
219 AuditStrategy::Exact { .. } =>
220 "Verifies the file's full content exactly matches expected text. Avoid for large files.",
221 }
222 }
223
224 pub fn validate(&self) -> Result<(), String> {
225 match self {
226 AuditStrategy::None => Ok(()),
227 AuditStrategy::Checksum { expected_sha256 } => {
228 if expected_sha256.trim().is_empty() {
229 return Err("Checksum: expected_sha256 must not be empty.".into());
230 }
231 if !expected_sha256.chars().all(|c| c.is_ascii_hexdigit()) {
232 return Err("Checksum: expected_sha256 must be a valid hex string.".into());
233 }
234 if expected_sha256.len() != 64 {
235 return Err("Checksum: must be a 64-character SHA-256 hex digest.".into());
236 }
237 Ok(())
238 }
239 AuditStrategy::LineMatch { rules } => {
240 if rules.is_empty() {
241 return Err("LineMatch: at least one rule is required.".into());
242 }
243 for (i, r) in rules.iter().enumerate() {
244 if r.line.trim().is_empty() {
245 return Err(format!("LineMatch rule {}: line must not be empty.", i + 1));
246 }
247 }
248 Ok(())
249 }
250 AuditStrategy::Regex { pattern, .. } => {
251 if pattern.trim().is_empty() {
252 return Err("Regex: pattern must not be empty.".into());
253 }
254 regex::Regex::new(pattern)
255 .map(|_| ())
256 .map_err(|e| format!("Regex: invalid pattern — {e}"))
257 }
258 AuditStrategy::Exact { expected_content } => {
259 if expected_content.is_empty() {
260 return Err("Exact: expected_content must not be empty.".into());
261 }
262 Ok(())
263 }
264 }
265 }
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
271pub struct LineRule {
272 pub action: LineAction,
273 pub line: String,
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
277pub enum LineAction { Added, Removed }
278
279impl std::fmt::Display for LineAction {
280 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281 match self {
282 LineAction::Added => write!(f, "Added"),
283 LineAction::Removed => write!(f, "Removed"),
284 }
285 }
286}
287
288#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
291pub enum RegexTarget {
292 #[default] AddedLines,
293 RemovedLines,
294 AllChangedLines,
295}
296
297impl std::fmt::Display for RegexTarget {
298 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299 match self {
300 RegexTarget::AddedLines => write!(f, "Added lines"),
301 RegexTarget::RemovedLines => write!(f, "Removed lines"),
302 RegexTarget::AllChangedLines => write!(f, "All changed lines"),
303 }
304 }
305}