use crate::core::error::ShellError;
use super::{
contains_double_semicolon, extract_all_commands, find_shell_word, quote_aware_token_end,
rewrite_case_constructs, shell_tokenize, skip_env_assignments,
};
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();
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;
}
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) = balanced_brace_inner(s) {
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)?;
}
}
out.push(s.to_string());
Ok(())
}
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 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 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 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 {
Some(trimmed[1..i].trim())
} else {
None
};
}
}
_ => {}
}
i += 1;
}
None
}
fn balanced_brace_inner(segment: &str) -> Option<&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 if i == len - 1 {
Some(trimmed[1..i].trim())
} else {
None
};
}
}
_ => {}
}
i += 1;
}
None
}