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
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
94/// Whether `s` is a rustup toolchain selector (`+nightly`, `+1.90.0`, `+nightly-2026-01-01`).
95///
96/// A POSITIVE shape test, so an unrecognized spelling is simply not a selector and dispatch
97/// proceeds without stripping it (which then denies on the unknown token). The selector names an
98/// ALREADY-INSTALLED toolchain — rustup resolves `+name` through its own toolchain list and cannot
99/// be pointed at an arbitrary directory — so the shape is restricted to what a toolchain name can
100/// be. `/`, `~`, `$`, `.` and `..` are excluded by construction rather than by exclusion: only
101/// alphanumerics and `._-` are admitted, and a name must start alphanumeric so `+..` cannot form.
102fn 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    // At most ONE, and only in front. `cargo +nightly build` selects which toolchain runs `build`;
118    // it does not change what `build` does, which is why the sub dispatch below is reached
119    // unchanged. Bounded to a single leading token so a `+`-prefixed OPERAND deeper in the line is
120    // never silently eaten.
121    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        // POSIX-style short-flag cluster (`-vv`, `-vy`): every byte after
148        // the dash must be a known standalone short. Mirrors the same
149        // logic in policy::check_flags for non-wrapper subs.
150        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
163/// The first-positional GLOB arm of a `Branching`: which verbs it admits, at what level, the flags
164/// it accepts once admitted, and the verbs that instead mark the invocation a credential-read.
165struct 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    // A first positional naming secret material (`kubectl get secret`/`secret/x`, `aws configure get
208    // aws_secret_access_key`) is a CREDENTIAL-READ — deny before the allow-glob admits it. The
209    // value-dependent complement to `profile=credential-read`; `arg` is already flag-aware. Matched
210    // CASE-INSENSITIVELY: kubectl resource kinds are case-insensitive (`get Secret`/`SECRET` reads
211    // secrets), so a case-variant must not slip past the deny into the `*` allow. The allow-glob
212    // below stays case-sensitive — a case-insensitive ALLOW would be fail-open.
213    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    // `export LD_PRELOAD=/tmp/evil.so` puts the variable in the environment of every command the
378    // shell spawns afterwards, so it is the `LD_PRELOAD=/tmp/evil.so cmd` prefix wearing different
379    // syntax. Classify each assignment the same way rather than letting the second spelling through.
380    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
388/// Dispatches a sub's kind directly, used by `registry::try_sub_dispatch`
389/// when a handler-using command consults its TOML-declared subs.
390pub(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
410/// A `verb-chain` grammar (`mlr`): `CMD [main-flags…] verb [args…] then verb …`. The main-flag
411/// region is a STRICT allowlist (standalone / valued / variadic) — an unlisted flag denies, so a
412/// mutating flag like mlr's `-I`/`--in-place` (omitted) is caught by omission. The region ends at
413/// `--` or the first non-flag token, which opens the verb chain: every `then`-segment's first token
414/// (the verb NAME) must be on the `verbs` allowlist; verb ARGS are open-ended (a pure verb has no
415/// shell/file escape). See `types::VerbChainSpec`.
416pub(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        // `--` terminates the main region; the verb chain follows it.
425        if s == "--" {
426            return verb_chain_tail(&tokens[i + 1..], spec);
427        }
428        // The first non-flag token opens the verb chain.
429        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            // Consume input files up to the `--` terminator (or end); the `--` is left for the
436            // loop to treat as the main/verb separator next iteration.
437            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    // Only allowlisted main flags, no verb (`mlr --version`, `mlr --csv`): nothing runs.
450    Verdict::Allowed(spec.level)
451}
452
453/// The verb region: a `then`-chain where every segment's first token must be an allowlisted verb.
454fn 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    // An empty region or a dangling separator leaves `expect_verb` set — no valid verb ran.
469    if expect_verb { Verdict::Denied } else { Verdict::Allowed(spec.level) }
470}
471
472/// Gate a code-execution command through the execution-origin engine (worktree code allows,
473/// foreign denies) instead of a flat level. Shared by executor SUBS (`go run`, `cargo run`)
474/// and executor FALLBACKS (interpreters). See docs/design/behavioral-taxonomy-execution-origin.md.
475///
476/// - `File`: the first positional is the executor path — `bash x.sh`, `go run ./cmd`. Tokens
477///   AFTER it are the script's own arguments (data, not validated). With no positional, it's a
478///   flag-only invocation (`python3 --version`) validated by `policy` (denies a bare REPL).
479/// - `Project`: the current project is the executor — `cargo run`. A `redirect_flag` value
480///   (`--manifest-path DIR/Cargo.toml`) moves the executor OUT of the project and is locus-gated
481///   like a file executor; without it, the project (worktree) is the executor.
482pub(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            // `-` is STDIN, not a file. Every interpreter reads its program from stdin when given
493            // it, so the code being run is not in the workspace and is not in the command string
494            // either — the classifier cannot see it at all. It was resolving as a bare relative
495            // path, which classifies worktree-local, so `curl … | python3 -` auto-approved: remote
496            // code execution straight through the allowlist. `/dev/stdin` and `/dev/fd/0` already
497            // denied as absolute foreign paths; only the short spelling slipped.
498            Some("-") => Verdict::Denied,
499            // A declared shape the executor path must satisfy (`go run` → `go-package`):
500            // a remote import path is not a worktree executor, so it denies here.
501            Some(first) if shape.is_some_and(|s| !s.matches(first)) => Verdict::Denied,
502            // Only the locus, NOT `check_owned`. For an interpreter every token after the script
503            // is the SCRIPT's argv (`python3 ./task.py --flag arg`), so the command's own flag
504            // grammar cannot describe it. The cost is that `max_positional` goes unenforced here
505            // — see TODO.md; a command that needs its positionals gated declares a `path_gate`
506            // with the `exec` role instead of relying on this.
507            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
523/// The value of valued flag `flag` in `tokens` — the space form (`--manifest-path P`) or the
524/// glued `=` form (`--manifest-path=P`). Scans only up to a `--` terminator, so a program
525/// argument after `--` can't be mistaken for the flag's value. `None` if the flag is absent.
526fn 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
544/// Applies a TOML-declared fallback grammar. Used by
545/// `registry::try_fallback_grammar()`.
546pub(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    /// The selector is OPT-IN, and must stay that way. Stripping a leading `+token` for every
570    /// structured command would silently swallow a first positional on tools that have no such
571    /// convention — `+nightly` is a rustup spelling, not a shell one.
572    ///
573    /// Enumerated over commands that dispatch subs but do NOT declare it: the bare sub allows, and
574    /// the same sub behind a `+toolchain` must NOT, because for these tools that token is an
575    /// unknown operand rather than a selector.
576    #[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    /// ...and IS stripped where declared, for every spelling a toolchain name takes.
594    #[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    /// The selector does not launder the SUBCOMMAND: what `cargo publish` is stays what
608    /// `cargo +nightly publish` is. This is the fail-open the strip would cause if it were applied
609    /// before, rather than instead of, the sub's own classification.
610    #[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}