1use crate::parse::Token;
2use crate::policy::FlagSet;
3use crate::verdict::{SafetyLevel, Verdict};
4
5use super::policy::check_owned;
6use super::types::*;
7use super::{CMD_HANDLERS, SUB_HANDLERS};
8
9type HandlerMap = std::collections::HashMap<&'static str, super::HandlerFn>;
10
11fn per_database_variant<'a>(subs: &'a [SubSpec], arg: &str) -> Option<&'a SubSpec> {
18 let (base, dbname) = arg.rsplit_once(':')?;
19 let plain = !dbname.is_empty()
20 && dbname.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
21 if !plain || crate::cst::check::is_opaque_value(dbname) {
26 return None;
27 }
28 subs.iter().find(|s| s.name_match == NameMatch::WithDatabaseSuffix && s.name == base)
29}
30
31fn short_flag_char(s: &str) -> Option<char> {
32 let bytes = s.as_bytes();
33 if bytes.len() == 2 && bytes[0] == b'-' && bytes[1] != b'-' {
34 s.chars().nth(1)
35 } else {
36 None
37 }
38}
39
40fn is_combined_short(s: &str) -> bool {
41 let bytes = s.as_bytes();
42 bytes.len() > 2 && bytes[0] == b'-' && bytes[1] != b'-'
43}
44
45fn dispatch_first_arg(
46 tokens: &[Token],
47 patterns: &[String],
48 level: SafetyLevel,
49 standalone: &[String],
50 valued: &[String],
51 loopback_valued: &[String],
52) -> Verdict {
53 if tokens.len() == 2 && (tokens[1] == "--help" || tokens[1] == "-h") {
54 return Verdict::Allowed(SafetyLevel::Inert);
55 }
56 let Some(arg) = tokens.get(1) else {
57 return Verdict::Denied;
58 };
59 let arg_str = arg.as_str();
60 let matches = patterns.iter().any(|p| {
61 if let Some(prefix) = p.strip_suffix('*') {
62 arg_str.starts_with(prefix)
63 } else {
64 arg_str == p
65 }
66 });
67 if !matches {
68 return Verdict::Denied;
69 }
70 if super::glob_presents_unlisted_flag(tokens, 2, standalone, valued, loopback_valued) {
71 return Verdict::Denied;
72 }
73 Verdict::Allowed(level)
74}
75
76fn dispatch_require_any(
77 tokens: &[Token],
78 require_any: &[String],
79 policy: &OwnedPolicy,
80 level: SafetyLevel,
81 accept_bare_help: bool,
82) -> Verdict {
83 if tokens.len() == 2 {
84 let t = tokens[1].as_str();
85 if t == "--help" || t == "-h" || (accept_bare_help && t == "help") {
86 return Verdict::Allowed(SafetyLevel::Inert);
87 }
88 }
89 let has_required = tokens[1..].iter().any(|t| {
90 require_any.iter().any(|r| {
91 let t_str = t.as_str();
92 if t_str == r.as_str() {
93 return true;
94 }
95 if r.starts_with("--") && t_str.starts_with(&format!("{r}=")) {
96 return true;
97 }
98 if let Some(short_char) = short_flag_char(r)
99 && is_combined_short(t_str)
100 && t_str[1..].contains(short_char)
101 {
102 return true;
103 }
104 false
105 })
106 });
107 if has_required && check_owned(tokens, policy) {
108 Verdict::Allowed(level)
109 } else {
110 Verdict::Denied
111 }
112}
113
114fn is_toolchain_selector(s: &str) -> bool {
123 let Some(name) = s.strip_prefix('+') else { return false };
124 !name.is_empty()
125 && name.starts_with(|c: char| c.is_ascii_alphanumeric())
126 && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
127}
128
129fn skip_pre_flags(
130 tokens: &[Token],
131 pre_standalone: &[String],
132 pre_valued: &[String],
133 toolchain_selector: bool,
134 start: usize,
135) -> usize {
136 let mut i = start;
137 if toolchain_selector
142 && let Some(t) = tokens.get(i)
143 && is_toolchain_selector(t.as_str())
144 {
145 i += 1;
146 }
147 while i < tokens.len() {
148 let t = &tokens[i];
149 let s = t.as_str();
150 if !s.starts_with('-') {
151 break;
152 }
153 if pre_valued.contains_flag(s) {
154 i += 2;
155 continue;
156 }
157 if let Some((flag, _)) = s.split_once('=')
158 && pre_valued.contains_flag(flag)
159 {
160 i += 1;
161 continue;
162 }
163 if pre_standalone.contains_flag(s) {
164 i += 1;
165 continue;
166 }
167 let bytes = s.as_bytes();
171 if bytes.len() > 2
172 && bytes[1] != b'-'
173 && bytes[1..].iter().all(|&b| pre_standalone.contains_short(b))
174 {
175 i += 1;
176 continue;
177 }
178 break;
179 }
180 i
181}
182
183struct GlobArm<'a> {
186 patterns: &'a [String],
187 level: SafetyLevel,
188 standalone: &'a [String],
189 valued: &'a [String],
190 loopback_valued: &'a [String],
191 credential: &'a [String],
192}
193
194fn dispatch_branching(
195 tokens: &[Token],
196 subs: &[SubSpec],
197 bare_flags: &[String],
198 bare_ok: bool,
199 pre_flags: (&[String], &[String]),
200 toolchain_selector: bool,
201 glob: &GlobArm<'_>,
202) -> Verdict {
203 let (pre_standalone, pre_valued) = pre_flags;
204 let start = skip_pre_flags(tokens, pre_standalone, pre_valued, toolchain_selector, 1);
205 if start >= tokens.len() {
206 return if bare_ok { Verdict::Allowed(SafetyLevel::Inert) } else { Verdict::Denied };
207 }
208 let arg = tokens[start].as_str();
209 let is_bare_flag = bare_flags.iter().any(|f| f == arg)
210 || (bare_flags.is_empty() && matches!(arg, "--help" | "-h"));
211 if is_bare_flag {
212 let after = skip_pre_flags(tokens, pre_standalone, pre_valued, toolchain_selector, start + 1);
213 if after >= tokens.len() {
214 return Verdict::Allowed(SafetyLevel::Inert);
215 }
216 if bare_flags.is_empty() {
217 return Verdict::Denied;
218 }
219 }
220 if let Some(sub) = subs.iter().find(|s| s.name == arg) {
221 return dispatch_kind(&tokens[start..], &sub.kind, &SUB_HANDLERS);
222 }
223 if let Some(sub) = per_database_variant(subs, arg) {
228 return dispatch_kind(&tokens[start..], &sub.kind, &SUB_HANDLERS);
229 }
230 let glob_match = |p: &str| match p.strip_suffix('*') {
231 Some(prefix) => arg.starts_with(prefix),
232 None => arg == p,
233 };
234 let glob_match_ci = |p: &str| match p.strip_suffix('*') {
241 Some(prefix) => {
242 arg.len() >= prefix.len()
243 && arg.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes())
244 }
245 None => arg.eq_ignore_ascii_case(p),
246 };
247 if glob.credential.iter().any(|p| glob_match_ci(p)) {
248 return Verdict::Denied;
249 }
250 if !glob.patterns.is_empty() && glob.patterns.iter().any(|p| glob_match(p)) {
251 if super::glob_presents_unlisted_flag(
252 tokens, start + 1, glob.standalone, glob.valued, glob.loopback_valued,
253 ) {
254 return Verdict::Denied;
255 }
256 return Verdict::Allowed(glob.level);
257 }
258 Verdict::Denied
259}
260
261fn dispatch_wrapper(
262 tokens: &[Token],
263 standalone: &[String],
264 valued: &[String],
265 positional_skip: usize,
266 separator: Option<&str>,
267 bare_ok: bool,
268) -> Verdict {
269 let mut i = 1;
270 while i < tokens.len() {
271 let t = &tokens[i];
272 if let Some(sep) = separator
273 && t == sep
274 {
275 i += 1;
276 break;
277 }
278 if !t.starts_with('-') {
279 break;
280 }
281 if valued.iter().any(|f| t == f.as_str()) {
282 i += 2;
283 continue;
284 }
285 if valued.iter().any(|f| t.as_str().starts_with(&format!("{f}="))) {
286 i += 1;
287 continue;
288 }
289 if standalone.iter().any(|f| t == f.as_str()) {
290 i += 1;
291 continue;
292 }
293 return Verdict::Denied;
294 }
295 for _ in 0..positional_skip {
296 if i >= tokens.len() {
297 return if bare_ok {
298 Verdict::Allowed(SafetyLevel::Inert)
299 } else {
300 Verdict::Denied
301 };
302 }
303 i += 1;
304 }
305 if i >= tokens.len() {
306 return if bare_ok {
307 Verdict::Allowed(SafetyLevel::Inert)
308 } else {
309 Verdict::Denied
310 };
311 }
312 let inner = shell_words::join(tokens[i..].iter().map(|t| t.as_str()));
313 crate::command_verdict(&inner)
314}
315
316fn dispatch_kind(tokens: &[Token], kind: &DispatchKind, handlers: &HandlerMap) -> Verdict {
317 match kind {
318 DispatchKind::Policy { policy, level } => {
319 if check_owned(tokens, policy) {
320 Verdict::Allowed(*level)
321 } else {
322 Verdict::Denied
323 }
324 }
325 DispatchKind::FirstArg { patterns, level, standalone, valued, loopback_valued } => {
326 dispatch_first_arg(tokens, patterns, *level, standalone, valued, loopback_valued)
327 }
328 DispatchKind::RequireAny { require_any, policy, level, accept_bare_help } => {
329 dispatch_require_any(tokens, require_any, policy, *level, *accept_bare_help)
330 }
331 DispatchKind::Branching {
332 subs, bare_flags, bare_ok, pre_standalone, pre_valued, first_arg, first_arg_level,
333 first_arg_standalone, first_arg_valued, first_arg_loopback_valued,
334 credential_first_arg, toolchain_selector,
335 } => {
336 dispatch_branching(
337 tokens, subs, bare_flags, *bare_ok, (pre_standalone, pre_valued),
338 *toolchain_selector,
339 &GlobArm {
340 patterns: first_arg,
341 level: *first_arg_level,
342 standalone: first_arg_standalone,
343 valued: first_arg_valued,
344 loopback_valued: first_arg_loopback_valued,
345 credential: credential_first_arg,
346 },
347 )
348 }
349 DispatchKind::WriteFlagged { policy, base_level, write_flags } => {
350 if !check_owned(tokens, policy) {
351 return Verdict::Denied;
352 }
353 let has_write = tokens[1..].iter().any(|t| {
354 write_flags.iter().any(|f| t == f.as_str() || t.as_str().starts_with(&format!("{f}=")))
355 });
356 if has_write {
357 Verdict::Allowed(SafetyLevel::SafeWrite)
358 } else {
359 Verdict::Allowed(*base_level)
360 }
361 }
362 DispatchKind::DelegateAfterSeparator { separator } => {
363 let sep_pos = tokens[1..].iter().position(|t| t == separator.as_str());
364 let Some(pos) = sep_pos else {
365 return Verdict::Denied;
366 };
367 let inner_start = pos + 2;
368 if inner_start >= tokens.len() {
369 return Verdict::Denied;
370 }
371 let inner = shell_words::join(tokens[inner_start..].iter().map(|t| t.as_str()));
372 crate::command_verdict(&inner)
373 }
374 DispatchKind::DelegateSkip { skip } => {
375 if tokens.len() <= *skip {
376 return Verdict::Denied;
377 }
378 let inner = shell_words::join(tokens[*skip..].iter().map(|t| t.as_str()));
379 crate::command_verdict(&inner)
380 }
381 DispatchKind::Wrapper {
382 standalone, valued, positional_skip, separator, bare_ok,
383 } => {
384 dispatch_wrapper(tokens, standalone, valued, *positional_skip, separator.as_deref(), *bare_ok)
385 }
386 DispatchKind::VerbChain(spec) => dispatch_verb_chain(tokens, spec),
387 DispatchKind::Executor { policy, level, kind, redirect_flag, shape } => {
388 dispatch_executor(tokens, policy, *kind, *level, redirect_flag.as_deref(), *shape)
389 }
390 DispatchKind::Custom { handler_name, .. } => {
391 handlers
392 .get(handler_name.as_str())
393 .map(|f| f(tokens))
394 .unwrap_or(Verdict::Denied)
395 }
396 }
397}
398
399pub fn dispatch_spec(tokens: &[Token], spec: &CommandSpec) -> Verdict {
400 let verdict = dispatch_kind(tokens, &spec.kind, &CMD_HANDLERS);
401 if !spec.env_assignment_positionals {
402 return verdict;
403 }
404 tokens[1..]
408 .iter()
409 .filter(|t| !t.starts_with('-'))
410 .filter_map(|t| t.as_str().split_once('='))
411 .map(|(name, value)| crate::envvars::assignment_verdict(name, value))
412 .fold(verdict, Verdict::combine)
413}
414
415pub(super) fn dispatch_sub_kind(tokens: &[Token], kind: &DispatchKind) -> Verdict {
418 dispatch_kind(tokens, kind, &SUB_HANDLERS)
419}
420
421pub(super) fn check_handler_policy_owned(tokens: &[Token], policy: &OwnedPolicy) -> bool {
422 check_owned(tokens, policy)
423}
424
425pub(super) fn dispatch_matrix_action(
426 tokens: &[Token],
427 policy: &OwnedPolicy,
428 level: SafetyLevel,
429) -> Verdict {
430 if check_owned(tokens, policy) {
431 Verdict::Allowed(level)
432 } else {
433 Verdict::Denied
434 }
435}
436
437pub(super) fn dispatch_verb_chain(tokens: &[Token], spec: &VerbChainSpec) -> Verdict {
444 if tokens.len() == 1 {
445 return Verdict::Denied;
446 }
447 let has = |set: &[String], s: &str| set.iter().any(|f| f == s);
448 let mut i = 1;
449 while i < tokens.len() {
450 let s = tokens[i].as_str();
451 if s == "--" {
453 return verb_chain_tail(&tokens[i + 1..], spec);
454 }
455 if !tokens[i].starts_with('-') {
457 return verb_chain_tail(&tokens[i..], spec);
458 }
459 if has(&spec.main_standalone, s) {
460 i += 1;
461 } else if has(&spec.main_variadic, s) {
462 i += 1;
465 while i < tokens.len() && tokens[i].as_str() != "--" {
466 i += 1;
467 }
468 } else if has(&spec.main_valued, s) {
469 i += if crate::policy::consumes_next_value(tokens.get(i + 1)) { 2 } else { 1 };
470 } else if s.split_once('=').is_some_and(|(f, _)| has(&spec.main_valued, f)) {
471 i += 1;
472 } else {
473 return Verdict::Denied;
474 }
475 }
476 Verdict::Allowed(spec.level)
478}
479
480fn verb_chain_tail(region: &[Token], spec: &VerbChainSpec) -> Verdict {
482 let mut expect_verb = true;
483 for t in region {
484 if t.as_str() == spec.separator {
485 expect_verb = true;
486 continue;
487 }
488 if expect_verb {
489 if !spec.verbs.contains(t.as_str()) {
490 return Verdict::Denied;
491 }
492 expect_verb = false;
493 }
494 }
495 if expect_verb { Verdict::Denied } else { Verdict::Allowed(spec.level) }
497}
498
499pub(super) fn dispatch_executor(
510 tokens: &[Token],
511 policy: &OwnedPolicy,
512 kind: ExecutorKind,
513 level: SafetyLevel,
514 redirect_flag: Option<&str>,
515 shape: Option<crate::policy::PositionalShape>,
516) -> Verdict {
517 match kind {
518 ExecutorKind::File => match super::policy::first_positional(tokens, policy) {
519 Some("-") => Verdict::Denied,
526 Some(first) if shape.is_some_and(|s| !s.matches(first)) => Verdict::Denied,
529 Some(first) => crate::engine::resolve::execute_file_verdict(first),
535 None if check_owned(tokens, policy) => Verdict::Allowed(level),
536 None => Verdict::Denied,
537 },
538 ExecutorKind::Project => {
539 if !check_owned(tokens, policy) {
540 return Verdict::Denied;
541 }
542 match redirect_flag.and_then(|f| flag_value(tokens, f)) {
543 Some(redirected) => crate::engine::resolve::execute_file_verdict(redirected),
544 None => crate::engine::resolve::execute_project_verdict(),
545 }
546 }
547 }
548}
549
550fn flag_value<'a>(tokens: &'a [Token], flag: &str) -> Option<&'a str> {
554 let mut i = 1;
555 while i < tokens.len() {
556 let t = tokens[i].as_str();
557 if t == "--" {
558 return None;
559 }
560 if t == flag {
561 return tokens.get(i + 1).map(Token::as_str);
562 }
563 if let Some(rest) = t.strip_prefix(flag).and_then(|r| r.strip_prefix('=')) {
564 return Some(rest);
565 }
566 i += 1;
567 }
568 None
569}
570
571pub(super) fn dispatch_fallback(tokens: &[Token], spec: &FallbackSpec) -> Verdict {
574 if let Some(kind) = spec.executor {
575 return dispatch_executor(
576 tokens, &spec.policy, kind, spec.level,
577 spec.executor_redirect_flag.as_deref(), spec.positional_shape,
578 );
579 }
580 if let Some(shape) = spec.positional_shape
581 && let Some(first) = super::policy::first_positional(tokens, &spec.policy)
582 && !shape.matches(first)
583 {
584 return Verdict::Denied;
585 }
586 if !check_owned(tokens, &spec.policy) {
587 return Verdict::Denied;
588 }
589 Verdict::Allowed(spec.level)
590}
591
592#[cfg(test)]
593mod toolchain_selector_tests {
594 use crate::is_safe_command;
595
596 #[test]
604 fn a_toolchain_selector_is_not_stripped_unless_declared() {
605 for (bare, with_selector) in [
606 ("git log", "git +nightly log"),
607 ("jj log", "jj +nightly log"),
608 ("docker compose logs", "docker +nightly compose logs"),
609 ("go version", "go +nightly version"),
610 ] {
611 assert!(is_safe_command(bare), "precondition: `{bare}` should allow");
612 assert!(
613 !is_safe_command(with_selector),
614 "`{with_selector}`: a toolchain selector was stripped for a command that does not \
615 declare `toolchain_selector`, so a leading operand is being silently discarded"
616 );
617 }
618 }
619
620 #[test]
622 fn a_declared_toolchain_selector_is_accepted() {
623 for line in [
624 "cargo +nightly build",
625 "cargo +stable test",
626 "cargo +1.90.0 check",
627 "cargo +nightly-2026-01-01 build",
628 "cargo +nightly-x86_64-apple-darwin build",
629 ] {
630 assert!(is_safe_command(line), "`{line}` should classify as its bare subcommand does");
631 }
632 }
633
634 #[test]
638 fn a_toolchain_selector_does_not_change_its_subcommand() {
639 for sub in ["publish", "install ripgrep", "login"] {
640 let bare = format!("cargo {sub}");
641 let selected = format!("cargo +nightly {sub}");
642 assert_eq!(
643 is_safe_command(&bare),
644 is_safe_command(&selected),
645 "`{selected}` disagrees with `{bare}` — the selector changed the verdict",
646 );
647 }
648 }
649}