aaai_core/config/definition.rs
1//! Audit definition — the "expected values" YAML document.
2//!
3//! The on-disk format is versioned YAML. Each [`AuditEntry`] describes one
4//! expected file-level difference plus the content-audit [`AuditStrategy`]
5//! and the mandatory human-readable `reason`.
6//!
7//! # File shape
8//!
9//! ```yaml
10//! version: "1"
11//! meta:
12//! description: "Release v2.3.0 audit"
13//! entries:
14//! - path: "config/server.toml"
15//! diff_type: Modified
16//! reason: "ポート番号の仕様変更"
17//! strategy:
18//! type: LineMatch
19//! rules:
20//! - action: Removed
21//! line: "port = 80"
22//! - action: Added
23//! line: "port = 8080"
24//! enabled: true
25//! note: "本番環境への適用に伴う変更"
26//! ```
27
28use serde::{Deserialize, Serialize};
29
30use crate::diff::entry::DiffType;
31
32// ── Top-level document ────────────────────────────────────────────────────
33
34/// The root of an audit definition file.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct AuditDefinition {
37 /// Schema version. Currently `"1"`.
38 pub version: String,
39
40 /// Optional human-readable metadata for the definition file itself.
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub meta: Option<AuditMeta>,
43
44 /// Ordered list of expected-value entries.
45 #[serde(default)]
46 pub entries: Vec<AuditEntry>,
47}
48
49impl AuditDefinition {
50 /// Construct a new, empty definition at the current schema version.
51 pub fn new_empty() -> Self {
52 Self {
53 version: "1".to_string(),
54 meta: None,
55 entries: Vec::new(),
56 }
57 }
58
59 /// Return the entry for `path`, if any. Comparison is
60 /// case-sensitive and uses Unix-style forward-slash separators.
61 pub fn find_entry(&self, path: &str) -> Option<&AuditEntry> {
62 self.entries.iter().find(|e| e.path == path)
63 }
64
65 /// Mutable variant of [`find_entry`].
66 pub fn find_entry_mut(&mut self, path: &str) -> Option<&mut AuditEntry> {
67 self.entries.iter_mut().find(|e| e.path == path)
68 }
69
70 /// Upsert an entry: replace the existing entry for the same path or
71 /// append a new one. Preserves the sort-stable order of existing
72 /// entries so that repeated saves do not produce spurious diffs.
73 pub fn upsert_entry(&mut self, entry: AuditEntry) {
74 if let Some(existing) = self.find_entry_mut(&entry.path.clone()) {
75 *existing = entry;
76 } else {
77 self.entries.push(entry);
78 }
79 }
80}
81
82// ── Metadata ─────────────────────────────────────────────────────────────
83
84/// Free-form metadata attached to the definition file.
85#[derive(Debug, Clone, Default, Serialize, Deserialize)]
86pub struct AuditMeta {
87 /// A short description of what this audit covers.
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub description: Option<String>,
90}
91
92// ── Per-file entry ────────────────────────────────────────────────────────
93
94/// One expected-value record for a single path.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct AuditEntry {
97 /// Root-relative path using forward slashes (`config/server.toml`).
98 pub path: String,
99
100 /// The difference type this entry expects to see.
101 pub diff_type: DiffType,
102
103 /// Mandatory human-readable justification. Empty strings are
104 /// rejected at validation time.
105 pub reason: String,
106
107 /// Content-audit strategy.
108 #[serde(default)]
109 pub strategy: AuditStrategy,
110
111 /// Whether this entry participates in auditing.
112 /// Disabled entries produce [`crate::audit::result::AuditStatus::Ignored`].
113 #[serde(default = "default_true")]
114 pub enabled: bool,
115
116 /// Optional free-form note (stored but not used for judgement).
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub note: Option<String>,
119}
120
121fn default_true() -> bool {
122 true
123}
124
125impl AuditEntry {
126 /// Return `true` when the entry is complete enough for a valid approval:
127 /// non-empty path, non-empty reason, and a strategy that passes its own
128 /// validation.
129 pub fn is_approvable(&self) -> Result<(), String> {
130 if self.path.trim().is_empty() {
131 return Err("Path must not be empty.".into());
132 }
133 if self.reason.trim().is_empty() {
134 return Err("Reason must not be empty.".into());
135 }
136 self.strategy.validate()?;
137 Ok(())
138 }
139}
140
141// ── Content-audit strategy ────────────────────────────────────────────────
142
143/// The content-level audit strategy to apply to a matched entry.
144///
145/// Each variant carries its own parameters. The engine dispatches on this
146/// enum to produce a per-file content verdict.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148#[serde(tag = "type")]
149pub enum AuditStrategy {
150 /// Check only that the expected diff kind occurred. No content
151 /// inspection.
152 None,
153
154 /// Verify that the file's SHA-256 digest matches `expected_sha256`.
155 Checksum {
156 /// Expected lowercase hex SHA-256 digest.
157 expected_sha256: String,
158 },
159
160 /// Verify that specific lines were added and/or removed.
161 LineMatch {
162 /// Ordered list of expected line changes.
163 rules: Vec<LineRule>,
164 },
165
166 /// Verify that added/removed lines match a regular expression.
167 Regex {
168 /// The regular expression pattern (applied per changed line).
169 pattern: String,
170 /// Which side of the diff to apply the pattern to.
171 #[serde(default)]
172 target: RegexTarget,
173 },
174
175 /// Verify that the *after* file's full content exactly matches
176 /// `expected_content`.
177 Exact {
178 /// Expected full file content.
179 expected_content: String,
180 },
181}
182
183impl Default for AuditStrategy {
184 fn default() -> Self {
185 AuditStrategy::None
186 }
187}
188
189impl AuditStrategy {
190 /// Human-readable short label for UI display.
191 pub fn label(&self) -> &'static str {
192 match self {
193 AuditStrategy::None => "None",
194 AuditStrategy::Checksum { .. } => "Checksum",
195 AuditStrategy::LineMatch { .. } => "LineMatch",
196 AuditStrategy::Regex { .. } => "Regex",
197 AuditStrategy::Exact { .. } => "Exact",
198 }
199 }
200
201 /// Brief description shown in the inspector UI.
202 pub fn description(&self) -> &'static str {
203 match self {
204 AuditStrategy::None =>
205 "Checks only that the expected change type occurred. \
206 No content inspection is performed.",
207 AuditStrategy::Checksum { .. } =>
208 "Verifies the file's SHA-256 digest. \
209 Suitable for binaries, images, and archives.",
210 AuditStrategy::LineMatch { .. } =>
211 "Verifies that specific lines were added or removed. \
212 The primary strategy for config-value changes.",
213 AuditStrategy::Regex { .. } =>
214 "Verifies that changed lines match a regular expression. \
215 Useful when the exact value is environment-dependent.",
216 AuditStrategy::Exact { .. } =>
217 "Verifies that the file's full content exactly matches \
218 the expected text. Avoid for large files.",
219 }
220 }
221
222 /// Validate strategy-specific parameters. Returns `Err` with a
223 /// human-readable message when the strategy is misconfigured.
224 pub fn validate(&self) -> Result<(), String> {
225 match self {
226 AuditStrategy::None => Ok(()),
227
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(
234 "Checksum: expected_sha256 must be a valid hex string.".into()
235 );
236 }
237 if expected_sha256.len() != 64 {
238 return Err(
239 "Checksum: expected_sha256 must be a 64-character SHA-256 hex digest."
240 .into(),
241 );
242 }
243 Ok(())
244 }
245
246 AuditStrategy::LineMatch { rules } => {
247 if rules.is_empty() {
248 return Err("LineMatch: at least one rule is required.".into());
249 }
250 for (i, r) in rules.iter().enumerate() {
251 if r.line.trim().is_empty() {
252 return Err(format!("LineMatch rule {}: line must not be empty.", i + 1));
253 }
254 }
255 Ok(())
256 }
257
258 AuditStrategy::Regex { pattern, .. } => {
259 if pattern.trim().is_empty() {
260 return Err("Regex: pattern must not be empty.".into());
261 }
262 regex::Regex::new(pattern)
263 .map(|_| ())
264 .map_err(|e| format!("Regex: invalid pattern — {e}"))
265 }
266
267 AuditStrategy::Exact { expected_content } => {
268 if expected_content.is_empty() {
269 return Err("Exact: expected_content must not be empty.".into());
270 }
271 Ok(())
272 }
273 }
274 }
275}
276
277// ── LineMatch rule ────────────────────────────────────────────────────────
278
279/// One expected line change in a [`AuditStrategy::LineMatch`] strategy.
280#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct LineRule {
282 /// Whether this line is expected to have been added or removed.
283 pub action: LineAction,
284 /// The exact line content (without a trailing newline).
285 pub line: String,
286}
287
288/// Direction of a line change in [`LineRule`].
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
290pub enum LineAction {
291 /// Line is expected to be present only in the *after* folder (added).
292 Added,
293 /// Line is expected to be present only in the *before* folder (removed).
294 Removed,
295}
296
297impl std::fmt::Display for LineAction {
298 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299 match self {
300 LineAction::Added => write!(f, "Added"),
301 LineAction::Removed => write!(f, "Removed"),
302 }
303 }
304}
305
306// ── Regex target ──────────────────────────────────────────────────────────
307
308/// Which lines the [`AuditStrategy::Regex`] pattern is applied to.
309#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
310pub enum RegexTarget {
311 /// Apply to added lines (present in *after* but not *before*).
312 #[default]
313 AddedLines,
314 /// Apply to removed lines (present in *before* but not *after*).
315 RemovedLines,
316 /// Apply to all changed lines (union of added and removed).
317 AllChangedLines,
318}
319
320impl std::fmt::Display for RegexTarget {
321 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
322 match self {
323 RegexTarget::AddedLines => write!(f, "Added lines"),
324 RegexTarget::RemovedLines => write!(f, "Removed lines"),
325 RegexTarget::AllChangedLines => write!(f, "All changed lines"),
326 }
327 }
328}