use crate::process_environment::{self, SubprocessEnvProfile};
use std::{
io,
path::Path,
process::{Child, Command, Stdio},
};
#[cfg(unix)]
use std::os::unix::process::CommandExt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ShellInvocation {
program: &'static str,
args: &'static [&'static str],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ShellStdin {
Null,
Piped,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ShellEnvPolicy {
Ambient,
Sanitized,
}
#[cfg(unix)]
fn shell_invocations(env: ShellEnvPolicy) -> &'static [ShellInvocation] {
match env {
ShellEnvPolicy::Ambient => &[ShellInvocation {
program: "/bin/bash",
args: &["-lc"],
}],
ShellEnvPolicy::Sanitized => &[ShellInvocation {
program: "/bin/bash",
args: &["--noprofile", "--norc", "-c"],
}],
}
}
#[cfg(windows)]
fn shell_invocations(_env: ShellEnvPolicy) -> &'static [ShellInvocation] {
&[
ShellInvocation {
program: "pwsh",
args: &["-NoProfile", "-NonInteractive", "-Command"],
},
ShellInvocation {
program: "powershell.exe",
args: &["-NoProfile", "-NonInteractive", "-Command"],
},
]
}
#[cfg(not(any(unix, windows)))]
fn shell_invocations(_env: ShellEnvPolicy) -> &'static [ShellInvocation] {
&[]
}
pub(crate) fn spawn_platform_shell(
command: &str,
cwd: &Path,
stdin: ShellStdin,
env: ShellEnvPolicy,
) -> io::Result<Child> {
try_shell_invocations(shell_invocations(env), |invocation| {
let mut command_builder = Command::new(invocation.program);
command_builder
.args(invocation.args)
.arg(command)
.current_dir(cwd)
.stdin(match stdin {
ShellStdin::Null => Stdio::null(),
ShellStdin::Piped => Stdio::piped(),
})
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if env == ShellEnvPolicy::Sanitized {
process_environment::apply_profile(&mut command_builder, SubprocessEnvProfile::Shell);
}
#[cfg(unix)]
{
command_builder.process_group(0);
}
command_builder.spawn()
})
}
fn try_shell_invocations<T>(
invocations: &[ShellInvocation],
mut spawn: impl FnMut(&ShellInvocation) -> io::Result<T>,
) -> io::Result<T> {
let mut last_not_found = None;
for invocation in invocations {
match spawn(invocation) {
Ok(child) => return Ok(child),
Err(error) if error.kind() == io::ErrorKind::NotFound => {
last_not_found = Some(error);
}
Err(error) => return Err(error),
}
}
Err(last_not_found.unwrap_or_else(|| {
io::Error::new(
io::ErrorKind::Unsupported,
"shell execution is unsupported on this platform",
)
}))
}
pub(crate) fn preflight_bash_cwd_scope(
command: &str,
allow_absolute_paths: bool,
allow_shell_expansion: bool,
) -> anyhow::Result<()> {
if !allow_shell_expansion
&& let Some(denied) = command
.chars()
.find(|ch| matches!(ch, '$' | '~' | '`' | '{' | '}'))
{
anyhow::bail!(
"bash command rejected by cwd-scope preflight: denied shell expansion character '{denied}' (tools.bash.shell_expansion is false)"
);
}
for token in shell_like_tokens(command) {
if !allow_absolute_paths {
if token == "cd" {
anyhow::bail!(
"bash command rejected by cwd-scope preflight: unsafe cd target (tools.bash.absolute_paths is false)"
);
}
if let Some(target) = token.strip_prefix("cd ")
&& target != "."
{
anyhow::bail!(
"bash command rejected by cwd-scope preflight: unsafe cd target (tools.bash.absolute_paths is false)"
);
}
}
for word in token.split_whitespace() {
if has_parent_directory_component(word) {
anyhow::bail!(
"bash command rejected by cwd-scope preflight: parent-directory path component"
);
}
if is_absolute_or_drive_qualified_path(word) && !allow_absolute_paths {
anyhow::bail!(
"bash command rejected by cwd-scope preflight: absolute path (tools.bash.absolute_paths is false)"
);
}
}
}
Ok(())
}
fn has_parent_directory_component(word: &str) -> bool {
cwd_scope_path_candidate_matches(word, |candidate| {
let normalized = candidate.replace('\\', "/");
normalized == ".."
|| normalized.starts_with("../")
|| normalized.contains("/../")
|| normalized.ends_with("/..")
})
}
fn is_absolute_or_drive_qualified_path(word: &str) -> bool {
cwd_scope_path_candidate_matches(word, |candidate| {
candidate.starts_with('/')
|| candidate.starts_with('\\')
|| has_windows_drive_prefix(candidate)
})
}
fn cwd_scope_path_candidate_matches(word: &str, mut matches: impl FnMut(&str) -> bool) -> bool {
let word = trim_path_candidate(word);
if matches(word) {
return true;
}
if let Some((_, value)) = word.split_once('=')
&& matches(trim_path_candidate(value))
{
return true;
}
if let Some(value) = compact_option_path_candidate(word)
&& matches(trim_path_candidate(value))
{
return true;
}
false
}
fn trim_path_candidate(word: &str) -> &str {
word.trim_matches(['\'', '"'])
}
fn compact_option_path_candidate(word: &str) -> Option<&str> {
if word.starts_with("--") || !word.starts_with('-') {
return None;
}
let value = word.get(2..)?;
(!value.is_empty()).then_some(value)
}
fn has_windows_drive_prefix(word: &str) -> bool {
let bytes = word.as_bytes();
bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
}
fn shell_like_tokens(command: &str) -> impl Iterator<Item = &str> {
command
.split(is_shell_token_separator)
.map(|token| token.trim_matches(['\'', '"', ',', ' ', '\t']))
.filter(|token| !token.is_empty())
}
fn is_shell_token_separator(character: char) -> bool {
matches!(
character,
';' | '|' | '&' | '<' | '>' | '(' | ')' | '`' | '\n' | '\r'
)
}