use std::path::Path;
use crate::config::CommandStem;
use crate::error::{
EchoedArgument, EchoedPath, InlineFlag, LaunchTrigger, LauncherRefusal, LongCommandName,
LongInlineFlag, OperandKind, PowerShellParameter, RunnerSubcommand, ShellFlag, StdinSource,
UnanalyzableLaunch,
};
#[derive(Debug, Clone, Copy)]
enum LaunchRule {
Always,
Subcommands(&'static [RunnerSubcommand]),
}
pub const NPM_PACKAGE_RUNNERS: &[&str] = &["npm", "npx", "bunx", "pnpx", "pnpm", "yarn"];
const RUNNERS: &[(&str, LaunchRule)] = &[
("uvx", LaunchRule::Always),
("corepack", LaunchRule::Always),
("make", LaunchRule::Always),
("just", LaunchRule::Always),
("task", LaunchRule::Always),
("rake", LaunchRule::Always),
("mvn", LaunchRule::Always),
("sbt", LaunchRule::Always),
("xargs", LaunchRule::Always),
("find", LaunchRule::Always),
("awk", LaunchRule::Always),
("script", LaunchRule::Always),
("su", LaunchRule::Always),
("flock", LaunchRule::Always),
("watch", LaunchRule::Always),
("gawk", LaunchRule::Always),
("mawk", LaunchRule::Always),
("nawk", LaunchRule::Always),
("sudo", LaunchRule::Always),
("sudo-rs", LaunchRule::Always),
("doas", LaunchRule::Always),
("run0", LaunchRule::Always),
("pkexec", LaunchRule::Always),
("runuser", LaunchRule::Always),
("setpriv", LaunchRule::Always),
("strace", LaunchRule::Always),
("unshare", LaunchRule::Always),
("chrt", LaunchRule::Always),
("taskset", LaunchRule::Always),
("ionice", LaunchRule::Always),
("chroot", LaunchRule::Always),
("nsenter", LaunchRule::Always),
("systemd-run", LaunchRule::Always),
("gosu", LaunchRule::Always),
("su-exec", LaunchRule::Always),
("chpst", LaunchRule::Always),
("setuidgid", LaunchRule::Always),
("envdir", LaunchRule::Always),
("runas", LaunchRule::Always),
("wsl", LaunchRule::Always),
("cmd", LaunchRule::Always),
(
"bun",
LaunchRule::Subcommands(&[
RunnerSubcommand::X,
RunnerSubcommand::Run,
RunnerSubcommand::Repl,
]),
),
(
"deno",
LaunchRule::Subcommands(&[
RunnerSubcommand::Run,
RunnerSubcommand::X,
RunnerSubcommand::Task,
RunnerSubcommand::Eval,
RunnerSubcommand::Repl,
]),
),
(
"cargo",
LaunchRule::Subcommands(&[
RunnerSubcommand::Run,
RunnerSubcommand::R,
RunnerSubcommand::Test,
RunnerSubcommand::T,
RunnerSubcommand::Bench,
]),
),
(
"go",
LaunchRule::Subcommands(&[RunnerSubcommand::Run, RunnerSubcommand::Tool]),
),
(
"uv",
LaunchRule::Subcommands(&[RunnerSubcommand::Run, RunnerSubcommand::Tool]),
),
("pipx", LaunchRule::Subcommands(&[RunnerSubcommand::Run])),
("poetry", LaunchRule::Subcommands(&[RunnerSubcommand::Run])),
("pdm", LaunchRule::Subcommands(&[RunnerSubcommand::Run])),
("hatch", LaunchRule::Subcommands(&[RunnerSubcommand::Run])),
("bundle", LaunchRule::Subcommands(&[RunnerSubcommand::Exec])),
(
"dotnet",
LaunchRule::Subcommands(&[RunnerSubcommand::Run, RunnerSubcommand::Tool]),
),
("pipenv", LaunchRule::Subcommands(&[RunnerSubcommand::Run])),
("pixi", LaunchRule::Subcommands(&[RunnerSubcommand::Run])),
("swift", LaunchRule::Subcommands(&[RunnerSubcommand::Run])),
(
"stack",
LaunchRule::Subcommands(&[
RunnerSubcommand::Run,
RunnerSubcommand::Exec,
RunnerSubcommand::Runghc,
RunnerSubcommand::Runhaskell,
RunnerSubcommand::Script,
RunnerSubcommand::Eval,
RunnerSubcommand::Ghci,
RunnerSubcommand::Repl,
]),
),
(
"cabal",
LaunchRule::Subcommands(&[
RunnerSubcommand::Run,
RunnerSubcommand::Exec,
RunnerSubcommand::V1Run,
RunnerSubcommand::V2Run,
RunnerSubcommand::NewRun,
RunnerSubcommand::V1Exec,
RunnerSubcommand::V2Exec,
RunnerSubcommand::NewExec,
RunnerSubcommand::Repl,
RunnerSubcommand::V1Repl,
RunnerSubcommand::V2Repl,
RunnerSubcommand::NewRepl,
]),
),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ShellFamily {
Posix,
PowerShell,
WindowsPowerShell,
Fish,
Nu,
GoFlag,
Xonsh,
}
const SHELLS: &[(&str, ShellFamily)] = &[
("sh", ShellFamily::Posix),
("bash", ShellFamily::Posix),
("rbash", ShellFamily::Posix),
("zsh", ShellFamily::Posix),
("dash", ShellFamily::Posix),
("ksh", ShellFamily::Posix),
("fish", ShellFamily::Fish),
("csh", ShellFamily::Posix),
("tcsh", ShellFamily::Posix),
("powershell", ShellFamily::WindowsPowerShell),
("pwsh", ShellFamily::PowerShell),
("ash", ShellFamily::Posix),
("hush", ShellFamily::Posix),
("mksh", ShellFamily::Posix),
("lksh", ShellFamily::Posix),
("oksh", ShellFamily::Posix),
("yash", ShellFamily::Posix),
("posh", ShellFamily::Posix),
("elvish", ShellFamily::GoFlag),
("nu", ShellFamily::Nu),
("xonsh", ShellFamily::Xonsh),
];
impl ShellFamily {
fn trigger(self, args: &[String]) -> Option<LaunchTrigger> {
let first_flag = |flag_of: &dyn Fn(&str) -> Option<ShellFlag>| {
args.iter()
.find_map(|arg| flag_of(arg.trim()))
.map(LaunchTrigger::CommandString)
};
match self {
Self::WindowsPowerShell => windows_powershell_trigger(args),
Self::PowerShell => first_flag(&powershell_flag),
Self::Posix | Self::Xonsh => first_flag(&|arg| grammar_flag(&POSIX_GRAMMAR, arg)),
Self::Fish => first_flag(&|arg| grammar_flag(&FISH_GRAMMAR, arg)),
Self::Nu => first_flag(&|arg| grammar_flag(&NU_GRAMMAR, arg)),
Self::GoFlag => first_flag(&go_flag),
}
}
fn check_stdin_program(
self,
args: &[String],
mut dir: WorkingDir,
probe: Option<&Path>,
) -> Result<(), Stop> {
let program = match self {
Self::Posix => walk_operand(args, &POSIX_OPERANDS, &mut dir)?,
Self::Xonsh => walk_operand(args, &XONSH_OPERANDS, &mut dir)?,
Self::Fish => walk_operand(args, &FISH_OPERANDS, &mut dir)?,
Self::Nu => walk_operand(args, &NU_OPERANDS, &mut dir)?,
Self::GoFlag => walk_operand(args, &ELVISH_OPERANDS, &mut dir)?,
Self::PowerShell => walk_powershell(args, PARAMETERS_7, &mut dir)?,
Self::WindowsPowerShell => walk_powershell(args, PARAMETERS_51, &mut dir)?,
};
check_program(program, Positional::Script, dir, probe)
}
}
fn operand_accepts(kind: OperandKind, text: &str) -> bool {
match kind {
OperandKind::Duration => is_duration(text),
}
}
struct ExecWrapper {
names: &'static [&'static str],
short_flags: &'static [char],
short_values: &'static [char],
long_flags: &'static [&'static str],
long_values: &'static [&'static str],
word_flags: &'static [&'static str],
word_values: &'static [&'static str],
operand: Option<OperandKind>,
grammar: WrapperGrammar,
}
#[derive(Clone, Copy)]
enum WrapperGrammar {
Env(EnvHooks),
Other,
}
impl WrapperGrammar {
const fn kind(self) -> WrapperKind {
match self {
Self::Env(_) => WrapperKind::Env,
Self::Other => WrapperKind::Other,
}
}
}
#[derive(Clone, Copy)]
struct EnvHooks {
chdir: &'static [OptionName],
split_short: char,
split_long: &'static str,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum OptionName {
Short(char),
Long(&'static str),
}
struct Step {
width: usize,
option: Option<OptionName>,
}
struct Invocation {
command: usize,
dir: WorkingDir,
}
impl ExecWrapper {
const fn named(names: &'static [&'static str]) -> Self {
Self {
names,
short_flags: &[],
short_values: &[],
long_flags: &[],
long_values: &[],
word_flags: &[],
word_values: &[],
operand: None,
grammar: WrapperGrammar::Other,
}
}
const fn env(&self) -> Option<EnvHooks> {
match self.grammar {
WrapperGrammar::Env(hooks) => Some(hooks),
WrapperGrammar::Other => None,
}
}
fn command_index(
&self,
args: &[String],
mut dir: WorkingDir,
) -> Result<Invocation, UnanalyzableLaunch> {
let mut index = 0;
while let Some(arg) = args.get(index) {
if arg == "--" {
index = index.saturating_add(1);
break;
}
if arg == "-" && self.env().is_some() {
index = index.saturating_add(1);
if let Some(next) = args.get(index).filter(|next| next.starts_with('-')) {
let letter = next.chars().nth(1).unwrap_or('-');
return Err(UnanalyzableLaunch::UnknownOption(EchoedArgument::name(
&format!("-{letter}"),
)));
}
break;
}
let Some(step) = self.option_width(args, index)? else {
break;
};
if step
.option
.zip(self.env())
.is_some_and(|(option, hooks)| hooks.chdir.contains(&option))
{
dir = WorkingDir::Changed;
}
index = index.saturating_add(step.width);
}
if let Some(operand) = self.operand {
if !args
.get(index)
.is_some_and(|text| operand_accepts(operand, text))
{
return Err(UnanalyzableLaunch::MalformedOperand(operand));
}
index = index.saturating_add(1);
}
if self.env().is_some() {
while let Some((name, _)) = args.get(index).and_then(|arg| arg.split_once('=')) {
if name.eq_ignore_ascii_case("PATH") {
return Err(UnanalyzableLaunch::PathAssignment);
}
index = index.saturating_add(1);
}
}
args.get(index)
.map(|_| Invocation {
command: index,
dir,
})
.ok_or(UnanalyzableLaunch::MissingCommand)
}
fn option_width(
&self,
args: &[String],
index: usize,
) -> Result<Option<Step>, UnanalyzableLaunch> {
let Some(arg) = args.get(index) else {
return Ok(None);
};
let value_follows = || {
if args.get(index.saturating_add(1)).is_some() {
Ok(2)
} else {
Err(UnanalyzableLaunch::MissingValue(EchoedArgument::name(arg)))
}
};
let step = |width, option| Ok(Some(Step { width, option }));
if arg.starts_with("--") {
let (name, value) = long_name(arg);
if self.env().is_some_and(|hooks| hooks.split_long == name) {
return Err(UnanalyzableLaunch::SplitString);
}
return if let Some(&known) = self.long_values.iter().find(|long| **long == name) {
let width = if value.is_some() { 1 } else { value_follows()? };
step(width, Some(OptionName::Long(known)))
} else if value.is_none() && self.long_flags.contains(&name) {
step(1, None)
} else {
Err(UnanalyzableLaunch::UnknownOption(EchoedArgument::name(arg)))
};
}
let Some(cluster) = arg.strip_prefix('-').filter(|cluster| !cluster.is_empty()) else {
return Ok(None);
};
if self.word_flags.contains(&arg.as_str()) {
return step(1, None);
}
if self.word_values.contains(&arg.as_str()) {
return step(value_follows()?, None);
}
for (offset, letter) in cluster.char_indices() {
if self.short_flags.contains(&letter) {
continue;
}
if self.short_values.contains(&letter) {
if self.env().is_some() && cluster.len() > letter.len_utf8() {
return Err(self.unknown_short(cluster, letter));
}
let glued = offset.saturating_add(letter.len_utf8()) < cluster.len();
let width = if glued { 1 } else { value_follows()? };
return step(width, Some(OptionName::Short(letter)));
}
return Err(self.unknown_short(cluster, letter));
}
step(1, None)
}
fn unknown_short(&self, cluster: &str, letter: char) -> UnanalyzableLaunch {
if self
.env()
.is_some_and(|hooks| cluster.contains(hooks.split_short))
{
UnanalyzableLaunch::SplitString
} else {
UnanalyzableLaunch::UnknownOption(EchoedArgument::name(&format!("-{letter}")))
}
}
}
fn long_name(arg: &str) -> (&str, Option<&str>) {
arg.split_once('=')
.map_or((arg, None), |(name, value)| (name, Some(value)))
}
const EXEC_WRAPPERS: &[ExecWrapper] = &[
ExecWrapper {
short_flags: &['a', 'p', 'v', 'q', 'l'],
short_values: &['f', 'o'],
long_flags: &["--append", "--portability", "--verbose", "--quiet"],
long_values: &["--format", "--output"],
..ExecWrapper::named(&["time", "gtime"])
},
ExecWrapper {
short_values: &['n'],
long_values: &["--adjustment"],
..ExecWrapper::named(&["nice", "gnice"])
},
ExecWrapper::named(&["nohup", "gnohup"]),
ExecWrapper {
short_flags: &['f', 'p', 'v'],
short_values: &['k', 's'],
long_flags: &["--foreground", "--preserve-status", "--verbose"],
long_values: &["--kill-after", "--signal"],
operand: Some(OperandKind::Duration),
..ExecWrapper::named(&["timeout", "gtimeout"])
},
ExecWrapper {
short_flags: &['c', 'f', 'w'],
long_flags: &["--ctty", "--fork", "--wait"],
..ExecWrapper::named(&["setsid"])
},
ExecWrapper {
short_values: &['i', 'o', 'e'],
long_values: &["--input", "--output", "--error"],
..ExecWrapper::named(&["stdbuf", "gstdbuf"])
},
ExecWrapper {
short_flags: &['d', 'i', 'm', 's', 'u'],
short_values: &['t', 'w'],
..ExecWrapper::named(&["caffeinate"])
},
ExecWrapper {
word_flags: &["-32", "-64", "-x86_64", "-arm64", "-arm64e", "-i386"],
word_values: &["-arch"],
..ExecWrapper::named(&["arch"])
},
ExecWrapper {
short_flags: &['i', '0', 'v'],
short_values: &['u', 'C'],
long_flags: &["--ignore-environment", "--null", "--debug"],
long_values: &["--unset", "--chdir"],
grammar: WrapperGrammar::Env(EnvHooks {
chdir: &[OptionName::Short('C'), OptionName::Long("--chdir")],
split_short: 'S',
split_long: "--split-string",
}),
..ExecWrapper::named(&["env", "genv"])
},
];
const NODE_NAMES: &[&str] = &["node", "nodejs"];
const BUN_NAMES: &[&str] = &["bun"];
pub fn is_script_interpreter(stem: &CommandStem) -> bool {
NODE_NAMES
.iter()
.chain(BUN_NAMES)
.any(|name| is_interpreter(stem.as_str(), name))
}
const INLINE_EVAL: &[InlineEval] = &[
InlineEval {
data_url_flags: DATA_URL_LOADERS,
flag_longs: NODE_FLAGS,
flag_prefixes: &["--no-"],
value_longs: NODE_VALUE_LONGS,
..InlineEval::new(NODE_NAMES, &['e', 'p'], &['r', 'C'], LONG_EVAL_PRINT)
},
InlineEval {
data_url_flags: DATA_URL_LOADERS,
flag_longs: NODE_FLAGS,
flag_prefixes: &["--smol"],
value_longs: NODE_VALUE_LONGS,
chdir_longs: &["--cwd"],
..InlineEval::new(BUN_NAMES, &['e', 'p'], &['r', 'c'], LONG_EVAL_PRINT)
},
InlineEval::new(&["lua"], &['e'], &['l'], &[]),
InlineEval {
flag_longs: &[
"--vanilla",
"--no-save",
"--no-restore",
"--no-environ",
"--slave",
"--quiet",
"--silent",
"--verbose",
],
..InlineEval::new(&["rscript"], &['e'], &[], &[])
},
InlineEval {
flag_longs: &["--project", "--quiet", "--color"],
separate_value_letters: &['L', 'J', 'C', 't', 'p', 'H'],
..InlineEval::new(
&["julia"],
&['e', 'E'],
&['L', 'J', 'C', 'O', 't', 'p', 'H'],
LONG_EVAL_PRINT,
)
},
InlineEval {
separate_value_letters: &['l', 's'],
..InlineEval::new(&["osascript"], &['e'], &['l', 's'], &[])
},
InlineEval {
program: &[ProgramFlag::Short('m')],
..InlineEval::new(&["python", "py", "pypy"], &['c'], PYTHON_VALUE_LETTERS, &[])
},
InlineEval {
flag_longs: &["--unstable", "--quiet"],
positional: Positional::Subcommand,
..InlineEval::new(&["deno"], &[], &[], &[])
},
InlineEval {
module_letters: &['M', 'm'],
debugger_letters: &['d'],
separate_value_letters: &['I'],
..InlineEval::new(
&["perl"],
&['e', 'E'],
&['I', 'M', 'm', 'x', 'i', 'F', 'C', 'd', 'D'],
&[],
)
},
InlineEval {
flag_longs: &["--verbose", "--yjit", "--jit", "--disable-gems"],
separate_value_letters: &['r', 'I', 'C', 'E', 'X'],
chdir_letters: &['C', 'X'],
..InlineEval::new(
&["ruby"],
&['e'],
&['r', 'I', 'C', 'E', 'X', 'K', 'x', 'F', 'T'],
&[],
)
},
InlineEval {
file_letters: &['f'],
..InlineEval::new(
&["php"],
&['r', 'R', 'B', 'E', 'F'],
&['d', 'c', 'f', 'z'],
PHP_LONG_PROGRAM_FLAGS,
)
},
];
const NODE_FLAGS: &[&str] = &[
"--enable-source-maps",
"--no-warnings",
"--no-deprecation",
"--trace-warnings",
"--expose-gc",
"--inspect",
"--inspect-brk",
"--inspect-wait",
"--preserve-symlinks",
"--preserve-symlinks-main",
"--experimental-vm-modules",
"--experimental-strip-types",
];
const NODE_VALUE_LONGS: &[&str] = &[
"--require",
"--import",
"--loader",
"--experimental-loader",
"--title",
"--conditions",
"--input-type",
"--env-file",
];
const PHP_LONG_PROGRAM_FLAGS: &[LongInlineFlag] = &[
LongInlineFlag::Run,
LongInlineFlag::ProcessBegin,
LongInlineFlag::ProcessCode,
LongInlineFlag::ProcessEnd,
LongInlineFlag::ProcessFile,
];
const LONG_EVAL_PRINT: &[LongInlineFlag] = &[LongInlineFlag::Eval, LongInlineFlag::Print];
const DATA_URL_LOADERS: &[LongInlineFlag] = &[
LongInlineFlag::Import,
LongInlineFlag::Loader,
LongInlineFlag::ExperimentalLoader,
];
const PYTHON_VALUE_LETTERS: &[char] = &['m', 'W', 'X', 'Q'];
struct InlineEval {
names: &'static [&'static str],
program_letters: &'static [char],
value_letters: &'static [char],
long: &'static [LongInlineFlag],
module_letters: &'static [char],
debugger_letters: &'static [char],
data_url_flags: &'static [LongInlineFlag],
flag_longs: &'static [&'static str],
flag_prefixes: &'static [&'static str],
program: &'static [ProgramFlag],
positional: Positional,
separate_value_letters: &'static [char],
value_longs: &'static [&'static str],
file_letters: &'static [char],
chdir_letters: &'static [char],
chdir_longs: &'static [&'static str],
}
impl InlineEval {
fn is(&self, stem: &str) -> bool {
self.names.iter().any(|name| is_interpreter(stem, name))
}
const fn operand_grammar(&self) -> OperandGrammar {
OperandGrammar {
value_letters: self.separate_value_letters,
value_longs: self.value_longs,
file_letters: self.file_letters,
chdir_letters: self.chdir_letters,
chdir_longs: self.chdir_longs,
flag_longs: self.flag_longs,
flag_prefixes: self.flag_prefixes,
program: self.program,
..GETOPT_OPERANDS
}
}
fn flag_in(&self, args: &[String], index: usize) -> Option<InlineFlag> {
let arg = args.get(index)?.trim();
let next = args.get(index.saturating_add(1)).map(String::as_str);
let reached =
|letter| self.program_letters.contains(&letter) || self.value_letters.contains(&letter);
if let Some((letter, rest)) = scan_short(arg, &['-'], reached) {
if self.program_letters.contains(&letter) {
return Some(InlineFlag::Short(letter));
}
if self.debugger_letters.contains(&letter) && !is_debugger_spec(rest) {
return Some(InlineFlag::Short(letter));
}
let module = if rest.is_empty() { next } else { Some(rest) };
if self.module_letters.contains(&letter) && !module.is_some_and(is_module_spec) {
return Some(InlineFlag::Short(letter));
}
}
let (name, attached) = long_name(arg);
if let Some(flag) = self.long.iter().find(|flag| flag.as_str() == name) {
return Some(InlineFlag::Long(*flag));
}
let name = name.replace('_', "-");
let flag = self
.data_url_flags
.iter()
.find(|flag| flag.as_str() == name)?;
attached
.or(next)
.is_some_and(is_data_url)
.then_some(InlineFlag::Long(*flag))
}
const fn new(
names: &'static [&'static str],
program_letters: &'static [char],
value_letters: &'static [char],
long: &'static [LongInlineFlag],
) -> Self {
Self {
names,
program_letters,
value_letters,
long,
module_letters: &[],
debugger_letters: &[],
data_url_flags: &[],
flag_longs: &[],
flag_prefixes: &[],
program: &[],
positional: Positional::Script,
separate_value_letters: value_letters,
value_longs: &[],
file_letters: &[],
chdir_letters: &[],
chdir_longs: &[],
}
}
}
pub const NPM_SPECIFIER_PREFIX: &str = "npm:";
const MAX_WRAPPER_DEPTH: usize = 8;
const APPLET_BINARIES: &[&str] = &["busybox", "toybox"];
const COREUTILS: &str = "coreutils";
const COREUTILS_PROG: &str = "--coreutils-prog";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WrapperKind {
Env,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WrappedProgram<'a> {
pub index: usize,
pub program: &'a str,
pub starter: WrapperKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct WrappedPrograms<'a>(Vec<WrappedProgram<'a>>);
impl<'a> WrappedPrograms<'a> {
#[must_use]
pub fn programs(&self) -> &[WrappedProgram<'a>] {
&self.0
}
}
pub fn analyze_launch<'a>(
command: &str,
args: &'a [String],
) -> Result<WrappedPrograms<'a>, LauncherRefusal> {
analyze_with(command, args, None)
}
pub fn analyze_launch_in<'a>(
command: &str,
args: &'a [String],
cwd: &'a Path,
) -> Result<WrappedPrograms<'a>, LauncherRefusal> {
analyze_with(command, args, Some(cwd))
}
fn analyze_with<'a>(
command: &str,
args: &'a [String],
probe: Option<&'a Path>,
) -> Result<WrappedPrograms<'a>, LauncherRefusal> {
if args.iter().any(|arg| arg.starts_with(NPM_SPECIFIER_PREFIX)) {
return Err(selects(command, LaunchTrigger::NpmSpecifier));
}
let mut analysis = Analysis {
probe,
..Analysis::default()
};
analysis.command(command, args, 0, 0, WorkingDir::Inherited)?;
Ok(WrappedPrograms(analysis.programs))
}
fn selects(command: &str, trigger: LaunchTrigger) -> LauncherRefusal {
LauncherRefusal::SelectsWorkspaceCode {
program: EchoedPath::program(command),
trigger,
}
}
fn unanalyzable(command: &str, reason: UnanalyzableLaunch) -> LauncherRefusal {
LauncherRefusal::Unanalyzable {
program: EchoedPath::program(command),
reason,
}
}
fn deepen(depth: usize) -> Result<(), LauncherRefusal> {
(depth < MAX_WRAPPER_DEPTH).ok_or(LauncherRefusal::TooDeep)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WorkingDir {
Inherited,
Changed,
}
#[derive(Clone, Copy)]
struct Frame {
base: usize,
depth: usize,
dir: WorkingDir,
}
#[derive(Default)]
struct Analysis<'a> {
programs: Vec<WrappedProgram<'a>>,
probe: Option<&'a Path>,
}
impl<'a> Analysis<'a> {
fn command(
&mut self,
command: &str,
args: &'a [String],
base: usize,
depth: usize,
dir: WorkingDir,
) -> Result<(), LauncherRefusal> {
let stem = CommandStem::of(command);
if let Some(wrapper) = EXEC_WRAPPERS
.iter()
.find(|wrapper| stem.is_any(wrapper.names))
{
deepen(depth)?;
if args.is_empty() {
return Ok(());
}
let invocation = match wrapper.command_index(args, dir) {
Err(UnanalyzableLaunch::MissingCommand) if wrapper.env().is_some() => return Ok(()),
other => other.map_err(|reason| unanalyzable(command, reason))?,
};
if invocation.dir == WorkingDir::Changed
&& args
.get(invocation.command)
.is_some_and(|program| is_relative_path(program))
{
return Err(unanalyzable(
command,
UnanalyzableLaunch::RelativeProgramAfterChdir,
));
}
let starter = wrapper.grammar.kind();
let frame = Frame {
base,
depth,
dir: invocation.dir,
};
return self.start(command, args, invocation.command, starter, frame);
}
if stem.is_any(APPLET_BINARIES) || stem.is(COREUTILS) {
deepen(depth)?;
let Some((first, rest)) = args.split_first() else {
return Err(unanalyzable(command, UnanalyzableLaunch::MissingCommand));
};
let applet = if stem.is(COREUTILS) {
coreutils_applet(first).map_err(|reason| unanalyzable(command, reason))?
} else {
first
};
return self.command(
applet,
rest,
base.saturating_add(1),
depth.saturating_add(1),
dir,
);
}
refuse_workspace_code(command, &stem, args, dir, self.probe)
}
fn start(
&mut self,
wrapper: &str,
args: &'a [String],
index: usize,
starter: WrapperKind,
frame: Frame,
) -> Result<(), LauncherRefusal> {
let Frame { base, depth, dir } = frame;
let Some(program) = args.get(index) else {
return Err(unanalyzable(wrapper, UnanalyzableLaunch::MissingCommand));
};
if program.is_empty() {
return Err(unanalyzable(wrapper, UnanalyzableLaunch::BlankProgram));
}
self.programs.push(WrappedProgram {
index: base.saturating_add(index),
program,
starter,
});
let rest = args.get(index.saturating_add(1)..).unwrap_or_default();
self.command(
program,
rest,
base.saturating_add(index).saturating_add(1),
depth.saturating_add(1),
dir,
)
}
}
fn coreutils_applet(first: &str) -> Result<&str, UnanalyzableLaunch> {
let Some(rest) = first.strip_prefix(COREUTILS_PROG) else {
return Ok(first);
};
match rest.strip_prefix('=') {
Some("") => Err(UnanalyzableLaunch::BlankProgram),
Some(applet) => Ok(applet),
None => Err(UnanalyzableLaunch::UnknownOption(EchoedArgument::name(
first,
))),
}
}
fn is_relative_path(program: &str) -> bool {
let path = Path::new(program);
!path.is_absolute() && path.components().count() > 1
}
fn refuse_workspace_code(
command: &str,
stem: &CommandStem,
args: &[String],
mut dir: WorkingDir,
probe: Option<&Path>,
) -> Result<(), LauncherRefusal> {
if let Some((_, family)) = SHELLS
.iter()
.find(|(shell, _)| is_interpreter(stem.as_str(), shell))
{
if let Some(trigger) = family.trigger(args) {
return Err(selects(command, trigger));
}
family
.check_stdin_program(args, dir, probe)
.map_err(|stop| stop.refusal(command))?;
}
if let Some(eval) = INLINE_EVAL.iter().find(|eval| eval.is(stem.as_str())) {
if let Some(flag) = (0..args.len()).find_map(|index| eval.flag_in(args, index)) {
return Err(selects(command, LaunchTrigger::InlineProgram(flag)));
}
walk_operand(args, &eval.operand_grammar(), &mut dir)
.and_then(|program| check_program(program, eval.positional, dir, probe))
.map_err(|stop| stop.refusal(command))?;
}
if stem.is_any(NPM_PACKAGE_RUNNERS) {
return Err(selects(command, LaunchTrigger::Always));
}
match RUNNERS.iter().find(|(name, _)| stem.is(name)) {
Some((_, LaunchRule::Always)) => Err(selects(command, LaunchTrigger::Always)),
Some((_, LaunchRule::Subcommands(subcommands))) => subcommands
.iter()
.find(|subcommand| args.iter().any(|arg| arg == subcommand.as_str()))
.map_or(Ok(()), |subcommand| {
Err(selects(command, LaunchTrigger::Subcommand(*subcommand)))
}),
None => Ok(()),
}
}
#[derive(Debug, Clone, Copy)]
enum LongMatch {
Exact,
Prefix,
}
impl LongMatch {
fn accepts(self, arg: &str, flag: &str) -> bool {
let (name, _) = long_name(arg);
match self {
Self::Exact => name == flag,
Self::Prefix => name.len() > 2 && name.starts_with("--") && flag.starts_with(name),
}
}
}
struct ShellGrammar {
short: &'static [ShellFlag],
value_letters: &'static [char],
long: &'static [LongCommandName],
long_match: LongMatch,
}
const POSIX_GRAMMAR: ShellGrammar = ShellGrammar {
short: &[ShellFlag::DashC, ShellFlag::PlusC],
value_letters: &[],
long: &[LongCommandName::Command, LongCommandName::Commands],
long_match: LongMatch::Exact,
};
const FISH_GRAMMAR: ShellGrammar = ShellGrammar {
short: &[ShellFlag::DashC, ShellFlag::DashCapitalC],
value_letters: &['d', 'o', 'D', 'f', 'p'],
long: &[
LongCommandName::Command,
LongCommandName::Commands,
LongCommandName::InitCommand,
],
long_match: LongMatch::Prefix,
};
const NU_GRAMMAR: ShellGrammar = ShellGrammar {
short: &[ShellFlag::DashC, ShellFlag::DashE],
value_letters: &['I'],
long: &[
LongCommandName::Command,
LongCommandName::Commands,
LongCommandName::Execute,
],
long_match: LongMatch::Exact,
};
fn grammar_flag(grammar: &ShellGrammar, arg: &str) -> Option<ShellFlag> {
grammar
.long
.iter()
.find(|name| grammar.long_match.accepts(arg, name.as_str()))
.map(|&name| ShellFlag::LongCommand(name))
.or_else(|| {
let prefix = arg.chars().next()?;
let spelled = |letter| {
grammar
.short
.iter()
.copied()
.find(|flag| flag.short_spelling() == Some((prefix, letter)))
};
let reached =
|letter| spelled(letter).is_some() || grammar.value_letters.contains(&letter);
let (letter, _) = scan_short(arg, &[prefix], reached)?;
spelled(letter)
})
}
fn go_flag(arg: &str) -> Option<ShellFlag> {
let name = strip_dashes(arg)?;
(long_name(name).0 == "c").then_some(ShellFlag::DashC)
}
fn strip_dashes(arg: &str) -> Option<&str> {
arg.strip_prefix("--").or_else(|| arg.strip_prefix('-'))
}
struct PowerShellArgument<'a> {
name: String,
value: Option<&'a str>,
}
const fn is_powershell_prefix(c: char) -> bool {
c == '/' || is_powershell_dash(c)
}
const fn is_powershell_dash(c: char) -> bool {
matches!(c, '-' | '\u{2013}' | '\u{2014}' | '\u{2015}')
}
fn powershell_argument(arg: &str) -> Option<PowerShellArgument<'_>> {
let rest = arg.strip_prefix(is_powershell_prefix)?;
let rest = rest.strip_prefix(is_powershell_dash).unwrap_or(rest);
let (name, value) = rest
.split_once([':', '='])
.map_or((rest, None), |(name, value)| (name, Some(value)));
Some(PowerShellArgument {
name: name.to_ascii_lowercase(),
value,
})
}
fn powershell_flag(arg: &str) -> Option<ShellFlag> {
powershell_argument(arg)
.and_then(|argument| powershell_parameter(&argument.name))
.map(ShellFlag::PowerShell)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParameterKind {
Switch,
Value,
File,
WorkingDirectory,
}
struct PowerShellParameterSpec {
name: &'static str,
shortest: usize,
kind: ParameterKind,
}
impl PowerShellParameterSpec {
const fn new(name: &'static str, shortest: usize, kind: ParameterKind) -> Self {
Self {
name,
shortest,
kind,
}
}
fn accepts(&self, spelled: &str) -> bool {
spelled.len() >= self.shortest && self.name.starts_with(spelled)
}
}
const PARAMETERS_51: &[PowerShellParameterSpec] = &[
PowerShellParameterSpec::new("file", 1, ParameterKind::File),
PowerShellParameterSpec::new("noexit", 3, ParameterKind::Switch),
PowerShellParameterSpec::new("noprofile", 3, ParameterKind::Switch),
PowerShellParameterSpec::new("nologo", 3, ParameterKind::Switch),
PowerShellParameterSpec::new("noninteractive", 4, ParameterKind::Switch),
PowerShellParameterSpec::new("sta", 1, ParameterKind::Switch),
PowerShellParameterSpec::new("mta", 3, ParameterKind::Switch),
PowerShellParameterSpec::new("help", 1, ParameterKind::Switch),
PowerShellParameterSpec::new("?", 1, ParameterKind::Switch),
PowerShellParameterSpec::new("importsystemmodules", 3, ParameterKind::Switch),
PowerShellParameterSpec::new("executionpolicy", 2, ParameterKind::Value),
PowerShellParameterSpec::new("ep", 2, ParameterKind::Value),
PowerShellParameterSpec::new("inputformat", 2, ParameterKind::Value),
PowerShellParameterSpec::new("if", 2, ParameterKind::Value),
PowerShellParameterSpec::new("outputformat", 1, ParameterKind::Value),
PowerShellParameterSpec::new("of", 2, ParameterKind::Value),
PowerShellParameterSpec::new("windowstyle", 1, ParameterKind::Value),
PowerShellParameterSpec::new("version", 1, ParameterKind::Value),
PowerShellParameterSpec::new("psconsolefile", 3, ParameterKind::Value),
PowerShellParameterSpec::new("configurationname", 6, ParameterKind::Value),
PowerShellParameterSpec::new("encodedarguments", 8, ParameterKind::Value),
PowerShellParameterSpec::new("ea", 2, ParameterKind::Value),
];
fn windows_powershell_trigger(args: &[String]) -> Option<LaunchTrigger> {
let mut args = args.iter().map(|arg| arg.trim());
while let Some(arg) = args.next() {
let Some(parameter) = powershell_argument(arg) else {
return Some(LaunchTrigger::ImplicitCommandString);
};
if let Some(command) = powershell_parameter(¶meter.name) {
return Some(LaunchTrigger::CommandString(ShellFlag::PowerShell(command)));
}
let known = PARAMETERS_51
.iter()
.find(|known| known.accepts(¶meter.name))
.filter(|_| parameter.value.is_none());
match known.map(|known| known.kind) {
None => return Some(LaunchTrigger::ImplicitCommandString),
Some(ParameterKind::File) => return None,
Some(ParameterKind::Value | ParameterKind::WorkingDirectory) => {
args.next();
}
Some(ParameterKind::Switch) => {}
}
}
None
}
fn powershell_parameter(name: &str) -> Option<PowerShellParameter> {
match name {
"ec" => return Some(PowerShellParameter::EncodedCommand),
"cwa" => return Some(PowerShellParameter::CommandWithArgs),
"" => return None,
_ => {}
}
PowerShellParameter::ALL.into_iter().find(|parameter| {
parameter
.as_str()
.strip_prefix('-')
.and_then(|full| full.get(..name.len()))
.is_some_and(|head| head.eq_ignore_ascii_case(name))
})
}
const PARAMETERS_7: &[PowerShellParameterSpec] = &[
PowerShellParameterSpec::new("file", 1, ParameterKind::File),
PowerShellParameterSpec::new("noexit", 3, ParameterKind::Switch),
PowerShellParameterSpec::new("nologo", 3, ParameterKind::Switch),
PowerShellParameterSpec::new("noprofile", 3, ParameterKind::Switch),
PowerShellParameterSpec::new("noninteractive", 4, ParameterKind::Switch),
PowerShellParameterSpec::new("interactive", 1, ParameterKind::Switch),
PowerShellParameterSpec::new("login", 1, ParameterKind::Switch),
PowerShellParameterSpec::new("mta", 3, ParameterKind::Switch),
PowerShellParameterSpec::new("sta", 3, ParameterKind::Switch),
PowerShellParameterSpec::new("help", 1, ParameterKind::Switch),
PowerShellParameterSpec::new("?", 1, ParameterKind::Switch),
PowerShellParameterSpec::new("version", 1, ParameterKind::Switch),
PowerShellParameterSpec::new("sshservermode", 4, ParameterKind::Switch),
PowerShellParameterSpec::new("workingdirectory", 2, ParameterKind::WorkingDirectory),
PowerShellParameterSpec::new("wd", 2, ParameterKind::WorkingDirectory),
];
#[derive(Clone, Copy)]
enum Program<'a> {
Operand(&'a str),
Flag,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Positional {
Script,
Subcommand,
}
#[derive(Debug)]
enum Stop {
Stdin(StdinSource),
Unanalyzable(UnanalyzableLaunch),
}
impl Stop {
fn refusal(self, command: &str) -> LauncherRefusal {
match self {
Self::Stdin(source) => selects(command, LaunchTrigger::ProgramFromStdin(source)),
Self::Unanalyzable(reason) => unanalyzable(command, reason),
}
}
}
#[derive(Debug, Clone, Copy)]
enum ShortStyle {
Cluster,
Getopt,
Go,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum UnknownLong {
Value,
Flag,
}
#[derive(Debug, Clone, Copy)]
enum ProgramFlag {
Short(char),
Long(&'static str),
LongPrefix(&'static str),
}
enum Read<'a> {
Program,
Values(usize),
Guess(&'a str),
File(Option<&'a str>),
}
struct OperandGrammar {
style: ShortStyle,
long_match: LongMatch,
unknown_long: UnknownLong,
value_letters: &'static [char],
value_longs: &'static [&'static str],
flag_longs: &'static [&'static str],
flag_prefixes: &'static [&'static str],
program: &'static [ProgramFlag],
stdin_letter: Option<char>,
unanalyzable_longs: &'static [&'static str],
file_letters: &'static [char],
chdir_letters: &'static [char],
chdir_longs: &'static [&'static str],
stdin_option: Option<&'static str>,
}
const GETOPT_OPERANDS: OperandGrammar = OperandGrammar {
style: ShortStyle::Getopt,
long_match: LongMatch::Exact,
unknown_long: UnknownLong::Value,
value_letters: &[],
value_longs: &[],
flag_longs: &[],
flag_prefixes: &[],
program: &[],
stdin_letter: None,
unanalyzable_longs: &[],
file_letters: &[],
chdir_letters: &[],
chdir_longs: &[],
stdin_option: None,
};
const POSIX_OPERANDS: OperandGrammar = OperandGrammar {
style: ShortStyle::Cluster,
unknown_long: UnknownLong::Flag,
value_letters: &['o', 'O', 'T', 'R'],
value_longs: &["--rcfile", "--init-file", "--emulate"],
stdin_letter: Some('s'),
stdin_option: Some("shinstdin"),
..GETOPT_OPERANDS
};
const XONSH_OPERANDS: OperandGrammar = OperandGrammar {
long_match: LongMatch::Prefix,
value_letters: &['D'],
flag_longs: &[
"--no-rc",
"--login",
"--interactive",
"--no-env",
"--no-script-cache",
"--cache-everything",
"--timings",
],
unanalyzable_longs: &["--rc"],
..GETOPT_OPERANDS
};
const FISH_OPERANDS: OperandGrammar = OperandGrammar {
long_match: LongMatch::Prefix,
value_letters: &['d', 'o', 'D', 'f', 'p'],
value_longs: &[
"--debug",
"--debug-output",
"--features",
"--profile",
"--profile-startup",
],
flag_longs: &[
"--no-config",
"--login",
"--interactive",
"--private",
"--no-execute",
],
..GETOPT_OPERANDS
};
const NU_OPERANDS: OperandGrammar = OperandGrammar {
value_letters: &['I', 'm', 't'],
flag_longs: &[
"--stdin",
"--no-config-file",
"--no-std-lib",
"--no-history",
"--login",
"--interactive",
],
program: &[
ProgramFlag::Long("--lsp"),
ProgramFlag::LongPrefix("--ide-"),
],
..GETOPT_OPERANDS
};
const ELVISH_OPERANDS: OperandGrammar = OperandGrammar {
style: ShortStyle::Go,
flag_longs: &["norc", "i", "compileonly", "json", "daemon"],
program: &[ProgramFlag::Long("lsp")],
..GETOPT_OPERANDS
};
fn normalized_option(name: &str) -> String {
name.chars()
.filter(|c| !matches!(c, '_' | '-'))
.map(|c| c.to_ascii_lowercase())
.collect()
}
fn flag_stays_on(attached: Option<&str>) -> bool {
attached.is_none_or(|value| matches!(value, "true" | "1"))
}
impl OperandGrammar {
fn names_stdin_option(&self, name: &str) -> bool {
self.stdin_option
.is_some_and(|option| normalized_option(name) == option)
}
fn read<'a>(&self, arg: &'a str, dir: &mut WorkingDir) -> Result<Read<'a>, Stop> {
match self.style {
ShortStyle::Go => Ok(self.read_go(arg)),
_ if arg.starts_with("--") => self.read_long(arg, dir),
_ => self.read_cluster(arg, dir),
}
}
fn read_long<'a>(&self, arg: &'a str, dir: &mut WorkingDir) -> Result<Read<'a>, Stop> {
let (name, attached) = long_name(arg);
let spells = |flag: &&str| self.long_match.accepts(arg, flag);
if self.unanalyzable_longs.iter().any(spells) {
return Err(Stop::Unanalyzable(UnanalyzableLaunch::UnknownOption(
EchoedArgument::name(name),
)));
}
if self.names_stdin_option(name) {
return Err(Stop::Stdin(StdinSource::DashS));
}
if self.chdir_longs.iter().any(spells) {
*dir = WorkingDir::Changed;
}
let provides_program = self.program.iter().any(|flag| match flag {
ProgramFlag::Long(long) => self.long_match.accepts(arg, long),
ProgramFlag::LongPrefix(prefix) => name.starts_with(prefix),
ProgramFlag::Short(_) => false,
});
if provides_program && flag_stays_on(attached) {
return Ok(Read::Program);
}
if self.value_longs.iter().any(spells) {
return Ok(Read::Values(usize::from(attached.is_none())));
}
let listed = self.flag_longs.iter().any(spells)
|| provides_program
|| self
.flag_prefixes
.iter()
.any(|prefix| name.starts_with(prefix));
if listed || attached.is_some() || self.unknown_long == UnknownLong::Flag {
Ok(Read::Values(0))
} else {
Ok(Read::Guess(name))
}
}
fn read_cluster<'a>(&self, arg: &'a str, dir: &mut WorkingDir) -> Result<Read<'a>, Stop> {
let cluster = arg.strip_prefix(['-', '+']).unwrap_or(arg);
let mut values = 0_usize;
for (offset, letter) in cluster.char_indices() {
let rest = cluster
.get(offset.saturating_add(letter.len_utf8())..)
.unwrap_or_default();
if self.stdin_letter == Some(letter) {
return Err(Stop::Stdin(StdinSource::DashS));
}
if self.chdir_letters.contains(&letter) {
*dir = WorkingDir::Changed;
}
if self.file_letters.contains(&letter) {
return Ok(Read::File(Some(rest).filter(|rest| !rest.is_empty())));
}
if self
.program
.iter()
.any(|flag| matches!(flag, ProgramFlag::Short(short) if *short == letter))
{
return Ok(Read::Program);
}
if self.value_letters.contains(&letter) {
if matches!(self.style, ShortStyle::Getopt) {
return Ok(Read::Values(usize::from(rest.is_empty())));
}
values = values.saturating_add(1);
}
}
Ok(Read::Values(values))
}
fn read_go<'a>(&self, arg: &'a str) -> Read<'a> {
let (name, attached) = long_name(strip_dashes(arg).unwrap_or(arg));
let provides_program = self
.program
.iter()
.any(|flag| matches!(flag, ProgramFlag::Long(long) if *long == name));
if provides_program && flag_stays_on(attached) {
Read::Program
} else if provides_program
|| self.flag_longs.contains(&name)
|| attached.is_some()
|| self.unknown_long == UnknownLong::Flag
{
Read::Values(0)
} else {
Read::Guess(arg)
}
}
}
fn vet_guessed_value(value: &str) -> Result<(), Stop> {
check_program(
Program::Operand(value),
Positional::Subcommand,
WorkingDir::Inherited,
None,
)
}
fn missing_after_guess(guessed: Option<&str>) -> Stop {
guessed.map_or(Stop::Stdin(StdinSource::NoProgram), |name| {
Stop::Unanalyzable(UnanalyzableLaunch::UnknownOption(EchoedArgument::name(
name,
)))
})
}
fn walk_operand<'a>(
args: &'a [String],
grammar: &OperandGrammar,
dir: &mut WorkingDir,
) -> Result<Program<'a>, Stop> {
let mut args = args.iter().map(|arg| arg.trim());
let mut guessed = None;
while let Some(arg) = args.next() {
match arg {
"--" => {
return args.next().map_or_else(
|| Err(missing_after_guess(guessed)),
|op| Ok(Program::Operand(op)),
);
}
"-" => return Ok(Program::Operand(arg)),
_ => {}
}
let is_option = match grammar.style {
ShortStyle::Cluster => arg.starts_with(['-', '+']),
ShortStyle::Getopt | ShortStyle::Go => arg.starts_with('-'),
};
if !is_option {
return Ok(Program::Operand(arg));
}
match grammar.read(arg, dir)? {
Read::Program => return Ok(Program::Flag),
Read::File(glued) => {
return glued.or_else(|| args.next()).map_or_else(
|| Err(missing_after_guess(guessed)),
|op| Ok(Program::Operand(op)),
);
}
Read::Values(count) => {
for value in args.by_ref().take(count) {
if grammar.names_stdin_option(value) {
return Err(Stop::Stdin(StdinSource::DashS));
}
}
}
Read::Guess(name) => {
guessed.get_or_insert(name);
if let Some(value) = args.next() {
vet_guessed_value(value)?;
}
}
}
}
Err(missing_after_guess(guessed))
}
fn walk_powershell<'a>(
args: &'a [String],
parameters: &[PowerShellParameterSpec],
dir: &mut WorkingDir,
) -> Result<Program<'a>, Stop> {
let mut args = args.iter().map(|arg| arg.trim());
let mut guessed = None;
while let Some(arg) = args.next() {
let Some(parameter) = powershell_argument(arg).filter(|_| arg != "-") else {
return Ok(Program::Operand(arg));
};
let known = parameters
.iter()
.find(|known| known.accepts(¶meter.name));
let kind = known.map_or(ParameterKind::Value, |known| known.kind);
let takes_next = parameter.value.is_none();
match kind {
ParameterKind::Switch => {}
ParameterKind::Value | ParameterKind::WorkingDirectory => {
if kind == ParameterKind::WorkingDirectory {
*dir = WorkingDir::Changed;
}
if takes_next {
if known.is_some() {
args.next();
} else {
guessed.get_or_insert(arg);
if let Some(value) = args.next() {
vet_guessed_value(value)?;
}
}
}
}
ParameterKind::File => {
return parameter.value.or_else(|| args.next()).map_or_else(
|| Err(missing_after_guess(guessed)),
|op| Ok(Program::Operand(op)),
);
}
}
}
Err(missing_after_guess(guessed))
}
fn is_rooted(operand: &str) -> bool {
let mut chars = operand.chars();
match (chars.next(), chars.next()) {
(Some('/' | '\\'), _) => true,
(Some(drive), Some(':')) => drive.is_ascii_alphabetic(),
_ => false,
}
}
fn check_program(
program: Program<'_>,
positional: Positional,
dir: WorkingDir,
probe: Option<&Path>,
) -> Result<(), Stop> {
let operand = match program {
Program::Flag => return Ok(()),
Program::Operand(operand) => operand,
};
if operand == "-" {
return Err(Stop::Stdin(StdinSource::DashOperand));
}
let parts: Vec<&str> = operand
.split(['/', '\\'])
.filter(|part| !part.is_empty() && *part != ".")
.collect();
if parts.contains(&"..") {
return Err(Stop::Stdin(StdinSource::ParentTraversal));
}
let under_device_root = parts.len() > 1
&& parts.first().is_some_and(|root| {
root.eq_ignore_ascii_case("dev") || root.eq_ignore_ascii_case("proc")
});
if operand.starts_with(['/', '\\']) && under_device_root {
return Err(Stop::Stdin(StdinSource::DeviceOperand));
}
if !is_rooted(operand) && dir == WorkingDir::Changed && positional == Positional::Script {
return Err(Stop::Unanalyzable(
UnanalyzableLaunch::RelativeOperandAfterChdir,
));
}
if let (Positional::Script, Some(base)) = (positional, probe)
&& std::fs::metadata(base.join(operand)).is_ok_and(|meta| !meta.is_file() && !meta.is_dir())
{
return Err(Stop::Stdin(StdinSource::SpecialFile));
}
Ok(())
}
fn scan_short<'a>(
arg: &'a str,
prefixes: &[char],
reached: impl Fn(char) -> bool,
) -> Option<(char, &'a str)> {
let cluster = arg
.strip_prefix(prefixes)
.filter(|cluster| !cluster.starts_with('-'))?;
let (offset, letter) = cluster
.char_indices()
.take_while(|(_, letter)| letter.is_alphanumeric())
.find(|&(_, letter)| reached(letter))?;
let rest = cluster.get(offset.saturating_add(letter.len_utf8())..);
Some((letter, rest.unwrap_or_default()))
}
const MAX_IMPORT_LIST_BYTES: usize = 1024;
fn is_debugger_spec(rest: &str) -> bool {
let rest = rest.strip_prefix('t').unwrap_or(rest);
rest.is_empty() || rest.strip_prefix(':').is_some_and(is_module_spec)
}
fn is_module_spec(value: &str) -> bool {
let spec = value.strip_prefix('-').unwrap_or(value);
let (module, imports) = spec.split_once('=').unwrap_or((spec, ""));
if imports.len() > MAX_IMPORT_LIST_BYTES {
return false;
}
let identifier = |part: &str| {
part.chars()
.next()
.is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
&& part.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
};
module.split("::").all(identifier)
}
fn is_data_url(value: &str) -> bool {
let mut chars = value
.chars()
.filter(|c| !matches!(c, '\t' | '\n' | '\r'))
.skip_while(|c| c.is_ascii_control() || *c == ' ');
"data:".chars().all(|expected| {
chars
.next()
.is_some_and(|c| c.eq_ignore_ascii_case(&expected))
})
}
const DISTRIBUTION_SUFFIXES: &[&str] = &["-preview", "-static", "-intel64", "-dbg", "_d", "w", "t"];
fn is_interpreter(stem: &str, name: &str) -> bool {
let Some(mut rest) = stem.strip_prefix(name) else {
return false;
};
while let Some(shorter) = DISTRIBUTION_SUFFIXES
.iter()
.find_map(|suffix| rest.strip_suffix(suffix))
{
rest = shorter;
}
let version = rest.strip_prefix('w').unwrap_or(rest);
version
.strip_prefix('-')
.unwrap_or(version)
.chars()
.all(|c| c.is_ascii_digit() || c == '.')
}
fn is_duration(text: &str) -> bool {
let number = text.strip_suffix(['s', 'm', 'h', 'd']).unwrap_or(text);
let digits = |part: &str| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit());
match number.split_once('.') {
Some((whole, fraction)) => digits(whole) && digits(fraction),
None => digits(number),
}
}
#[cfg(test)]
mod tests {
use std::assert_matches;
use super::*;
use crate::util::MAX_ECHOED_NAME_BYTES;
fn strings(args: &[&str]) -> Vec<String> {
args.iter().map(ToString::to_string).collect()
}
fn analyze(command: &str, args: &[&str]) -> Result<Vec<usize>, LauncherRefusal> {
analyze_launch(command, &strings(args)).map(|programs| {
programs
.programs()
.iter()
.map(|wrapped| wrapped.index)
.collect()
})
}
fn launches(command: &str, args: &[&str]) -> bool {
analyze(command, args).is_err()
}
fn unanalyzable_reason(command: &str, args: &[&str]) -> UnanalyzableLaunch {
match analyze(command, args) {
Err(LauncherRefusal::Unanalyzable { reason, .. }) => reason,
other => panic!("expected an unanalyzable launch, got {other:?}"),
}
}
#[test]
fn always_rules_refuse_every_use() {
let always = RUNNERS
.iter()
.filter(|(_, rule)| matches!(rule, LaunchRule::Always))
.map(|(name, _)| *name)
.chain(NPM_PACKAGE_RUNNERS.iter().copied());
for runner in always {
assert!(launches(runner, &[]), "{runner}");
assert!(
launches(&format!("/usr/local/bin/{runner}"), &["x"]),
"{runner}"
);
}
}
#[test]
fn subcommand_rules_refuse_only_the_listed_uses() {
for (command, rule) in RUNNERS {
let LaunchRule::Subcommands(subcommands) = rule else {
continue;
};
for subcommand in *subcommands {
assert_eq!(
analyze(command, &[subcommand.as_str(), "server"]).unwrap_err(),
selects(command, LaunchTrigger::Subcommand(*subcommand)),
"{command} {subcommand}"
);
}
assert!(!launches(command, &["version"]), "{command}");
}
}
#[test]
fn perl_clusters_with_digits_still_carry_an_inline_program() {
for flags in ["-0777e", "-l0e", "-0e", "-l0777e", "-nle", "-pe", "-ne"] {
std::assert_matches!(
analyze("perl", &[flags, "code"]),
Err(LauncherRefusal::SelectsWorkspaceCode {
trigger: LaunchTrigger::InlineProgram(InlineFlag::Short('e' | 'p')),
..
}),
"{flags}"
);
}
assert!(launches("perl", &["-Mstrict", "-e", "code"]));
assert!(launches("perl", &["-0", "-e", "code"]));
assert!(!launches("perl", &["-Mstrict", "script.pl"]));
assert!(!launches("perl", &["-0777", "script.pl"]));
assert!(!launches("perl", &["-l0", "script.pl"]));
}
#[test]
fn the_trigger_names_the_matched_flag() {
assert_eq!(
analyze("node", &["--print", "1"]).unwrap_err(),
selects(
"node",
LaunchTrigger::InlineProgram(InlineFlag::Long(LongInlineFlag::Print))
)
);
assert_eq!(
analyze("bun", &["--eval=1"]).unwrap_err(),
selects(
"bun",
LaunchTrigger::InlineProgram(InlineFlag::Long(LongInlineFlag::Eval))
)
);
assert_eq!(
analyze("ruby", &["-eputs 1"]).unwrap_err(),
selects("ruby", LaunchTrigger::InlineProgram(InlineFlag::Short('e')))
);
assert_eq!(
analyze("fish", &["--command=x"]).unwrap_err(),
selects(
"fish",
LaunchTrigger::CommandString(ShellFlag::LongCommand(LongCommandName::Command))
)
);
assert_eq!(
analyze("bash", &["-lc", "x"]).unwrap_err(),
selects("bash", LaunchTrigger::CommandString(ShellFlag::DashC))
);
}
#[test]
fn posix_shell_flags_are_case_exact_but_powershell_is_not() {
assert!(!launches("bash", &["-C", "script.sh"]));
assert!(!launches("sh", &["-C", "script.sh"]));
assert!(launches("pwsh", &["-COMMAND", "x"]));
}
#[test]
fn deno_lsp_and_servers_started_directly_are_allowed() {
assert!(!launches("deno", &["lsp"]));
assert!(!launches("rust-analyzer", &[]));
assert!(!launches("/usr/bin/gopls", &["serve"]));
assert!(!launches("typescript-language-server", &["--stdio"]));
}
#[test]
fn an_npm_specifier_argument_refuses_any_command() {
assert_eq!(
analyze("deno", &["lsp", "npm:typescript-language-server"]).unwrap_err(),
selects("deno", LaunchTrigger::NpmSpecifier)
);
assert!(launches("node", &["npm:server"]));
}
#[test]
fn windows_extensions_and_case_are_ignored() {
assert!(launches("NPX.CMD", &[]));
assert!(launches("yarn.exe", &[]));
}
#[test]
fn env_is_unwrapped_to_the_command_it_starts() {
assert!(launches("env", &["npx", "server"]));
assert!(launches("env", &["-i", "-u", "BAR", "make"]));
assert!(launches("/usr/bin/env", &["--", "cargo", "run"]));
assert!(!launches("env", &["FOO=1", "rust-analyzer"]));
assert!(!launches("env", &["-i", "cargo", "--version"]));
assert!(!launches("env", &[]));
}
#[test]
fn env_split_string_and_deep_chains_are_unanalyzable() {
assert_eq!(
unanalyzable_reason("env", &["-S", "rust-analyzer --flag"]),
UnanalyzableLaunch::SplitString
);
assert_eq!(
unanalyzable_reason("env", &["--split-string=rust-analyzer"]),
UnanalyzableLaunch::SplitString
);
let mut chain: Vec<&str> = vec!["env"; MAX_WRAPPER_DEPTH + 1];
chain.push("rust-analyzer");
assert_eq!(
analyze("env", &chain).unwrap_err(),
LauncherRefusal::TooDeep
);
}
#[test]
fn env_options_that_change_what_runs_are_unanalyzable() {
for args in [
&["-iS", "npx srv"][..],
&["-iC", "/dir", "rust-analyzer"],
&["-vu", "X", "rust-analyzer"],
&["-uX", "rust-analyzer"],
&["-P", "/ws/bin", "rust-analyzer"],
&["-Pi", "rust-analyzer"],
&["--chdir", "/d", "--bogus", "rust-analyzer"],
&["--ignore-environment=x", "rust-analyzer"],
&["-z", "rust-analyzer"],
&["PATH=/ws/bin", "rust-analyzer"],
&["path=/ws/bin", "rust-analyzer"],
&["FOO=1", "Path=/ws/bin", "rust-analyzer"],
] {
assert!(launches("env", args), "{args:?}");
}
assert_eq!(
unanalyzable_reason("env", &["PATH=/ws/bin", "srv"]),
UnanalyzableLaunch::PathAssignment
);
}
#[test]
fn env_options_that_only_edit_the_environment_are_unwrapped() {
for args in [
&["-i", "rust-analyzer"][..],
&["-0", "-v", "rust-analyzer"],
&["-iv", "rust-analyzer"],
&["-u", "FOO", "rust-analyzer"],
&["-C", "/dir", "rust-analyzer"],
&["--ignore-environment", "--null", "--debug", "rust-analyzer"],
&["--unset", "FOO", "--chdir=/d", "rust-analyzer"],
&["--unset=FOO", "rust-analyzer"],
&["FOO=1", "PATHEXT=.exe", "rust-analyzer"],
] {
assert!(!launches("env", args), "{args:?}");
}
}
#[test]
fn env_stops_option_parsing_at_a_lone_dash_and_the_first_assignment() {
for args in [
&["-C", "d", "-", "-u", "srv"][..],
&["-", "-u", "X", "srv"],
&["-", "-i", "srv"],
] {
std::assert_matches!(
unanalyzable_reason("env", args),
UnanalyzableLaunch::UnknownOption(_),
"{args:?}"
);
}
assert_eq!(analyze("env", &["-", "srv"]), Ok(vec![1]));
assert_eq!(analyze("env", &["-i", "-", "A=1", "srv"]), Ok(vec![3]));
assert_eq!(analyze("env", &["FOO=1", "-i", "srv"]), Ok(vec![1]));
}
#[test]
fn env_after_a_lone_dash_echoes_only_the_offending_letter() {
let Err(LauncherRefusal::Unanalyzable {
reason: UnanalyzableLaunch::UnknownOption(option),
..
}) = analyze("env", &["-", "-uSECRETTOKEN", "srv"])
else {
panic!("expected an unknown option");
};
assert_eq!(option.as_str(), "-u");
}
#[test]
fn env_applies_assignments_after_the_end_of_options_marker() {
assert_eq!(analyze("env", &["--", "FOO=1", "srv"]), Ok(vec![2]));
assert_eq!(
unanalyzable_reason("env", &["--", "PATH=/ws/bin", "srv"]),
UnanalyzableLaunch::PathAssignment
);
assert_eq!(
unanalyzable_reason("env", &["-i", "A=1", "path=/ws/bin", "srv"]),
UnanalyzableLaunch::PathAssignment
);
}
#[test]
fn gnu_aliases_and_multi_call_binaries_are_unwrapped() {
for (command, args, index) in [
("genv", &["FOO=1", "srv"][..], 1),
("gtimeout", &["5", "srv"], 1),
("gnice", &["-n", "5", "srv"], 2),
("gnohup", &["srv"], 0),
("gstdbuf", &["-oL", "srv"], 1),
("gtime", &["-p", "srv"], 1),
] {
assert_eq!(analyze(command, args), Ok(vec![index]), "{command}");
}
assert!(launches("genv", &["sh", "-c", "x"]));
assert!(launches("gtimeout", &["5", "npx", "srv"]));
assert!(launches("toybox", &["sh", "-c", "x"]));
assert!(launches("coreutils", &["env", "npx", "srv"]));
assert_eq!(analyze("coreutils", &["nice", "srv"]), Ok(vec![1]));
}
#[test]
fn user_switching_wrappers_are_refused_but_ast_grep_is_not() {
for wrapper in [
"gosu",
"su-exec",
"chpst",
"setuidgid",
"envdir",
"runas",
"wsl",
] {
assert!(launches(wrapper, &["srv"]), "{wrapper}");
}
assert!(!launches("sg", &["lsp"]));
}
#[test]
fn env_refuses_a_missing_option_value() {
assert_eq!(
unanalyzable_reason("env", &["-i", "-u"]),
UnanalyzableLaunch::MissingValue(EchoedArgument::name("-u"))
);
std::assert_matches!(
unanalyzable_reason("env", &["--chdir"]),
UnanalyzableLaunch::MissingValue(_)
);
}
#[test]
fn env_refuses_a_relative_program_after_a_directory_change() {
for args in [
&["-C", "/d", "./srv"][..],
&["--chdir=/d", "bin/srv"],
&["--chdir", "/d", "--", "bin/srv"],
] {
assert_eq!(
unanalyzable_reason("env", args),
UnanalyzableLaunch::RelativeProgramAfterChdir,
"{args:?}"
);
}
assert!(!launches("env", &["-C", "/d", "rust-analyzer"]));
assert!(!launches("env", &["./srv"]));
}
#[test]
fn a_blank_program_after_a_wrapper_is_refused() {
assert_eq!(
unanalyzable_reason("env", &["FOO=1", ""]),
UnanalyzableLaunch::BlankProgram
);
assert_eq!(
unanalyzable_reason("nice", &["-n", "5", ""]),
UnanalyzableLaunch::BlankProgram
);
}
#[test]
fn cmd_is_refused_on_every_spelling() {
for args in [
&[][..],
&["/c", "server"],
&["/C", "server"],
&["/k", "server"],
&["/ccmd"],
&["/q/c", "server"],
&["/q", "/c", "server"],
&[" /c", "server"],
&["/q", "server.bat"],
] {
assert_eq!(
analyze("cmd", args).unwrap_err(),
selects("cmd", LaunchTrigger::Always),
"{args:?}"
);
assert!(launches("cmd.exe", args), "{args:?}");
}
}
#[test]
fn plus_c_is_reported_as_plus_c() {
for shell in ["bash", "sh", "zsh"] {
assert_eq!(
analyze(shell, &["+c", "x"]).unwrap_err(),
selects(shell, LaunchTrigger::CommandString(ShellFlag::PlusC))
);
assert_eq!(
analyze(shell, &["+ec", "x"]).unwrap_err(),
selects(shell, LaunchTrigger::CommandString(ShellFlag::PlusC))
);
}
assert_eq!(ShellFlag::PlusC.to_string(), "+c");
assert_eq!(ShellFlag::DashC.to_string(), "-c");
}
#[test]
fn powershell_dash_lookalikes_are_dashes() {
for dash in ['\u{2013}', '\u{2014}', '\u{2015}'] {
for command in ["pwsh", "powershell", "pwsh-preview"] {
for name in ["c", "Command", "ec", "cwa"] {
let flag = format!("{dash}{name}");
assert!(launches(command, &[&flag, "x"]), "{command} {flag:?}");
}
}
}
}
#[test]
fn powershell_trims_both_ends_of_an_argument() {
for flag in [" -c", "-c ", " -Command ", "\t-c\n", "-Command\u{a0}"] {
for command in ["pwsh", "powershell"] {
assert!(launches(command, &[flag, "x"]), "{command} {flag:?}");
}
}
assert!(!launches("pwsh", &[" -NoProfile ", "server.ps1"]));
}
#[test]
fn posix_and_other_shells_trim_both_ends_of_an_argument() {
for flag in [" -c", "-c ", "\t-lc\n", " --command ", " --commands"] {
for shell in ["bash", "sh", "nu"] {
assert!(launches(shell, &[flag, "x"]), "{shell} {flag:?}");
}
}
assert!(launches("bash", &[" +c", "x"]));
assert!(launches("fish", &[" -c", "x"]));
assert!(launches("fish", &[" --init-command=x"]));
assert!(launches("elvish", &[" -c", "x"]));
assert!(launches("node", &[" -e", "1"]));
assert!(launches("node", &["--eval ", "1"]));
assert!(launches("python3", &["\t-c", "1"]));
}
#[test]
fn windows_powershell_reads_a_bare_argument_as_a_command() {
for command in ["powershell", "powershell.exe", "PowerShell5.exe"] {
for args in [
&["& x.ps1"][..],
&["x.ps1"],
&["-NoProfile", "x.ps1"],
&["-ExecutionPolicy:Bypass", "& x.ps1"],
&["-ExecutionPolicy=Bypass", "& x.ps1"],
&["-NoLogo", "-ep", "Bypass", "x.ps1"],
&["-Unknown", "x.ps1"],
&[""],
&["-Version", "5", "x.ps1"],
] {
assert_eq!(
analyze(command, args).unwrap_err(),
selects(command, LaunchTrigger::ImplicitCommandString),
"{command} {args:?}"
);
}
}
}
#[test]
fn windows_powershell_admits_parameters_values_and_a_script_file() {
for args in [
&["-ep", "Bypass", "-File", "x.ps1"][..],
&[
"-ExecutionPolicy",
"Bypass",
"-NoProfile",
"-File",
"x.ps1",
"a",
"b",
],
&["-File", "x.ps1", "-c"],
&["-f", "x.ps1", "-c"],
&["-WindowStyle", "Hidden", "-File", "x.ps1"],
&[
"-InputFormat",
"Text",
"-OutputFormat",
"Text",
"-File",
"x.ps1",
],
&["-NoLogo", "-Sta", "-File", "x.ps1"],
] {
assert!(!launches("powershell", args), "{args:?}");
}
for args in [
&[][..],
&["-NoProfile", "-NonInteractive"],
&["-NoLogo", "-Sta"],
] {
assert_eq!(
stdin_source("powershell", args),
StdinSource::NoProgram,
"{args:?}"
);
}
}
#[test]
fn windows_powershell_still_refuses_command_parameters_after_values() {
for args in [
&["-ep", "Bypass", "-Command", "x"][..],
&["-NoProfile", "-c:x"],
&["-NoProfile", "-ec", "eA=="],
] {
std::assert_matches!(
analyze("powershell", args),
Err(LauncherRefusal::SelectsWorkspaceCode {
trigger: LaunchTrigger::CommandString(ShellFlag::PowerShell(_)),
..
}),
"{args:?}"
);
}
}
#[test]
fn windows_powershell_refuses_a_parameter_it_does_not_recognize() {
for args in [
&["-x;Start-Process calc"][..],
&["/x;Start-Process calc"],
&["-NoProfile", "-x", "-y;Start-Process calc"],
&["-conf", ";Start-Process calc"],
&["-no", "x.ps1"],
&["-noni", "-nolog;calc"],
&["-"],
&["--"],
&["-ExecutionPolicy:Bypass", "-bogus", "x"],
&["-ExecutionPolicy:Bypass;& C:\\ws\\x.ps1"],
&["-NoProfile:;& C:\\ws\\x.ps1"],
&["-File:x;& C:\\ws\\y.ps1"],
&["-File:x.ps1", "positional"],
&["-NoLogo=1", "x"],
] {
assert_eq!(
analyze("powershell", args).unwrap_err(),
selects("powershell", LaunchTrigger::ImplicitCommandString),
"{args:?}"
);
}
for args in [
&["-config", "name", "-File", "x.ps1"][..],
&["-noe", "-nop", "-nol", "-noni", "-sta", "-File", "x.ps1"],
&["-mta", "-imp", "-h", "-File", "x.ps1"],
&[
"-psc", "c.psc1", "-w", "hidden", "-v", "2", "-File", "x.ps1",
],
&[
"-in", "text", "-o", "xml", "-ex", "Bypass", "-File", "x.ps1",
],
&["-encodeda", "x", "-File", "x.ps1"],
] {
assert!(!launches("powershell", args), "{args:?}");
}
}
#[test]
fn python_windowless_and_debug_names_are_interpreters() {
for command in [
"pythonw3",
"pythonw3.12",
"python_d",
"pythonw_d",
"python3.12_d",
"pyw3",
] {
assert!(launches(command, &["-c", "x"]), "{command}");
}
assert!(!launches("pythonista", &["-c", "x"]));
assert!(!launches("pyright-langserver", &["-c", "x"]));
}
#[test]
fn code_running_cargo_stack_and_cabal_aliases_are_refused() {
for (command, subcommand) in [
("cargo", "r"),
("cargo", "t"),
("cargo", "test"),
("cargo", "bench"),
("stack", "eval"),
("stack", "ghci"),
("stack", "repl"),
("cabal", "repl"),
("cabal", "v1-repl"),
("cabal", "v2-repl"),
("cabal", "new-repl"),
] {
assert!(
launches(command, &[subcommand, "x"]),
"{command} {subcommand}"
);
}
}
#[test]
fn pwsh_keeps_a_bare_argument_as_a_script_file() {
assert!(!launches("pwsh", &["x.ps1"]));
assert!(!launches("pwsh", &["-NoProfile", "x.ps1"]));
assert!(!launches("pwsh7", &["x.ps1"]));
}
#[test]
fn cabal_and_stack_run_aliases_are_refused() {
for (command, subcommand) in [
("cabal", "v1-run"),
("cabal", "v2-run"),
("cabal", "new-run"),
("cabal", "v1-exec"),
("cabal", "v2-exec"),
("cabal", "new-exec"),
("stack", "runghc"),
("stack", "runhaskell"),
("stack", "script"),
] {
assert!(
launches(command, &[subcommand, "srv"]),
"{command} {subcommand}"
);
}
assert!(!launches("cabal", &["build"]));
assert!(!launches("stack", &["build"]));
}
#[test]
fn elvish_reads_one_and_two_dash_flags_alike() {
for args in [
&["-c", "x"][..],
&["--c", "x"],
&["-c=true", "x"],
&["--c=true", "x"],
] {
assert_eq!(
analyze("elvish", args).unwrap_err(),
selects("elvish", LaunchTrigger::CommandString(ShellFlag::DashC)),
"{args:?}"
);
}
assert!(!launches("elvish", &["script.elv"]));
assert!(!launches("elvish", &["-norc", "script.elv"]));
assert!(!launches("elvish", &["--cc=1", "script.elv"]));
}
#[test]
fn fish_matches_long_flags_by_prefix() {
for flag in [
"--c",
"--co",
"--comm",
"--command",
"--init-c",
"--i",
"--c=x",
] {
assert!(launches("fish", &[flag, "x"]), "{flag}");
}
for args in [
&["--no-config", "script.fish"][..],
&["--login", "script.fish"],
&["--private", "script.fish"],
&["--debug=all", "script.fish"],
&["--", "script.fish"],
] {
assert!(!launches("fish", args), "{args:?}");
}
assert!(!launches("bash", &["--comm", "script.sh"]));
assert!(!launches("nu", &["--comm=1", "script.nu"]));
}
#[test]
fn distribution_names_of_listed_interpreters_are_interpreters() {
for (command, args) in [
("pwsh-preview", &["-Command", "x"][..]),
("bash-static", &["-c", "x"]),
("zsh-static", &["-c", "x"]),
("mksh-static", &["-c", "x"]),
("lksh", &["-c", "x"]),
("pythonw", &["-c", "x"]),
("py", &["-3", "-c", "x"]),
("pyw", &["-c", "x"]),
("zsh-5.9", &["-c", "x"]),
("python3.13t", &["-c", "x"]),
("python3-intel64", &["-c", "x"]),
("python3.13-intel64", &["-c", "x"]),
("python3-dbg", &["-c", "x"]),
("/usr/bin/bash-static.exe", &["-c", "x"]),
] {
assert!(launches(command, args), "{command}");
}
}
#[test]
fn tools_sharing_a_prefix_with_a_listed_interpreter_are_not_interpreters() {
for (command, args) in [
("nodemon", &["-e", "js"][..]),
("phpunit", &["-r"]),
("pyright", &["-c", "x"]),
("pytest", &["-c", "x"]),
("pyls", &["-c", "x"]),
("bash-language-server", &["-c", "x"]),
] {
assert!(!launches(command, args), "{command}");
}
}
#[test]
fn php_refuses_every_code_flag() {
for flag in ["-r", "-R", "-B", "-E", "-F", "-Rsystem('id');", "-nR"] {
assert!(launches("php", &[flag, "code"]), "{flag}");
}
for flag in [
"--run",
"--run=1",
"--process-begin",
"--process-code=x",
"--process-end",
"--process-file",
] {
assert!(launches("php", &[flag, "code"]), "{flag}");
}
assert_matches!(
unanalyzable_reason("php", &["--version"]),
UnanalyzableLaunch::UnknownOption(_)
);
assert!(!launches("php", &["-f", "script.php"]));
assert!(!launches("php", &["-n", "script.php"]));
assert!(!launches("php", &["-d", "memory_limit=1G", "script.php"]));
}
#[test]
fn powershell_command_parameters_are_refused_but_other_switches_are_not() {
for flag in [
"-c",
"-Command",
"-comm",
"-CommandWithArgs",
"-EncodedCommand",
"-e",
"-ec",
"/command",
] {
assert!(launches("pwsh", &[flag, "x"]), "{flag}");
assert!(launches("powershell.exe", &[flag, "x"]), "{flag}");
}
for flag in ["-NonInteractive", "-NoProfile", "-NoLogo", "-Version"] {
assert!(!launches("pwsh", &[flag, "server.ps1"]), "{flag}");
}
}
#[test]
fn tools_sharing_an_interpreter_prefix_are_not_interpreters() {
assert!(!launches("nodemon", &["-e", "js"]));
assert!(!launches("phpunit", &["-r"]));
assert!(!launches("perltidy", &["-e"]));
assert!(launches("python3.12", &["-c", "x"]));
assert!(launches("node18", &["-e", "x"]));
}
#[test]
fn fish_command_is_recognized_in_long_and_short_form() {
assert!(launches("fish", &["--command", "server"]));
assert!(launches("fish", &["-c", "server"]));
assert!(!launches("fish", &["server.fish"]));
}
#[test]
fn busybox_applets_are_unwrapped() {
assert!(launches("busybox", &["sh", "-c", "rust-analyzer"]));
assert!(launches("busybox", &["env", "npx", "server"]));
assert_eq!(
unanalyzable_reason("busybox", &[]),
UnanalyzableLaunch::MissingCommand
);
assert_eq!(analyze("busybox", &["rust-analyzer"]), Ok(vec![]));
}
#[test]
fn interpreters_given_an_inline_program_are_refused() {
for (command, flag) in [
("node", "-e"),
("node", "--eval"),
("node", "-p"),
("node", "-pe"),
("python", "-c"),
("python3.12", "-c"),
("/usr/bin/perl", "-e"),
("ruby", "-e"),
("php", "-r"),
] {
assert!(launches(command, &[flag, "code"]), "{command} {flag}");
assert!(!launches(command, &["server.js"]), "{command}");
}
assert_matches!(
unanalyzable_reason("node", &["--version"]),
UnanalyzableLaunch::UnknownOption(_)
);
assert_eq!(
analyze("python3.12", &["-c", "x"]).unwrap_err(),
selects(
"python3.12",
LaunchTrigger::InlineProgram(InlineFlag::Short('c'))
)
);
}
#[test]
fn shells_with_a_command_string_are_unanalyzable() {
assert!(launches("sh", &["-c", "rust-analyzer"]));
assert!(launches("/bin/bash", &["-lc", "rust-analyzer"]));
assert!(launches("cmd.exe", &["/C", "server"]));
assert_eq!(
analyze("powershell", &["-Command", "server"]).unwrap_err(),
selects(
"powershell",
LaunchTrigger::CommandString(ShellFlag::PowerShell(PowerShellParameter::Command))
)
);
assert!(!launches("sh", &["server.sh"]));
assert!(!launches("bash", &["--norc", "server.sh"]));
}
#[test]
fn every_listed_shell_refuses_a_command_string() {
for (shell, _) in SHELLS {
assert!(launches(shell, &["-c", "x"]), "{shell}");
assert!(
launches(&format!("/opt/bin/{shell}"), &["-c", "x"]),
"{shell}"
);
let script: &[&str] = if *shell == "powershell" {
&["-File", "script"]
} else {
&["script"]
};
assert!(!launches(shell, script), "{shell}");
}
assert!(launches("busybox", &["ash", "-c", "x"]));
assert!(launches("busybox", &["hush", "-c", "x"]));
}
#[test]
fn nu_refuses_both_command_spellings() {
assert!(launches("nu", &["--commands", "x"]));
assert!(launches("nu", &["--command", "x"]));
assert!(launches("nu", &["-c", "x"]));
assert!(!launches("nu", &["server.nu"]));
}
#[test]
fn deno_eval_and_bun_inline_programs_are_refused() {
assert!(launches("deno", &["eval", "code"]));
for flag in ["-e", "-p", "--eval", "--print"] {
assert!(launches("bun", &[flag, "code"]), "{flag}");
}
assert!(!launches("bun", &["server.ts"]));
assert!(!launches("bunyan", &["-e"]));
}
#[test]
fn other_inline_interpreters_are_refused() {
for (command, flag) in [
("lua", "-e"),
("Rscript", "-e"),
("julia", "-e"),
("osascript", "-e"),
] {
assert!(launches(command, &[flag, "code"]), "{command}");
assert!(!launches(command, &["script"]), "{command}");
}
}
#[test]
fn programs_that_run_their_arguments_are_refused() {
for command in ["xargs", "find", "awk", "gawk", "mawk", "nawk"] {
assert!(launches(command, &["x"]), "{command}");
}
}
#[test]
fn wrappers_that_are_not_parsed_are_refused_outright() {
for wrapper in [
"sudo",
"sudo-rs",
"doas",
"run0",
"pkexec",
"runuser",
"setpriv",
"strace",
"unshare",
"chrt",
"taskset",
"ionice",
"chroot",
"nsenter",
"systemd-run",
"su",
] {
for args in [&["rust-analyzer"][..], &[]] {
assert_eq!(
analyze(wrapper, args).unwrap_err(),
selects(wrapper, LaunchTrigger::Always),
"{wrapper} {args:?}"
);
}
}
assert!(launches("sudo", &["-u", "nobody", "gopls"]));
assert!(launches("nice", &["sudo", "-u", "x", "srv"]));
}
#[test]
fn parsed_wrappers_find_the_command_past_their_options() {
for (command, args, index) in [
("nice", &["-n", "5", "gopls"][..], 2),
("nice", &["-n5", "gopls"], 1),
("nice", &["--adjustment=5", "gopls"], 1),
("nice", &["--adjustment", "5", "gopls"], 2),
("nice", &["gopls", "-logfile", "x"], 0),
("nice", &["--", "gopls"], 1),
("time", &["-p", "-o", "out", "srv"], 3),
("time", &["-pf", "%e", "srv"], 2),
("nohup", &["srv", "--stdio"], 0),
("timeout", &["5", "rust-analyzer", "make"], 1),
("timeout", &["-k", "1", "-s", "KILL", "1.5m", "srv"], 5),
(
"timeout",
&["--kill-after=1", "--foreground", "5", "srv"],
3,
),
("timeout", &["--", "5", "srv"], 2),
("setsid", &["-f", "srv"], 1),
("setsid", &["-cfw", "srv"], 1),
("stdbuf", &["-oL", "srv"], 1),
("stdbuf", &["-o", "L", "-e0", "srv"], 3),
("caffeinate", &["-i", "srv"], 1),
("caffeinate", &["-t", "60", "-dims", "srv"], 3),
("arch", &["-arm64", "srv"], 1),
("arch", &["-arch", "arm64", "srv"], 2),
] {
assert_eq!(
analyze(command, args),
Ok(vec![index]),
"{command} {args:?}"
);
}
}
#[test]
fn what_follows_the_wrapped_command_belongs_to_it() {
assert!(!launches("nice", &["jdtls", "-configuration", "/cfg/env"]));
assert!(!launches("timeout", &["5", "srv", "-c", "x"]));
assert!(!launches("nice", &["gopls", "make", "sudo"]));
assert!(launches("nice", &["make"]));
assert!(launches("time", &["-p", "sh", "-c", "x"]));
assert!(launches("timeout", &["5", "npx", "srv"]));
}
#[test]
fn parsed_wrappers_refuse_what_their_table_does_not_list() {
for (command, args) in [
("nice", &["-5", "srv"][..]),
("nice", &["--adj", "5", "srv"]),
("nice", &["-x", "srv"]),
("nohup", &["--help"]),
("time", &["--verbose=1", "srv"]),
("timeout", &["--foreground=1", "5", "srv"]),
("setsid", &["--bogus", "srv"]),
("stdbuf", &["-x", "srv"]),
("arch", &["-e", "PATH=/ws/bin", "srv"]),
("arch", &["-arm64=x", "srv"]),
("caffeinate", &["-z", "srv"]),
] {
std::assert_matches!(
unanalyzable_reason(command, args),
UnanalyzableLaunch::UnknownOption(_),
"{command} {args:?}"
);
}
}
#[test]
fn parsed_wrappers_refuse_a_missing_value_or_command() {
std::assert_matches!(
unanalyzable_reason("nice", &["-n"]),
UnanalyzableLaunch::MissingValue(_)
);
std::assert_matches!(
unanalyzable_reason("arch", &["-arch"]),
UnanalyzableLaunch::MissingValue(_)
);
for (command, args) in [
("nice", &["-n", "5"][..]),
("time", &["-p"]),
("nice", &["--"]),
("setsid", &["-f"]),
] {
assert_eq!(
unanalyzable_reason(command, args),
UnanalyzableLaunch::MissingCommand,
"{command}"
);
}
assert_eq!(
unanalyzable_reason("timeout", &["5"]),
UnanalyzableLaunch::MissingCommand
);
assert_eq!(analyze("nice", &[]), Ok(vec![]));
}
#[test]
fn timeout_checks_the_shape_of_its_duration() {
for good in ["5", "0.5", "10s", "2m", "1.5h", "3d", "007"] {
assert_eq!(analyze("timeout", &[good, "srv"]), Ok(vec![1]), "{good}");
}
for bad in [
"srv", "5x", "1e3", ".5", "5.", "inf", "\u{665}", "1.2.3", "",
] {
assert_eq!(
unanalyzable_reason("timeout", &[bad, "srv"]),
UnanalyzableLaunch::MalformedOperand(OperandKind::Duration),
"{bad:?}"
);
}
}
#[test]
fn refusals_echo_only_bounded_names_never_values() {
let long = format!("--{}=secret", "a".repeat(2 * MAX_ECHOED_NAME_BYTES));
let Err(LauncherRefusal::Unanalyzable {
reason: UnanalyzableLaunch::UnknownOption(option),
..
}) = analyze("nice", &[&long, "srv"])
else {
panic!("expected an unknown option");
};
assert!(!option.as_str().contains("secret"));
assert!(option.as_str().len() <= MAX_ECHOED_NAME_BYTES + 64);
assert_eq!(EchoedArgument::name("API_TOKEN=abc").as_str(), "API_TOKEN");
assert_eq!(EchoedArgument::name("srv --token abc").as_str(), "srv");
assert_eq!(EchoedArgument::name("-x5").as_str(), "-x5");
assert!(!EchoedArgument::name("a\nb").as_str().contains('\n'));
let Err(LauncherRefusal::Unanalyzable { program, .. }) =
analyze("env", &["PATH=/ws/bin", "srv"])
else {
panic!("expected a refusal");
};
assert_eq!(program.as_str(), "env");
}
#[test]
fn inline_code_glued_to_the_flag_or_given_with_equals_is_refused() {
for (command, arg) in [
("python3", "-cprint(1)"),
("perl", "-eprint 1"),
("ruby", "-eputs 1"),
("bun", "-econsole.log(1)"),
("lua", "-eprint(1)"),
("php", "-rphpinfo();"),
("node", "--eval=console.log(1)"),
("julia", "--eval=1"),
("bun", "--print=1"),
("fish", "-cmake"),
("fish", "--command=make"),
("bash", "-lcmake"),
] {
assert!(launches(command, &[arg]), "{command} {arg}");
}
assert!(!launches("python3", &["-m", "pytest"]));
assert!(!launches(
"node",
&["--max-old-space-size=4096", "server.js"]
));
}
#[test]
fn value_taking_letters_end_the_flag_scan() {
assert!(!launches("python3", &["-mcoverage", "run"]));
assert!(!launches("ruby", &["-rbundler/setup", "app.rb"]));
assert!(!launches("node", &["-rts-node/register", "server.js"]));
assert!(launches("python3", &["-Ic'import os'"]));
assert!(launches("ruby", &["-weputs 1"]));
}
#[test]
fn deno_repl_and_shell_runners_are_refused() {
assert!(launches("deno", &["repl", "--eval=1"]));
for command in ["script", "su", "flock", "watch"] {
assert!(launches(command, &["-c", "x"]), "{command}");
}
}
#[test]
fn versioned_shells_are_shells() {
for shell in ["ksh93", "bash5", "zsh5.9"] {
assert!(launches(shell, &["-c", "x"]), "{shell}");
}
assert!(!launches("shellcheck", &["-c", "x"]));
}
#[test]
fn nested_wrappers_are_followed_and_deep_chains_fail_closed() {
assert!(launches("nice", &["time", "nohup", "sh", "-c", "x"]));
assert!(launches("env", &["nice", "env", "time", "sh", "-c", "x"]));
let mut chain: Vec<&str> = Vec::new();
for _ in 0..MAX_WRAPPER_DEPTH {
chain.extend(["env", "nice"]);
}
chain.push("rust-analyzer");
assert_eq!(
analyze("env", &chain).unwrap_err(),
LauncherRefusal::TooDeep
);
}
#[test]
fn wrapped_program_indices_survive_every_layer() {
assert_eq!(analyze("busybox", &["nice", "srv"]), Ok(vec![1]));
assert_eq!(analyze("busybox", &["env", "FOO=1", "srv"]), Ok(vec![2]));
assert_eq!(
analyze("nice", &["env", "FOO=1", "timeout", "5", "srv"]),
Ok(vec![0, 2, 4])
);
assert_eq!(
analyze("env", &["nice", "busybox", "timeout", "5", "srv"]),
Ok(vec![0, 1, 4])
);
assert_eq!(analyze("rust-analyzer", &["--stdio"]), Ok(vec![]));
}
#[test]
fn wrapped_programs_name_the_wrapper_that_starts_them() {
let args = strings(&["5", "env", "FOO=1", "nice", "-n", "5", "srv"]);
let programs = analyze_launch("timeout", &args).unwrap();
let seen: Vec<_> = programs
.programs()
.iter()
.map(|wrapped| (wrapped.program, wrapped.starter))
.collect();
assert_eq!(
seen,
[
("env", WrapperKind::Other),
("nice", WrapperKind::Env),
("srv", WrapperKind::Other),
]
);
}
#[test]
fn a_sudo_inside_another_wrapper_is_still_refused() {
assert_eq!(
analyze("timeout", &["5", "sudo", "-u", "x", "srv"]).unwrap_err(),
selects("sudo", LaunchTrigger::Always)
);
}
#[test]
fn versioned_powershell_stems_use_the_powershell_grammar() {
for command in ["pwsh7", "PowerShell7.exe", "/opt/pwsh7.2", "powershell5"] {
for flag in ["-Command", "-c", "-ec", "/command"] {
assert!(launches(command, &[flag, "x"]), "{command} {flag}");
}
}
for command in ["pwsh7", "/opt/pwsh7.2"] {
assert!(
!launches(command, &["-NoProfile", "server.ps1"]),
"{command}"
);
}
for command in ["PowerShell7.exe", "powershell5"] {
assert_eq!(
analyze(command, &["-NoProfile", "server.ps1"]).unwrap_err(),
selects(command, LaunchTrigger::ImplicitCommandString),
"{command}"
);
assert!(
!launches(command, &["-NoProfile", "-File", "server.ps1"]),
"{command}"
);
}
}
#[test]
fn the_refusal_names_the_spelling_that_matched() {
let trigger = |command, arg| match analyze(command, &[arg, "x"]) {
Err(LauncherRefusal::SelectsWorkspaceCode {
trigger: LaunchTrigger::CommandString(flag),
..
}) => flag.to_string(),
other => panic!("expected a command string, got {other:?}"),
};
assert_eq!(trigger("bash", "+c"), "+c");
assert_eq!(trigger("bash", "-c"), "-c");
assert_eq!(trigger("fish", "-C"), "-C");
assert_eq!(trigger("fish", "--comm"), "--command");
assert_eq!(trigger("nu", "--commands"), "--commands");
assert_eq!(trigger("nu", "--command=x"), "--command");
assert_eq!(trigger("pwsh", "-comm"), "-Command");
assert_eq!(trigger("pwsh", "-commandw"), "-CommandWithArgs");
assert_eq!(trigger("pwsh", "-ec"), "-EncodedCommand");
assert_eq!(trigger("pwsh", "-e"), "-EncodedCommand");
}
#[test]
fn env_and_the_other_wrappers_read_long_options_alike() {
for command in ["env", "nice"] {
let args = ["--bogus=1", "srv"];
std::assert_matches!(
unanalyzable_reason(command, &args),
UnanalyzableLaunch::UnknownOption(_),
"{command}"
);
}
assert_eq!(analyze("env", &["--unset=A", "srv"]), Ok(vec![1]));
assert_eq!(analyze("nice", &["--adjustment=5", "srv"]), Ok(vec![1]));
assert_eq!(long_name("--a=b=c"), ("--a", Some("b=c")));
assert_eq!(long_name("--a"), ("--a", None));
}
#[test]
fn env_chdir_is_tracked_through_the_shared_parser() {
for args in [
&["--chdir=/d", "bin/srv"][..],
&["-i", "-C", "/d", "bin/srv"],
] {
assert_eq!(
unanalyzable_reason("env", args),
UnanalyzableLaunch::RelativeProgramAfterChdir,
"{args:?}"
);
}
assert_eq!(analyze("env", &["-i", "bin/srv"]), Ok(vec![1]));
}
#[test]
fn powershell_cwa_alias_is_refused() {
for command in ["pwsh", "pwsh7", "PowerShell7.exe"] {
assert_eq!(
analyze(command, &["-cwa", "& x"]).unwrap_err(),
selects(
command,
LaunchTrigger::CommandString(ShellFlag::PowerShell(
PowerShellParameter::CommandWithArgs
))
),
"{command}"
);
}
}
#[test]
fn fish_init_commands_are_refused_but_posix_capital_c_is_not() {
for (arg, flag) in [
("-C", ShellFlag::DashCapitalC),
("-Ccd /ws", ShellFlag::DashCapitalC),
("-lC", ShellFlag::DashCapitalC),
(
"--init-command=cd /ws",
ShellFlag::LongCommand(LongCommandName::InitCommand),
),
(
"--init-command",
ShellFlag::LongCommand(LongCommandName::InitCommand),
),
] {
assert_eq!(
analyze("fish", &[arg, "x"]).unwrap_err(),
selects("fish", LaunchTrigger::CommandString(flag)),
"{arg}"
);
}
for shell in ["bash", "sh", "zsh", "ksh"] {
assert!(!launches(shell, &["-C", "script.sh"]), "{shell}");
assert!(
!launches(shell, &["--init-command=x", "script.sh"]),
"{shell}"
);
}
assert!(!launches("fish", &["-N", "server.fish"]));
assert!(!launches("fish", &["-dcategory", "server.fish"]));
}
#[test]
fn nu_execute_is_refused_but_other_shells_keep_dash_e() {
for (arg, flag) in [
("-e", ShellFlag::DashE),
("-ecode", ShellFlag::DashE),
(
"--execute=^x",
ShellFlag::LongCommand(LongCommandName::Execute),
),
(
"--execute",
ShellFlag::LongCommand(LongCommandName::Execute),
),
] {
assert_eq!(
analyze("nu", &[arg, "x"]).unwrap_err(),
selects("nu", LaunchTrigger::CommandString(flag)),
"{arg}"
);
}
assert!(!launches("bash", &["-e", "script.sh"]));
assert!(!launches("fish", &["--execute=x", "server.fish"]));
assert!(!launches("nu", &["-n", "server.nu"]));
}
#[test]
fn gnu_coreutils_dispatches_on_the_coreutils_prog_option() {
assert_eq!(
unanalyzable_reason("coreutils", &["--coreutils-prog=env", "-S", "srv"]),
UnanalyzableLaunch::SplitString
);
assert!(launches(
"coreutils",
&["--coreutils-prog=env", "PATH=/ws/bin", "srv"]
));
assert!(launches(
"/usr/bin/coreutils",
&["--coreutils-prog=env", "sh", "-c", "x"]
));
assert_eq!(
analyze("coreutils", &["--coreutils-prog=nice", "srv"]),
Ok(vec![1])
);
assert_eq!(
analyze("coreutils", &["--coreutils-prog=env", "FOO=1", "srv"]),
Ok(vec![2])
);
assert_eq!(analyze("coreutils", &["nice", "srv"]), Ok(vec![1]));
}
#[test]
fn coreutils_forms_that_cannot_be_analyzed_are_refused() {
for arg in [
"--coreutils-prog-shebang=env",
"--coreutils-prog",
"--coreutils-progx=env",
] {
std::assert_matches!(
unanalyzable_reason("coreutils", &[arg, "srv"]),
UnanalyzableLaunch::UnknownOption(_),
"{arg}"
);
}
assert_eq!(
unanalyzable_reason("coreutils", &["--coreutils-prog=", "srv"]),
UnanalyzableLaunch::BlankProgram
);
assert!(launches("busybox", &["env", "sh", "-c", "x"]));
assert!(launches("toybox", &["nice", "npx", "srv"]));
}
#[test]
fn perl_module_values_that_carry_code_are_refused() {
for args in [
&["-MPOSIX;do(q{/ws/Evil.pm})", "script.pl"][..],
&["-mPOSIX;system('x')", "script.pl"],
&["-M", "POSIX;system('x')", "script.pl"],
&["-MPOSIX qw(); system('x')", "script.pl"],
&["-M", "script.pl"],
&["-M"],
&["-lM-X;code", "script.pl"],
] {
std::assert_matches!(
analyze("perl", args),
Err(LauncherRefusal::SelectsWorkspaceCode {
trigger: LaunchTrigger::InlineProgram(InlineFlag::Short('M' | 'm')),
..
}),
"{args:?}"
);
}
}
#[test]
fn perl_module_values_that_are_modules_are_admitted() {
for args in [
&["-MPOSIX", "script.pl"][..],
&["-MList::Util=sum,max", "script.pl"],
&["-M-strict", "script.pl"],
&["-mFoo::Bar", "script.pl"],
&["-M", "POSIX", "script.pl"],
&["-Mstrict", "-Mwarnings", "script.pl"],
] {
assert!(!launches("perl", args), "{args:?}");
}
}
#[test]
fn data_url_modules_given_to_node_flags_are_refused() {
for command in ["node", "nodejs", "node22", "bun"] {
for args in [
&["--import=data:text/javascript,x", "srv.mjs"][..],
&["--import", "data:text/javascript,x", "srv.mjs"],
&["--loader=DATA:text/javascript,x", "srv.mjs"],
&["--experimental-loader=data:text/javascript,x", "srv.mjs"],
&["--experimental_loader=data:text/javascript,x", "srv.mjs"],
&["--import= data:text/javascript,x", "srv.mjs"],
&["--import=da\tta:text/javascript,x", "srv.mjs"],
&["--import=dat\na:text/javascript,x", "srv.mjs"],
&["--import", "data\r\n:text/javascript,x", "srv.mjs"],
&["--import=\tdata:text/javascript,x", "srv.mjs"],
&[" --import=data:text/javascript,x", "srv.mjs"],
] {
std::assert_matches!(
analyze(command, args),
Err(LauncherRefusal::SelectsWorkspaceCode {
trigger: LaunchTrigger::InlineProgram(InlineFlag::Long(
LongInlineFlag::Import
| LongInlineFlag::Loader
| LongInlineFlag::ExperimentalLoader
)),
..
}),
"{command} {args:?}"
);
}
}
}
#[test]
fn node_modules_that_are_not_data_urls_are_admitted() {
for args in [
&["--import=./register.mjs", "srv.mjs"][..],
&["--import", "file:///opt/register.mjs", "srv.mjs"],
&["--loader=ts-node/esm", "srv.ts"],
&["--import", "x.mjs", "srv.mjs"],
&["--import=dat", "srv.mjs"],
] {
assert!(!launches("node", args), "{args:?}");
}
assert!(!launches("python3", &["--import=data:x", "srv.py"]));
}
#[test]
fn run_subcommands_of_more_toolchains_are_refused() {
for (command, subcommand) in [
("dotnet", RunnerSubcommand::Run),
("pipenv", RunnerSubcommand::Run),
("pixi", RunnerSubcommand::Run),
("swift", RunnerSubcommand::Run),
("stack", RunnerSubcommand::Run),
("stack", RunnerSubcommand::Exec),
("cabal", RunnerSubcommand::Run),
("cabal", RunnerSubcommand::Exec),
] {
assert_eq!(
analyze(command, &[subcommand.as_str(), "server"]).unwrap_err(),
selects(command, LaunchTrigger::Subcommand(subcommand)),
"{command} {subcommand}"
);
assert!(!launches(command, &["--version"]), "{command}");
}
assert!(!launches("dotnet", &["build"]));
assert!(!launches("swift", &["build"]));
}
#[test]
fn posix_shells_refuse_a_command_string_behind_an_option_cluster_or_plus() {
for shell in ["bash", "dash", "zsh", "ksh", "sh"] {
for args in [
&["-oc", "errexit", "x"][..],
&["-eoc", "errexit", "x"],
&["+c", "x"],
&["+ec", "x"],
&["-o", "errexit", "-c", "x"],
] {
assert!(launches(shell, args), "{shell} {args:?}");
}
assert!(
!launches(shell, &["-o", "pipefail", "script.sh"]),
"{shell}"
);
assert!(!launches(shell, &["+e", "script.sh"]), "{shell}");
assert!(!launches(shell, &["+x", "script.sh"]), "{shell}");
}
}
#[test]
fn powershell_accepts_double_dash_and_attached_values() {
for flag in [
"--command",
"--c",
"--commandwithargs",
"--CommandWithArgs",
"/cwa",
"-CommandWithArgs",
"-command:x",
"-Command=x",
"--cwa",
] {
assert!(launches("pwsh", &[flag, "x"]), "{flag}");
}
assert!(!launches("pwsh", &["-NoProfile", "server.ps1"]));
}
#[test]
fn perl_debugger_modules_must_be_module_names() {
for arg in ["-d:Mod;BEGIN{system(1)}", "-dt:Mod;x", "-dx", "-d:"] {
assert!(launches("perl", &[arg, "srv.pl"]), "{arg}");
}
for arg in ["-d", "-dt", "-d:Devel::NYTProf", "-dt:Foo=a,b"] {
assert!(!launches("perl", &[arg, "srv.pl"]), "{arg}");
}
let long = format!("-MFoo={}", "a".repeat(MAX_IMPORT_LIST_BYTES + 1));
assert!(launches("perl", &[&long, "srv.pl"]));
let fits = format!("-MFoo={}", "a".repeat(MAX_IMPORT_LIST_BYTES));
assert!(!launches("perl", &[&fits, "srv.pl"]));
}
#[test]
fn refusals_echo_the_whole_program_path() {
let Err(LauncherRefusal::SelectsWorkspaceCode { program, .. }) =
analyze("/opt/Program Files/PowerShell/pwsh", &["-c", "x"])
else {
panic!("expected a refusal");
};
assert_eq!(program.as_str(), "/opt/Program Files/PowerShell/pwsh");
let Err(LauncherRefusal::Unanalyzable { program, .. }) =
analyze("/opt/Program Files/env", &["-z", "srv"])
else {
panic!("expected a refusal");
};
assert_eq!(program.as_str(), "/opt/Program Files/env");
}
fn stdin_source(command: &str, args: &[&str]) -> StdinSource {
match analyze(command, args) {
Err(LauncherRefusal::SelectsWorkspaceCode {
trigger: LaunchTrigger::ProgramFromStdin(source),
..
}) => source,
other => panic!("expected a program read from stdin, got {other:?}"),
}
}
fn refused_in(cwd: &Path, command: &str, args: &[&str]) -> Option<StdinSource> {
match analyze_launch_in(command, &strings(args), cwd) {
Err(LauncherRefusal::SelectsWorkspaceCode {
trigger: LaunchTrigger::ProgramFromStdin(source),
..
}) => Some(source),
Err(other) => panic!("unexpected refusal {other:?}"),
Ok(_) => None,
}
}
#[test]
fn script_operand_that_does_not_exist_or_is_a_directory_is_admitted() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("srv.py"), "").unwrap();
std::fs::create_dir(dir.path().join("pkg")).unwrap();
for (command, operand) in [
("python", "srv.py"),
("bash", "srv.py"),
("python", "pkg"),
("node", "bin/cli"),
("ruby", "solargraph"),
] {
assert_eq!(
refused_in(dir.path(), command, &[operand]),
None,
"{operand}"
);
}
assert_eq!(refused_in(dir.path(), "deno", &["lsp"]), None);
}
#[cfg(unix)]
#[test]
fn script_operand_that_aliases_a_special_file_is_refused() {
let dir = tempfile::tempdir().unwrap();
let link = dir.path().join("stdin-alias");
std::os::unix::fs::symlink("/dev/null", &link).unwrap();
let fifo = dir.path().join("pipe");
crate::test_lsp::make_fifo(&fifo);
for command in ["python", "bash"] {
for operand in [link.to_str().unwrap(), "stdin-alias", "pipe"] {
assert_eq!(
refused_in(dir.path(), command, &[operand]),
Some(StdinSource::SpecialFile),
"{command} {operand}"
);
}
}
}
#[test]
fn programs_read_from_stdin_are_refused() {
let cases: &[(&str, &[&str], StdinSource)] = &[
("bash", &[], StdinSource::NoProgram),
("bash", &["-s"], StdinSource::DashS),
("bash", &["-os", "x"], StdinSource::DashS),
("dash", &["-so", "x"], StdinSource::DashS),
("bash", &["--norc"], StdinSource::NoProgram),
("bash", &["-", "s.sh"], StdinSource::DashOperand),
("bash", &["--", "-"], StdinSource::DashOperand),
("bash", &["/dev//stdin"], StdinSource::DeviceOperand),
("python", &[], StdinSource::NoProgram),
("python", &["-"], StdinSource::DashOperand),
("python", &["--", "-"], StdinSource::DashOperand),
("node", &["--", "-"], StdinSource::DashOperand),
(
"python",
&["/proc/thread-self/fd/0"],
StdinSource::DeviceOperand,
),
(
"python",
&["/var/run/../dev/stdin"],
StdinSource::ParentTraversal,
),
("python", &["../../dev/stdin"], StdinSource::ParentTraversal),
("python", &["/DEV/stdin"], StdinSource::DeviceOperand),
("python", &["\\dev\\stdin"], StdinSource::DeviceOperand),
("node", &["--require", "/p.js"], StdinSource::NoProgram),
("node", &["--title", "x"], StdinSource::NoProgram),
("fish", &["--profile", "f"], StdinSource::NoProgram),
("nu", &["-m", "compact"], StdinSource::NoProgram),
("deno", &[], StdinSource::NoProgram),
("pwsh", &["-File", "-"], StdinSource::DashOperand),
("pwsh", &["-NoProfile"], StdinSource::NoProgram),
(
"pwsh",
&["-wd", "/dev", "-File", "/dev/stdin"],
StdinSource::DeviceOperand,
),
(
"powershell",
&["-NoProfile", "-File", "-"],
StdinSource::DashOperand,
),
("elvish", &[], StdinSource::NoProgram),
];
for (command, args, expected) in cases {
assert_eq!(stdin_source(command, args), *expected, "{command} {args:?}");
}
assert_matches!(
analyze("bun", &["repl"]),
Err(LauncherRefusal::SelectsWorkspaceCode {
trigger: LaunchTrigger::Subcommand(RunnerSubcommand::Repl),
..
})
);
}
#[test]
fn a_chdir_makes_a_relative_script_unanalyzable_but_not_a_subcommand_or_module() {
for (command, args) in [
("python", &["stdin"][..]),
("node", &["0"]),
("bash", &["srv.sh"]),
] {
let mut wrapped = vec!["-C", "/dev"];
wrapped.push(command);
wrapped.extend(args);
assert_eq!(
unanalyzable_reason("env", &wrapped),
UnanalyzableLaunch::RelativeOperandAfterChdir,
"{wrapped:?}"
);
}
assert_eq!(
unanalyzable_reason("env", &["--chdir=/dev/fd", "node", "0"]),
UnanalyzableLaunch::RelativeOperandAfterChdir
);
assert_eq!(
unanalyzable_reason("pwsh", &["-wd", "/x", "-File", "s.ps1"]),
UnanalyzableLaunch::RelativeOperandAfterChdir
);
assert!(!launches("env", &["-C", "/x", "deno", "lsp"]));
assert!(!launches("env", &["-C", "/x", "python", "-m", "pylsp"]));
assert!(!launches("env", &["-C", "/x", "python", "/abs/srv.py"]));
}
#[test]
fn real_launch_lines_are_admitted() {
for (command, args) in [
("node", &["--enable-source-maps", "/abs/srv.js"][..]),
("node", &["--no-experimental-fetch", "/abs/s.js"]),
("bun", &["--smol", "/abs/s.js"]),
("deno", &["lsp"]),
("bash", &["-o", "pipefail", "s.sh"]),
("bash", &["--rcfile", "rc", "s.sh"]),
("python", &["-m", "pylsp"]),
("python", &["-X", "utf8", "-u", "srv.py"]),
("pypy3", &["-m", "pylsp"]),
("nu", &["--lsp"]),
("nu", &["--ide-check", "10", "s.nu"]),
("nu", &["--stdin", "s.nu"]),
("nu", &["--no-config-file", "s.nu"]),
("elvish", &["-lsp"]),
("fish", &["--no-config", "s.fish"]),
("fish", &["--login", "s.fish"]),
("pwsh", &["-NoProfile", "-File", "s.ps1"]),
("pwsh", &["s.ps1"]),
("Rscript", &["--vanilla", "srv.R"]),
("ruby", &["--yjit", "srv.rb"]),
("julia", &["--project=.", "srv.jl"]),
] {
assert!(!launches(command, args), "{command} {args:?}");
}
}
#[test]
fn xonsh_rc_is_unanalyzable() {
assert_matches!(
unanalyzable_reason("xonsh", &["--rc", "a", "b"]),
UnanalyzableLaunch::UnknownOption(_)
);
assert!(!launches("xonsh", &["--no-rc", "s.xsh"]));
}
#[test]
fn an_unknown_long_option_that_swallows_the_script_is_reported_as_unknown() {
for (command, args) in [
("python", &["--unknown", "srv.py"][..]),
("node", &["--openssl-legacy-provider", "/abs/srv.js"]),
("node", &["--nolazy", "srv.js"]),
("pwsh", &["-unknownparam", "s.ps1"]),
] {
assert_matches!(
unanalyzable_reason(command, args),
UnanalyzableLaunch::UnknownOption(_),
"{command} {args:?}"
);
}
assert!(!launches("python", &["--unknown=1", "srv.py"]));
assert!(!launches(
"pwsh",
&["-nop", "-nol", "-noe", "-noni", "s.ps1"]
));
}
#[test]
fn a_value_taken_for_an_unlisted_option_is_vetted_as_an_operand() {
for args in [
&["--frozen-intrinsics", "/dev/stdin", "/abs/s.js"][..],
&["--frozen-intrinsics", "-", "/abs/s.js"],
&["--frozen-intrinsics", "../x", "/abs/s.js"],
] {
assert!(
matches!(
analyze("node", args),
Err(LauncherRefusal::SelectsWorkspaceCode {
trigger: LaunchTrigger::ProgramFromStdin(_),
..
})
),
"{args:?}"
);
}
assert_eq!(
stdin_source("pwsh", &["-unknownparam", "/dev/stdin", "-File", "s.ps1"]),
StdinSource::DeviceOperand
);
}
#[test]
fn options_that_hide_stdin_behind_a_script_are_refused() {
for (command, args, expected) in [
("osascript", &["-s", "h"][..], Some(StdinSource::NoProgram)),
("nu", &["-t", "4"], Some(StdinSource::NoProgram)),
("xonsh", &["--r", "a", "b"], None),
(
"zsh",
&["-o", "shinstdin", "s.sh"],
Some(StdinSource::DashS),
),
(
"zsh",
&["-o", "SHIN_STDIN", "s.sh"],
Some(StdinSource::DashS),
),
("zsh", &["--shinstdin", "s.sh"], Some(StdinSource::DashS)),
(
"php",
&["-f", "/dev/stdin"],
Some(StdinSource::DeviceOperand),
),
("php", &["-f/dev/stdin"], Some(StdinSource::DeviceOperand)),
("elvish", &["-lsp=false"], Some(StdinSource::NoProgram)),
("nu", &["--lsp=false"], Some(StdinSource::NoProgram)),
] {
if let Some(source) = expected {
assert_eq!(stdin_source(command, args), source, "{command} {args:?}");
} else {
assert!(launches(command, args), "{command} {args:?}");
}
}
assert!(!launches("osascript", &["-s", "h", "file.scpt"]));
assert!(!launches("php", &["-f", "/abs/s.php"]));
assert!(!launches("zsh", &["-o", "pipefail", "s.sh"]));
}
#[test]
fn interpreter_directory_changes_make_a_relative_script_unanalyzable() {
for (command, args) in [
("ruby", &["-C", "/dev", "stdin"][..]),
("ruby", &["-X", "/dev", "stdin"]),
("bun", &["--cwd", "/dev", "stdin"]),
] {
assert_eq!(
unanalyzable_reason(command, args),
UnanalyzableLaunch::RelativeOperandAfterChdir,
"{command} {args:?}"
);
}
assert!(!launches("ruby", &["-C", "/x", "/abs/s.rb"]));
}
#[test]
fn real_server_launch_lines_stay_admitted() {
for (command, args) in [
("node", &["/abs/srv.js", "--stdio"][..]),
("node", &["-r", "x", "srv.js"]),
("node", &["--inspect=9229", "srv.js"]),
("py", &["-3", "-m", "pylsp"]),
(
"pwsh",
&["-NoLogo", "-NoProfile", "-NonInteractive", "-File", "s.ps1"],
),
] {
assert!(!launches(command, args), "{command} {args:?}");
}
}
}