1use crate::parse::{has_flag, Token};
2use crate::policy::{self, FlagPolicy};
3use crate::verdict::{SafetyLevel, Verdict};
4#[cfg(test)]
5use crate::policy::FlagStyle;
6
7pub type CheckFn = fn(&[Token]) -> Verdict;
8
9pub enum SubDef {
10 Policy {
11 name: &'static str,
12 policy: &'static FlagPolicy,
13 level: SafetyLevel,
14 },
15 Nested {
16 name: &'static str,
17 subs: &'static [SubDef],
18 },
19 Guarded {
20 name: &'static str,
21 guard_short: Option<&'static str>,
22 guard_long: &'static str,
23 policy: &'static FlagPolicy,
24 level: SafetyLevel,
25 },
26 Custom {
27 name: &'static str,
28 check: CheckFn,
29 doc: &'static str,
30 test_suffix: Option<&'static str>,
31 },
32 Delegation {
33 name: &'static str,
34 skip: usize,
35 doc: &'static str,
36 },
37}
38
39pub struct CommandDef {
40 pub name: &'static str,
41 pub subs: &'static [SubDef],
42 pub bare_flags: &'static [&'static str],
43 pub url: &'static str,
44 pub aliases: &'static [&'static str],
45}
46
47impl SubDef {
48 pub fn name(&self) -> &'static str {
49 match self {
50 Self::Policy { name, .. }
51 | Self::Nested { name, .. }
52 | Self::Guarded { name, .. }
53 | Self::Custom { name, .. }
54 | Self::Delegation { name, .. } => name,
55 }
56 }
57
58 pub fn check(&self, tokens: &[Token]) -> Verdict {
59 match self {
60 Self::Policy { policy, level, .. } => {
61 if policy::check(tokens, policy) {
62 Verdict::Allowed(*level)
63 } else {
64 Verdict::Denied
65 }
66 }
67 Self::Nested { subs, .. } => {
68 if tokens.len() < 2 {
69 return Verdict::Denied;
70 }
71 let sub = tokens[1].as_str();
72 subs.iter()
73 .find(|s| s.name() == sub)
74 .map(|s| s.check(&tokens[1..]))
75 .unwrap_or(Verdict::Denied)
76 }
77 Self::Guarded {
78 guard_short,
79 guard_long,
80 policy,
81 level,
82 ..
83 } => {
84 if has_flag(tokens, *guard_short, Some(guard_long))
85 && policy::check(tokens, policy)
86 {
87 Verdict::Allowed(*level)
88 } else {
89 Verdict::Denied
90 }
91 }
92 Self::Custom { check: f, .. } => f(tokens),
93 Self::Delegation { skip, .. } => {
94 if tokens.len() <= *skip {
95 return Verdict::Denied;
96 }
97 let inner = shell_words::join(tokens[*skip..].iter().map(|t| t.as_str()));
98 crate::command_verdict(&inner)
99 }
100 }
101 }
102}
103
104impl CommandDef {
105 pub fn opencode_patterns(&self) -> Vec<String> {
106 let mut patterns = Vec::new();
107 let names: Vec<&str> = std::iter::once(self.name)
108 .chain(self.aliases.iter().copied())
109 .collect();
110 for name in &names {
111 for sub in self.subs {
112 sub_opencode_patterns(name, sub, &mut patterns);
113 }
114 }
115 patterns
116 }
117
118 pub fn check(&self, tokens: &[Token]) -> Verdict {
119 if tokens.len() < 2 {
120 return Verdict::Denied;
121 }
122 let arg = tokens[1].as_str();
123 if tokens.len() == 2 && self.bare_flags.contains(&arg) {
124 return Verdict::Allowed(SafetyLevel::Inert);
125 }
126 self.subs
127 .iter()
128 .find(|s| s.name() == arg)
129 .map(|s| s.check(&tokens[1..]))
130 .unwrap_or(Verdict::Denied)
131 }
132
133 pub fn dispatch(
134 &self,
135 cmd: &str,
136 tokens: &[Token],
137 ) -> Option<Verdict> {
138 if cmd == self.name || self.aliases.contains(&cmd) {
139 Some(self.check(tokens))
140 } else {
141 None
142 }
143 }
144
145 pub fn to_doc(&self) -> crate::docs::CommandDoc {
146 let mut lines = Vec::new();
147
148 if !self.bare_flags.is_empty() {
149 lines.push(format!("- Allowed standalone flags: {}", self.bare_flags.join(", ")));
150 }
151
152 let mut sub_lines: Vec<String> = Vec::new();
153 for sub in self.subs {
154 sub_doc_line(sub, "", &mut sub_lines);
155 }
156 sub_lines.sort();
157 lines.extend(sub_lines);
158
159 let mut doc = crate::docs::CommandDoc::handler(self.name, self.url, lines.join("\n"));
160 doc.aliases = self.aliases.iter().map(|a| a.to_string()).collect();
161 doc
162 }
163}
164
165pub struct FlatDef {
166 pub name: &'static str,
167 pub policy: &'static FlagPolicy,
168 pub level: SafetyLevel,
169 pub url: &'static str,
170 pub aliases: &'static [&'static str],
171}
172
173impl FlatDef {
174 pub fn opencode_patterns(&self) -> Vec<String> {
175 let mut patterns = Vec::new();
176 let names: Vec<&str> = std::iter::once(self.name)
177 .chain(self.aliases.iter().copied())
178 .collect();
179 for name in names {
180 patterns.push(name.to_string());
181 patterns.push(format!("{name} *"));
182 }
183 patterns
184 }
185
186 pub fn dispatch(&self, cmd: &str, tokens: &[Token]) -> Option<Verdict> {
187 if cmd == self.name || self.aliases.contains(&cmd) {
188 if policy::check(tokens, self.policy) {
189 Some(Verdict::Allowed(self.level))
190 } else {
191 Some(Verdict::Denied)
192 }
193 } else {
194 None
195 }
196 }
197
198 pub fn to_doc(&self) -> crate::docs::CommandDoc {
199 let mut doc = crate::docs::CommandDoc::handler(self.name, self.url, self.policy.describe());
200 doc.aliases = self.aliases.iter().map(|a| a.to_string()).collect();
201 doc
202 }
203}
204
205#[cfg(test)]
206impl FlatDef {
207 pub fn auto_test_reject_unknown(&self) {
208 if self.policy.flag_style == FlagStyle::Positional {
209 return;
210 }
211 let test = format!("{} --xyzzy-unknown-42", self.name);
212 assert!(
213 !crate::is_safe_command(&test),
214 "{}: accepted unknown flag: {test}",
215 self.name,
216 );
217 for alias in self.aliases {
218 let test = format!("{alias} --xyzzy-unknown-42");
219 assert!(
220 !crate::is_safe_command(&test),
221 "{alias}: alias accepted unknown flag: {test}",
222 );
223 }
224 }
225}
226
227fn sub_opencode_patterns(prefix: &str, sub: &SubDef, out: &mut Vec<String>) {
228 match sub {
229 SubDef::Policy { name, .. } => {
230 out.push(format!("{prefix} {name}"));
231 out.push(format!("{prefix} {name} *"));
232 }
233 SubDef::Nested { name, subs } => {
234 let path = format!("{prefix} {name}");
235 for s in *subs {
236 sub_opencode_patterns(&path, s, out);
237 }
238 }
239 SubDef::Guarded {
240 name, guard_long, ..
241 } => {
242 out.push(format!("{prefix} {name} {guard_long}"));
243 out.push(format!("{prefix} {name} {guard_long} *"));
244 }
245 SubDef::Custom { name, .. } => {
246 out.push(format!("{prefix} {name}"));
247 out.push(format!("{prefix} {name} *"));
248 }
249 SubDef::Delegation { .. } => {}
250 }
251}
252
253fn sub_doc_line(sub: &SubDef, prefix: &str, out: &mut Vec<String>) {
254 match sub {
255 SubDef::Policy { name, policy, .. } => {
256 let summary = policy.flag_summary();
257 let label = if prefix.is_empty() {
258 (*name).to_string()
259 } else {
260 format!("{prefix} {name}")
261 };
262 if summary.is_empty() {
263 out.push(format!("- **{label}**"));
264 } else {
265 out.push(format!("- **{label}**: {summary}"));
266 }
267 }
268 SubDef::Nested { name, subs } => {
269 let path = if prefix.is_empty() {
270 (*name).to_string()
271 } else {
272 format!("{prefix} {name}")
273 };
274 for s in *subs {
275 sub_doc_line(s, &path, out);
276 }
277 }
278 SubDef::Guarded {
279 name,
280 guard_long,
281 policy,
282 ..
283 } => {
284 let summary = policy.flag_summary();
285 let label = if prefix.is_empty() {
286 (*name).to_string()
287 } else {
288 format!("{prefix} {name}")
289 };
290 if summary.is_empty() {
291 out.push(format!("- **{label}** (requires {guard_long})"));
292 } else {
293 out.push(format!("- **{label}** (requires {guard_long}): {summary}"));
294 }
295 }
296 SubDef::Custom { name, doc, .. } => {
297 if !doc.is_empty() && doc.trim().is_empty() {
298 return;
299 }
300 let label = if prefix.is_empty() {
301 (*name).to_string()
302 } else {
303 format!("{prefix} {name}")
304 };
305 if doc.is_empty() {
306 out.push(format!("- **{label}**"));
307 } else {
308 out.push(format!("- **{label}**: {doc}"));
309 }
310 }
311 SubDef::Delegation { name, doc, .. } => {
312 if doc.is_empty() {
313 return;
314 }
315 let label = if prefix.is_empty() {
316 (*name).to_string()
317 } else {
318 format!("{prefix} {name}")
319 };
320 out.push(format!("- **{label}**: {doc}"));
321 }
322 }
323}
324
325#[cfg(test)]
326impl CommandDef {
327 pub fn auto_test_reject_unknown(&self) {
328 let mut failures = Vec::new();
329
330 assert!(
331 !crate::is_safe_command(self.name),
332 "{}: accepted bare invocation",
333 self.name,
334 );
335
336 let test = format!("{} xyzzy-unknown-42", self.name);
337 assert!(
338 !crate::is_safe_command(&test),
339 "{}: accepted unknown subcommand: {test}",
340 self.name,
341 );
342
343 for sub in self.subs {
344 auto_test_sub(self.name, sub, &mut failures);
345 }
346 assert!(
347 failures.is_empty(),
348 "{}: unknown flags/subcommands accepted:\n{}",
349 self.name,
350 failures.join("\n"),
351 );
352 }
353}
354
355#[cfg(test)]
356fn auto_test_sub(prefix: &str, sub: &SubDef, failures: &mut Vec<String>) {
357 const UNKNOWN: &str = "--xyzzy-unknown-42";
358
359 match sub {
360 SubDef::Policy { name, policy, .. } => {
361 if policy.flag_style == FlagStyle::Positional {
362 return;
363 }
364 let test = format!("{prefix} {name} {UNKNOWN}");
365 if crate::is_safe_command(&test) {
366 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
367 }
368 }
369 SubDef::Nested { name, subs } => {
370 let path = format!("{prefix} {name}");
371 let test = format!("{path} xyzzy-unknown-42");
372 if crate::is_safe_command(&test) {
373 failures.push(format!("{path}: accepted unknown subcommand: {test}"));
374 }
375 for s in *subs {
376 auto_test_sub(&path, s, failures);
377 }
378 }
379 SubDef::Guarded {
380 name, guard_long, ..
381 } => {
382 let test = format!("{prefix} {name} {guard_long} {UNKNOWN}");
383 if crate::is_safe_command(&test) {
384 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
385 }
386 }
387 SubDef::Custom {
388 name, test_suffix, ..
389 } => {
390 if let Some(suffix) = test_suffix {
391 let test = format!("{prefix} {name} {suffix} {UNKNOWN}");
392 if crate::is_safe_command(&test) {
393 failures.push(format!(
394 "{prefix} {name}: accepted unknown flag: {test}"
395 ));
396 }
397 }
398 }
399 SubDef::Delegation { .. } => {}
400 }
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406 use crate::parse::WordSet;
407 use crate::policy::FlagStyle;
408
409 fn toks(words: &[&str]) -> Vec<Token> {
410 words.iter().map(|s| Token::from_test(s)).collect()
411 }
412
413
414 static TEST_POLICY: FlagPolicy = FlagPolicy {
415 standalone: WordSet::new(&["--help", "--verbose", "-h", "-v"]),
416 valued: WordSet::new(&["--output", "-o"]),
417 bare: true,
418 max_positional: None,
419 flag_style: FlagStyle::Strict,
420 };
421
422 static SIMPLE_CMD: CommandDef = CommandDef {
423 name: "mycmd",
424 subs: &[SubDef::Policy {
425 name: "build",
426 policy: &TEST_POLICY,
427 level: SafetyLevel::SafeWrite,
428 }],
429 bare_flags: &["--help", "--info", "--version", "-V", "-h"],
430 url: "",
431 aliases: &[],
432 };
433
434 #[test]
435 fn bare_rejected() {
436 assert_eq!(SIMPLE_CMD.check(&toks(&["mycmd"])), Verdict::Denied);
437 }
438
439 #[test]
440 fn bare_flag_accepted() {
441 assert_eq!(
442 SIMPLE_CMD.check(&toks(&["mycmd", "--info"])),
443 Verdict::Allowed(SafetyLevel::Inert),
444 );
445 }
446
447 #[test]
448 fn bare_flag_with_extra_rejected() {
449 assert_eq!(
450 SIMPLE_CMD.check(&toks(&["mycmd", "--info", "extra"])),
451 Verdict::Denied,
452 );
453 }
454
455 #[test]
456 fn policy_sub_bare() {
457 assert_eq!(
458 SIMPLE_CMD.check(&toks(&["mycmd", "build"])),
459 Verdict::Allowed(SafetyLevel::SafeWrite),
460 );
461 }
462
463 #[test]
464 fn policy_sub_with_flag() {
465 assert_eq!(
466 SIMPLE_CMD.check(&toks(&["mycmd", "build", "--verbose"])),
467 Verdict::Allowed(SafetyLevel::SafeWrite),
468 );
469 }
470
471 #[test]
472 fn policy_sub_unknown_flag() {
473 assert_eq!(
474 SIMPLE_CMD.check(&toks(&["mycmd", "build", "--bad"])),
475 Verdict::Denied,
476 );
477 }
478
479 #[test]
480 fn unknown_sub_rejected() {
481 assert_eq!(
482 SIMPLE_CMD.check(&toks(&["mycmd", "deploy"])),
483 Verdict::Denied,
484 );
485 }
486
487 #[test]
488 fn dispatch_matches() {
489 assert_eq!(
490 SIMPLE_CMD.dispatch("mycmd", &toks(&["mycmd", "build"])),
491 Some(Verdict::Allowed(SafetyLevel::SafeWrite)),
492 );
493 }
494
495 #[test]
496 fn dispatch_no_match() {
497 assert_eq!(
498 SIMPLE_CMD.dispatch("other", &toks(&["other", "build"])),
499 None
500 );
501 }
502
503 static NESTED_CMD: CommandDef = CommandDef {
504 name: "nested",
505 subs: &[SubDef::Nested {
506 name: "package",
507 subs: &[SubDef::Policy {
508 name: "describe",
509 policy: &TEST_POLICY,
510 level: SafetyLevel::Inert,
511 }],
512 }],
513 bare_flags: &[],
514 url: "",
515 aliases: &[],
516 };
517
518 #[test]
519 fn nested_sub() {
520 assert!(NESTED_CMD.check(&toks(&["nested", "package", "describe"])).is_allowed());
521 }
522
523 #[test]
524 fn nested_sub_with_flag() {
525 assert!(NESTED_CMD.check(
526 &toks(&["nested", "package", "describe", "--verbose"]),
527 ).is_allowed());
528 }
529
530 #[test]
531 fn nested_bare_rejected() {
532 assert_eq!(
533 NESTED_CMD.check(&toks(&["nested", "package"])),
534 Verdict::Denied,
535 );
536 }
537
538 #[test]
539 fn nested_unknown_sub_rejected() {
540 assert_eq!(
541 NESTED_CMD.check(&toks(&["nested", "package", "deploy"])),
542 Verdict::Denied,
543 );
544 }
545
546 static GUARDED_POLICY: FlagPolicy = FlagPolicy {
547 standalone: WordSet::new(&["--all", "--check", "--help", "-h"]),
548 valued: WordSet::new(&[]),
549 bare: false,
550 max_positional: None,
551 flag_style: FlagStyle::Strict,
552 };
553
554 static GUARDED_CMD: CommandDef = CommandDef {
555 name: "guarded",
556 subs: &[SubDef::Guarded {
557 name: "fmt",
558 guard_short: None,
559 guard_long: "--check",
560 policy: &GUARDED_POLICY,
561 level: SafetyLevel::Inert,
562 }],
563 bare_flags: &[],
564 url: "",
565 aliases: &[],
566 };
567
568 #[test]
569 fn guarded_with_guard() {
570 assert!(GUARDED_CMD.check(&toks(&["guarded", "fmt", "--check"])).is_allowed());
571 }
572
573 #[test]
574 fn guarded_without_guard() {
575 assert_eq!(
576 GUARDED_CMD.check(&toks(&["guarded", "fmt"])),
577 Verdict::Denied,
578 );
579 }
580
581 #[test]
582 fn guarded_with_guard_and_flag() {
583 assert!(GUARDED_CMD.check(
584 &toks(&["guarded", "fmt", "--check", "--all"]),
585 ).is_allowed());
586 }
587
588 static DELEGATION_CMD: CommandDef = CommandDef {
589 name: "runner",
590 subs: &[SubDef::Delegation {
591 name: "run",
592 skip: 2,
593 doc: "run delegates to inner command.",
594 }],
595 bare_flags: &[],
596 url: "",
597 aliases: &[],
598 };
599
600 #[test]
601 fn delegation_safe_inner() {
602 assert!(DELEGATION_CMD.check(
603 &toks(&["runner", "run", "stable", "echo", "hello"]),
604 ).is_allowed());
605 }
606
607 #[test]
608 fn delegation_unsafe_inner() {
609 assert_eq!(
610 DELEGATION_CMD.check(&toks(&["runner", "run", "stable", "rm", "-rf"])),
611 Verdict::Denied,
612 );
613 }
614
615 #[test]
616 fn delegation_no_inner() {
617 assert_eq!(
618 DELEGATION_CMD.check(&toks(&["runner", "run", "stable"])),
619 Verdict::Denied,
620 );
621 }
622
623 fn custom_check(tokens: &[Token]) -> Verdict {
624 if tokens.len() >= 2 && tokens[1] == "safe" {
625 Verdict::Allowed(SafetyLevel::Inert)
626 } else {
627 Verdict::Denied
628 }
629 }
630
631 static CUSTOM_CMD: CommandDef = CommandDef {
632 name: "custom",
633 subs: &[SubDef::Custom {
634 name: "special",
635 check: custom_check,
636 doc: "special (safe only).",
637 test_suffix: Some("safe"),
638 }],
639 bare_flags: &[],
640 url: "",
641 aliases: &[],
642 };
643
644 #[test]
645 fn custom_passes() {
646 assert!(CUSTOM_CMD.check(&toks(&["custom", "special", "safe"])).is_allowed());
647 }
648
649 #[test]
650 fn custom_fails() {
651 assert_eq!(
652 CUSTOM_CMD.check(&toks(&["custom", "special", "bad"])),
653 Verdict::Denied,
654 );
655 }
656
657 #[test]
658 fn help_on_sub_uses_sub_level() {
659 assert_eq!(
660 SIMPLE_CMD.check(&toks(&["mycmd", "build", "--help"])),
661 Verdict::Allowed(SafetyLevel::SafeWrite),
662 );
663 }
664
665 #[test]
666 fn help_on_command_uses_bare_flags() {
667 assert_eq!(
668 SIMPLE_CMD.check(&toks(&["mycmd", "--help"])),
669 Verdict::Allowed(SafetyLevel::Inert),
670 );
671 }
672
673 #[test]
674 fn doc_simple() {
675 let doc = SIMPLE_CMD.to_doc();
676 assert_eq!(doc.name, "mycmd");
677 assert_eq!(
678 doc.description,
679 "- Allowed standalone flags: --help, --info, --version, -V, -h\n- **build**: Flags: --help, --verbose, -h, -v. Valued: --output, -o"
680 );
681 }
682
683 #[test]
684 fn doc_nested() {
685 let doc = NESTED_CMD.to_doc();
686 assert_eq!(
687 doc.description,
688 "- **package describe**: Flags: --help, --verbose, -h, -v. Valued: --output, -o"
689 );
690 }
691
692 #[test]
693 fn doc_guarded() {
694 let doc = GUARDED_CMD.to_doc();
695 assert_eq!(
696 doc.description,
697 "- **fmt** (requires --check): Flags: --all, --check, --help, -h"
698 );
699 }
700
701 #[test]
702 fn doc_delegation() {
703 let doc = DELEGATION_CMD.to_doc();
704 assert_eq!(doc.description, "- **run**: run delegates to inner command.");
705 }
706
707 #[test]
708 fn doc_custom() {
709 let doc = CUSTOM_CMD.to_doc();
710 assert_eq!(doc.description, "- **special**: special (safe only).");
711 }
712
713 #[test]
714 fn opencode_patterns_simple() {
715 let patterns = SIMPLE_CMD.opencode_patterns();
716 assert!(patterns.contains(&"mycmd build".to_string()));
717 assert!(patterns.contains(&"mycmd build *".to_string()));
718 }
719
720 #[test]
721 fn opencode_patterns_nested() {
722 let patterns = NESTED_CMD.opencode_patterns();
723 assert!(patterns.contains(&"nested package describe".to_string()));
724 assert!(patterns.contains(&"nested package describe *".to_string()));
725 assert!(!patterns.iter().any(|p| p == "nested package"));
726 }
727
728 #[test]
729 fn opencode_patterns_guarded() {
730 let patterns = GUARDED_CMD.opencode_patterns();
731 assert!(patterns.contains(&"guarded fmt --check".to_string()));
732 assert!(patterns.contains(&"guarded fmt --check *".to_string()));
733 assert!(!patterns.iter().any(|p| p == "guarded fmt"));
734 }
735
736 #[test]
737 fn opencode_patterns_delegation_skipped() {
738 let patterns = DELEGATION_CMD.opencode_patterns();
739 assert!(patterns.is_empty());
740 }
741
742 #[test]
743 fn opencode_patterns_custom() {
744 let patterns = CUSTOM_CMD.opencode_patterns();
745 assert!(patterns.contains(&"custom special".to_string()));
746 assert!(patterns.contains(&"custom special *".to_string()));
747 }
748
749 #[test]
750 fn opencode_patterns_aliases() {
751 static ALIASED: CommandDef = CommandDef {
752 name: "primary",
753 subs: &[SubDef::Policy {
754 name: "list",
755 policy: &TEST_POLICY,
756 level: SafetyLevel::Inert,
757 }],
758 bare_flags: &[],
759 url: "",
760 aliases: &["alt"],
761 };
762 let patterns = ALIASED.opencode_patterns();
763 assert!(patterns.contains(&"primary list".to_string()));
764 assert!(patterns.contains(&"alt list".to_string()));
765 assert!(patterns.contains(&"alt list *".to_string()));
766 }
767
768 #[test]
769 fn flat_def_opencode_patterns() {
770 static FLAT: FlatDef = FlatDef {
771 name: "grep",
772 policy: &TEST_POLICY,
773 level: SafetyLevel::Inert,
774 url: "",
775 aliases: &["rg"],
776 };
777 let patterns = FLAT.opencode_patterns();
778 assert_eq!(patterns, vec!["grep", "grep *", "rg", "rg *"]);
779 }
780}