Skip to main content

aaai_core/config/
definition.rs

1//! Audit definition — the "expected values" YAML document (version 1).
2//!
3//! # Phase 3 additions
4//!
5//! Each [`AuditEntry`] now carries optional metadata for traceability:
6//!
7//! ```yaml
8//! - path: "config/server.toml"
9//!   diff_type: Modified
10//!   reason: "Port change — INF-42"
11//!   ticket: "INF-42"
12//!   approved_by: "alice"
13//!   approved_at: "2025-01-15T09:23:00Z"
14//!   expires_at: "2025-07-01"
15//!   strategy:
16//!     type: LineMatch
17//!     rules:
18//!       - action: Removed
19//!         line: "port = 80"
20//!       - action: Added
21//!         line: "port = 8080"
22//!   enabled: true
23//! ```
24
25use chrono::{DateTime, NaiveDate, Utc};
26use serde::{Deserialize, Serialize};
27
28use crate::diff::entry::DiffType;
29
30// ── Top-level document ────────────────────────────────────────────────────
31
32#[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    /// Find by exact path first, then by first matching glob.
47    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    /// Return all entries whose `expires_at` is today or in the past.
65    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    /// Return entries expiring within `days` days from today.
73    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// ── Metadata ─────────────────────────────────────────────────────────────
83
84#[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// ── Per-file entry ────────────────────────────────────────────────────────
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct AuditEntry {
94    /// Root-relative path or glob pattern.
95    pub path: String,
96    pub diff_type: DiffType,
97    /// Mandatory human-readable justification.
98    pub reason: String,
99    #[serde(default)]
100    pub strategy: AuditStrategy,
101    #[serde(default = "default_true")]
102    pub enabled: bool,
103
104    // ── Phase 3: traceability fields ────────────────────────────────────
105    /// Ticket or issue reference (e.g. "JIRA-123", "INF-42").
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub ticket: Option<String>,
108
109    /// Identity of the person who approved this entry.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub approved_by: Option<String>,
112
113    /// UTC timestamp when approval was recorded.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub approved_at: Option<DateTime<Utc>>,
116
117    /// Date after which this entry should be re-reviewed.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub expires_at: Option<NaiveDate>,
120
121    /// Free-form note (stored but not used for judgement).
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub note: Option<String>,
124
125    // ── Phase 6: versioning ──────────────────────────────────────────
126    /// UTC timestamp when this entry was first created.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub created_at: Option<DateTime<Utc>>,
129
130    /// UTC timestamp when this entry was last modified.
131    #[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    /// True if `expires_at` is today or in the past.
150    pub fn is_expired(&self) -> bool {
151        self.expires_at.map_or(false, |d| d <= Utc::now().date_naive())
152    }
153
154    /// True if expiring within `days` days but not yet expired.
155    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    /// Stamp created_at (first time) and updated_at (always) with the current UTC time.
162    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// ── Content-audit strategy ────────────────────────────────────────────────
183
184#[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// ── LineMatch rule ────────────────────────────────────────────────────────
269
270#[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// ── Regex target ──────────────────────────────────────────────────────────
289
290#[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}