1use 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
50pub 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
135pub 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}