use base64::{Engine as _, engine::general_purpose::STANDARD};
use super::NinjaGenError;
use super::ninja_gen_escape::{NinjaValue, ShellText, escape_ninja_value};
use crate::recipe_shell::RecipeShell;
const MAX_POWER_SHELL_COMMAND_LINE: usize = 32_766;
const MAX_POWER_SHELL_RECIPE_BYTES: usize = 1024 * 1024;
const POWER_SHELL_COMMAND_PREFIX: &str =
"powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ";
const POWER_SHELL_RESPONSE_FILE_COMMAND: &str =
"powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"$rspfile\"";
const POWER_SHELL_RESPONSE_FILE_SCRIPT_PREFIX: &str = "$netsukePayload = '";
const POWER_SHELL_RESPONSE_FILE_SCRIPT_SUFFIX: &str = concat!(
"'; $netsukeScript = try { [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($netsukePayload)) } ",
"catch { throw \"Netsuke could not decode the PowerShell response file: $($_.Exception.Message)\" }; ",
"try { . ([ScriptBlock]::Create($netsukeScript)) } finally { Remove-Item -LiteralPath $PSCommandPath -Force }; ",
"if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }"
);
pub(super) enum RenderedRecipeCommand {
Direct(NinjaValue),
ResponseFile {
command: NinjaValue,
content: NinjaValue,
},
}
impl RenderedRecipeCommand {
pub(super) const fn command(&self) -> &NinjaValue {
match self {
Self::Direct(command) | Self::ResponseFile { command, .. } => command,
}
}
pub(super) const fn response_file_content(&self) -> Option<&NinjaValue> {
match self {
Self::Direct(_) => None,
Self::ResponseFile { content, .. } => Some(content),
}
}
}
impl RecipeShell {
pub(super) fn command_value(
self,
script: &ShellText,
) -> Result<RenderedRecipeCommand, NinjaGenError> {
match self {
Self::Posix => escape_ninja_value(script).map(RenderedRecipeCommand::Direct),
Self::PowerShell => {
Self::validate_power_shell_recipe_size(script)?;
let power_shell_script = Self::power_shell_script(script);
Self::power_shell_command(&power_shell_script)
}
Self::Bash => Self::bash_command(script).map(RenderedRecipeCommand::Direct),
}
}
pub(crate) fn command_list_script(self, entries: &[String]) -> Option<String> {
if self != Self::PowerShell {
return None;
}
let mut script = String::new();
for entry in entries {
script.push_str("$LASTEXITCODE = 0\n");
script.push_str(entry);
script.push_str("\nif ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }\n");
}
Some(script)
}
fn power_shell_script(script: &ShellText) -> ShellText {
ShellText::new(format!(
"$ErrorActionPreference = 'Stop'\n$LASTEXITCODE = 0\n{}\nif ($LASTEXITCODE -ne 0) {{ exit $LASTEXITCODE }}",
script.as_str()
))
}
fn validate_power_shell_recipe_size(script: &ShellText) -> Result<(), NinjaGenError> {
let actual_bytes = script.as_str().len();
if actual_bytes > MAX_POWER_SHELL_RECIPE_BYTES {
return Err(NinjaGenError::PowerShellRecipeTooLarge {
actual_bytes,
maximum_bytes: MAX_POWER_SHELL_RECIPE_BYTES,
});
}
Ok(())
}
fn power_shell_command(script: &ShellText) -> Result<RenderedRecipeCommand, NinjaGenError> {
if script.as_str().contains('\0') {
return Err(NinjaGenError::UnsafeNinjaValue);
}
let utf16le = script
.as_str()
.encode_utf16()
.flat_map(|code_unit| {
#[expect(
clippy::little_endian_bytes,
reason = "PowerShell -EncodedCommand requires UTF-16LE input"
)]
let bytes = code_unit.to_le_bytes();
bytes
})
.collect::<Vec<_>>();
let command = STANDARD.encode(utf16le);
let length = POWER_SHELL_COMMAND_PREFIX.len() + command.len();
if length > MAX_POWER_SHELL_COMMAND_LINE {
return Ok(RenderedRecipeCommand::ResponseFile {
command: NinjaValue::from_encoded(POWER_SHELL_RESPONSE_FILE_COMMAND.into()),
content: power_shell_response_file_content(&command)?,
});
}
Ok(RenderedRecipeCommand::Direct(NinjaValue::from_encoded(
format!("{POWER_SHELL_COMMAND_PREFIX}{command}"),
)))
}
fn bash_command(script: &ShellText) -> Result<NinjaValue, NinjaGenError> {
let command = format!("bash.exe -e -c {}", windows_argument(script.as_str()));
escape_ninja_value(&ShellText::new(command))
}
}
fn power_shell_response_file_content(encoded_script: &str) -> Result<NinjaValue, NinjaGenError> {
let bootstrap = format!(
"{POWER_SHELL_RESPONSE_FILE_SCRIPT_PREFIX}{encoded_script}{POWER_SHELL_RESPONSE_FILE_SCRIPT_SUFFIX}"
);
escape_ninja_value(&ShellText::new(bootstrap))
}
fn windows_argument(argument: &str) -> String {
let mut quoted = String::with_capacity(argument.len() + 2);
quoted.push('"');
let mut backslashes = 0usize;
for character in argument.chars() {
if character == '\\' {
backslashes += 1;
} else if character == '"' {
quoted.push_str(&"\\".repeat(backslashes.saturating_mul(2).saturating_add(1)));
quoted.push('"');
backslashes = 0;
} else {
quoted.push_str(&"\\".repeat(backslashes));
quoted.push(character);
backslashes = 0;
}
}
quoted.push_str(&"\\".repeat(backslashes.saturating_mul(2)));
quoted.push('"');
quoted
}
pub(super) fn escape_posix_script(script: &str) -> String {
script
.replace('\\', "\\\\")
.replace('$', "\\$")
.replace('"', "\\\"")
.replace('`', "\\`")
.replace('\'', r"'\''")
.replace('\n', "\\n")
}
#[cfg(test)]
mod tests {
use super::{
MAX_POWER_SHELL_RECIPE_BYTES, POWER_SHELL_COMMAND_PREFIX,
POWER_SHELL_RESPONSE_FILE_COMMAND, RecipeShell, RenderedRecipeCommand, windows_argument,
};
use crate::ninja_gen::ninja_gen_escape::ShellText;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use proptest::prelude::*;
#[test]
fn power_shell_command_hides_recipe_dollars_from_ninja() {
let rendered = RecipeShell::PowerShell
.command_value(&ShellText::new("$env:NETSUKE_SMOKE".into()))
.expect("PowerShell encoding should succeed")
.command()
.to_string();
assert!(rendered.starts_with("powershell.exe "));
assert!(!rendered.contains("NETSUKE_SMOKE"));
}
#[test]
fn windows_argument_preserves_quotes_and_trailing_backslashes() {
assert_eq!(
windows_argument("a \\\"b\\\"\\\\"),
"\"a \\\\\\\"b\\\\\\\"\\\\\\\\\""
);
}
#[test]
fn bash_command_value_uses_the_explicit_windows_compatibility_runtime() {
let rendered = RecipeShell::Bash
.command_value(&ShellText::new("echo \"hello world\"\\".into()))
.expect("Bash command rendering should succeed")
.command()
.to_string();
assert_eq!(rendered, "bash.exe -e -c \"echo \\\"hello world\\\"\\\\\"");
}
#[test]
fn power_shell_lists_check_the_captured_status_before_the_next_entry() {
let script = RecipeShell::PowerShell
.command_list_script(&["cmd.exe /c exit 7".into(), "Write-Output later".into()])
.expect("PowerShell should render command-list scripts");
let failure_check = script
.find("if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }")
.expect("first command should have an immediate status check");
let next_entry = script
.find("Write-Output later")
.expect("second command should be rendered");
assert!(failure_check < next_entry);
}
#[test]
fn power_shell_lists_do_not_reserve_user_variable_names() {
let script = RecipeShell::PowerShell
.command_list_script(&[
"$netsuke_exit_code = 'keep'".into(),
"if ($netsuke_exit_code -ne 'keep') { throw 'lost' }".into(),
])
.expect("PowerShell should render command-list scripts");
assert!(!script.contains("$netsuke_exit_code = $LASTEXITCODE"));
}
#[test]
fn power_shell_commands_exceeding_the_windows_limit_use_a_response_file() {
let recipe = "x".repeat(12_500);
let result = RecipeShell::PowerShell
.command_value(&ShellText::new(recipe.clone()))
.expect("oversized recipes should use a response file");
assert!(matches!(
&result,
RenderedRecipeCommand::ResponseFile { .. }
));
assert!(!result.command().to_string().contains(&recipe));
assert_eq!(
result.command().to_string(),
POWER_SHELL_RESPONSE_FILE_COMMAND
);
let content = result
.response_file_content()
.expect("oversized recipes should include a response-file script")
.to_string();
assert!(!content.contains(&recipe));
assert!(content.contains("$$netsukePayload"));
}
#[test]
fn power_shell_commands_reject_unsafe_ninja_control_characters() {
let result = RecipeShell::PowerShell.command_value(&ShellText::new("safe\0unsafe".into()));
assert!(matches!(
result,
Err(super::NinjaGenError::UnsafeNinjaValue)
));
}
#[test]
fn power_shell_commands_reject_recipes_larger_than_the_encoding_limit() {
let recipe = "x".repeat(MAX_POWER_SHELL_RECIPE_BYTES + 1);
let result = RecipeShell::PowerShell.command_value(&ShellText::new(recipe));
assert!(matches!(
result,
Err(super::NinjaGenError::PowerShellRecipeTooLarge {
actual_bytes,
maximum_bytes: MAX_POWER_SHELL_RECIPE_BYTES,
}) if actual_bytes == MAX_POWER_SHELL_RECIPE_BYTES + 1
));
}
proptest! {
#[test]
fn power_shell_scalar_encoding_round_trips(
text in proptest::collection::vec(any::<char>().prop_filter("no NUL", |character| *character != '\0'), 0..96),
) {
let ordinary = text.into_iter().collect::<String>();
let recipe = format!("{ordinary} $env:NAME $value ${{name}}");
let rendered = RecipeShell::PowerShell
.command_value(&ShellText::new(recipe.clone()))
.expect("bounded PowerShell recipe should encode")
.command()
.to_string();
let payload = rendered
.strip_prefix(POWER_SHELL_COMMAND_PREFIX)
.expect("rendered command should carry an encoded payload");
let decoded_bytes = STANDARD.decode(payload).expect("payload should be Base64");
let (pairs, remainder) = decoded_bytes.as_chunks::<2>();
prop_assert!(remainder.is_empty());
let code_units = pairs
.iter()
.map(|[low, high]| u16::from(*low) | (u16::from(*high) << 8))
.collect::<Vec<_>>();
let decoded = String::from_utf16(&code_units).expect("payload should be UTF-16LE");
let expected = RecipeShell::power_shell_script(&ShellText::new(recipe.clone()));
prop_assert_eq!(decoded, expected.as_str());
prop_assert!(!rendered.contains(&recipe));
prop_assert!(!rendered.contains("$env:NAME"));
}
#[test]
fn power_shell_lists_check_each_entry_before_the_next(
labels in proptest::collection::vec("[a-z]{1,12}", 1..8),
) {
let entries = labels
.iter()
.map(|label| format!("Write-Output {label}"))
.collect::<Vec<_>>();
let script = RecipeShell::PowerShell
.command_list_script(&entries)
.expect("PowerShell should render command-list scripts");
let check = "if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }";
let expected = entries.iter().fold(String::new(), |mut expected_script, entry| {
expected_script.push_str("$LASTEXITCODE = 0\n");
expected_script.push_str(entry);
expected_script.push('\n');
expected_script.push_str(check);
expected_script.push('\n');
expected_script
});
prop_assert_eq!(script, expected);
}
}
}