Skip to main content

aft/bash_rewrite/
differential.rs

1//! Closed corpus schema and reusable validation for the bash rewrite campaign.
2//!
3//! The process-level runner lives in the integration test because it needs the
4//! public AFT binary. This module owns the versioned schema, fixture
5//! materialization, inventories, and aggregate failure formatting so schema
6//! checks also run on Windows without executing Unix utilities.
7
8use std::collections::BTreeSet;
9use std::fmt::Write as _;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use super::catalog;
14use super::observation::{deterministic_filesystem_manifest, FilesystemManifest};
15use serde::de::DeserializeOwned;
16use serde::Deserialize;
17
18pub const CORPUS_SCHEMA_VERSION: u32 = 1;
19
20#[derive(Debug, Clone, Deserialize)]
21#[serde(deny_unknown_fields)]
22pub struct Corpus {
23    pub schema_version: u32,
24    pub corpus_id: String,
25    pub rows: Vec<CorpusRow>,
26}
27
28#[derive(Debug, Clone, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct CorpusRow {
31    pub id: String,
32    pub command: String,
33    pub route: String,
34    pub basis: String,
35    #[serde(default)]
36    pub normalizations: Vec<String>,
37    #[serde(default)]
38    pub expectations: Vec<String>,
39    #[serde(default)]
40    pub platform: PlatformGate,
41    pub decision_class: Option<String>,
42    #[serde(default)]
43    pub branch_ids: Vec<String>,
44    pub semantic_dimensions: Vec<String>,
45    #[serde(default = "default_characterization_mode")]
46    pub mode: String,
47    #[serde(default)]
48    pub mutating: bool,
49    #[serde(default)]
50    pub manifest: Vec<ManifestEntrySpec>,
51    #[serde(default)]
52    pub workdir: String,
53}
54
55fn default_characterization_mode() -> String {
56    "characterization-only".to_string()
57}
58
59#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
60#[serde(rename_all = "lowercase")]
61pub enum PlatformGate {
62    #[default]
63    All,
64    Unix,
65    Macos,
66    Linux,
67    Windows,
68}
69
70impl PlatformGate {
71    pub fn enabled_on_host(self) -> bool {
72        match self {
73            Self::All => true,
74            Self::Unix => cfg!(unix),
75            Self::Macos => cfg!(target_os = "macos"),
76            Self::Linux => cfg!(target_os = "linux"),
77            Self::Windows => cfg!(windows),
78        }
79    }
80}
81
82#[derive(Debug, Clone, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct ManifestEntrySpec {
85    pub path: String,
86    #[serde(default = "default_file_kind")]
87    pub kind: String,
88    #[serde(default)]
89    pub content: String,
90    #[serde(default)]
91    pub content_base64: Option<String>,
92    #[serde(default)]
93    pub target: Option<String>,
94}
95
96fn default_file_kind() -> String {
97    "file".to_string()
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct ParsedRoute {
102    pub native: bool,
103    pub rule_id: Option<String>,
104}
105
106impl CorpusRow {
107    pub fn parsed_route(&self) -> Result<ParsedRoute, String> {
108        if self.route == "native" {
109            return Ok(ParsedRoute {
110                native: true,
111                rule_id: None,
112            });
113        }
114        let Some(rule_id) = self.route.strip_prefix("rewritten:") else {
115            return Err(format!("row {} has invalid route {}", self.id, self.route));
116        };
117        if !catalog::rule_exists(rule_id) {
118            return Err(format!("row {} names unknown rule {rule_id}", self.id));
119        }
120        Ok(ParsedRoute {
121            native: false,
122            rule_id: Some(rule_id.to_string()),
123        })
124    }
125
126    pub fn materialize(&self, root: &Path) -> Result<(), String> {
127        validate_fixture_entries(&self.manifest)?;
128        for entry in &self.manifest {
129            let path = safe_fixture_path(root, &entry.path)?;
130            match entry.kind.as_str() {
131                "directory" => fs::create_dir_all(&path)
132                    .map_err(|error| format!("create {}: {error}", entry.path))?,
133                "file" => {
134                    if let Some(parent) = path.parent() {
135                        fs::create_dir_all(parent)
136                            .map_err(|error| format!("create parent {}: {error}", entry.path))?;
137                    }
138                    let bytes = entry_bytes(entry)?;
139                    fs::write(&path, bytes)
140                        .map_err(|error| format!("write {}: {error}", entry.path))?;
141                }
142                "symlink" => {
143                    let target = entry
144                        .target
145                        .as_deref()
146                        .ok_or_else(|| format!("symlink {} is missing target", entry.path))?;
147                    if let Some(parent) = path.parent() {
148                        fs::create_dir_all(parent)
149                            .map_err(|error| format!("create parent {}: {error}", entry.path))?;
150                    }
151                    create_symlink(target, &path)?;
152                }
153                other => return Err(format!("row {} has unknown fixture kind {other}", self.id)),
154            }
155        }
156        Ok(())
157    }
158
159    pub fn initial_manifest(&self, root: &Path) -> Result<FilesystemManifest, String> {
160        self.materialize(root)?;
161        deterministic_filesystem_manifest(root).map_err(|error| error.to_string())
162    }
163}
164
165fn create_symlink(target: &str, path: &Path) -> Result<(), String> {
166    #[cfg(unix)]
167    {
168        std::os::unix::fs::symlink(target, path).map_err(|error| error.to_string())
169    }
170    #[cfg(windows)]
171    {
172        // The schema and validation run on Windows, but Windows corpus rows
173        // are not executed. A directory target gets the directory API; all
174        // other targets use the file API.
175        if target.ends_with('/') || target.ends_with('\\') {
176            std::os::windows::fs::symlink_dir(target, path).map_err(|error| error.to_string())
177        } else {
178            std::os::windows::fs::symlink_file(target, path).map_err(|error| error.to_string())
179        }
180    }
181}
182
183fn safe_fixture_path(root: &Path, relative: &str) -> Result<PathBuf, String> {
184    let path = Path::new(relative);
185    if relative.is_empty()
186        || path.is_absolute()
187        || path
188            .components()
189            .any(|component| matches!(component, std::path::Component::ParentDir))
190    {
191        return Err(format!("fixture path is not root-relative: {relative:?}"));
192    }
193    Ok(root.join(path))
194}
195
196fn entry_bytes(entry: &ManifestEntrySpec) -> Result<Vec<u8>, String> {
197    if entry.content_base64.is_some() && !entry.content.is_empty() {
198        return Err(format!(
199            "fixture {} specifies both content and content_base64",
200            entry.path
201        ));
202    }
203    match entry.content_base64.as_deref() {
204        Some(encoded) => {
205            base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded)
206                .map_err(|error| format!("fixture {} has invalid base64: {error}", entry.path))
207        }
208        None => Ok(entry.content.as_bytes().to_vec()),
209    }
210}
211
212fn validate_fixture_entries(entries: &[ManifestEntrySpec]) -> Result<(), String> {
213    let mut paths = BTreeSet::new();
214    for entry in entries {
215        safe_fixture_path(Path::new("."), &entry.path)?;
216        if !paths.insert(entry.path.clone()) {
217            return Err(format!("duplicate fixture path {}", entry.path));
218        }
219        match entry.kind.as_str() {
220            "directory" => {
221                if !entry.content.is_empty()
222                    || entry.content_base64.is_some()
223                    || entry.target.is_some()
224                {
225                    return Err(format!(
226                        "directory {} has file or symlink payload",
227                        entry.path
228                    ));
229                }
230            }
231            "file" => {
232                if entry.target.is_some() {
233                    return Err(format!("file {} has symlink target", entry.path));
234                }
235                let _ = entry_bytes(entry)?;
236            }
237            "symlink" => {
238                if entry.target.is_none()
239                    || !entry.content.is_empty()
240                    || entry.content_base64.is_some()
241                {
242                    return Err(format!("symlink {} has invalid payload", entry.path));
243                }
244            }
245            other => return Err(format!("unknown fixture kind {other}")),
246        }
247    }
248    Ok(())
249}
250
251pub fn parse_corpus_str<T: DeserializeOwned>(source: &str) -> Result<T, String> {
252    toml::from_str(source).map_err(|error| error.to_string())
253}
254
255pub fn parse_corpus(source: &str) -> Result<Corpus, String> {
256    let corpus: Corpus = parse_corpus_str(source)?;
257    validate_corpus(&corpus)?;
258    Ok(corpus)
259}
260
261pub fn validate_corpus(corpus: &Corpus) -> Result<(), String> {
262    catalog::validate_catalog()?;
263    if corpus.schema_version != CORPUS_SCHEMA_VERSION {
264        return Err(format!(
265            "unsupported corpus schema version {}; expected {}",
266            corpus.schema_version, CORPUS_SCHEMA_VERSION
267        ));
268    }
269    if corpus.corpus_id.trim().is_empty() {
270        return Err("corpus_id must not be empty".to_string());
271    }
272    if corpus.rows.is_empty() {
273        return Err("corpus must contain at least one row".to_string());
274    }
275
276    let mut row_ids = BTreeSet::new();
277    let mut covered_branches = BTreeSet::new();
278    let mut covered_dimensions = BTreeSet::new();
279    for row in &corpus.rows {
280        if row.id.trim().is_empty() || !row_ids.insert(row.id.clone()) {
281            return Err(format!("duplicate or empty row ID {:?}", row.id));
282        }
283        if row.command.trim().is_empty() {
284            return Err(format!("row {} has an empty command", row.id));
285        }
286        let route = row.parsed_route()?;
287        if row.mode != "characterization-only" {
288            return Err(format!("row {} must be characterization-only", row.id));
289        }
290        if row.semantic_dimensions.is_empty() {
291            return Err(format!("row {} has no semantic dimensions", row.id));
292        }
293        for dimension in &row.semantic_dimensions {
294            if !catalog::semantic_dimension_exists(dimension) {
295                return Err(format!(
296                    "row {} names unknown dimension {dimension}",
297                    row.id
298                ));
299            }
300            covered_dimensions.insert(dimension.clone());
301        }
302        if !catalog::COMPARISON_BASES.contains(&row.basis.as_str()) {
303            return Err(format!(
304                "row {} names unknown comparison basis {}",
305                row.id, row.basis
306            ));
307        }
308        if row.normalizations.iter().any(|normalization| {
309            !catalog::PRESENTATION_NORMALIZATIONS.contains(&normalization.as_str())
310        }) {
311            return Err(format!(
312                "row {} has unknown presentation normalization",
313                row.id
314            ));
315        }
316        if row
317            .expectations
318            .iter()
319            .any(|expectation| !catalog::EXPECTATIONS.contains(&expectation.as_str()))
320        {
321            return Err(format!("row {} has unknown expectation", row.id));
322        }
323        if row
324            .expectations
325            .iter()
326            .any(|expectation| expectation == &row.basis)
327            || row
328                .normalizations
329                .iter()
330                .any(|normalization| normalization == &row.basis)
331        {
332            return Err(format!(
333                "row {} places a basis ID in a disjoint vocabulary",
334                row.id
335            ));
336        }
337        for branch_id in &row.branch_ids {
338            if !catalog::branch_exists(branch_id) {
339                return Err(format!("row {} names unknown branch {branch_id}", row.id));
340            }
341            covered_branches.insert(branch_id.clone());
342        }
343        match route {
344            ParsedRoute {
345                native: true,
346                rule_id: None,
347            } => {
348                if row.decision_class.is_some() {
349                    return Err(format!(
350                        "native row {} must not have a decision class",
351                        row.id
352                    ));
353                }
354            }
355            ParsedRoute {
356                native: false,
357                rule_id: Some(ref rule_id),
358            } => {
359                let class_id = row
360                    .decision_class
361                    .as_deref()
362                    .ok_or_else(|| format!("rewritten row {} is missing decision_class", row.id))?;
363                let class = catalog::decision_class(class_id).ok_or_else(|| {
364                    format!("row {} names unknown decision class {class_id}", row.id)
365                })?;
366                if class.rule_id != rule_id {
367                    return Err(format!(
368                        "row {} decision class does not match route",
369                        row.id
370                    ));
371                }
372            }
373            _ => unreachable!(),
374        }
375        validate_fixture_entries(&row.manifest)?;
376        if row.mutating && !row.semantic_dimensions.iter().any(|dim| dim == "mutation") {
377            return Err(format!(
378                "mutating row {} must declare mutation dimension",
379                row.id
380            ));
381        }
382        if row.workdir.starts_with('/') || row.workdir.contains("..") {
383            return Err(format!("row {} workdir must be root-relative", row.id));
384        }
385    }
386
387    for branch in catalog::BRANCH_INVENTORY {
388        if !covered_branches.contains(branch.id) {
389            return Err(format!(
390                "branch inventory entry {} has no corpus row",
391                branch.id
392            ));
393        }
394    }
395    for dimension in catalog::SEMANTIC_DIMENSIONS {
396        if !covered_dimensions.contains(*dimension) {
397            return Err(format!("semantic dimension {dimension} has no corpus row"));
398        }
399    }
400    Ok(())
401}
402
403#[derive(Debug, Clone, PartialEq, Eq)]
404pub struct HarnessFailure {
405    pub row_id: String,
406    pub message: String,
407}
408
409#[derive(Debug, Default)]
410pub struct AggregateFailures {
411    failures: Vec<HarnessFailure>,
412}
413
414impl AggregateFailures {
415    pub fn push(&mut self, row_id: impl Into<String>, message: impl Into<String>) {
416        self.failures.push(HarnessFailure {
417            row_id: row_id.into(),
418            message: message.into(),
419        });
420    }
421
422    pub fn is_empty(&self) -> bool {
423        self.failures.is_empty()
424    }
425
426    pub fn failures(&self) -> &[HarnessFailure] {
427        &self.failures
428    }
429
430    pub fn finish(self) -> Result<(), String> {
431        if self.failures.is_empty() {
432            return Ok(());
433        }
434        let mut report = String::from("bash rewrite differential failures:\n");
435        for failure in self.failures {
436            let _ = writeln!(report, "- {}: {}", failure.row_id, failure.message);
437        }
438        Err(report)
439    }
440}
441
442pub fn manifest_for(root: &Path) -> Result<FilesystemManifest, String> {
443    deterministic_filesystem_manifest(root).map_err(|error| error.to_string())
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    const VALID: &str = r#"
451        schema_version = 1
452        corpus_id = "test"
453
454        [[rows]]
455        id = "native"
456        command = "printf ok"
457        route = "native"
458        basis = "bytes"
459        branch_ids = ["dispatch.native.no_rule"]
460        semantic_dimensions = ["shell-tokenization"]
461        mode = "characterization-only"
462    "#;
463
464    #[test]
465    fn schema_rejects_unknown_fields_and_bad_version() {
466        let unknown = VALID.replace("mode =", "unknown = true\nmode =");
467        assert!(parse_corpus(&unknown).is_err());
468        let bad_version = VALID.replace("schema_version = 1", "schema_version = 2");
469        assert!(parse_corpus(&bad_version).is_err());
470    }
471
472    #[test]
473    fn aggregate_failures_are_not_first_failure_only() {
474        let mut failures = AggregateFailures::default();
475        failures.push("r1", "first");
476        failures.push("r2", "second");
477        let report = failures.finish().unwrap_err();
478        assert!(report.contains("r1") && report.contains("r2"));
479    }
480}