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 #[allow(dead_code)]
200 pub fn label(&self) -> &'static str {
201 match self {
202 AuditStrategy::None => "None",
203 AuditStrategy::Checksum { .. } => "Checksum",
204 AuditStrategy::LineMatch { .. } => "LineMatch",
205 AuditStrategy::Regex { .. } => "Regex",
206 AuditStrategy::Exact { .. } => "Exact",
207 }
208 }
209
210 pub fn description(&self) -> &'static str {
211 match self {
212 AuditStrategy::None =>
213 "Checks only that the expected change type occurred. No content inspection.",
214 AuditStrategy::Checksum { .. } =>
215 "Verifies the file's SHA-256 digest. Best for binaries, images, archives.",
216 AuditStrategy::LineMatch { .. } =>
217 "Verifies specific lines were added or removed. Primary strategy for config changes.",
218 AuditStrategy::Regex { .. } =>
219 "Verifies changed lines match a regular expression. Good for environment-dependent values.",
220 AuditStrategy::Exact { .. } =>
221 "Verifies the file's full content exactly matches expected text. Avoid for large files.",
222 }
223 }
224
225 pub fn validate(&self) -> Result<(), String> {
226 match self {
227 AuditStrategy::None => Ok(()),
228 AuditStrategy::Checksum { expected_sha256 } => {
229 if expected_sha256.trim().is_empty() {
230 return Err("Checksum: expected_sha256 must not be empty.".into());
231 }
232 if !expected_sha256.chars().all(|c| c.is_ascii_hexdigit()) {
233 return Err("Checksum: expected_sha256 must be a valid hex string.".into());
234 }
235 if expected_sha256.len() != 64 {
236 return Err("Checksum: must be a 64-character SHA-256 hex digest.".into());
237 }
238 Ok(())
239 }
240 AuditStrategy::LineMatch { rules } => {
241 if rules.is_empty() {
242 return Err("LineMatch: at least one rule is required.".into());
243 }
244 for (i, r) in rules.iter().enumerate() {
245 if r.line.trim().is_empty() {
246 return Err(format!("LineMatch rule {}: line must not be empty.", i + 1));
247 }
248 }
249 Ok(())
250 }
251 AuditStrategy::Regex { pattern, .. } => {
252 if pattern.trim().is_empty() {
253 return Err("Regex: pattern must not be empty.".into());
254 }
255 regex::Regex::new(pattern)
256 .map(|_| ())
257 .map_err(|e| format!("Regex: invalid pattern — {e}"))
258 }
259 AuditStrategy::Exact { expected_content } => {
260 if expected_content.is_empty() {
261 return Err("Exact: expected_content must not be empty.".into());
262 }
263 Ok(())
264 }
265 }
266 }
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct LineRule {
273 pub action: LineAction,
274 pub line: String,
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
278pub enum LineAction { Added, Removed }
279
280impl std::fmt::Display for LineAction {
281 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
282 match self {
283 LineAction::Added => write!(f, "Added"),
284 LineAction::Removed => write!(f, "Removed"),
285 }
286 }
287}
288
289#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
292pub enum RegexTarget {
293 #[default] AddedLines,
294 RemovedLines,
295 AllChangedLines,
296}
297
298impl std::fmt::Display for RegexTarget {
299 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
300 match self {
301 RegexTarget::AddedLines => write!(f, "Added lines"),
302 RegexTarget::RemovedLines => write!(f, "Removed lines"),
303 RegexTarget::AllChangedLines => write!(f, "All changed lines"),
304 }
305 }
306}