use crate::core::error::ShellError;
use super::ps_statements::Found;
use super::{
contains_double_semicolon, extract_all_commands, extract_base_from_segment, find_shell_word,
quote_aware_token_end, rewrite_case_constructs, shell_tokenize, skip_env_assignments,
skip_powershell_assignment,
};
const HEADER_KEYWORDS: &[&str] = &["for", "select", "in", "done", "fi"];
const BODY_INTRO_KEYWORDS: &[&str] = &[
"do", "then", "else", "elif", "if", "while", "until", "time", "!",
];
pub fn expand_to_leaf_segments(command: &str) -> Result<Vec<String>, ShellError> {
let command = rewrite_case_constructs(command)?;
if find_shell_word(&command, "esac", 0).is_some() || contains_double_semicolon(&command) {
return Err(format!(
"[BLOCKED — DO NOT RETRY] Unparsed case-arm terminator or `esac` \
construct cannot be leaf-validated safely in restricted (allowlisted) \
shell mode. Run a script file or disable the allowlist instead.\n\
Command: {command}"
)
.into());
}
let mut leaves = Vec::new();
resolve_commands(&command, 0, &mut leaves)?;
Ok(leaves)
}
fn resolve_segment_leaves(
segment: &str,
depth: usize,
out: &mut Vec<String>,
) -> Result<(), ShellError> {
if depth > 4 {
return Err(format!(
"[BLOCKED — DO NOT RETRY] Shell command nests compound/subshell groups too \
deeply to validate safely.\nCommand: {segment}"
)
.into());
}
let mut s = segment.trim();
if is_zsh_short_loop(strip_body_intro_keywords(s)) {
return Err(zsh_only_form(segment));
}
if let Some(expr) = arithmetic_command_expr(strip_body_intro_keywords(s)) {
for inner in arithmetic_substitutions(expr, segment)? {
resolve_commands(&inner, depth + 1, out)?;
}
return Ok(());
}
if let Some(found) = super::ps_statements::statement_commands(s) {
for item in found? {
match item {
Found::Command(text) => resolve_commands(&text, depth + 1, out)?,
Found::Block(body) => resolve_block_body(&body, depth, out)?,
}
}
return Ok(());
}
if let Some(commands) = super::ps_statements::expression_commands(s) {
for command in commands {
resolve_commands(&command, depth + 1, out)?;
}
return Ok(());
}
loop {
let tokens = shell_tokenize(s);
let Some(first) = tokens.first() else {
return Ok(()); };
let kw = first.as_str();
if HEADER_KEYWORDS.contains(&kw) {
return Ok(()); }
if BODY_INTRO_KEYWORDS.contains(&kw) {
s = remainder_after_first_token(s).trim();
if s.is_empty() {
return Ok(());
}
continue;
}
break;
}
let assignment_rhs = skip_powershell_assignment(s);
if assignment_rhs.len() != s.len() {
return resolve_segment_leaves(assignment_rhs, depth + 1, out);
}
if let Some(inner) = balanced_paren_inner(s) {
return resolve_commands(inner, depth + 1, out);
}
if let Some(inner) = powershell_property_expression_inner(s) {
return resolve_commands(inner, depth + 1, out);
}
if let Some((inner, suffix)) = brace_group(s) {
if !suffix.trim().is_empty() && !is_redirect_only_suffix(suffix) {
return Err(zsh_only_form(segment));
}
return resolve_commands(inner, depth + 1, out);
}
for inner in assignment_substitution_leaves(s) {
resolve_commands(inner, depth + 1, out)?;
}
resolve_command_leaf(s, segment, depth, out)
}
fn resolve_command_leaf(
s: &str,
segment: &str,
depth: usize,
out: &mut Vec<String>,
) -> Result<(), ShellError> {
let base = extract_base_from_segment(s);
let bodies = super::ps_statements::script_block_bodies(s, &base);
if bodies.is_empty()
&& super::tokenizer::detect_function_def(s).is_none()
&& shell_tokenize(s).iter().skip(1).any(|t| t == "{")
{
return Err(zsh_only_form(segment));
}
out.push(s.to_string());
for body in bodies {
resolve_block_body(&body, depth, out)?;
}
Ok(())
}
fn is_zsh_short_loop(s: &str) -> bool {
let tokens = shell_tokenize(s);
matches!(
tokens.first().map(String::as_str),
Some("for" | "select" | "foreach")
) && tokens.get(1).is_some_and(|name| !name.starts_with('('))
&& tokens[2..]
.iter()
.any(|t| t.starts_with('(') && !t.starts_with("(("))
}
fn zsh_only_form(segment: &str) -> ShellError {
format!(
"[BLOCKED — DO NOT RETRY] This command uses a brace body or short loop form that \
zsh runs but this gate cannot split into its commands (`while c {{ … }}`, \
`for x (…) cmd`, `{{ … }} always {{ … }}`). Use `do … done`/`then … fi` and end \
brace groups with `; }}`.\nCommand: {segment}"
)
.into()
}
fn resolve_commands(text: &str, depth: usize, out: &mut Vec<String>) -> Result<(), ShellError> {
for (segment, call_target) in call_operator_segments(text)? {
if call_target {
resolve_call_target(&segment, depth, out)?;
} else {
resolve_segment_leaves(&segment, depth, out)?;
}
}
Ok(())
}
pub fn call_operator_segments(text: &str) -> Result<Vec<(String, bool)>, ShellError> {
let powershell = super::ps_statements::host_is_powershell();
if powershell && super::tokenizer::has_call_operator_before_break(text) {
return Err(format!(
"[BLOCKED — DO NOT RETRY] PowerShell's call operator `&` is followed by a line \
break, so the command it runs cannot be checked against the allowlist.\n\
Put the program on the same line as `&`.\nCommand: {text}"
)
.into());
}
Ok(super::tokenizer::segments_after_separators(text)
.into_iter()
.map(|(segment, previous)| {
let call_target =
powershell && previous == Some(super::tokenizer::Separator::Background);
(segment, call_target)
})
.collect())
}
fn resolve_call_target(
target: &str,
depth: usize,
out: &mut Vec<String>,
) -> Result<(), ShellError> {
if depth > 4 {
return Err(format!(
"[BLOCKED — DO NOT RETRY] PowerShell's call operator `&` is nested too deeply \
to validate safely: a call target is resolved at most four deep.\n\
Run the commands directly instead.\nCommand: & {target}"
)
.into());
}
if let Some(rest) = target.strip_prefix('{') {
let spaced = format!("{{ {rest}");
if let Some((body, suffix)) = brace_group(&spaced)
&& (suffix.trim().is_empty() || is_redirect_only_suffix(suffix))
{
return resolve_block_body(body, depth, out);
}
return Err(format!(
"[BLOCKED — DO NOT RETRY] PowerShell's call operator `&` runs a script block \
this gate cannot check: only a closed `& {{ … }}` followed by nothing or a \
redirection is resolved into its commands.\n\
Run the commands directly instead.\nCommand: & {target}"
)
.into());
}
if target.starts_with(['$', '(', '@', '[']) || first_word_expands(target) {
return Err(format!(
"[BLOCKED — DO NOT RETRY] PowerShell's call operator `&` runs whatever its target \
names. Only a literal program name or path can be checked against the allowlist, \
not a variable, an expansion or an expression.\n\
Call the program by its name or path instead.\nCommand: & {target}"
)
.into());
}
resolve_command_leaf(target, target, depth, out)
}
fn first_word_expands(target: &str) -> bool {
let word = &target[..quote_aware_token_end(target)];
let (mut single, mut double) = (false, false);
for ch in word.chars() {
match ch {
'\'' if !double => single = !single,
'"' if !single => double = !double,
'$' if !single => return true,
_ => {}
}
}
false
}
fn resolve_block_body(body: &str, depth: usize, out: &mut Vec<String>) -> Result<(), ShellError> {
for (statement, call_target) in call_operator_segments(body)? {
if call_target {
resolve_call_target(&statement, depth + 1, out)?;
continue;
}
match super::ps_statements::block_statement_commands(&statement) {
Some(commands) => {
for command in commands {
resolve_commands(&command, depth + 1, out)?;
}
}
None => resolve_segment_leaves(&statement, depth + 1, out)?,
}
}
Ok(())
}
pub fn balanced_paren_at(s: &str, open: usize) -> Option<(&str, usize)> {
let bytes = s.as_bytes();
let len = bytes.len();
let mut depth: i32 = 0;
let mut in_single_quote = false;
let mut in_double_quote = false;
let mut i = open;
while i < len {
let ch = bytes[i];
if in_single_quote {
if ch == b'\'' {
in_single_quote = false;
}
i += 1;
continue;
}
if in_double_quote {
match ch {
b'\\' => i = (i + 2).min(len),
b'"' => {
in_double_quote = false;
i += 1;
}
_ => i += 1,
}
continue;
}
match ch {
b'\\' => i = (i + 2).min(len),
b'\'' => {
in_single_quote = true;
i += 1;
}
b'"' => {
in_double_quote = true;
i += 1;
}
b'(' => {
depth += 1;
i += 1;
}
b')' => {
depth -= 1;
i += 1;
if depth == 0 {
return Some((&s[open + 1..i - 1], i));
}
}
_ => i += 1,
}
}
None
}
fn powershell_property_expression_inner(segment: &str) -> Option<&str> {
let trimmed = segment.trim();
if !trimmed.starts_with('(') {
return None;
}
let (inner, end) = balanced_paren_at(trimmed, 0)?;
let mut suffix = trimmed[end..].trim();
if suffix.is_empty() {
return None;
}
while let Some(rest) = suffix.strip_prefix('.') {
let name_len = rest
.bytes()
.take_while(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
.count();
if name_len == 0 || !rest.as_bytes()[0].is_ascii_alphabetic() {
return None;
}
suffix = &rest[name_len..];
}
suffix.is_empty().then_some(inner)
}
fn leading_assignment_prefix(s: &str) -> &str {
let rest = skip_env_assignments(s);
let start = s.as_ptr() as usize;
let at = rest.as_ptr() as usize;
if at < start || at > start + s.len() {
return s;
}
&s[..at - start]
}
fn assignment_substitution_leaves(s: &str) -> Vec<&str> {
let prefix = leading_assignment_prefix(s);
if prefix.is_empty() {
return Vec::new();
}
let mut found = Vec::new();
let bytes = prefix.as_bytes();
let len = bytes.len();
let mut in_single_quote = false;
let mut in_double_quote = false;
let mut i = 0;
while i < len {
let ch = bytes[i];
if in_single_quote {
if ch == b'\'' {
in_single_quote = false;
}
i += 1;
continue;
}
if in_double_quote {
match ch {
b'\\' => {
i = (i + 2).min(len);
continue;
}
b'"' => in_double_quote = false,
b'`' => {
if let Some(end) = super::case_construct::skip_backticks(bytes, i) {
found.push(&prefix[i + 1..end - 1]);
i = end;
continue;
}
}
_ => {}
}
i += 1;
continue;
}
match ch {
b'\\' => {
i = (i + 2).min(len);
continue;
}
b'`' => {
if let Some(end) = super::case_construct::skip_backticks(bytes, i) {
found.push(&prefix[i + 1..end - 1]);
i = end;
continue;
}
}
b'\'' => in_single_quote = true,
b'"' => in_double_quote = true,
b'$' if i + 1 < len && bytes[i + 1] == b'(' => {
if bytes.get(i + 2) == Some(&b'(') {
if let Some((_, end)) = balanced_paren_at(prefix, i + 2) {
i = if prefix.as_bytes().get(end) == Some(&b')') {
end + 1
} else {
end
};
continue;
}
}
if let Some((inner, end)) = balanced_paren_at(prefix, i + 1) {
found.push(inner);
i = end;
continue;
}
}
_ => {}
}
i += 1;
}
found
}
fn remainder_after_first_token(s: &str) -> &str {
let trimmed = s.trim_start();
let end = quote_aware_token_end(trimmed);
&trimmed[end..]
}
fn is_redirect_only_suffix(suffix: &str) -> bool {
let mut rest = suffix.trim();
while !rest.is_empty() {
let bytes = rest.as_bytes();
let mut head = 0;
while head < bytes.len() && bytes[head].is_ascii_digit() {
head += 1;
}
if head == 0 && bytes.first() == Some(&b'&') && bytes.get(1) == Some(&b'>') {
head = 1;
}
let op = &rest[head..];
let op_len = if op.starts_with(">>")
|| op.starts_with(">|")
|| op.starts_with(">&")
|| op.starts_with("<&")
{
2
} else if op.starts_with('>') || op.starts_with('<') {
1
} else {
return false;
};
rest = op[op_len..].trim_start();
let Some(target) = redirect_target_len(rest) else {
return false;
};
if target == 0 {
return false;
}
rest = rest[target..].trim_start();
}
true
}
fn redirect_target_len(rest: &str) -> Option<usize> {
let bytes = rest.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b if b.is_ascii_whitespace() => break,
b';' | b'|' | b'&' | b'(' | b')' | b'<' | b'>' => break,
b'\\' => i += 2,
b'\'' => i += rest[i + 1..].find('\'')? + 2,
b'"' => i = closing_double_quote(rest, i + 1)? + 1,
b'$' if bytes.get(i + 1) == Some(&b'(') => i = balanced_paren_at(rest, i + 1)?.1,
b'`' => i = super::case_construct::skip_backticks(bytes, i)?,
_ => i += 1,
}
}
Some(i.min(bytes.len()))
}
fn closing_double_quote(s: &str, from: usize) -> Option<usize> {
let bytes = s.as_bytes();
let mut i = from;
while i < bytes.len() {
match bytes[i] {
b'\\' => i += 2,
b'"' => return Some(i),
b'$' if bytes.get(i + 1) == Some(&b'(') => i = balanced_paren_at(s, i + 1)?.1,
b'`' => i = super::case_construct::skip_backticks(bytes, i)?,
_ => i += 1,
}
}
None
}
pub fn arithmetic_command_expr(segment: &str) -> Option<&str> {
let trimmed = segment.trim();
if !trimmed.starts_with("((") {
return None;
}
let (_, inner_end) = balanced_paren_at(trimmed, 1)?;
(trimmed.as_bytes().get(inner_end) == Some(&b')') && inner_end + 1 == trimmed.len())
.then(|| &trimmed[2..inner_end - 1])
}
pub fn has_arithmetic_command(command: &str) -> bool {
let command = rewrite_case_constructs(command).unwrap_or_else(|_| command.to_string());
extract_all_commands(&command)
.iter()
.any(|segment| arithmetic_command_expr(strip_body_intro_keywords(segment)).is_some())
}
fn strip_body_intro_keywords(segment: &str) -> &str {
let mut s = segment.trim();
while shell_tokenize(s)
.first()
.is_some_and(|kw| BODY_INTRO_KEYWORDS.contains(&kw.as_str()))
{
s = remainder_after_first_token(s).trim();
}
s
}
pub fn arithmetic_substitutions<'a>(
expr: &'a str,
segment: &str,
) -> Result<Vec<std::borrow::Cow<'a, str>>, ShellError> {
let bytes = expr.as_bytes();
let mut found = Vec::new();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'\\' => i += 2,
b'$' if bytes.get(i + 1) == Some(&b'(') => {
let Some((inner, end)) = balanced_paren_at(expr, i + 1) else {
return Err(unterminated_arithmetic_substitution(segment));
};
let nested_arithmetic = bytes.get(i + 2) == Some(&b'(')
&& balanced_paren_at(expr, i + 2).is_some_and(|(_, e)| e + 1 == end);
if nested_arithmetic {
i += 3;
} else {
found.push(inner.into());
i = end;
}
}
b'`' => {
let Some(end) = super::case_construct::skip_backticks(bytes, i) else {
return Err(unterminated_arithmetic_substitution(segment));
};
found.push(super::lexer::backtick_script(&expr[i + 1..end - 1]));
i = end;
}
_ => i += 1,
}
}
Ok(found)
}
fn unterminated_arithmetic_substitution(segment: &str) -> ShellError {
format!(
"[BLOCKED — DO NOT RETRY] Arithmetic command `(( … ))` contains an \
unterminated command substitution that cannot be validated safely.\n\
Command: {segment}"
)
.into()
}
fn balanced_paren_inner(segment: &str) -> Option<&str> {
let trimmed = segment.trim();
let bytes = trimmed.as_bytes();
if bytes.first() != Some(&b'(') {
return None;
}
let len = bytes.len();
let mut depth: i32 = 0;
let mut in_single_quote = false;
let mut in_double_quote = false;
let mut i = 0;
while i < len {
let ch = bytes[i];
if in_single_quote {
if ch == b'\'' {
in_single_quote = false;
}
i += 1;
continue;
}
if in_double_quote {
match ch {
b'\\' => i += 1, b'"' => in_double_quote = false,
_ => {}
}
i += 1;
continue;
}
match ch {
b'\\' => i += 1,
b'\'' => in_single_quote = true,
b'"' => in_double_quote = true,
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
return if i == len - 1 || is_redirect_only_suffix(&trimmed[i + 1..]) {
Some(trimmed[1..i].trim())
} else {
None
};
}
}
_ => {}
}
i += 1;
}
None
}
pub fn balanced_brace_inner(segment: &str) -> Option<&str> {
brace_group(segment).and_then(|(inner, suffix)| suffix.is_empty().then_some(inner))
}
fn brace_group(segment: &str) -> Option<(&str, &str)> {
let trimmed = segment.trim();
let bytes = trimmed.as_bytes();
if bytes.first() != Some(&b'{') {
return None;
}
match bytes.get(1) {
Some(&(b' ' | b'\t' | b'\n' | b'\r')) => {}
_ => return None,
}
let len = bytes.len();
let mut depth: i32 = 0;
let mut in_single_quote = false;
let mut in_double_quote = false;
let mut i = 0;
while i < len {
let ch = bytes[i];
if in_single_quote {
if ch == b'\'' {
in_single_quote = false;
}
i += 1;
continue;
}
if in_double_quote {
match ch {
b'\\' => i += 1, b'"' => in_double_quote = false,
_ => {}
}
i += 1;
continue;
}
match ch {
b'\\' => i += 1, b'\'' => in_single_quote = true,
b'"' => in_double_quote = true,
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
return Some((trimmed[1..i].trim(), &trimmed[i + 1..]));
}
}
_ => {}
}
i += 1;
}
None
}