Add a fail-closed Tree-sitter PowerShell lowerer (#39213)

## What changed

- Add a Tree-sitter-based lowerer that converts a conservative subset of literal PowerShell commands into argument vectors.
- Reject dynamic expressions, parse recovery, unsupported value conversions, directives, and source outside recognized command nodes instead of guessing their meaning.
- Keep the lowerer alongside the existing production parser for later adoption.

## Testing

- Add fixture-driven coverage for supported literal commands and unsupported or ambiguous syntax, including a dedicated `#requires` rejection test.

GitOrigin-RevId: a6e7acc264ca40df264db4b271e38ae7d89e1ec4
This commit is contained in:
iceweasel-oai
2026-08-18 14:49:38 +00:00
committed by copyberry
parent a04940cb12
commit bb701f1e8c
9 changed files with 610 additions and 1 deletions

1
MODULE.bazel.lock generated
View File

@@ -1796,6 +1796,7 @@
"tracing_0.1.44": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.6\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.21\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.17\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.9\"},{\"name\":\"tracing-attributes\",\"optional\":true,\"req\":\"^0.1.31\"},{\"default_features\":false,\"name\":\"tracing-core\",\"req\":\"^0.1.36\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.38\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"}],\"features\":{\"async-await\":[],\"attributes\":[\"tracing-attributes\"],\"default\":[\"std\",\"attributes\"],\"log-always\":[\"log\"],\"max_level_debug\":[],\"max_level_error\":[],\"max_level_info\":[],\"max_level_off\":[],\"max_level_trace\":[],\"max_level_warn\":[],\"release_max_level_debug\":[],\"release_max_level_error\":[],\"release_max_level_info\":[],\"release_max_level_off\":[],\"release_max_level_trace\":[],\"release_max_level_warn\":[],\"std\":[\"tracing-core/std\"],\"valuable\":[\"tracing-core/valuable\"]}}",
"tree-sitter-bash_0.25.1": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.1\"},{\"kind\":\"dev\",\"name\":\"tree-sitter\",\"req\":\"^0.25\"},{\"name\":\"tree-sitter-language\",\"req\":\"^0.1\"}],\"features\":{}}",
"tree-sitter-language_0.1.7": "{\"dependencies\":[],\"features\":{}}",
"tree-sitter-powershell_0.26.4": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2\"},{\"kind\":\"dev\",\"name\":\"tree-sitter\",\"req\":\"^0.26.5\"},{\"name\":\"tree-sitter-language\",\"req\":\"^0.1\"}],\"features\":{}}",
"tree-sitter_0.25.10": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.71.1\"},{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.10\"},{\"default_features\":false,\"features\":[\"unicode\"],\"name\":\"regex\",\"req\":\"^1.11.1\"},{\"default_features\":false,\"name\":\"regex-syntax\",\"req\":\"^0.8.5\"},{\"features\":[\"preserve_order\"],\"kind\":\"build\",\"name\":\"serde_json\",\"req\":\"^1.0.137\"},{\"name\":\"streaming-iterator\",\"req\":\"^0.1.9\"},{\"name\":\"tree-sitter-language\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"cranelift\",\"gc-drc\"],\"name\":\"wasmtime-c-api\",\"optional\":true,\"package\":\"wasmtime-c-api-impl\",\"req\":\"^29.0.1\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"regex/std\",\"regex/perf\",\"regex-syntax/unicode\"],\"wasm\":[\"std\",\"wasmtime-c-api\"]}}",
"tree_magic_mini_3.2.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.0\"},{\"name\":\"memchr\",\"req\":\"^2.0\"},{\"name\":\"nom\",\"req\":\"^8.0\"},{\"default_features\":false,\"name\":\"petgraph\",\"req\":\"^0.8.0\"},{\"name\":\"tree_magic_db\",\"optional\":true,\"req\":\"^3.0\"}],\"features\":{\"with-gpl-data\":[\"dep:tree_magic_db\"]}}",
"triomphe_0.1.15": "{\"dependencies\":[{\"name\":\"arc-swap\",\"optional\":true,\"req\":\"^1.3.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"optional\":true,\"req\":\"^1.1.1\"},{\"name\":\"unsize\",\"optional\":true,\"req\":\"^1.1\"}],\"features\":{\"default\":[\"serde\",\"stable_deref_trait\",\"std\"],\"std\":[],\"unstable_dropck_eyepatch\":[]}}",

