1use std::collections::HashMap;
2
3use serde::Deserialize;
4
5use crate::parse::Token;
6use crate::policy::FlagStyle;
7use crate::verdict::{SafetyLevel, Verdict};
8
9#[derive(Debug, Deserialize)]
10struct TomlFile {
11 command: Vec<TomlCommand>,
12}
13
14#[derive(Debug, Deserialize)]
15struct TomlCommand {
16 name: String,
17 #[serde(default)]
18 aliases: Vec<String>,
19 #[serde(default)]
20 url: String,
21 #[serde(default)]
22 level: Option<TomlLevel>,
23 #[serde(default)]
24 bare: Option<bool>,
25 #[serde(default)]
26 max_positional: Option<usize>,
27 #[serde(default)]
28 positional_style: Option<bool>,
29 #[serde(default)]
30 standalone: Vec<String>,
31 #[serde(default)]
32 valued: Vec<String>,
33 #[serde(default)]
34 bare_flags: Vec<String>,
35 #[serde(default)]
36 sub: Vec<TomlSub>,
37 #[serde(default)]
38 handler: Option<String>,
39 #[allow(dead_code)]
40 #[serde(default)]
41 doc: Option<String>,
42}
43
44#[derive(Debug, Deserialize)]
45struct TomlSub {
46 name: String,
47 #[serde(default)]
48 level: Option<TomlLevel>,
49 #[serde(default)]
50 bare: Option<bool>,
51 #[serde(default)]
52 max_positional: Option<usize>,
53 #[serde(default)]
54 positional_style: Option<bool>,
55 #[serde(default)]
56 standalone: Vec<String>,
57 #[serde(default)]
58 valued: Vec<String>,
59 #[serde(default)]
60 guard: Option<String>,
61 #[serde(default)]
62 guard_short: Option<String>,
63 #[serde(default)]
64 allow_all: Option<bool>,
65 #[serde(default)]
66 sub: Vec<TomlSub>,
67 #[serde(default)]
68 write_flags: Vec<String>,
69 #[serde(default)]
70 delegate_after: Option<String>,
71 #[serde(default)]
72 delegate_skip: Option<usize>,
73 #[serde(default)]
74 handler: Option<String>,
75 #[serde(default)]
76 doc: Option<String>,
77}
78
79#[derive(Debug, Clone, Copy, Deserialize)]
80enum TomlLevel {
81 Inert,
82 SafeRead,
83 SafeWrite,
84}
85
86impl From<TomlLevel> for SafetyLevel {
87 fn from(l: TomlLevel) -> Self {
88 match l {
89 TomlLevel::Inert => SafetyLevel::Inert,
90 TomlLevel::SafeRead => SafetyLevel::SafeRead,
91 TomlLevel::SafeWrite => SafetyLevel::SafeWrite,
92 }
93 }
94}
95
96#[derive(Debug)]
97pub struct CommandSpec {
98 pub name: String,
99 pub aliases: Vec<String>,
100 pub url: String,
101 kind: CommandKind,
102}
103
104#[derive(Debug)]
105enum CommandKind {
106 Flat {
107 policy: OwnedPolicy,
108 level: SafetyLevel,
109 },
110 Structured {
111 bare_flags: Vec<String>,
112 subs: Vec<SubSpec>,
113 },
114 Custom {
115 #[allow(dead_code)]
116 handler_name: String,
117 },
118}
119
120#[derive(Debug)]
121struct SubSpec {
122 name: String,
123 kind: SubKind,
124}
125
126#[derive(Debug)]
127enum SubKind {
128 Policy {
129 policy: OwnedPolicy,
130 level: SafetyLevel,
131 },
132 Guarded {
133 guard_long: String,
134 guard_short: Option<String>,
135 policy: OwnedPolicy,
136 level: SafetyLevel,
137 },
138 Nested {
139 subs: Vec<SubSpec>,
140 },
141 AllowAll {
142 level: SafetyLevel,
143 },
144 WriteFlagged {
145 policy: OwnedPolicy,
146 base_level: SafetyLevel,
147 write_flags: Vec<String>,
148 },
149 DelegateAfterSeparator {
150 separator: String,
151 },
152 DelegateSkip {
153 skip: usize,
154 #[allow(dead_code)]
155 doc: String,
156 },
157 Custom {
158 #[allow(dead_code)]
159 handler_name: String,
160 },
161}
162
163#[derive(Debug)]
164pub struct OwnedPolicy {
165 pub standalone: Vec<String>,
166 pub valued: Vec<String>,
167 pub bare: bool,
168 pub max_positional: Option<usize>,
169 pub flag_style: FlagStyle,
170}
171
172fn check_owned(tokens: &[Token], policy: &OwnedPolicy) -> bool {
173 if tokens.len() == 1 {
174 return policy.bare;
175 }
176
177 let mut i = 1;
178 let mut positionals: usize = 0;
179 while i < tokens.len() {
180 let t = &tokens[i];
181
182 if *t == "--" {
183 positionals += tokens.len() - i - 1;
184 break;
185 }
186
187 if !t.starts_with('-') {
188 positionals += 1;
189 i += 1;
190 continue;
191 }
192
193 if policy.standalone.iter().any(|f| t == f.as_str()) {
194 i += 1;
195 continue;
196 }
197
198 if policy.valued.iter().any(|f| t == f.as_str()) {
199 i += 2;
200 continue;
201 }
202
203 if let Some(flag) = t.as_str().split_once('=').map(|(f, _)| f) {
204 if policy.valued.iter().any(|f| f.as_str() == flag) {
205 i += 1;
206 continue;
207 }
208 if policy.flag_style == FlagStyle::Positional {
209 positionals += 1;
210 i += 1;
211 continue;
212 }
213 return false;
214 }
215
216 if t.starts_with("--") {
217 if policy.flag_style == FlagStyle::Positional {
218 positionals += 1;
219 i += 1;
220 continue;
221 }
222 return false;
223 }
224
225 let bytes = t.as_bytes();
226 let mut j = 1;
227 while j < bytes.len() {
228 let b = bytes[j];
229 let is_last = j == bytes.len() - 1;
230 if policy.standalone.iter().any(|f| f.len() == 2 && f.as_bytes()[1] == b) {
231 j += 1;
232 continue;
233 }
234 if policy.valued.iter().any(|f| f.len() == 2 && f.as_bytes()[1] == b) {
235 if is_last {
236 i += 1;
237 }
238 break;
239 }
240 if policy.flag_style == FlagStyle::Positional {
241 positionals += 1;
242 break;
243 }
244 return false;
245 }
246 i += 1;
247 }
248 policy.max_positional.is_none_or(|max| positionals <= max)
249}
250
251fn build_policy(
252 standalone: Vec<String>,
253 valued: Vec<String>,
254 bare: Option<bool>,
255 max_positional: Option<usize>,
256 positional_style: Option<bool>,
257) -> OwnedPolicy {
258 OwnedPolicy {
259 standalone,
260 valued,
261 bare: bare.unwrap_or(true),
262 max_positional,
263 flag_style: if positional_style.unwrap_or(false) {
264 FlagStyle::Positional
265 } else {
266 FlagStyle::Strict
267 },
268 }
269}
270
271fn build_sub(toml: TomlSub) -> SubSpec {
272 if let Some(handler_name) = toml.handler {
273 return SubSpec {
274 name: toml.name,
275 kind: SubKind::Custom { handler_name },
276 };
277 }
278
279 if toml.allow_all.unwrap_or(false) {
280 return SubSpec {
281 name: toml.name,
282 kind: SubKind::AllowAll {
283 level: toml.level.unwrap_or(TomlLevel::Inert).into(),
284 },
285 };
286 }
287
288 if let Some(sep) = toml.delegate_after {
289 return SubSpec {
290 name: toml.name,
291 kind: SubKind::DelegateAfterSeparator { separator: sep },
292 };
293 }
294
295 if let Some(skip) = toml.delegate_skip {
296 return SubSpec {
297 name: toml.name,
298 kind: SubKind::DelegateSkip {
299 skip,
300 doc: toml.doc.unwrap_or_default(),
301 },
302 };
303 }
304
305 if !toml.sub.is_empty() {
306 return SubSpec {
307 name: toml.name,
308 kind: SubKind::Nested {
309 subs: toml.sub.into_iter().map(build_sub).collect(),
310 },
311 };
312 }
313
314 let policy = build_policy(
315 toml.standalone,
316 toml.valued,
317 toml.bare,
318 toml.max_positional,
319 toml.positional_style,
320 );
321 let level: SafetyLevel = toml.level.unwrap_or(TomlLevel::Inert).into();
322
323 if !toml.write_flags.is_empty() {
324 return SubSpec {
325 name: toml.name,
326 kind: SubKind::WriteFlagged {
327 policy,
328 base_level: level,
329 write_flags: toml.write_flags,
330 },
331 };
332 }
333
334 if let Some(guard) = toml.guard {
335 return SubSpec {
336 name: toml.name,
337 kind: SubKind::Guarded {
338 guard_long: guard,
339 guard_short: toml.guard_short,
340 policy,
341 level,
342 },
343 };
344 }
345
346 SubSpec {
347 name: toml.name,
348 kind: SubKind::Policy { policy, level },
349 }
350}
351
352fn build_command(toml: TomlCommand) -> CommandSpec {
353 if let Some(handler_name) = toml.handler {
354 return CommandSpec {
355 name: toml.name,
356 aliases: toml.aliases,
357 url: toml.url,
358 kind: CommandKind::Custom { handler_name },
359 };
360 }
361
362 if !toml.sub.is_empty() || !toml.bare_flags.is_empty() {
363 return CommandSpec {
364 name: toml.name,
365 aliases: toml.aliases,
366 url: toml.url,
367 kind: CommandKind::Structured {
368 bare_flags: toml.bare_flags,
369 subs: toml.sub.into_iter().map(build_sub).collect(),
370 },
371 };
372 }
373
374 let policy = build_policy(
375 toml.standalone,
376 toml.valued,
377 toml.bare,
378 toml.max_positional,
379 toml.positional_style,
380 );
381
382 CommandSpec {
383 name: toml.name,
384 aliases: toml.aliases,
385 url: toml.url,
386 kind: CommandKind::Flat {
387 policy,
388 level: toml.level.unwrap_or(TomlLevel::Inert).into(),
389 },
390 }
391}
392
393pub fn load_toml(source: &str) -> Vec<CommandSpec> {
394 let file: TomlFile = toml::from_str(source).expect("invalid TOML command definition");
395 file.command.into_iter().map(build_command).collect()
396}
397
398pub fn build_registry(specs: Vec<CommandSpec>) -> HashMap<String, CommandSpec> {
399 let mut map = HashMap::new();
400 for spec in specs {
401 for alias in &spec.aliases {
402 map.insert(alias.clone(), CommandSpec {
403 name: spec.name.clone(),
404 aliases: vec![],
405 url: spec.url.clone(),
406 kind: match &spec.kind {
407 CommandKind::Flat { policy, level } => CommandKind::Flat {
408 policy: OwnedPolicy {
409 standalone: policy.standalone.clone(),
410 valued: policy.valued.clone(),
411 bare: policy.bare,
412 max_positional: policy.max_positional,
413 flag_style: policy.flag_style,
414 },
415 level: *level,
416 },
417 _ => continue,
418 },
419 });
420 }
421 map.insert(spec.name.clone(), spec);
422 }
423 map
424}
425
426fn has_flag_owned(tokens: &[Token], short: Option<&str>, long: &str) -> bool {
427 tokens[1..].iter().any(|t| {
428 t == long
429 || short.is_some_and(|s| t == s)
430 || t.as_str().starts_with(&format!("{long}="))
431 })
432}
433
434fn dispatch_sub(tokens: &[Token], sub: &SubSpec) -> Verdict {
435 match &sub.kind {
436 SubKind::Policy { policy, level } => {
437 if check_owned(tokens, policy) {
438 Verdict::Allowed(*level)
439 } else {
440 Verdict::Denied
441 }
442 }
443 SubKind::Guarded {
444 guard_long,
445 guard_short,
446 policy,
447 level,
448 } => {
449 if has_flag_owned(tokens, guard_short.as_deref(), guard_long)
450 && check_owned(tokens, policy)
451 {
452 Verdict::Allowed(*level)
453 } else {
454 Verdict::Denied
455 }
456 }
457 SubKind::Nested { subs } => {
458 if tokens.len() < 2 {
459 return Verdict::Denied;
460 }
461 let name = tokens[1].as_str();
462 subs.iter()
463 .find(|s| s.name == name)
464 .map(|s| dispatch_sub(&tokens[1..], s))
465 .unwrap_or(Verdict::Denied)
466 }
467 SubKind::AllowAll { level } => Verdict::Allowed(*level),
468 SubKind::WriteFlagged {
469 policy,
470 base_level,
471 write_flags,
472 } => {
473 if !check_owned(tokens, policy) {
474 return Verdict::Denied;
475 }
476 let has_write = tokens[1..].iter().any(|t| {
477 write_flags.iter().any(|f| t == f.as_str() || t.as_str().starts_with(&format!("{f}=")))
478 });
479 if has_write {
480 Verdict::Allowed(SafetyLevel::SafeWrite)
481 } else {
482 Verdict::Allowed(*base_level)
483 }
484 }
485 SubKind::DelegateAfterSeparator { separator } => {
486 let sep_pos = tokens[1..].iter().position(|t| t == separator.as_str());
487 let Some(pos) = sep_pos else {
488 return Verdict::Denied;
489 };
490 let inner_start = pos + 2;
491 if inner_start >= tokens.len() {
492 return Verdict::Denied;
493 }
494 let inner = shell_words::join(tokens[inner_start..].iter().map(|t| t.as_str()));
495 crate::command_verdict(&inner)
496 }
497 SubKind::DelegateSkip { skip, .. } => {
498 if tokens.len() <= *skip {
499 return Verdict::Denied;
500 }
501 let inner = shell_words::join(tokens[*skip..].iter().map(|t| t.as_str()));
502 crate::command_verdict(&inner)
503 }
504 SubKind::Custom { .. } => Verdict::Denied,
505 }
506}
507
508pub fn dispatch_spec(tokens: &[Token], spec: &CommandSpec) -> Verdict {
509 match &spec.kind {
510 CommandKind::Flat { policy, level } => {
511 if check_owned(tokens, policy) {
512 Verdict::Allowed(*level)
513 } else {
514 Verdict::Denied
515 }
516 }
517 CommandKind::Structured { bare_flags, subs } => {
518 if tokens.len() < 2 {
519 return Verdict::Denied;
520 }
521 let arg = tokens[1].as_str();
522 if tokens.len() == 2 && bare_flags.iter().any(|f| f == arg) {
523 return Verdict::Allowed(SafetyLevel::Inert);
524 }
525 subs.iter()
526 .find(|s| s.name == arg)
527 .map(|s| dispatch_sub(&tokens[1..], s))
528 .unwrap_or(Verdict::Denied)
529 }
530 CommandKind::Custom { .. } => Verdict::Denied,
531 }
532}
533
534use std::sync::LazyLock;
535
536static TOML_REGISTRY: LazyLock<HashMap<String, CommandSpec>> = LazyLock::new(|| {
537 let mut all = Vec::new();
538 all.extend(load_toml(include_str!("../commands/hash.toml")));
539 build_registry(all)
540});
541
542pub fn toml_dispatch(tokens: &[Token]) -> Option<Verdict> {
543 let cmd = tokens[0].command_name();
544 TOML_REGISTRY.get(cmd).map(|spec| dispatch_spec(tokens, spec))
545}
546
547pub fn toml_command_names() -> Vec<&'static str> {
548 TOML_REGISTRY
549 .keys()
550 .map(|k| k.as_str())
551 .collect()
552}
553
554pub fn toml_command_docs() -> Vec<crate::docs::CommandDoc> {
555 TOML_REGISTRY
556 .values()
557 .map(|spec| spec.to_command_doc())
558 .collect()
559}
560
561impl CommandSpec {
562 fn to_command_doc(&self) -> crate::docs::CommandDoc {
563 let description = match &self.kind {
564 CommandKind::Flat { policy, .. } => policy.describe(),
565 CommandKind::Structured { bare_flags, subs } => {
566 let mut lines = Vec::new();
567 if !bare_flags.is_empty() {
568 lines.push(format!("- Allowed standalone flags: {}", bare_flags.join(", ")));
569 }
570 for sub in subs {
571 sub.doc_line("", &mut lines);
572 }
573 lines.sort();
574 lines.join("\n")
575 }
576 CommandKind::Custom { .. } => String::new(),
577 };
578 let mut doc = crate::docs::CommandDoc::handler(
579 Box::leak(self.name.clone().into_boxed_str()),
580 Box::leak(self.url.clone().into_boxed_str()),
581 description,
582 );
583 doc.aliases = self.aliases.iter().map(|a| a.to_string()).collect();
584 doc
585 }
586}
587
588impl OwnedPolicy {
589 fn describe(&self) -> String {
590 let mut lines = Vec::new();
591 if !self.standalone.is_empty() {
592 lines.push(format!("- Allowed standalone flags: {}", self.standalone.join(", ")));
593 }
594 if !self.valued.is_empty() {
595 lines.push(format!("- Allowed valued flags: {}", self.valued.join(", ")));
596 }
597 if self.bare {
598 lines.push("- Bare invocation allowed".to_string());
599 }
600 if self.flag_style == FlagStyle::Positional {
601 lines.push("- Hyphen-prefixed positional arguments accepted".to_string());
602 }
603 if lines.is_empty() && !self.bare {
604 return "- Positional arguments only".to_string();
605 }
606 lines.join("\n")
607 }
608
609 fn flag_summary(&self) -> String {
610 let mut parts = Vec::new();
611 if !self.standalone.is_empty() {
612 parts.push(format!("Flags: {}", self.standalone.join(", ")));
613 }
614 if !self.valued.is_empty() {
615 parts.push(format!("Valued: {}", self.valued.join(", ")));
616 }
617 if self.flag_style == FlagStyle::Positional {
618 parts.push("Positional args accepted".to_string());
619 }
620 parts.join(". ")
621 }
622}
623
624impl SubSpec {
625 fn doc_line(&self, prefix: &str, out: &mut Vec<String>) {
626 let label = if prefix.is_empty() {
627 self.name.clone()
628 } else {
629 format!("{prefix} {}", self.name)
630 };
631 match &self.kind {
632 SubKind::Policy { policy, .. } => {
633 let summary = policy.flag_summary();
634 if summary.is_empty() {
635 out.push(format!("- **{label}**"));
636 } else {
637 out.push(format!("- **{label}**: {summary}"));
638 }
639 }
640 SubKind::Guarded { guard_long, policy, .. } => {
641 let summary = policy.flag_summary();
642 if summary.is_empty() {
643 out.push(format!("- **{label}** (requires {guard_long})"));
644 } else {
645 out.push(format!("- **{label}** (requires {guard_long}): {summary}"));
646 }
647 }
648 SubKind::Nested { subs } => {
649 for sub in subs {
650 sub.doc_line(&label, out);
651 }
652 }
653 SubKind::AllowAll { .. } => {
654 out.push(format!("- **{label}**"));
655 }
656 SubKind::WriteFlagged { policy, .. } => {
657 let summary = policy.flag_summary();
658 if summary.is_empty() {
659 out.push(format!("- **{label}**"));
660 } else {
661 out.push(format!("- **{label}**: {summary}"));
662 }
663 }
664 SubKind::DelegateAfterSeparator { .. } | SubKind::DelegateSkip { .. } => {}
665 SubKind::Custom { .. } => {}
666 }
667 }
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673 use crate::parse::Token;
674
675 fn toks(words: &[&str]) -> Vec<Token> {
676 words.iter().map(|s| Token::from_test(s)).collect()
677 }
678
679 fn load_one(toml_str: &str) -> CommandSpec {
680 let mut specs = load_toml(toml_str);
681 assert_eq!(specs.len(), 1);
682 specs.remove(0)
683 }
684
685 #[test]
690 fn flat_bare_allowed() {
691 let spec = load_one(r#"
692 [[command]]
693 name = "wc"
694 bare = true
695 "#);
696 assert_eq!(dispatch_spec(&toks(&["wc"]), &spec), Verdict::Allowed(SafetyLevel::Inert));
697 }
698
699 #[test]
700 fn flat_bare_denied_when_false() {
701 let spec = load_one(r#"
702 [[command]]
703 name = "grep"
704 bare = false
705 "#);
706 assert_eq!(dispatch_spec(&toks(&["grep"]), &spec), Verdict::Denied);
707 }
708
709 #[test]
710 fn flat_standalone_flag() {
711 let spec = load_one(r#"
712 [[command]]
713 name = "wc"
714 bare = true
715 standalone = ["-l", "--lines"]
716 "#);
717 assert_eq!(
718 dispatch_spec(&toks(&["wc", "-l", "file.txt"]), &spec),
719 Verdict::Allowed(SafetyLevel::Inert),
720 );
721 }
722
723 #[test]
724 fn flat_unknown_flag_rejected() {
725 let spec = load_one(r#"
726 [[command]]
727 name = "wc"
728 standalone = ["-l"]
729 "#);
730 assert_eq!(dispatch_spec(&toks(&["wc", "--evil"]), &spec), Verdict::Denied);
731 }
732
733 #[test]
734 fn flat_valued_flag_space() {
735 let spec = load_one(r#"
736 [[command]]
737 name = "grep"
738 bare = false
739 valued = ["--max-count", "-m"]
740 "#);
741 assert_eq!(
742 dispatch_spec(&toks(&["grep", "--max-count", "5", "pattern"]), &spec),
743 Verdict::Allowed(SafetyLevel::Inert),
744 );
745 }
746
747 #[test]
748 fn flat_valued_flag_eq() {
749 let spec = load_one(r#"
750 [[command]]
751 name = "grep"
752 bare = false
753 valued = ["--max-count"]
754 "#);
755 assert_eq!(
756 dispatch_spec(&toks(&["grep", "--max-count=5", "pattern"]), &spec),
757 Verdict::Allowed(SafetyLevel::Inert),
758 );
759 }
760
761 #[test]
762 fn flat_combined_short_flags() {
763 let spec = load_one(r#"
764 [[command]]
765 name = "grep"
766 bare = false
767 standalone = ["-r", "-n", "-i"]
768 "#);
769 assert_eq!(
770 dispatch_spec(&toks(&["grep", "-rni", "pattern", "."]), &spec),
771 Verdict::Allowed(SafetyLevel::Inert),
772 );
773 }
774
775 #[test]
776 fn flat_combined_short_unknown_rejected() {
777 let spec = load_one(r#"
778 [[command]]
779 name = "grep"
780 bare = false
781 standalone = ["-r", "-n"]
782 "#);
783 assert_eq!(
784 dispatch_spec(&toks(&["grep", "-rnz", "pattern"]), &spec),
785 Verdict::Denied,
786 );
787 }
788
789 #[test]
790 fn flat_combined_short_with_valued_last() {
791 let spec = load_one(r#"
792 [[command]]
793 name = "grep"
794 bare = false
795 standalone = ["-r", "-n"]
796 valued = ["-m"]
797 "#);
798 assert_eq!(
799 dispatch_spec(&toks(&["grep", "-rnm", "5", "pattern"]), &spec),
800 Verdict::Allowed(SafetyLevel::Inert),
801 );
802 }
803
804 #[test]
805 fn flat_double_dash_stops_flag_checking() {
806 let spec = load_one(r#"
807 [[command]]
808 name = "grep"
809 bare = false
810 standalone = ["-r"]
811 "#);
812 assert_eq!(
813 dispatch_spec(&toks(&["grep", "-r", "--", "--not-a-flag", "file"]), &spec),
814 Verdict::Allowed(SafetyLevel::Inert),
815 );
816 }
817
818 #[test]
819 fn flat_max_positional_enforced() {
820 let spec = load_one(r#"
821 [[command]]
822 name = "uniq"
823 bare = true
824 max_positional = 1
825 "#);
826 assert_eq!(
827 dispatch_spec(&toks(&["uniq", "a"]), &spec),
828 Verdict::Allowed(SafetyLevel::Inert),
829 );
830 assert_eq!(
831 dispatch_spec(&toks(&["uniq", "a", "b"]), &spec),
832 Verdict::Denied,
833 );
834 }
835
836 #[test]
837 fn flat_max_positional_after_double_dash() {
838 let spec = load_one(r#"
839 [[command]]
840 name = "uniq"
841 bare = true
842 max_positional = 1
843 "#);
844 assert_eq!(
845 dispatch_spec(&toks(&["uniq", "--", "a", "b"]), &spec),
846 Verdict::Denied,
847 );
848 }
849
850 #[test]
851 fn flat_positional_style() {
852 let spec = load_one(r#"
853 [[command]]
854 name = "echo"
855 bare = true
856 positional_style = true
857 standalone = ["-n", "-e"]
858 "#);
859 assert_eq!(
860 dispatch_spec(&toks(&["echo", "--unknown", "hello"]), &spec),
861 Verdict::Allowed(SafetyLevel::Inert),
862 );
863 }
864
865 #[test]
866 fn flat_level_safe_read() {
867 let spec = load_one(r#"
868 [[command]]
869 name = "cargo"
870 level = "SafeRead"
871 bare = true
872 "#);
873 assert_eq!(
874 dispatch_spec(&toks(&["cargo"]), &spec),
875 Verdict::Allowed(SafetyLevel::SafeRead),
876 );
877 }
878
879 #[test]
880 fn flat_level_safe_write() {
881 let spec = load_one(r#"
882 [[command]]
883 name = "cargo"
884 level = "SafeWrite"
885 bare = true
886 "#);
887 assert_eq!(
888 dispatch_spec(&toks(&["cargo"]), &spec),
889 Verdict::Allowed(SafetyLevel::SafeWrite),
890 );
891 }
892
893 #[test]
898 fn structured_bare_rejected() {
899 let spec = load_one(r#"
900 [[command]]
901 name = "cargo"
902 bare_flags = ["--help"]
903
904 [[command.sub]]
905 name = "build"
906 level = "SafeWrite"
907 "#);
908 assert_eq!(dispatch_spec(&toks(&["cargo"]), &spec), Verdict::Denied);
909 }
910
911 #[test]
912 fn structured_bare_flag() {
913 let spec = load_one(r#"
914 [[command]]
915 name = "cargo"
916 bare_flags = ["--help", "-h"]
917
918 [[command.sub]]
919 name = "build"
920 "#);
921 assert_eq!(
922 dispatch_spec(&toks(&["cargo", "--help"]), &spec),
923 Verdict::Allowed(SafetyLevel::Inert),
924 );
925 }
926
927 #[test]
928 fn structured_bare_flag_with_extra_rejected() {
929 let spec = load_one(r#"
930 [[command]]
931 name = "cargo"
932 bare_flags = ["--help"]
933
934 [[command.sub]]
935 name = "build"
936 "#);
937 assert_eq!(
938 dispatch_spec(&toks(&["cargo", "--help", "extra"]), &spec),
939 Verdict::Denied,
940 );
941 }
942
943 #[test]
944 fn structured_unknown_sub_rejected() {
945 let spec = load_one(r#"
946 [[command]]
947 name = "cargo"
948
949 [[command.sub]]
950 name = "build"
951 "#);
952 assert_eq!(
953 dispatch_spec(&toks(&["cargo", "deploy"]), &spec),
954 Verdict::Denied,
955 );
956 }
957
958 #[test]
959 fn structured_sub_policy() {
960 let spec = load_one(r#"
961 [[command]]
962 name = "cargo"
963
964 [[command.sub]]
965 name = "test"
966 level = "SafeRead"
967 standalone = ["--release", "-h"]
968 valued = ["--jobs", "-j"]
969 "#);
970 assert_eq!(
971 dispatch_spec(&toks(&["cargo", "test", "--release", "-j", "4"]), &spec),
972 Verdict::Allowed(SafetyLevel::SafeRead),
973 );
974 }
975
976 #[test]
977 fn structured_sub_unknown_flag_rejected() {
978 let spec = load_one(r#"
979 [[command]]
980 name = "cargo"
981
982 [[command.sub]]
983 name = "test"
984 standalone = ["--release"]
985 "#);
986 assert_eq!(
987 dispatch_spec(&toks(&["cargo", "test", "--evil"]), &spec),
988 Verdict::Denied,
989 );
990 }
991
992 #[test]
997 fn guarded_with_guard() {
998 let spec = load_one(r#"
999 [[command]]
1000 name = "cargo"
1001
1002 [[command.sub]]
1003 name = "fmt"
1004 guard = "--check"
1005 standalone = ["--all", "--check", "-h"]
1006 "#);
1007 assert_eq!(
1008 dispatch_spec(&toks(&["cargo", "fmt", "--check"]), &spec),
1009 Verdict::Allowed(SafetyLevel::Inert),
1010 );
1011 }
1012
1013 #[test]
1014 fn guarded_without_guard_rejected() {
1015 let spec = load_one(r#"
1016 [[command]]
1017 name = "cargo"
1018
1019 [[command.sub]]
1020 name = "fmt"
1021 guard = "--check"
1022 standalone = ["--all", "--check"]
1023 "#);
1024 assert_eq!(
1025 dispatch_spec(&toks(&["cargo", "fmt"]), &spec),
1026 Verdict::Denied,
1027 );
1028 }
1029
1030 #[test]
1031 fn guarded_with_short_form() {
1032 let spec = load_one(r#"
1033 [[command]]
1034 name = "cargo"
1035
1036 [[command.sub]]
1037 name = "package"
1038 guard = "--list"
1039 guard_short = "-l"
1040 standalone = ["--list", "-l"]
1041 "#);
1042 assert_eq!(
1043 dispatch_spec(&toks(&["cargo", "package", "-l"]), &spec),
1044 Verdict::Allowed(SafetyLevel::Inert),
1045 );
1046 }
1047
1048 #[test]
1049 fn guarded_with_eq_syntax() {
1050 let spec = load_one(r#"
1051 [[command]]
1052 name = "tool"
1053
1054 [[command.sub]]
1055 name = "sub"
1056 guard = "--mode"
1057 valued = ["--mode"]
1058 "#);
1059 assert_eq!(
1060 dispatch_spec(&toks(&["tool", "sub", "--mode=check"]), &spec),
1061 Verdict::Allowed(SafetyLevel::Inert),
1062 );
1063 }
1064
1065 #[test]
1070 fn nested_sub() {
1071 let spec = load_one(r#"
1072 [[command]]
1073 name = "mise"
1074
1075 [[command.sub]]
1076 name = "config"
1077
1078 [[command.sub.sub]]
1079 name = "get"
1080 standalone = ["--help", "-h"]
1081
1082 [[command.sub.sub]]
1083 name = "list"
1084 standalone = ["--help", "-h"]
1085 "#);
1086 assert_eq!(
1087 dispatch_spec(&toks(&["mise", "config", "get"]), &spec),
1088 Verdict::Allowed(SafetyLevel::Inert),
1089 );
1090 assert_eq!(
1091 dispatch_spec(&toks(&["mise", "config", "delete"]), &spec),
1092 Verdict::Denied,
1093 );
1094 }
1095
1096 #[test]
1097 fn nested_bare_rejected() {
1098 let spec = load_one(r#"
1099 [[command]]
1100 name = "mise"
1101
1102 [[command.sub]]
1103 name = "config"
1104
1105 [[command.sub.sub]]
1106 name = "get"
1107 "#);
1108 assert_eq!(
1109 dispatch_spec(&toks(&["mise", "config"]), &spec),
1110 Verdict::Denied,
1111 );
1112 }
1113
1114 #[test]
1119 fn allow_all_accepts_anything() {
1120 let spec = load_one(r#"
1121 [[command]]
1122 name = "git"
1123
1124 [[command.sub]]
1125 name = "help"
1126 allow_all = true
1127 "#);
1128 assert_eq!(
1129 dispatch_spec(&toks(&["git", "help"]), &spec),
1130 Verdict::Allowed(SafetyLevel::Inert),
1131 );
1132 assert_eq!(
1133 dispatch_spec(&toks(&["git", "help", "commit", "--verbose"]), &spec),
1134 Verdict::Allowed(SafetyLevel::Inert),
1135 );
1136 }
1137
1138 #[test]
1143 fn write_flagged_base_level() {
1144 let spec = load_one(r#"
1145 [[command]]
1146 name = "sk"
1147
1148 [[command.sub]]
1149 name = "run"
1150 write_flags = ["--history"]
1151 standalone = ["--help", "-h"]
1152 valued = ["--history", "--query", "-q"]
1153 "#);
1154 assert_eq!(
1155 dispatch_spec(&toks(&["sk", "run", "-q", "test"]), &spec),
1156 Verdict::Allowed(SafetyLevel::Inert),
1157 );
1158 }
1159
1160 #[test]
1161 fn write_flagged_with_write_flag() {
1162 let spec = load_one(r#"
1163 [[command]]
1164 name = "sk"
1165
1166 [[command.sub]]
1167 name = "run"
1168 write_flags = ["--history"]
1169 standalone = ["--help"]
1170 valued = ["--history", "--query"]
1171 "#);
1172 assert_eq!(
1173 dispatch_spec(&toks(&["sk", "run", "--history", "/tmp/h"]), &spec),
1174 Verdict::Allowed(SafetyLevel::SafeWrite),
1175 );
1176 }
1177
1178 #[test]
1179 fn write_flagged_with_eq_syntax() {
1180 let spec = load_one(r#"
1181 [[command]]
1182 name = "sk"
1183
1184 [[command.sub]]
1185 name = "run"
1186 write_flags = ["--history"]
1187 valued = ["--history"]
1188 "#);
1189 assert_eq!(
1190 dispatch_spec(&toks(&["sk", "run", "--history=/tmp/h"]), &spec),
1191 Verdict::Allowed(SafetyLevel::SafeWrite),
1192 );
1193 }
1194
1195 #[test]
1200 fn delegate_after_separator_safe() {
1201 let spec = load_one(r#"
1202 [[command]]
1203 name = "mise"
1204
1205 [[command.sub]]
1206 name = "exec"
1207 delegate_after = "--"
1208 "#);
1209 assert_eq!(
1210 dispatch_spec(&toks(&["mise", "exec", "--", "echo", "hello"]), &spec),
1211 Verdict::Allowed(SafetyLevel::Inert),
1212 );
1213 }
1214
1215 #[test]
1216 fn delegate_after_separator_unsafe() {
1217 let spec = load_one(r#"
1218 [[command]]
1219 name = "mise"
1220
1221 [[command.sub]]
1222 name = "exec"
1223 delegate_after = "--"
1224 "#);
1225 assert_eq!(
1226 dispatch_spec(&toks(&["mise", "exec", "--", "rm", "-rf", "/"]), &spec),
1227 Verdict::Denied,
1228 );
1229 }
1230
1231 #[test]
1232 fn delegate_after_separator_no_separator() {
1233 let spec = load_one(r#"
1234 [[command]]
1235 name = "mise"
1236
1237 [[command.sub]]
1238 name = "exec"
1239 delegate_after = "--"
1240 "#);
1241 assert_eq!(
1242 dispatch_spec(&toks(&["mise", "exec", "echo"]), &spec),
1243 Verdict::Denied,
1244 );
1245 }
1246
1247 #[test]
1252 fn delegate_skip_safe() {
1253 let spec = load_one(r#"
1254 [[command]]
1255 name = "rustup"
1256
1257 [[command.sub]]
1258 name = "run"
1259 delegate_skip = 2
1260 "#);
1261 assert_eq!(
1262 dispatch_spec(&toks(&["rustup", "run", "stable", "echo", "hello"]), &spec),
1263 Verdict::Allowed(SafetyLevel::Inert),
1264 );
1265 }
1266
1267 #[test]
1268 fn delegate_skip_unsafe() {
1269 let spec = load_one(r#"
1270 [[command]]
1271 name = "rustup"
1272
1273 [[command.sub]]
1274 name = "run"
1275 delegate_skip = 2
1276 "#);
1277 assert_eq!(
1278 dispatch_spec(&toks(&["rustup", "run", "stable", "rm", "-rf"]), &spec),
1279 Verdict::Denied,
1280 );
1281 }
1282
1283 #[test]
1284 fn delegate_skip_no_inner() {
1285 let spec = load_one(r#"
1286 [[command]]
1287 name = "rustup"
1288
1289 [[command.sub]]
1290 name = "run"
1291 delegate_skip = 2
1292 "#);
1293 assert_eq!(
1294 dispatch_spec(&toks(&["rustup", "run", "stable"]), &spec),
1295 Verdict::Denied,
1296 );
1297 }
1298
1299 #[test]
1304 fn alias_dispatch() {
1305 let specs = load_toml(r#"
1306 [[command]]
1307 name = "grep"
1308 aliases = ["egrep"]
1309 bare = false
1310 standalone = ["-r"]
1311 "#);
1312 let registry = build_registry(specs);
1313 let spec = registry.get("egrep").expect("alias registered");
1314 assert_eq!(
1315 dispatch_spec(&toks(&["egrep", "-r", "pattern"]), spec),
1316 Verdict::Allowed(SafetyLevel::Inert),
1317 );
1318 }
1319
1320 #[test]
1325 fn custom_handler_returns_denied_by_default() {
1326 let spec = load_one(r#"
1327 [[command]]
1328 name = "curl"
1329 handler = "curl"
1330 "#);
1331 assert_eq!(
1332 dispatch_spec(&toks(&["curl", "http://example.com"]), &spec),
1333 Verdict::Denied,
1334 );
1335 }
1336
1337 #[test]
1342 fn multiple_commands() {
1343 let specs = load_toml(r#"
1344 [[command]]
1345 name = "cat"
1346 bare = true
1347 standalone = ["-n"]
1348
1349 [[command]]
1350 name = "head"
1351 bare = false
1352 valued = ["-n"]
1353 "#);
1354 assert_eq!(specs.len(), 2);
1355 assert_eq!(specs[0].name, "cat");
1356 assert_eq!(specs[1].name, "head");
1357 }
1358
1359 #[test]
1364 fn valued_flag_at_end_without_value() {
1365 let spec = load_one(r#"
1366 [[command]]
1367 name = "grep"
1368 bare = false
1369 valued = ["--max-count"]
1370 "#);
1371 assert_eq!(
1372 dispatch_spec(&toks(&["grep", "--max-count"]), &spec),
1373 Verdict::Allowed(SafetyLevel::Inert),
1374 );
1375 }
1376
1377 #[test]
1378 fn bare_dash_as_stdin() {
1379 let spec = load_one(r#"
1380 [[command]]
1381 name = "grep"
1382 bare = false
1383 standalone = ["-r"]
1384 "#);
1385 assert_eq!(
1386 dispatch_spec(&toks(&["grep", "pattern", "-"]), &spec),
1387 Verdict::Allowed(SafetyLevel::Inert),
1388 );
1389 }
1390
1391 #[test]
1392 fn positional_style_unknown_eq() {
1393 let spec = load_one(r#"
1394 [[command]]
1395 name = "echo"
1396 bare = true
1397 positional_style = true
1398 "#);
1399 assert_eq!(
1400 dispatch_spec(&toks(&["echo", "--foo=bar"]), &spec),
1401 Verdict::Allowed(SafetyLevel::Inert),
1402 );
1403 }
1404
1405 #[test]
1406 fn positional_style_with_max() {
1407 let spec = load_one(r#"
1408 [[command]]
1409 name = "echo"
1410 bare = true
1411 positional_style = true
1412 max_positional = 2
1413 "#);
1414 assert_eq!(
1415 dispatch_spec(&toks(&["echo", "--a", "--b"]), &spec),
1416 Verdict::Allowed(SafetyLevel::Inert),
1417 );
1418 assert_eq!(
1419 dispatch_spec(&toks(&["echo", "--a", "--b", "--c"]), &spec),
1420 Verdict::Denied,
1421 );
1422 }
1423
1424 #[test]
1429 fn toml_registry_rejects_unknown_flags() {
1430 let mut failures = Vec::new();
1431 for (name, _spec) in TOML_REGISTRY.iter() {
1432 let test = format!("{name} --xyzzy-unknown-42");
1433 if crate::is_safe_command(&test) {
1434 failures.push(format!("{name}: accepted unknown flag"));
1435 }
1436 }
1437 assert!(failures.is_empty(), "TOML commands accepted unknown flags:\n{}", failures.join("\n"));
1438 }
1439
1440 #[test]
1441 fn toml_hash_commands_work() {
1442 assert!(crate::is_safe_command("md5sum file.txt"));
1443 assert!(crate::is_safe_command("sha256sum file.txt"));
1444 assert!(crate::is_safe_command("b2sum file.txt"));
1445 assert!(crate::is_safe_command("shasum -a 256 file.txt"));
1446 assert!(crate::is_safe_command("cksum file.txt"));
1447 assert!(crate::is_safe_command("md5 file.txt"));
1448 assert!(crate::is_safe_command("sum file.txt"));
1449 assert!(crate::is_safe_command("md5sum --check checksums.md5"));
1450 }
1451
1452 #[test]
1453 fn toml_hash_commands_reject_unknown() {
1454 assert!(!crate::is_safe_command("md5sum --evil"));
1455 assert!(!crate::is_safe_command("sha256sum --evil"));
1456 assert!(!crate::is_safe_command("b2sum --evil"));
1457 }
1458}