Skip to main content

aft/bash_rewrite/
catalog.rs

1//! Versioned metadata for the bash rewrite dispatch surface.
2//!
3//! Keeping these identifiers next to the dispatch implementation gives the
4//! differential corpus a closed vocabulary. The corpus can therefore name the
5//! behavior it covers without depending on Rust type names or log messages.
6
7use std::fs;
8use std::path::Path;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ControlRole {
12    Accept,
13    Decline,
14    Native,
15    Sandbox,
16}
17
18impl ControlRole {
19    pub const fn id(self) -> &'static str {
20        match self {
21            Self::Accept => "accept",
22            Self::Decline => "decline",
23            Self::Native => "native",
24            Self::Sandbox => "sandbox",
25        }
26    }
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct DecisionClass {
31    pub id: &'static str,
32    pub rule_id: &'static str,
33    pub baseline_source: &'static str,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct BranchInventoryEntry {
38    pub id: &'static str,
39    pub rule_id: Option<&'static str>,
40    pub role: ControlRole,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct BypassInventoryEntry {
45    pub id: &'static str,
46    pub rule_id: Option<&'static str>,
47    pub reason: &'static str,
48}
49
50/// The seven production rules. `grep` and `rg` intentionally have separate
51/// rule IDs but share the `grep_request` builder.
52pub const RULE_INVENTORY: &[(&str, &str)] = &[
53    ("grep", "grep_request"),
54    ("rg", "grep_request"),
55    ("find", "find_request"),
56    ("cat", "cat_read_request"),
57    ("cat_append", "append_request"),
58    ("sed", "sed_request"),
59    ("ls", "ls_request"),
60];
61
62pub const DECISION_CLASSES: &[DecisionClass] = &[
63    DecisionClass {
64        id: "dc.grep.decline.v1",
65        rule_id: "grep",
66        baseline_source: "src/bash_rewrite/rules.rs::grep_request",
67    },
68    DecisionClass {
69        id: "dc.rg.decline.v1",
70        rule_id: "rg",
71        baseline_source: "src/bash_rewrite/rules.rs::grep_request",
72    },
73    DecisionClass {
74        id: "dc.find.decline.v1",
75        rule_id: "find",
76        baseline_source: "src/bash_rewrite/rules.rs::find_request",
77    },
78    DecisionClass {
79        id: "dc.cat.decline.v1",
80        rule_id: "cat",
81        baseline_source: "src/bash_rewrite/rules.rs::cat_read_request",
82    },
83    DecisionClass {
84        id: "dc.cat_append.decline.v1",
85        rule_id: "cat_append",
86        baseline_source: "src/bash_rewrite/rules.rs::append_request",
87    },
88    DecisionClass {
89        id: "dc.sed.decline.v1",
90        rule_id: "sed",
91        baseline_source: "src/bash_rewrite/rules.rs::sed_request",
92    },
93    DecisionClass {
94        id: "dc.ls.decline.v1",
95        rule_id: "ls",
96        baseline_source: "src/bash_rewrite/rules.rs::ls_request",
97    },
98    DecisionClass {
99        id: "dc.grep.accept.v1",
100        rule_id: "grep",
101        baseline_source: "src/commands/grep.rs::handle_grep",
102    },
103    DecisionClass {
104        id: "dc.rg.accept.v1",
105        rule_id: "rg",
106        baseline_source: "src/commands/grep.rs::handle_grep",
107    },
108    DecisionClass {
109        id: "dc.find.accept.v1",
110        rule_id: "find",
111        baseline_source: "src/commands/glob.rs::handle_glob",
112    },
113    DecisionClass {
114        id: "dc.cat.accept.v1",
115        rule_id: "cat",
116        baseline_source: "src/commands/read.rs::handle_read",
117    },
118    DecisionClass {
119        id: "dc.cat_append.accept.v1",
120        rule_id: "cat_append",
121        baseline_source: "src/commands/edit_match.rs::handle_edit_match",
122    },
123    DecisionClass {
124        id: "dc.sed.accept.v1",
125        rule_id: "sed",
126        baseline_source: "src/commands/read.rs::handle_read",
127    },
128    DecisionClass {
129        id: "dc.ls.accept.v1",
130        rule_id: "ls",
131        baseline_source: "src/commands/read.rs::handle_read",
132    },
133];
134
135/// The generated branch table is deliberately explicit. A new production arm
136/// must add an entry here before a corpus change can claim coverage.
137pub const BRANCH_INVENTORY: &[BranchInventoryEntry] = &[
138    BranchInventoryEntry {
139        id: "grep.accept",
140        rule_id: Some("grep"),
141        role: ControlRole::Accept,
142    },
143    BranchInventoryEntry {
144        id: "grep.decline",
145        rule_id: Some("grep"),
146        role: ControlRole::Decline,
147    },
148    BranchInventoryEntry {
149        id: "rg.accept",
150        rule_id: Some("rg"),
151        role: ControlRole::Accept,
152    },
153    BranchInventoryEntry {
154        id: "rg.decline",
155        rule_id: Some("rg"),
156        role: ControlRole::Decline,
157    },
158    BranchInventoryEntry {
159        id: "find.accept",
160        rule_id: Some("find"),
161        role: ControlRole::Accept,
162    },
163    BranchInventoryEntry {
164        id: "find.decline",
165        rule_id: Some("find"),
166        role: ControlRole::Decline,
167    },
168    BranchInventoryEntry {
169        id: "cat.accept",
170        rule_id: Some("cat"),
171        role: ControlRole::Accept,
172    },
173    BranchInventoryEntry {
174        id: "cat.decline",
175        rule_id: Some("cat"),
176        role: ControlRole::Decline,
177    },
178    BranchInventoryEntry {
179        id: "cat_append.accept",
180        rule_id: Some("cat_append"),
181        role: ControlRole::Accept,
182    },
183    BranchInventoryEntry {
184        id: "cat_append.decline",
185        rule_id: Some("cat_append"),
186        role: ControlRole::Decline,
187    },
188    BranchInventoryEntry {
189        id: "sed.accept",
190        rule_id: Some("sed"),
191        role: ControlRole::Accept,
192    },
193    BranchInventoryEntry {
194        id: "sed.decline",
195        rule_id: Some("sed"),
196        role: ControlRole::Decline,
197    },
198    BranchInventoryEntry {
199        id: "ls.accept",
200        rule_id: Some("ls"),
201        role: ControlRole::Accept,
202    },
203    BranchInventoryEntry {
204        id: "ls.decline",
205        rule_id: Some("ls"),
206        role: ControlRole::Decline,
207    },
208    BranchInventoryEntry {
209        id: "dispatch.native.no_rule",
210        rule_id: None,
211        role: ControlRole::Native,
212    },
213    BranchInventoryEntry {
214        id: "dispatch.native.sandbox",
215        rule_id: None,
216        role: ControlRole::Sandbox,
217    },
218    BranchInventoryEntry {
219        id: "dispatch.native.non_root_workdir",
220        rule_id: None,
221        role: ControlRole::Native,
222    },
223];
224
225pub const BYPASS_INVENTORY: &[BypassInventoryEntry] = &[
226    BypassInventoryEntry {
227        id: "bypass.unsupported_shape",
228        rule_id: None,
229        reason: "the command is outside the seven-rule accepted shape surface",
230    },
231    BypassInventoryEntry {
232        id: "bypass.external_path",
233        rule_id: None,
234        reason: "the internal handler cannot represent an external path safely",
235    },
236    BypassInventoryEntry {
237        id: "bypass.non_root_workdir",
238        rule_id: None,
239        reason: "rewrite paths are project-root relative, while bash honors cwd",
240    },
241    BypassInventoryEntry {
242        id: "bypass.sandbox",
243        rule_id: None,
244        reason: "native sandboxing must own process execution",
245    },
246];
247
248pub const SEMANTIC_DIMENSIONS: &[&str] = &[
249    "shell-tokenization",
250    "path-resolution",
251    "working-directory",
252    "hidden-files",
253    "ordering",
254    "match-completeness",
255    "exit-status",
256    "error-outcome",
257    "content-limit",
258    "mutation",
259    "append-no-double-apply",
260    "locale",
261    "environment-path",
262    "handler-lifecycle",
263];
264
265pub const COMPARISON_BASES: &[&str] = &[
266    "bytes",
267    "ls-entry-set",
268    "ls-entry-sequence",
269    "find-path-set",
270    "grep-match-set",
271    "grep-value-multiset",
272];
273
274pub const PRESENTATION_NORMALIZATIONS: &[&str] = &["footer-removal", "gutter-removal"];
275pub const EXPECTATIONS: &[&str] = &["truncation-disclosed", "characterization-only"];
276
277pub fn rule_inventory() -> &'static [(&'static str, &'static str)] {
278    RULE_INVENTORY
279}
280
281pub fn branch_inventory() -> &'static [BranchInventoryEntry] {
282    BRANCH_INVENTORY
283}
284
285pub fn bypass_inventory() -> &'static [BypassInventoryEntry] {
286    BYPASS_INVENTORY
287}
288
289pub fn rule_exists(rule_id: &str) -> bool {
290    RULE_INVENTORY.iter().any(|(id, _)| *id == rule_id)
291}
292
293pub fn decision_class(id: &str) -> Option<&'static DecisionClass> {
294    DECISION_CLASSES.iter().find(|class| class.id == id)
295}
296
297pub fn branch_exists(id: &str) -> bool {
298    BRANCH_INVENTORY.iter().any(|branch| branch.id == id)
299}
300
301pub fn semantic_dimension_exists(id: &str) -> bool {
302    SEMANTIC_DIMENSIONS.contains(&id)
303}
304
305pub fn baseline_source_resolves(source: &str) -> bool {
306    let Some((file, symbol)) = source.split_once("::") else {
307        return false;
308    };
309    if file.is_empty() || symbol.is_empty() || symbol.contains("::") {
310        return false;
311    }
312    let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(file);
313    path.is_file()
314        && fs::read_to_string(path)
315            .ok()
316            .is_some_and(|source| source.contains(symbol))
317}
318
319pub fn validate_catalog() -> Result<(), String> {
320    for class in DECISION_CLASSES {
321        if !rule_exists(class.rule_id) {
322            return Err(format!("decision class {} names unknown rule", class.id));
323        }
324        if !baseline_source_resolves(class.baseline_source) {
325            return Err(format!(
326                "decision class {} has unresolved baseline source {}",
327                class.id, class.baseline_source
328            ));
329        }
330    }
331    for branch in BRANCH_INVENTORY {
332        if let Some(rule_id) = branch.rule_id {
333            if !rule_exists(rule_id) {
334                return Err(format!("branch {} names unknown rule", branch.id));
335            }
336        }
337    }
338    Ok(())
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn catalog_sources_and_rule_counts_are_closed() {
347        validate_catalog().expect("catalog sources resolve");
348        assert_eq!(RULE_INVENTORY.len(), 7);
349        assert_eq!(RULE_INVENTORY[0].1, RULE_INVENTORY[1].1);
350        assert_eq!(RULE_INVENTORY[0].1, "grep_request");
351    }
352
353    #[test]
354    fn reserved_control_roles_are_stable() {
355        assert_eq!(ControlRole::Accept.id(), "accept");
356        assert_eq!(ControlRole::Decline.id(), "decline");
357        assert_eq!(ControlRole::Native.id(), "native");
358        assert_eq!(ControlRole::Sandbox.id(), "sandbox");
359    }
360}