11
codex-rs/Cargo.lock generated
View File

@@ -4143,6 +4143,7 @@ dependencies = [
"shlex",
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-powershell",
"url",
"which 8.0.0",
]
@@ -14522,6 +14523,16 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
[[package]]
name = "tree-sitter-powershell"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3faf304d44b9ddd4a7d97804bb8de7daf564336dd5a526dc6de5b39238243022"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"

View File

@@ -475,6 +475,7 @@ tonic = { version = "0.14.3", default-features = false, features = ["channel", "
tonic-prost = "0.14.3"
tree-sitter = "0.25.10"
tree-sitter-bash = "0.25"
tree-sitter-powershell = "=0.26.4"
ts-rs = "11"
tungstenite = { version = "0.27.0", features = ["deflate", "proxy"] }
uds_windows = "1.1.0"

View File

@@ -2,6 +2,9 @@ load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "shell-command",
compile_data = ["src/command_safety/powershell_parser.ps1"],
compile_data = [
"src/command_safety/fixtures/powershell_lowering.json",
"src/command_safety/powershell_parser.ps1",
],
crate_name = "codex_shell_command",
)

View File

@@ -19,6 +19,7 @@ serde_json = { workspace = true }
shlex = { workspace = true }
tree-sitter = { workspace = true }
tree-sitter-bash = { workspace = true }
tree-sitter-powershell = { workspace = true }
url = { workspace = true }
which = { workspace = true }

View File

@@ -0,0 +1,70 @@
[
{"name":"echo","script":"echo hi","expected":[["echo","hi"]]},
{"name":"single_quote","script":"Get-Content 'foo bar'","expected":[["Get-Content","foo bar"]]},
{"name":"quoted_comma","script":"Get-Content 'foo,bar'","expected":[["Get-Content","foo,bar"]]},
{"name":"double_quote","script":"Get-Content \"foo bar\"","expected":[["Get-Content","foo bar"]]},
{"name":"doubled_single_quote","script":"Write-Output 'a''b'","expected":[["Write-Output","a'b"]]},
{"name":"escaped_double_quote","script":"Write-Output \"a`\"b\"","expected":[["Write-Output","a\"b"]]},
{"name":"powershell_7_escape_in_double_quote","script":"rg \"--pr`e=helper.exe\" pattern","expected":null},
{"name":"unicode_escape_in_double_quote","script":"rg \"--pre=`u{68}elper.exe\" pattern","expected":null},
{"name":"escaped_space","script":"Get-Content foo` bar","expected":[["Get-Content","foo bar"]]},
{"name":"powershell_7_escape_in_bare_word","script":"rg --pr`e helper.exe pattern","expected":null},
{"name":"command_parameter","script":"Get-ChildItem -Path .","expected":[["Get-ChildItem","-Path","."]]},
{"name":"attached_parameter_argument","script":"Get-Content -Path:1","expected":null},
{"name":"attached_quoted_parameter_argument","script":"Get-Content -Path:'foo bar'","expected":null},
{"name":"pipeline","script":"Write-Output foo | Measure-Object","expected":[["Write-Output","foo"],["Measure-Object"]]},
{"name":"semicolon","script":"Get-Content Cargo.toml; Test-Path Cargo.toml","expected":[["Get-Content","Cargo.toml"],["Test-Path","Cargo.toml"]]},
{"name":"and_chain","script":"pwd && ls","expected":[["pwd"],["ls"]]},
{"name":"or_chain","script":"pwd || ls","expected":[["pwd"],["ls"]]},
{"name":"parenthesized_command","script":"(Get-Content foo.rs -Raw)","expected":null},
{"name":"parenthesized_pipeline","script":"(Get-Content foo | Measure-Object)","expected":null},
{"name":"parenthesized_chain","script":"(pwd && ls)","expected":null},
{"name":"canonical_numeric_argument","script":"Get-Content -TotalCount 100","expected":[["Get-Content","-TotalCount","100"]]},
{"name":"negative_numeric_argument","script":"git log -1 Cargo.toml","expected":[["git","log","-1","Cargo.toml"]]},
{"name":"hexadecimal_numeric_argument","script":"Write-Output 0x10","expected":null},
{"name":"signed_hexadecimal_numeric_argument","script":"Write-Output 0xFFFFFFFF","expected":null},
{"name":"binary_numeric_argument","script":"Write-Output 0b101","expected":null},
{"name":"type_suffixed_numeric_argument","script":"Write-Output 1d","expected":null},
{"name":"powershell_7_type_suffixed_numeric_argument","script":"Write-Output 1u","expected":null},
{"name":"size_suffixed_numeric_argument","script":"Write-Output 1kb","expected":null},
{"name":"leading_zero_numeric_argument","script":"Write-Output 01","expected":null},
{"name":"real_numeric_argument","script":"Write-Output 1.0","expected":null},
{"name":"exponent_numeric_argument","script":"Write-Output 1e3","expected":null},
{"name":"numeric_leading_filename","script":"Get-Content 1.0.0","expected":null},
{"name":"inline_double_dash_parameter","script":"Get-Content --flag=value","expected":[["Get-Content","--flag=value"]]},
{"name":"inline_double_dash_parameter_pipeline","script":"Get-Content --flag=value | Measure-Object","expected":[["Get-Content","--flag=value"],["Measure-Object"]]},
{"name":"git_dir_inline","script":"git --git-dir=.git status","expected":[["git","--git-dir=.git","status"]]},
{"name":"windows_path","script":"Get-Content C:\\tmp\\x","expected":[["Get-Content","C:\\tmp\\x"]]},
{"name":"verbatim_here_string","script":"Write-Output @'\nhello\n'@","expected":null},
{"name":"expandable_here_string_without_expansion","script":"Write-Output @\"\nhello\n\"@","expected":null},
{"name":"expandable_here_string_with_escaped_dollar","script":"Write-Output @\"\n`$foo\n\"@","expected":null},
{"name":"dangerous_literal_url","script":"Start-Process 'https://example.com'","expected":[["Start-Process","https://example.com"]]},
{"name":"dangerous_literal_force","script":"Remove-Item test -Force","expected":[["Remove-Item","test","-Force"]]},
{"name":"unicode_dash_parameter","script":"Remove-Item test Force","expected":null},
{"name":"trailing_comment","script":"Get-Content Cargo.toml # comment","expected":[["Get-Content","Cargo.toml"]]},
{"name":"hash_inside_token","script":"Get-Content foo#bar","expected":[["Get-Content","foo#bar"]]},
{"name":"embedded_hash_before_separator","script":"git status --short#; Remove-Item victim","expected":null},
{"name":"smart_quote_delimiter","script":"rg “--pre=helper.exe” pattern","expected":null},
{"name":"hash_inside_recovered_command_token","script":"pwd#evil --flag=value","expected":[["pwd#evil","--flag=value"]]},
{"name":"block_comment_before_command","script":"<# note #>\nGet-Content foo","expected":[["Get-Content","foo"]]},
{"name":"stop_parsing","script":"git log --% HEAD --output=codex_poc.txt","expected":null},
{"name":"param_block","script":"param([string]$path = (Get-Location)) Write-Output test","expected":null},
{"name":"named_blocks","script":"begin { Set-Content codex_poc.txt pwned } end { Get-Content Cargo.toml }","expected":null},
{"name":"using_statement","script":"using module ./codex_poc.psm1\nGet-Content Cargo.toml","expected":null},
{"name":"mixed_case_using_statement","script":"Using module ./codex_poc.psm1\nGet-Content Cargo.toml","expected":null},
{"name":"trap_block","script":"trap { Set-Content codex_poc.txt pwned; continue } Get-Content missing -ErrorAction Stop","expected":null},
{"name":"redirection","script":"echo hi > out.txt","expected":null},
{"name":"invocation_operator","script":"& Remove-Item foo","expected":null},
{"name":"nested_command","script":"Write-Output (Set-Content foo6.txt 'abc')","expected":null},
{"name":"subexpression","script":"Write-Output $(Get-Content foo)","expected":null},
{"name":"array_expression","script":"ls @(calc.exe)","expected":null},
{"name":"splat","script":"Get-Content @args","expected":null},
{"name":"empty_word","script":"''","expected":null},
{"name":"dynamic_argument","script":"Get-Content $foo","expected":null},
{"name":"expandable_dynamic","script":"Write-Output \"foo $bar\"","expected":null},
{"name":"comma_form","script":"Get-Content foo,bar","expected":null},
{"name":"single_dash_equals_is_not_recovery","script":"Get-Content -foo=bar","expected":null},
{"name":"uncovered_closing_paren","script":"Get-Content --flag=value )","expected":null},
{"name":"uncovered_invalid_tail","script":"Get-Content --flag=value | if (","expected":null},
{"name":"dangling_escape_after_recovery","script":"Get-Content --flag=value `","expected":null}
]

View File

@@ -1,4 +1,7 @@
mod powershell_parser;
// Production safety and exec-policy callers migrate to this lowerer in a follow-up.
#[allow(dead_code)]
mod powershell_tree_sitter;
pub mod is_dangerous_command;
pub mod is_safe_command;

View File

@@ -0,0 +1,482 @@
use tree_sitter::Node;
use tree_sitter::Parser;
/// Lowers a literal PowerShell script into argv-like command vectors.
///
/// This module does not decide whether a command is safe or dangerous; callers apply those
/// policies to the lowered words. Its job is only to recognize a deliberately small literal
/// PowerShell subset and fail closed for everything else.
///
/// Unknown syntax, parse recovery, and dynamic expressions fail closed instead of being guessed
/// at. The accepted CST shapes intentionally cover only common literal command forms; rare
/// PowerShell syntax and value-conversion cases stay opaque.
pub(super) fn try_parse_powershell_commands(script: &str) -> Option<Vec<Vec<String>>> {
lower_with_tree_sitter(script).ok()
}
fn lower_with_tree_sitter(script: &str) -> Result<Vec<Vec<String>>, String> {
// PowerShell treats these Unicode characters as syntax aliases even when tree-sitter leaves
// them inside generic tokens. Keep that whole spelling family opaque rather than guessing at
// whether a quote or dash is structural in a particular position.
if script
.chars()
.any(|ch| matches!(ch, '' | '' | '“' | '”' | '' | '—' | '―'))
{
return Err("PowerShell Unicode syntax alias".to_string());
}
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_powershell::LANGUAGE.into())
.map_err(|error| format!("load grammar: {error}"))?;
// The grammar rejects native `--flag=value`. Mask only the `=` in conservatively
// recognized bare tokens; the one-byte replacement keeps CST ranges valid for `script`.
let mut parse_source = script.as_bytes().to_vec();
for equals in inline_double_dash_parameter_equals(script) {
parse_source[equals] = b' ';
}
let parse_source = String::from_utf8(parse_source)
.map_err(|_| "masked command source is not UTF-8".to_string())?;
let tree = parser
.parse(&parse_source, None)
.ok_or_else(|| "tree-sitter returned no tree".to_string())?;
let root = tree.root_node();
if root.has_error() {
return Err("tree contains ERROR or missing nodes".to_string());
}
if has_requires_directive(root, script) {
return Err("requires directives can execute before command lowering".to_string());
}
if let Some(kind) = first_unrecognized_named_kind(root) {
return Err(format!("unrecognized named node: {kind}"));
}
let mut command_nodes = Vec::new();
collect_command_nodes(root, &mut command_nodes);
if command_nodes.is_empty() {
return Err("no literal command nodes".to_string());
}
let mut commands = Vec::with_capacity(command_nodes.len());
let mut command_ranges = Vec::with_capacity(command_nodes.len());
for node in command_nodes {
let text = node
.utf8_text(script.as_bytes())
.map_err(|_| "command source is not UTF-8".to_string())?;
command_ranges.push(node.start_byte()..node.end_byte());
commands.push(lower_command_text(text)?);
}
if !source_is_covered_by_commands(script, &command_ranges) {
return Err("source outside literal command nodes".to_string());
}
if commands.iter().any(|command| {
command
.first()
.is_some_and(|word| word.eq_ignore_ascii_case("using"))
}) {
return Err("using declarations require the PowerShell AST oracle".to_string());
}
if commands
.iter()
.any(|command| command.is_empty() || command.iter().any(String::is_empty))
{
return Err("empty lowered command or word".to_string());
}
Ok(commands)
}
fn collect_command_nodes<'tree>(root: Node<'tree>, commands: &mut Vec<Node<'tree>>) {
// Script nesting is model-controlled, so keep CST traversal off the call stack.
let mut stack = vec![root];
while let Some(node) = stack.pop() {
if node.kind() == "command" {
commands.push(node);
continue;
}
for child_index in (0..node.named_child_count()).rev() {
if let Some(child) = node.named_child(child_index) {
stack.push(child);
}
}
}
}
fn has_requires_directive(root: Node<'_>, script: &str) -> bool {
// Tree-sitter exposes #requires as a comment, but PowerShell evaluates it before the
// script body and can load modules or assemblies.
let mut stack = vec![root];
while let Some(node) = stack.pop() {
if node.kind() == "comment"
&& node
.utf8_text(script.as_bytes())
.ok()
.is_some_and(|comment| {
comment
.trim_start()
.to_ascii_lowercase()
.starts_with("#requires")
})
{
return true;
}
let mut cursor = node.walk();
stack.extend(node.named_children(&mut cursor));
}
false
}
// These helpers are the allowlist for PowerShell CST forms we intentionally understand. A new
// named tree-sitter node is rejected until its lowering semantics are reviewed.
fn first_unrecognized_named_kind(root: Node<'_>) -> Option<String> {
let mut stack = vec![root];
while let Some(node) = stack.pop() {
if node.is_named() && !is_allowed_named_kind(node.kind()) {
return Some(node.kind().to_string());
}
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
stack.push(child);
}
}
None
}
fn is_allowed_named_kind(kind: &str) -> bool {
matches!(
kind,
"program"
| "statement_list"
| "pipeline"
| "pipeline_chain"
| "pipeline_chain_tail"
| "command"
| "command_name"
| "command_elements"
| "command_argument_sep"
| "command_parameter"
| "generic_token"
| "array_literal_expression"
| "unary_expression"
| "string_literal"
| "verbatim_string_characters"
| "expandable_string_literal"
| "integer_literal"
| "decimal_integer_literal"
| "comment"
| "empty_statement"
// Negative numeric flags such as git log -1 use this wrapper.
| "expression_with_unary_operator"
)
}
// Find only bare literal `--flag=value` forms whose `=` can be masked before parsing.
fn inline_double_dash_parameter_equals(script: &str) -> Vec<usize> {
let mut equals = Vec::new();
let mut start = 0;
for (index, ch) in script.char_indices() {
if is_inline_parameter_token_separator(ch) {
push_inline_parameter_equals(&mut equals, script, start, index);
start = index + ch.len_utf8();
}
}
push_inline_parameter_equals(&mut equals, script, start, script.len());
equals
}
fn push_inline_parameter_equals(equals: &mut Vec<usize>, script: &str, start: usize, end: usize) {
let token = &script[start..end];
if token.starts_with("--")
&& token
.chars()
.all(|ch| !is_rejected_inline_parameter_character(ch))
&& let Some(relative_equals) = token.find('=')
&& relative_equals > 2
&& relative_equals + 1 < token.len()
{
equals.push(start + relative_equals);
}
}
fn is_inline_parameter_token_separator(ch: char) -> bool {
ch.is_whitespace() || "|&;#><(){}".contains(ch)
}
fn is_rejected_inline_parameter_character(ch: char) -> bool {
ch.is_whitespace() || is_rejected_bare_character(ch) || matches!(ch, '\u{60}' | '#')
}
fn source_is_covered_by_commands(script: &str, command_ranges: &[std::ops::Range<usize>]) -> bool {
// Command nodes alone are not enough: reject any source outside the literal commands and
// separators/comments we explicitly understand.
let mut index = 0;
let mut range_index = 0;
let mut can_chain = false;
let mut needs_command = false;
let mut paren_depth = 0;
while index < script.len() {
if let Some(range) = command_ranges.get(range_index)
&& index == range.start
{
index = range.end;
range_index += 1;
can_chain = true;
needs_command = false;
continue;
}
let Some(ch) = script[index..].chars().next() else {
return false;
};
let next = index + ch.len_utf8();
if ch == '\r' || ch == '\n' {
can_chain = false;
index = next;
continue;
}
if ch.is_whitespace() {
index = next;
continue;
}
if ch == ';' {
if needs_command {
return false;
}
can_chain = false;
index = next;
continue;
}
if ch == '(' && !can_chain {
paren_depth += 1;
index = next;
continue;
}
if ch == ')' && paren_depth > 0 && !needs_command {
paren_depth -= 1;
index = next;
continue;
}
if ch == '|' && can_chain {
can_chain = false;
needs_command = true;
index = if script[next..].starts_with('|') {
next + '|'.len_utf8()
} else {
next
};
continue;
}
if ch == '&' && can_chain && script[next..].starts_with('&') {
can_chain = false;
needs_command = true;
index = next + '&'.len_utf8();
continue;
}
if ch == '#' && !needs_command {
// `#` starts a comment only at a token boundary. Tree-sitter can split an
// embedded `#` out of a bare token, so reject that recovery instead of dropping
// the rest of the line.
if index > 0
&& !script[..index]
.chars()
.next_back()
.is_some_and(|previous| previous.is_whitespace() || previous == ';')
{
return false;
}
index = script[next..]
.find(['\r', '\n'])
.map_or(script.len(), |offset| next + offset);
continue;
}
if script[index..].starts_with("<#")
&& !needs_command
&& (index == 0
|| script[..index]
.chars()
.next_back()
.is_some_and(|previous| previous.is_whitespace() || previous == ';'))
{
let Some(end) = script[next..].find("#>") else {
return false;
};
index = next + end + "#>".len();
continue;
}
return false;
}
range_index == command_ranges.len() && !needs_command && paren_depth == 0
}
fn lower_command_text(command_text: &str) -> Result<Vec<String>, String> {
// This is literal argv lowering, not safe/dangerous classification. Quoting and escapes are
// decoded only for forms whose runtime value is statically known.
let mut words = Vec::new();
let chars: Vec<char> = command_text.trim().chars().collect();
let mut index = 0;
while index < chars.len() {
while index < chars.len() && chars[index].is_whitespace() {
index += 1;
}
if index == chars.len() || chars[index] == '#' {
break;
}
let (word, next, is_bare) = if chars[index] == '\'' {
let (word, next) = parse_single_quoted(&chars, index)?;
(word, next, false)
} else if chars[index] == '"' {
let (word, next) = parse_double_quoted(&chars, index)?;
(word, next, false)
} else {
let (word, next) = parse_bare_word(&chars, index)?;
(word, next, true)
};
index = next;
if index < chars.len() && !chars[index].is_whitespace() && chars[index] != '#' {
return Err("adjacent/concatenated command elements".to_string());
}
if word.is_empty() {
return Err("empty word".to_string());
}
if is_bare {
reject_unsupported_bare_word(&word)?;
}
words.push(word);
}
if words.is_empty() {
return Err("command lowered to no words".to_string());
}
Ok(words)
}
fn parse_single_quoted(chars: &[char], start: usize) -> Result<(String, usize), String> {
let mut value = String::new();
let mut index = start + 1;
while index < chars.len() {
if chars[index] == '\'' {
if chars.get(index + 1) == Some(&'\'') {
value.push('\'');
index += 2;
continue;
}
return Ok((value, index + 1));
}
value.push(chars[index]);
index += 1;
}
Err("unterminated single-quoted string".to_string())
}
fn parse_double_quoted(chars: &[char], start: usize) -> Result<(String, usize), String> {
let mut value = String::new();
let mut index = start + 1;
while index < chars.len() {
match chars[index] {
'"' => return Ok((value, index + 1)),
'$' => return Err("expandable string contains variable syntax".to_string()),
'`' => {
let escaped = *chars
.get(index + 1)
.ok_or_else(|| "trailing PowerShell escape".to_string())?;
// `e is ESC only in PowerShell 6+, so it has no version-neutral value.
if escaped == 'e' {
return Err("PowerShell-version-dependent escape".to_string());
}
if escaped == 'u' && chars.get(index + 2) == Some(&'{') {
return Err("PowerShell Unicode escape".to_string());
}
value.push(decode_backtick_escape(escaped));
index += 2;
}
ch => {
value.push(ch);
index += 1;
}
}
}
Err("unterminated double-quoted string".to_string())
}
fn decode_backtick_escape(ch: char) -> char {
match ch {
'0' => '\0',
'a' => '\u{7}',
'b' => '\u{8}',
'f' => '\u{c}',
'n' => '\n',
'r' => '\r',
't' => '\t',
'v' => '\u{b}',
other => other,
}
}
fn parse_bare_word(chars: &[char], start: usize) -> Result<(String, usize), String> {
let mut value = String::new();
let mut index = start;
while index < chars.len() && !chars[index].is_whitespace() {
let ch = chars[index];
if ch == '`' {
let escaped = *chars
.get(index + 1)
.ok_or_else(|| "trailing PowerShell escape".to_string())?;
if escaped == 'e' {
return Err("PowerShell-version-dependent escape".to_string());
}
value.push(decode_backtick_escape(escaped));
index += 2;
continue;
}
if is_rejected_bare_character(ch) {
return Err(format!("dynamic or structural bare character: {ch:?}"));
}
value.push(ch);
index += 1;
}
if value.is_empty() {
return Err("empty bare word".to_string());
}
Ok((value, index))
}
fn is_rejected_bare_character(ch: char) -> bool {
matches!(
ch,
'$' | '@'
| '\''
| '"'
| '('
| ')'
| '{'
| '}'
| '['
| ']'
| ';'
| '|'
| '&'
| '>'
| '<'
| ','
)
}
fn reject_unsupported_bare_word(word: &str) -> Result<(), String> {
// These forms require PowerShell-specific value conversion. Keeping them opaque is safer
// than reproducing that conversion in the policy parser.
if word.starts_with('-') && !word.starts_with("--") && word.contains(':') {
return Err("attached PowerShell parameter value".to_string());
}
// Tree-sitter leaves some PowerShell numerics as generic tokens. Keep only canonical decimal
// spellings whose runtime string is obviously identical to the source spelling.
if word.chars().next().is_some_and(|ch| ch.is_ascii_digit())
&& !(word == "0" || (!word.starts_with('0') && word.chars().all(|ch| ch.is_ascii_digit())))
{
return Err("non-canonical numeric-leading bare word".to_string());
}
Ok(())
}
#[cfg(test)]
#[path = "powershell_tree_sitter_tests.rs"]
mod tests;

View File

@@ -0,0 +1,37 @@
use pretty_assertions::assert_eq;
use serde::Deserialize;
use super::try_parse_powershell_commands;
#[derive(Debug, Deserialize)]
struct FixtureCase {
name: String,
script: String,
expected: Option<Vec<Vec<String>>>,
}
#[test]
fn lowers_compact_literal_fixture() {
// Supported outputs were captured from the PowerShell 7 AST subprocess parser. Rare forms
// that need additional PowerShell-specific lowering are intentionally recorded as unsupported.
let cases: Vec<FixtureCase> =
serde_json::from_str(include_str!("fixtures/powershell_lowering.json"))
.expect("valid PowerShell lowering fixture");
for case in cases {
assert_eq!(
try_parse_powershell_commands(&case.script),
case.expected,
"fixture case: {}",
case.name
);
}
}
#[test]
fn rejects_requires_directives() {
assert_eq!(
try_parse_powershell_commands("#requires -Modules Evil\nGet-Location"),
None
);
}