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