Skip to main content

safe_chains/registry/
dispatch.rs

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
11/// The `per_database` sub that `arg` is a variant of: `arg` is `<sub.name>:<dbname>`.
12///
13/// The suffix must be ONE plain identifier — the shape of a key in `config/database.yml`. That is
14/// what keeps this from degenerating into "ignore anything after a colon": a further `:` would
15/// name a different task, and a suffix carrying a `/`, a space or a substitution is not a database
16/// name and must not inherit a schema task's classification.
17fn 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    // The character test alone is not enough, and failing to say so was a fail-open in the first
22    // cut of this: a substitution reaches dispatch already rewritten to `__SAFE_CHAINS_CMDSUB__`,
23    // which is alphanumeric-and-underscore and sailed straight through as a "plain identifier".
24    // `db:migrate:$(anything)` was admitted as a per-database variant.
25    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
114/// Whether `s` is a rustup toolchain selector (`+nightly`, `+1.90.0`, `+nightly-2026-01-01`).
115///
116/// A POSITIVE shape test, so an unrecognized spelling is simply not a selector and dispatch
117/// proceeds without stripping it (which then denies on the unknown token). The selector names an
118/// ALREADY-INSTALLED toolchain — rustup resolves `+name` through its own toolchain list and cannot
119/// be pointed at an arbitrary directory — so the shape is restricted to what a toolchain name can
120/// be. `/`, `~`, `$`, `.` and `..` are excluded by construction rather than by exclusion: only
121/// alphanumerics and `._-` are admitted, and a name must start alphanumeric so `+..` cannot form.
122fn 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    // At most ONE, and only in front. `cargo +nightly build` selects which toolchain runs `build`;
138    // it does not change what `build` does, which is why the sub dispatch below is reached
139    // unchanged. Bounded to a single leading token so a `+`-prefixed OPERAND deeper in the line is
140    // never silently eaten.
141    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        // POSIX-style short-flag cluster (`-vv`, `-vy`): every byte after
168        // the dash must be a known standalone short. Mirrors the same
169        // logic in policy::check_flags for non-wrapper subs.
170        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
183/// The first-positional GLOB arm of a `Branching`: which verbs it admits, at what level, the flags
184/// it accepts once admitted, and the verbs that instead mark the invocation a credential-read.
185struct 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    // A per-database variant: `db:migrate:primary` is `db:migrate` aimed at one of the databases
224    // it would otherwise migrate all of. Only subs that OPT IN (`per_database`) match this way,
225    // and only for a single trailing plain identifier — see `TomlSub::per_database` for why the
226    // set cannot be enumerated and why inheriting the base's classification cannot widen it.
227    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    // A first positional naming secret material (`kubectl get secret`/`secret/x`, `aws configure get
235    // aws_secret_access_key`) is a CREDENTIAL-READ — deny before the allow-glob admits it. The
236    // value-dependent complement to `profile=credential-read`; `arg` is already flag-aware. Matched
237    // CASE-INSENSITIVELY: kubectl resource kinds are case-insensitive (`get Secret`/`SECRET` reads
238    // secrets), so a case-variant must not slip past the deny into the `*` allow. The allow-glob
239    // below stays case-sensitive — a case-insensitive ALLOW would be fail-open.
240    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    // `export LD_PRELOAD=/tmp/evil.so` puts the variable in the environment of every command the
405    // shell spawns afterwards, so it is the `LD_PRELOAD=/tmp/evil.so cmd` prefix wearing different
406    // syntax. Classify each assignment the same way rather than letting the second spelling through.
407    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
415/// Dispatches a sub's kind directly, used by `registry::try_sub_dispatch`
416/// when a handler-using command consults its TOML-declared subs.
417pub(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
437/// A `verb-chain` grammar (`mlr`): `CMD [main-flags…] verb [args…] then verb …`. The main-flag
438/// region is a STRICT allowlist (standalone / valued / variadic) — an unlisted flag denies, so a
439/// mutating flag like mlr's `-I`/`--in-place` (omitted) is caught by omission. The region ends at
440/// `--` or the first non-flag token, which opens the verb chain: every `then`-segment's first token
441/// (the verb NAME) must be on the `verbs` allowlist; verb ARGS are open-ended (a pure verb has no
442/// shell/file escape). See `types::VerbChainSpec`.
443pub(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        // `--` terminates the main region; the verb chain follows it.
452        if s == "--" {
453            return verb_chain_tail(&tokens[i + 1..], spec);
454        }
455        // The first non-flag token opens the verb chain.
456        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            // Consume input files up to the `--` terminator (or end); the `--` is left for the
463            // loop to treat as the main/verb separator next iteration.
464            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    // Only allowlisted main flags, no verb (`mlr --version`, `mlr --csv`): nothing runs.
477    Verdict::Allowed(spec.level)
478}
479
480/// The verb region: a `then`-chain where every segment's first token must be an allowlisted verb.
481fn 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    // An empty region or a dangling separator leaves `expect_verb` set — no valid verb ran.
496    if expect_verb { Verdict::Denied } else { Verdict::Allowed(spec.level) }
497}
498
499/// Gate a code-execution command through the execution-origin engine (worktree code allows,
500/// foreign denies) instead of a flat level. Shared by executor SUBS (`go run`, `cargo run`)
501/// and executor FALLBACKS (interpreters). See docs/design/behavioral-taxonomy-execution-origin.md.
502///
503/// - `File`: the first positional is the executor path — `bash x.sh`, `go run ./cmd`. Tokens
504///   AFTER it are the script's own arguments (data, not validated). With no positional, it's a
505///   flag-only invocation (`python3 --version`) validated by `policy` (denies a bare REPL).
506/// - `Project`: the current project is the executor — `cargo run`. A `redirect_flag` value
507///   (`--manifest-path DIR/Cargo.toml`) moves the executor OUT of the project and is locus-gated
508///   like a file executor; without it, the project (worktree) is the executor.
509pub(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            // `-` is STDIN, not a file. Every interpreter reads its program from stdin when given
520            // it, so the code being run is not in the workspace and is not in the command string
521            // either — the classifier cannot see it at all. It was resolving as a bare relative
522            // path, which classifies worktree-local, so `curl … | python3 -` auto-approved: remote
523            // code execution straight through the allowlist. `/dev/stdin` and `/dev/fd/0` already
524            // denied as absolute foreign paths; only the short spelling slipped.
525            Some("-") => Verdict::Denied,
526            // A declared shape the executor path must satisfy (`go run` → `go-package`):
527            // a remote import path is not a worktree executor, so it denies here.
528            Some(first) if shape.is_some_and(|s| !s.matches(first)) => Verdict::Denied,
529            // Only the locus, NOT `check_owned`. For an interpreter every token after the script
530            // is the SCRIPT's argv (`python3 ./task.py --flag arg`), so the command's own flag
531            // grammar cannot describe it. The cost is that `max_positional` goes unenforced here
532            // — see TODO.md; a command that needs its positionals gated declares a `path_gate`
533            // with the `exec` role instead of relying on this.
534            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
550/// The value of valued flag `flag` in `tokens` — the space form (`--manifest-path P`) or the
551/// glued `=` form (`--manifest-path=P`). Scans only up to a `--` terminator, so a program
552/// argument after `--` can't be mistaken for the flag's value. `None` if the flag is absent.
553fn 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
571/// Applies a TOML-declared fallback grammar. Used by
572/// `registry::try_fallback_grammar()`.
573pub(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    /// The selector is OPT-IN, and must stay that way. Stripping a leading `+token` for every
597    /// structured command would silently swallow a first positional on tools that have no such
598    /// convention — `+nightly` is a rustup spelling, not a shell one.
599    ///
600    /// Enumerated over commands that dispatch subs but do NOT declare it: the bare sub allows, and
601    /// the same sub behind a `+toolchain` must NOT, because for these tools that token is an
602    /// unknown operand rather than a selector.
603    #[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    /// ...and IS stripped where declared, for every spelling a toolchain name takes.
621    #[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    /// The selector does not launder the SUBCOMMAND: what `cargo publish` is stays what
635    /// `cargo +nightly publish` is. This is the fail-open the strip would cause if it were applied
636    /// before, rather than instead of, the sub's own classification.
637    #[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}