Skip to main content

aaai_core/config/
definition.rs

1//! Audit definition — the "expected values" YAML document.
2//!
3//! # File shape (version 1)
4//!
5//! ```yaml
6//! version: "1"
7//! meta:
8//!   description: "Release v2.3.0 audit"
9//! entries:
10//!   - path: "config/server.toml"          # exact path OR glob pattern
11//!     diff_type: Modified
12//!     reason: "ポート番号の仕様変更 — INF-42"
13//!     strategy:
14//!       type: LineMatch
15//!       rules:
16//!         - action: Removed
17//!           line: "port = 80"
18//!         - action: Added
19//!           line: "port = 8080"
20//!     enabled: true
21//!     note: "本番環境への適用に伴う変更"
22//! ```
23
24use serde::{Deserialize, Serialize};
25
26use crate::diff::entry::DiffType;
27
28// ── Top-level document ────────────────────────────────────────────────────
29
30/// The root of an audit definition file.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct AuditDefinition {
33    /// Schema version. Currently `"1"`.
34    pub version: String,
35
36    /// Optional human-readable metadata.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub meta: Option<AuditMeta>,
39
40    /// Ordered list of expected-value entries.
41    #[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    /// Find an entry that matches `path` by exact path or glob pattern.
51    /// Exact-path entries take priority over glob entries.
52    pub fn find_entry(&self, path: &str) -> Option<&AuditEntry> {
53        // 1. Exact match first.
54        if let Some(e) = self.entries.iter().find(|e| !e.is_glob() && e.path == path) {
55            return Some(e);
56        }
57        // 2. Glob match (first winning entry).
58        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        // Only exact-path entries are mutable via this API.
63        self.entries.iter_mut().find(|e| !e.is_glob() && e.path == path)
64    }
65
66    /// Upsert an entry: replace the existing exact-path entry or append.
67    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// ── Metadata ─────────────────────────────────────────────────────────────
77
78#[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// ── Per-file entry ────────────────────────────────────────────────────────
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct AuditEntry {
88    /// Root-relative path with forward slashes, OR a glob pattern.
89    /// Glob patterns may contain `*`, `**`, and `?`.
90    pub path: String,
91
92    pub diff_type: DiffType,
93
94    /// Mandatory human-readable justification. Must not be empty for approval.
95    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    /// Returns `true` if `path` contains a glob metacharacter.
111    pub fn is_glob(&self) -> bool {
112        self.path.contains('*') || self.path.contains('?') || self.path.contains('[')
113    }
114
115    /// Whether this glob pattern matches `candidate`.
116    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    /// Validate that the entry is complete enough for approval.
127    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// ── Content-audit strategy ────────────────────────────────────────────────
140
141#[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// ── LineMatch rule ────────────────────────────────────────────────────────
226
227#[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// ── Regex target ──────────────────────────────────────────────────────────
249
250#[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}