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                "Only checks that the expected change happened — no content inspection.",
214            AuditStrategy::Checksum { .. } =>
215                "Verifies the file is byte-for-byte identical to when you approved it. Good for images, ZIPs, or any binary.",
216            AuditStrategy::LineMatch { .. } =>
217                "Verifies specific lines were added or removed. Best choice for config file changes.",
218            AuditStrategy::Regex { .. } =>
219                "Verifies changed lines match a text pattern. Good for values that vary by environment (URLs, ports, version numbers).",
220            AuditStrategy::Exact { .. } =>
221                "Verifies the entire file content matches exactly. Use only for small, critical 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}
307
308// ── RFC 066: AuditDefinition direct unit tests ────────────────────────────
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use chrono::{Duration, Utc};
313
314    fn entry(path: &str) -> AuditEntry {
315        AuditEntry {
316            path: path.to_string(),
317            diff_type: crate::DiffType::Modified,
318            reason: "test reason".into(),
319            strategy: AuditStrategy::None,
320            enabled: true,
321            ticket: None,
322            approved_by: None,
323            approved_at: None,
324            expires_at: None,
325            note: None,
326            created_at: None,
327            updated_at: None,
328        }
329    }
330
331    // find_entry — exact match
332    #[test]
333    fn find_entry_exact_match() {
334        let mut def = AuditDefinition::new_empty();
335        def.upsert_entry(entry("src/main.rs"));
336        assert!(def.find_entry("src/main.rs").is_some());
337        assert!(def.find_entry("src/lib.rs").is_none());
338    }
339
340    // find_entry — glob fallback after exact miss
341    #[test]
342    fn find_entry_glob_fallback() {
343        let mut def = AuditDefinition::new_empty();
344        def.upsert_entry(entry("node_modules/**"));
345        // exact path should match via glob
346        assert!(def.find_entry("node_modules/lodash/index.js").is_some(),
347            "glob entry should match concrete path");
348        assert!(def.find_entry("src/main.rs").is_none(),
349            "glob should not match unrelated path");
350    }
351
352    // exact entry wins over glob when both present
353    #[test]
354    fn find_entry_exact_wins_over_glob() {
355        let mut def = AuditDefinition::new_empty();
356        def.upsert_entry(entry("node_modules/**"));
357        let mut specific = entry("node_modules/lodash/index.js");
358        specific.reason = "specific override".into();
359        def.upsert_entry(specific);
360        let found = def.find_entry("node_modules/lodash/index.js").unwrap();
361        assert_eq!(found.reason, "specific override",
362            "exact path should take precedence over glob");
363    }
364
365    // is_glob
366    #[test]
367    fn is_glob_detection() {
368        assert!(entry("src/**").is_glob());
369        assert!(entry("**/*.rs").is_glob());
370        assert!(entry("src/?.rs").is_glob());
371        assert!(entry("[ab].rs").is_glob());
372        assert!(!entry("src/main.rs").is_glob());
373        assert!(!entry("a/b/c.txt").is_glob());
374    }
375
376    // glob_matches edge cases
377    #[test]
378    fn glob_matches_depth() {
379        let e = entry("dist/**");
380        assert!(e.glob_matches("dist/bundle.js"));
381        assert!(e.glob_matches("dist/css/style.css"));
382        assert!(!e.glob_matches("src/main.rs"));
383    }
384
385    #[test]
386    fn glob_matches_extension_pattern() {
387        let e = entry("**/*.lock");
388        assert!(e.glob_matches("Cargo.lock"));
389        assert!(e.glob_matches("package-lock.json") == false,
390            "*.lock should not match .json");
391        assert!(e.glob_matches("sub/dir/yarn.lock"));
392    }
393
394    // upsert_entry — updates in place
395    #[test]
396    fn upsert_entry_updates_existing() {
397        let mut def = AuditDefinition::new_empty();
398        def.upsert_entry(entry("a.txt"));
399        assert_eq!(def.entries.len(), 1);
400        let mut updated = entry("a.txt");
401        updated.reason = "updated".into();
402        def.upsert_entry(updated);
403        assert_eq!(def.entries.len(), 1, "should not duplicate");
404        assert_eq!(def.entries[0].reason, "updated");
405    }
406
407    // expired_entries
408    #[test]
409    fn expired_entries_detects_past_date() {
410        let mut def = AuditDefinition::new_empty();
411        let mut e = entry("old.txt");
412        e.expires_at = Some((Utc::now() - Duration::days(1)).date_naive());
413        def.upsert_entry(e);
414        def.upsert_entry(entry("current.txt"));
415        let expired = def.expired_entries();
416        assert_eq!(expired.len(), 1);
417        assert_eq!(expired[0].path, "old.txt");
418    }
419
420    // expiring_soon
421    #[test]
422    fn expiring_soon_within_window() {
423        let mut def = AuditDefinition::new_empty();
424        let mut e = entry("soon.txt");
425        e.expires_at = Some((Utc::now() + Duration::days(10)).date_naive());
426        def.upsert_entry(e);
427        let mut far = entry("far.txt");
428        far.expires_at = Some((Utc::now() + Duration::days(60)).date_naive());
429        def.upsert_entry(far);
430        let soon = def.expiring_soon(30);
431        assert_eq!(soon.len(), 1);
432        assert_eq!(soon[0].path, "soon.txt");
433    }
434
435    // is_approvable
436    #[test]
437    fn is_approvable_requires_non_empty_reason() {
438        let mut e = entry("a.txt");
439        assert!(e.is_approvable().is_ok());
440        e.reason = "  ".into();   // whitespace-only
441        assert!(e.is_approvable().is_err(), "whitespace-only reason should fail");
442        e.reason = String::new();
443        assert!(e.is_approvable().is_err(), "empty reason should fail");
444    }
445}