use super::*;
pub(crate) struct InterpFinding {
pub(crate) targets: Vec<String>,
pub(crate) opaque_write: bool,
}
pub(crate) fn interp_lang(verb: &str) -> Option<&'static str> {
match verb {
"python" | "python3" | "python2" => Some("python"),
"node" | "nodejs" => Some("node"),
"ruby" => Some("ruby"),
_ => None,
}
}
pub(crate) fn emit_interp(
lang: &'static str,
operands: &[&str],
bodies: &[String],
out: &mut Vec<BashMutation>,
) {
let mut source = String::new();
for b in bodies {
source.push_str(b);
source.push('\n');
}
let mut i = 0usize;
while i < operands.len() {
if matches!(operands[i], "-c" | "-e" | "--eval") {
if let Some(script) = operands.get(i + 1) {
source.push_str(strip_quotes(script));
source.push('\n');
}
i += 2;
continue;
}
i += 1;
}
if source.is_empty() {
return;
}
let finding = analyze_interp_source(&source);
for t in &finding.targets {
out.push(BashMutation {
path: t.clone(),
verb: "interp-write",
cwd_at: CwdAt::Spawn,
});
}
if finding.opaque_write {
out.push(BashMutation {
path: format!("interp:{lang}"),
verb: "interp",
cwd_at: CwdAt::Spawn,
});
}
}
pub(crate) fn analyze_interp_source(source: &str) -> InterpFinding {
let mut targets: Vec<String> = Vec::new();
let mut opaque = false;
for line in source.lines() {
for (idx, kind) in write_call_sites(line) {
match write_target_at(line, idx, kind, source) {
CallVerdict::Target(t) => {
if !targets.contains(&t) {
targets.push(t);
}
}
CallVerdict::Opaque => opaque = true,
CallVerdict::NotAWrite => {}
}
}
}
InterpFinding {
targets,
opaque_write: opaque,
}
}
enum CallVerdict {
Target(String),
Opaque,
NotAWrite,
}
#[derive(Clone, Copy, PartialEq)]
pub(crate) enum WriteKind {
OpenMode,
Receiver,
FirstArg,
}
fn write_call_sites(line: &str) -> Vec<(usize, WriteKind)> {
let mut sites = Vec::new();
for (needle, kind) in [
("open(", WriteKind::OpenMode),
(".write_text(", WriteKind::Receiver),
(".write_bytes(", WriteKind::Receiver),
("writeFileSync(", WriteKind::FirstArg),
("File.write(", WriteKind::FirstArg),
] {
let mut from = 0usize;
while let Some(rel) = line[from..].find(needle) {
let at = from + rel;
let head_ok =
needle != "open(" || at == 0 || !line.as_bytes()[at - 1].is_ascii_alphanumeric();
if head_ok {
sites.push((at + needle.len() - 1, kind));
}
from = at + needle.len();
}
}
sites
}
fn write_target_at(line: &str, paren: usize, kind: WriteKind, source: &str) -> CallVerdict {
let to_verdict = |lit: Option<String>| match lit {
Some(t) => CallVerdict::Target(t),
None => CallVerdict::Opaque,
};
match kind {
WriteKind::OpenMode => {
let args = &line[paren + 1..];
let Some(first) = first_argument(args) else {
return CallVerdict::Opaque;
};
match second_argument_literal(args) {
Some(m) if m.contains(['w', 'a', 'x']) => {}
_ => return CallVerdict::NotAWrite,
}
to_verdict(argument_to_literal(&first, source))
}
WriteKind::Receiver => {
let name_len = line[..paren].rfind('.').map_or(0, |dot| paren - dot);
match receiver_before(line, paren - name_len) {
Some(recv) => to_verdict(argument_to_literal(&recv, source)),
None => CallVerdict::Opaque,
}
}
WriteKind::FirstArg => {
let args = &line[paren + 1..];
match first_argument(args) {
Some(first) => to_verdict(argument_to_literal(&first, source)),
None => CallVerdict::Opaque,
}
}
}
}
fn split_argument(args: &str) -> Option<(String, usize)> {
let mut depth = 0usize;
let mut in_str: Option<char> = None;
for (i, c) in args.char_indices() {
match (in_str, c) {
(Some(q), _) if c == q => in_str = None,
(Some(_), _) => {}
(None, '\'' | '"') => in_str = Some(c),
(None, '(') => depth += 1,
(None, ')') if depth == 0 => return Some((args[..i].trim().to_string(), i)),
(None, ')') => depth -= 1,
(None, ',') if depth == 0 => return Some((args[..i].trim().to_string(), i)),
_ => {}
}
}
None
}
fn first_argument(args: &str) -> Option<String> {
split_argument(args).map(|(a, _)| a)
}
fn second_argument_literal(args: &str) -> Option<String> {
let (_, delim) = split_argument(args)?;
if args.as_bytes().get(delim) != Some(&b',') {
return None; }
let (second, _) = split_argument(&args[delim + 1..])?;
string_literal(&second)
}
fn receiver_before(line: &str, end: usize) -> Option<String> {
let bytes = line.as_bytes();
let mut i = end;
let mut depth = 0usize;
while i > 0 {
let c = bytes[i - 1];
match c {
b')' => depth += 1,
b'(' if depth > 0 => depth -= 1,
b'(' => break,
c if depth == 0
&& !(c.is_ascii_alphanumeric() || matches!(c, b'_' | b'.' | b'\'' | b'"')) =>
{
break
}
_ => {}
}
i -= 1;
}
let recv = line[i..end].trim();
(!recv.is_empty()).then(|| recv.to_string())
}
fn argument_to_literal(expr: &str, source: &str) -> Option<String> {
if let Some(lit) = string_literal(expr) {
return Some(lit);
}
if let Some(lit) = path_constructor_literal(expr) {
return Some(lit);
}
if is_identifier(expr) {
return one_hop_literal(expr, source);
}
None
}
fn string_literal(expr: &str) -> Option<String> {
let b = expr.as_bytes();
if b.len() >= 2 && (b[0] == b'\'' || b[0] == b'"') && b[b.len() - 1] == b[0] {
let inner = &expr[1..expr.len() - 1];
let q = b[0] as char;
if !inner.contains(q) && !inner.is_empty() {
return Some(inner.to_string());
}
}
None
}
fn path_constructor_literal(expr: &str) -> Option<String> {
let open = expr.find('(')?;
let head = &expr[..open];
if !head.ends_with("Path") {
return None;
}
let inner = expr[open + 1..].strip_suffix(')')?;
string_literal(inner.trim())
}
fn is_identifier(expr: &str) -> bool {
!expr.is_empty()
&& expr.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& !expr.chars().next().is_some_and(|c| c.is_ascii_digit())
}
fn one_hop_literal(ident: &str, source: &str) -> Option<String> {
let mut binding: Option<String> = None;
for line in source.lines() {
let t = line.trim_start();
if let Some(head) = t.strip_prefix("for ") {
if head
.split([' ', ',', '('])
.any(|w| w.trim_matches([')', ':']) == ident)
{
return None;
}
}
let assign = t
.strip_prefix("const ")
.or_else(|| t.strip_prefix("let "))
.or_else(|| t.strip_prefix("var "))
.unwrap_or(t);
let Some(rest) = assign.strip_prefix(ident) else {
continue;
};
let rest = rest.trim_start();
let Some(rhs) = rest.strip_prefix('=') else {
continue;
};
if rhs.starts_with('=') {
continue; }
let rhs = rhs.trim().trim_end_matches([';']);
if binding.is_some() {
return None; }
let lit = string_literal(rhs).or_else(|| path_constructor_literal(rhs));
match lit {
Some(l) => binding = Some(l),
None => return None,
}
}
binding
}