mirror of
https://github.com/openai/codex.git
synced 2026-08-23 13:09:46 +00:00
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:
1
MODULE.bazel.lock
generated
1
MODULE.bazel.lock
generated
@@ -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
11
codex-rs/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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}
|
||||
]
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user