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