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
126fn default_true() -> bool { true }
127
128impl AuditEntry {
129 pub fn is_glob(&self) -> bool {
130 self.path.contains('*') || self.path.contains('?') || self.path.contains('[')
131 }
132
133 pub fn glob_matches(&self, candidate: &str) -> bool {
134 if !self.is_glob() { return self.path == candidate; }
135 glob::Pattern::new(&self.path)
136 .map(|p| p.matches(candidate))
137 .unwrap_or(false)
138 }
139
140 pub fn is_expired(&self) -> bool {
142 self.expires_at.map_or(false, |d| d <= Utc::now().date_naive())
143 }
144
145 pub fn expires_soon(&self, days: i64) -> bool {
147 let today = Utc::now().date_naive();
148 let threshold = today + chrono::Duration::days(days);
149 self.expires_at.map_or(false, |d| d > today && d <= threshold)
150 }
151
152 pub fn is_approvable(&self) -> Result<(), String> {
153 if self.path.trim().is_empty() {
154 return Err("Path must not be empty.".into());
155 }
156 if self.reason.trim().is_empty() {
157 return Err("Reason must not be empty.".into());
158 }
159 self.strategy.validate()?;
160 Ok(())
161 }
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
167#[serde(tag = "type")]
168pub enum AuditStrategy {
169 None,
170 Checksum { expected_sha256: String },
171 LineMatch { rules: Vec<LineRule> },
172 Regex { pattern: String, #[serde(default)] target: RegexTarget },
173 Exact { expected_content: String },
174}
175
176impl Default for AuditStrategy {
177 fn default() -> Self { AuditStrategy::None }
178}
179
180impl AuditStrategy {
181 pub fn label(&self) -> &'static str {
182 match self {
183 AuditStrategy::None => "None",
184 AuditStrategy::Checksum { .. } => "Checksum",
185 AuditStrategy::LineMatch { .. } => "LineMatch",
186 AuditStrategy::Regex { .. } => "Regex",
187 AuditStrategy::Exact { .. } => "Exact",
188 }
189 }
190
191 pub fn description(&self) -> &'static str {
192 match self {
193 AuditStrategy::None =>
194 "Checks only that the expected change type occurred. No content inspection.",
195 AuditStrategy::Checksum { .. } =>
196 "Verifies the file's SHA-256 digest. Best for binaries, images, archives.",
197 AuditStrategy::LineMatch { .. } =>
198 "Verifies specific lines were added or removed. Primary strategy for config changes.",
199 AuditStrategy::Regex { .. } =>
200 "Verifies changed lines match a regular expression. Good for environment-dependent values.",
201 AuditStrategy::Exact { .. } =>
202 "Verifies the file's full content exactly matches expected text. Avoid for large files.",
203 }
204 }
205
206 pub fn validate(&self) -> Result<(), String> {
207 match self {
208 AuditStrategy::None => Ok(()),
209 AuditStrategy::Checksum { expected_sha256 } => {
210 if expected_sha256.trim().is_empty() {
211 return Err("Checksum: expected_sha256 must not be empty.".into());
212 }
213 if !expected_sha256.chars().all(|c| c.is_ascii_hexdigit()) {
214 return Err("Checksum: expected_sha256 must be a valid hex string.".into());
215 }
216 if expected_sha256.len() != 64 {
217 return Err("Checksum: must be a 64-character SHA-256 hex digest.".into());
218 }
219 Ok(())
220 }
221 AuditStrategy::LineMatch { rules } => {
222 if rules.is_empty() {
223 return Err("LineMatch: at least one rule is required.".into());
224 }
225 for (i, r) in rules.iter().enumerate() {
226 if r.line.trim().is_empty() {
227 return Err(format!("LineMatch rule {}: line must not be empty.", i + 1));
228 }
229 }
230 Ok(())
231 }
232 AuditStrategy::Regex { pattern, .. } => {
233 if pattern.trim().is_empty() {
234 return Err("Regex: pattern must not be empty.".into());
235 }
236 regex::Regex::new(pattern)
237 .map(|_| ())
238 .map_err(|e| format!("Regex: invalid pattern — {e}"))
239 }
240 AuditStrategy::Exact { expected_content } => {
241 if expected_content.is_empty() {
242 return Err("Exact: expected_content must not be empty.".into());
243 }
244 Ok(())
245 }
246 }
247 }
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct LineRule {
254 pub action: LineAction,
255 pub line: String,
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
259pub enum LineAction { Added, Removed }
260
261impl std::fmt::Display for LineAction {
262 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263 match self {
264 LineAction::Added => write!(f, "Added"),
265 LineAction::Removed => write!(f, "Removed"),
266 }
267 }
268}
269
270#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
273pub enum RegexTarget {
274 #[default] AddedLines,
275 RemovedLines,
276 AllChangedLines,
277}
278
279impl std::fmt::Display for RegexTarget {
280 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281 match self {
282 RegexTarget::AddedLines => write!(f, "Added lines"),
283 RegexTarget::RemovedLines => write!(f, "Removed lines"),
284 RegexTarget::AllChangedLines => write!(f, "All changed lines"),
285 }
286 }
287}