use std::ffi::{OsStr, OsString};
use std::path::Path;
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub(crate) enum WindowsShimArgError {
#[error(
"claude code: argument cannot be passed safely through a Windows cmd/bat shim \
(contains CR, LF, or NUL)"
)]
CmdDisallowedByte,
#[error(
"claude code: Windows cmd/bat shim path is invalid \
(contains quote or ends with backslash)"
)]
InvalidScriptPath,
#[error(
"claude code: argument cannot be passed safely through a Windows PowerShell shim \
(contains NUL)"
)]
PowerShellDisallowedByte,
}
pub(crate) fn bat_command_line(
script: &Path,
args: &[impl AsRef<OsStr>],
) -> Result<OsString, WindowsShimArgError> {
let mut line = OsString::from("cmd.exe ");
line.push(bat_raw_arg_tail(script, args)?);
Ok(line)
}
pub(crate) fn bat_raw_arg_tail(
script: &Path,
args: &[impl AsRef<OsStr>],
) -> Result<OsString, WindowsShimArgError> {
let script_os = script.as_os_str();
validate_script_path(script_os)?;
let mut line = String::from("/e:ON /v:OFF /d /c \"");
line.push('"');
push_os_str(&mut line, script_os)?;
line.push('"');
for arg in args {
line.push(' ');
append_bat_arg_str(&mut line, arg.as_ref())?;
}
line.push('"');
Ok(OsString::from(line))
}
pub(crate) fn validate_powershell_args(
args: &[impl AsRef<OsStr>],
) -> Result<(), WindowsShimArgError> {
for arg in args {
if os_contains_nul(arg.as_ref()) {
return Err(WindowsShimArgError::PowerShellDisallowedByte);
}
}
Ok(())
}
fn validate_script_path(script: &OsStr) -> Result<(), WindowsShimArgError> {
let bytes = script.as_encoded_bytes();
if bytes.contains(&0) {
return Err(WindowsShimArgError::CmdDisallowedByte);
}
if bytes.contains(&b'"') || bytes.last() == Some(&b'\\') {
return Err(WindowsShimArgError::InvalidScriptPath);
}
Ok(())
}
fn append_bat_arg_str(out: &mut String, arg: &OsStr) -> Result<(), WindowsShimArgError> {
if os_contains_nul(arg) {
return Err(WindowsShimArgError::CmdDisallowedByte);
}
let Some(text) = arg.to_str() else {
return Err(WindowsShimArgError::CmdDisallowedByte);
};
if text.chars().any(|c| c == '\r' || c == '\n') {
return Err(WindowsShimArgError::CmdDisallowedByte);
}
let mut quote = text.is_empty() || text.as_bytes().last() == Some(&b'\\');
static UNQUOTED: &str = r"#$*+-./:?@\_";
for cp in text.chars() {
let ascii_needs_quotes =
cp.is_ascii() && !(cp.is_ascii_alphanumeric() || UNQUOTED.contains(cp));
if ascii_needs_quotes || cp.is_control() {
quote = true;
break;
}
}
if quote {
out.push('"');
}
let mut backslashes = 0usize;
for ch in text.chars() {
if ch == '\\' {
backslashes += 1;
} else {
if ch == '"' {
for _ in 0..backslashes {
out.push('\\');
}
out.push('"');
} else if ch == '%' || ch == '\r' {
out.push_str("%%cd:~,");
}
backslashes = 0;
}
out.push(ch);
}
if quote {
for _ in 0..backslashes {
out.push('\\');
}
out.push('"');
}
Ok(())
}
fn os_contains_nul(arg: &OsStr) -> bool {
arg.as_encoded_bytes().contains(&0)
}
fn push_os_str(out: &mut String, arg: &OsStr) -> Result<(), WindowsShimArgError> {
let Some(text) = arg.to_str() else {
return Err(WindowsShimArgError::InvalidScriptPath);
};
out.push_str(text);
Ok(())
}
#[cfg(test)]
#[path = "windows_shim_args_tests.rs"]
mod tests;