Skip to main content

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}