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(super) 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();
for seg in extract_all_commands(&command) {
resolve_segment_leaves(&seg, 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)? {
for inner_seg in extract_all_commands(inner) {
resolve_segment_leaves(&inner_seg, 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) {
for inner_seg in extract_all_commands(inner) {
resolve_segment_leaves(&inner_seg, depth + 1, out)?;
}
return Ok(());
}
if let Some(inner) = powershell_property_expression_inner(s) {
for inner_seg in extract_all_commands(inner) {
resolve_segment_leaves(&inner_seg, depth + 1, out)?;
}
return Ok(());
}
if let Some((inner, suffix)) = brace_group(s) {
if !suffix.trim().is_empty() && !is_redirect_only_suffix(suffix) {
return Err(zsh_only_form(segment));
}
for inner_seg in extract_all_commands(inner) {
resolve_segment_leaves(&inner_seg, depth + 1, out)?;
}
return Ok(());
}
for inner in assignment_substitution_leaves(s) {
for inner_seg in extract_all_commands(inner) {
resolve_segment_leaves(&inner_seg, depth + 1, out)?;
}
}
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 in extract_all_commands(text) {
resolve_segment_leaves(&segment, depth, out)?;
}
Ok(())
}
fn resolve_block_body(body: &str, depth: usize, out: &mut Vec<String>) -> Result<(), ShellError> {
for statement in extract_all_commands(body) {
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(super) 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 offset = (rest.as_ptr() as usize).saturating_sub(s.as_ptr() as usize);
&s[..offset.min(s.len())]
}
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,
_ => {}
}
i += 1;
continue;
}
match ch {
b'\\' => {
i = (i + 2).min(len);
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(super) 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(super) 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(super) fn arithmetic_substitutions<'a>(
expr: &'a str,
segment: &str,
) -> Result<Vec<&'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);
i = end;
}
}
b'`' => {
let Some(end) = super::case_construct::skip_backticks(bytes, i) else {
return Err(unterminated_arithmetic_substitution(segment));
};
found.push(&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(super) 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
}