use super::enforcement::{INTERPRETER_COMMANDS, enforce_with};
use super::lexer::lex;
use super::mode::ShellSecurity;
use super::tokenizer::{
extract_base_from_segment, segments_with_separators, sequence_statements, skip_env_assignments,
};
#[derive(Debug, PartialEq, Eq)]
pub struct PartialRun {
pub run: String,
pub skipped: Vec<String>,
}
const RESERVED: &[&str] = &[
"if", "then", "elif", "else", "fi", "for", "while", "until", "do", "done", "case", "esac",
"select", "function", "time", "coproc", "!", "{", "}", "[[",
];
const LINE_CONTROL: &[&str] = &[
"exit", "return", "set", "shopt", "command", "builtin", "eval", "exec", "source", ".", "trap",
"logout",
];
#[must_use]
pub fn partial_run(command: &str, granted: &[String]) -> Option<PartialRun> {
if ShellSecurity::resolve() != ShellSecurity::Enforce || cfg!(windows) {
return None;
}
if command.contains("<<")
|| command.contains(['\r', '\u{2028}', '\u{2029}'])
|| command.contains("\\\n")
|| !lexes_clean(command)
{
return None;
}
let statements = sequence_statements(command);
if statements.len() < 2 {
return None;
}
let mut run = Vec::new();
let mut skipped = Vec::new();
for statement in statements {
if !lexes_clean(statement) {
return None;
}
let segments = segments_with_separators(statement);
if segments.iter().any(|(segment, _)| controls_line(segment)) {
return None;
}
match enforce_with(statement, granted, None) {
Ok(()) => run.push(statement),
Err(error) => {
if !is_bare_interpreter_call(statement, segments.len()) {
return None;
}
skipped.push(format!(
"skipped: {} ({})",
preview(statement),
short_reason(error.message())
));
}
}
}
if run.is_empty() || skipped.is_empty() {
return None;
}
let run = run.join("\n");
enforce_with(&run, granted, None).ok()?;
Some(PartialRun { run, skipped })
}
fn lexes_clean(text: &str) -> bool {
let lexed = lex(text);
lexed.problem.is_none() && lexed.comments.is_empty()
}
fn first_word(segment: &str) -> &str {
let rest = skip_env_assignments(segment.trim());
rest.split_whitespace().next().unwrap_or("")
}
fn controls_line(segment: &str) -> bool {
let word = first_word(segment);
RESERVED.contains(&word)
|| word.starts_with('(')
|| word.starts_with('{')
|| LINE_CONTROL.contains(&extract_base_from_segment(segment).as_str())
}
fn is_bare_interpreter_call(statement: &str, segment_count: usize) -> bool {
let trimmed = statement.trim();
segment_count == 1
&& skip_env_assignments(trimmed) == trimmed
&& !has_unquoted_meta(trimmed)
&& INTERPRETER_COMMANDS.contains(&extract_base_from_segment(trimmed).as_str())
}
fn has_unquoted_meta(text: &str) -> bool {
let mut chars = text.chars();
let (mut single, mut double) = (false, false);
while let Some(c) = chars.next() {
match c {
'\'' if !double => single = !single,
'"' if !single => double = !double,
'\\' if !single => {
chars.next();
}
'<' | '>' | '|' | '&' | ';' | '(' | ')' | '$' | '`' | '{' | '}'
if !single && !double =>
{
return true;
}
_ => {}
}
}
single || double
}
fn preview(statement: &str) -> String {
let flat = statement.split_whitespace().collect::<Vec<_>>().join(" ");
if flat.chars().count() <= 60 {
return flat;
}
let cut: String = flat.chars().take(57).collect();
format!("{cut}…")
}
fn short_reason(message: &str) -> String {
let first = message.lines().next().unwrap_or_default();
let reason = first
.strip_prefix("[BLOCKED — DO NOT RETRY] ")
.or_else(|| first.strip_prefix("[BLOCKED] "))
.unwrap_or(first);
let reason = reason.split(". ").next().unwrap_or(reason);
reason.trim_end_matches('.').to_string()
}