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    #[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// ── LineMatch rule ────────────────────────────────────────────────────────
270
271#[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// ── Regex target ──────────────────────────────────────────────────────────
290
291#[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}