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
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    /// True if `expires_at` is today or in the past.
141    pub fn is_expired(&self) -> bool {
142        self.expires_at.map_or(false, |d| d <= Utc::now().date_naive())
143    }
144
145    /// True if expiring within `days` days but not yet expired.
146    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// ── Content-audit strategy ────────────────────────────────────────────────
165
166#[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// ── LineMatch rule ────────────────────────────────────────────────────────
251
252#[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// ── Regex target ──────────────────────────────────────────────────────────
271
272#[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}