1use serde::{Deserialize, Serialize};
25
26use crate::diff::entry::DiffType;
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct AuditDefinition {
33 pub version: String,
35
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub meta: Option<AuditMeta>,
39
40 #[serde(default)]
42 pub entries: Vec<AuditEntry>,
43}
44
45impl AuditDefinition {
46 pub fn new_empty() -> Self {
47 Self { version: "1".to_string(), meta: None, entries: Vec::new() }
48 }
49
50 pub fn find_entry(&self, path: &str) -> Option<&AuditEntry> {
53 if let Some(e) = self.entries.iter().find(|e| !e.is_glob() && e.path == path) {
55 return Some(e);
56 }
57 self.entries.iter().find(|e| e.is_glob() && e.glob_matches(path))
59 }
60
61 pub fn find_entry_mut(&mut self, path: &str) -> Option<&mut AuditEntry> {
62 self.entries.iter_mut().find(|e| !e.is_glob() && e.path == path)
64 }
65
66 pub fn upsert_entry(&mut self, entry: AuditEntry) {
68 if let Some(existing) = self.find_entry_mut(&entry.path.clone()) {
69 *existing = entry;
70 } else {
71 self.entries.push(entry);
72 }
73 }
74}
75
76#[derive(Debug, Clone, Default, Serialize, Deserialize)]
79pub struct AuditMeta {
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub description: Option<String>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct AuditEntry {
88 pub path: String,
91
92 pub diff_type: DiffType,
93
94 pub reason: String,
96
97 #[serde(default)]
98 pub strategy: AuditStrategy,
99
100 #[serde(default = "default_true")]
101 pub enabled: bool,
102
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub note: Option<String>,
105}
106
107fn default_true() -> bool { true }
108
109impl AuditEntry {
110 pub fn is_glob(&self) -> bool {
112 self.path.contains('*') || self.path.contains('?') || self.path.contains('[')
113 }
114
115 pub fn glob_matches(&self, candidate: &str) -> bool {
117 if !self.is_glob() {
118 return self.path == candidate;
119 }
120 match glob::Pattern::new(&self.path) {
121 Ok(pat) => pat.matches(candidate),
122 Err(_) => false,
123 }
124 }
125
126 pub fn is_approvable(&self) -> Result<(), String> {
128 if self.path.trim().is_empty() {
129 return Err("Path must not be empty.".into());
130 }
131 if self.reason.trim().is_empty() {
132 return Err("Reason must not be empty.".into());
133 }
134 self.strategy.validate()?;
135 Ok(())
136 }
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
142#[serde(tag = "type")]
143pub enum AuditStrategy {
144 None,
145 Checksum { expected_sha256: String },
146 LineMatch { rules: Vec<LineRule> },
147 Regex { pattern: String, #[serde(default)] target: RegexTarget },
148 Exact { expected_content: String },
149}
150
151impl Default for AuditStrategy {
152 fn default() -> Self { AuditStrategy::None }
153}
154
155impl AuditStrategy {
156 pub fn label(&self) -> &'static str {
157 match self {
158 AuditStrategy::None => "None",
159 AuditStrategy::Checksum { .. } => "Checksum",
160 AuditStrategy::LineMatch { .. } => "LineMatch",
161 AuditStrategy::Regex { .. } => "Regex",
162 AuditStrategy::Exact { .. } => "Exact",
163 }
164 }
165
166 pub fn description(&self) -> &'static str {
167 match self {
168 AuditStrategy::None =>
169 "Checks only that the expected change type occurred. No content inspection.",
170 AuditStrategy::Checksum { .. } =>
171 "Verifies the file's SHA-256 digest. Best for binaries, images, archives.",
172 AuditStrategy::LineMatch { .. } =>
173 "Verifies specific lines were added or removed. Primary strategy for config changes.",
174 AuditStrategy::Regex { .. } =>
175 "Verifies changed lines match a regular expression. Good for environment-dependent values.",
176 AuditStrategy::Exact { .. } =>
177 "Verifies the file's full content exactly matches expected text. Avoid for large files.",
178 }
179 }
180
181 pub fn validate(&self) -> Result<(), String> {
182 match self {
183 AuditStrategy::None => Ok(()),
184 AuditStrategy::Checksum { expected_sha256 } => {
185 if expected_sha256.trim().is_empty() {
186 return Err("Checksum: expected_sha256 must not be empty.".into());
187 }
188 if !expected_sha256.chars().all(|c| c.is_ascii_hexdigit()) {
189 return Err("Checksum: expected_sha256 must be a valid hex string.".into());
190 }
191 if expected_sha256.len() != 64 {
192 return Err("Checksum: must be a 64-character SHA-256 hex digest.".into());
193 }
194 Ok(())
195 }
196 AuditStrategy::LineMatch { rules } => {
197 if rules.is_empty() {
198 return Err("LineMatch: at least one rule is required.".into());
199 }
200 for (i, r) in rules.iter().enumerate() {
201 if r.line.trim().is_empty() {
202 return Err(format!("LineMatch rule {}: line must not be empty.", i + 1));
203 }
204 }
205 Ok(())
206 }
207 AuditStrategy::Regex { pattern, .. } => {
208 if pattern.trim().is_empty() {
209 return Err("Regex: pattern must not be empty.".into());
210 }
211 regex::Regex::new(pattern)
212 .map(|_| ())
213 .map_err(|e| format!("Regex: invalid pattern — {e}"))
214 }
215 AuditStrategy::Exact { expected_content } => {
216 if expected_content.is_empty() {
217 return Err("Exact: expected_content must not be empty.".into());
218 }
219 Ok(())
220 }
221 }
222 }
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct LineRule {
229 pub action: LineAction,
230 pub line: String,
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
234pub enum LineAction {
235 Added,
236 Removed,
237}
238
239impl std::fmt::Display for LineAction {
240 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241 match self {
242 LineAction::Added => write!(f, "Added"),
243 LineAction::Removed => write!(f, "Removed"),
244 }
245 }
246}
247
248#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
251pub enum RegexTarget {
252 #[default]
253 AddedLines,
254 RemovedLines,
255 AllChangedLines,
256}
257
258impl std::fmt::Display for RegexTarget {
259 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260 match self {
261 RegexTarget::AddedLines => write!(f, "Added lines"),
262 RegexTarget::RemovedLines => write!(f, "Removed lines"),
263 RegexTarget::AllChangedLines => write!(f, "All changed lines"),
264 }
265 }
266}