use super::bash_tokens::{RedirectRole, redirect_role, tokenize};
use super::heredoc::split_heredoc_bodies;
use super::write_targets::{
UnplaceableWrite, input_redirect, segment_write_targets, shell_segments_map,
};
use crate::commands::hook_rewrite::{first_command_token, strip_wrapper_prefix};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum AnchorWrite {
File(String),
Literal(String),
Into {
dest: String,
names: Vec<String>,
dest_too: bool,
},
Source(String),
IntoUnnamed(String),
DirChange,
}
const UNLEXABLE_VERB: UnplaceableWrite =
"a `cp`/`mv`/`ln`/`install`/`sed`/`cd` whose arguments do not lex";
const XARGS_WRITE: UnplaceableWrite = "a `cp`/`mv`/`ln`/`install`/`sed -i` run by `xargs`, \
which appends operands the guard never sees";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Verb {
Cp,
Mv,
Ln,
Install,
Sed,
Cd,
}
pub(crate) fn anchor_writes(command: &str) -> Result<Vec<AnchorWrite>, UnplaceableWrite> {
shell_segments_map(command, &segment_anchor_writes)
}
fn segment_anchor_writes(segment: &str) -> Result<Vec<AnchorWrite>, UnplaceableWrite> {
let mut out: Vec<AnchorWrite> = segment_write_targets(segment)?
.into_iter()
.map(AnchorWrite::File)
.collect();
let Some((argv, at)) = program_argv(segment)? else {
return Ok(out);
};
if is_xargs(&argv[at]) {
out.extend(xargs_writes(&argv[at + 1..])?);
return Ok(out);
}
let Some(verb) = verb_of(&argv[at]) else {
return Ok(out);
};
let args = operands(&argv[at + 1..]);
match verb {
Verb::Cd => out.push(AnchorWrite::DirChange),
Verb::Sed => out.extend(
sed_edits(&args)
.unwrap_or_default()
.into_iter()
.map(AnchorWrite::File),
),
copy => out.extend(copy_writes(copy, &args)),
}
Ok(out)
}
fn program_argv(segment: &str) -> Result<Option<(Vec<String>, usize)>, UnplaceableWrite> {
let argv = match tokenize(&split_heredoc_bodies(segment).0) {
Ok(argv) => argv,
Err(_)
if first_command_token(segment)
.as_deref()
.is_some_and(|word| verb_of(word).is_some() || is_xargs(word)) =>
{
return Err(UNLEXABLE_VERB);
}
Err(_) => return Ok(None),
};
let at = strip_wrapper_prefix(&argv).or_else(|| {
argv.iter()
.position(|word| verb_of(word).is_some() || is_xargs(word))
});
Ok(at.filter(|at| *at < argv.len()).map(|at| (argv, at)))
}
fn program_name(word: &str) -> &str {
let word = word.strip_prefix('\\').unwrap_or(word);
word.rsplit('/').next().unwrap_or(word)
}
fn verb_of(word: &str) -> Option<Verb> {
match program_name(word) {
"cp" | "gcp" => Some(Verb::Cp),
"mv" | "gmv" => Some(Verb::Mv),
"ln" | "gln" => Some(Verb::Ln),
"install" | "ginstall" => Some(Verb::Install),
"sed" | "gsed" => Some(Verb::Sed),
"cd" | "pushd" | "popd" => Some(Verb::Cd),
_ => None,
}
}
fn is_xargs(word: &str) -> bool {
matches!(program_name(word), "xargs" | "gxargs")
}
fn operands(words: &[String]) -> Vec<String> {
let mut out = Vec::new();
let mut skip_next = false;
for word in words {
if std::mem::take(&mut skip_next) {
continue;
}
match redirect_role(word) {
RedirectRole::TargetFollows => skip_next = true,
RedirectRole::Target(_) | RedirectRole::FileDescriptor => {}
RedirectRole::None => match input_redirect(word) {
Some(target_follows) => skip_next = target_follows,
None => out.push(word.clone()),
},
}
}
out
}
fn xargs_writes(tail: &[String]) -> Result<Vec<AnchorWrite>, UnplaceableWrite> {
let Some((at, verb)) = tail
.iter()
.enumerate()
.find_map(|(at, word)| verb_of(word).filter(|v| *v != Verb::Cd).map(|v| (at, v)))
else {
return Ok(Vec::new());
};
let replace = tail[..at].iter().any(|word| {
word.starts_with("--replace")
|| (!word.starts_with("--")
&& word
.strip_prefix('-')
.is_some_and(|flags| flags.contains(['I', 'i', 'J'])))
});
let args = operands(&tail[at + 1..]);
match verb {
Verb::Sed if sed_edits(&args).is_none() => Ok(Vec::new()),
Verb::Cp | Verb::Install => {
let parsed = CopyArgs::parse(verb, &args);
match parsed.target_dir {
Some(dir) if !replace && !parsed.link && !parsed.dirs => {
Ok(vec![AnchorWrite::IntoUnnamed(dir)])
}
_ => Err(XARGS_WRITE),
}
}
_ => Err(XARGS_WRITE),
}
}
#[derive(Debug, Default)]
struct CopyArgs {
positional: Vec<String>,
target_dir: Option<String>,
no_target: bool,
dirs: bool,
link: bool,
symbolic: bool,
parents: bool,
}
impl CopyArgs {
fn parse(verb: Verb, args: &[String]) -> Self {
let mut out = Self::default();
let mut options = true;
let mut words = args.iter();
while let Some(word) = words.next() {
if options && word == "--" {
options = false;
} else if options && word.starts_with("--") {
let (name, value) = match word.split_once('=') {
Some((name, value)) => (name, Some(value.to_string())),
None => (word.as_str(), None),
};
match name {
"--target-directory" => {
out.target_dir = value.or_else(|| words.next().cloned())
}
"--no-target-directory" => out.no_target = true,
"--directory" => out.dirs |= verb == Verb::Install,
"--link" => out.link |= verb == Verb::Cp,
"--symbolic-link" | "--symbolic" => {
out.link |= verb == Verb::Cp;
out.symbolic = true;
}
"--parents" => out.parents |= verb == Verb::Cp,
"--suffix" | "--mode" | "--owner" | "--group" if value.is_none() => {
words.next();
}
_ => {}
}
} else if options && word.starts_with('-') && word.len() > 1 {
out.short_cluster(verb, word, &mut words);
} else {
out.positional.push(word.clone());
}
}
out
}
fn short_cluster<'a>(
&mut self,
verb: Verb,
word: &str,
words: &mut impl Iterator<Item = &'a String>,
) {
for (at, c) in word.char_indices().skip(1) {
let takes_value =
c == 't' || (verb == Verb::Install && matches!(c, 'm' | 'o' | 'g' | 'B' | 'f'));
if takes_value {
let rest = &word[at + c.len_utf8()..];
let value = if rest.is_empty() {
words.next().cloned()
} else {
Some(rest.to_string())
};
if c == 't' {
self.target_dir = value;
}
return;
}
match (verb, c) {
(_, 'T') => self.no_target = true,
(Verb::Install, 'd') => self.dirs = true,
(Verb::Cp, 'l') => self.link = true,
(Verb::Cp, 's') | (Verb::Install, 'l') => {
self.link = true;
self.symbolic = true;
}
(Verb::Ln, 's') => self.symbolic = true,
_ => {}
}
}
}
}
fn copy_writes(verb: Verb, args: &[String]) -> Vec<AnchorWrite> {
let parsed = CopyArgs::parse(verb, args);
if parsed.dirs {
return parsed
.positional
.into_iter()
.map(AnchorWrite::File)
.collect();
}
let (dest, sources) = match (&parsed.target_dir, parsed.positional.split_last()) {
(Some(dir), _) => (dir.clone(), parsed.positional.as_slice()),
(None, Some((only, []))) if verb == Verb::Ln => {
(".".to_string(), std::slice::from_ref(only))
}
(None, Some((dest, sources))) if !sources.is_empty() => (dest.clone(), sources),
_ => return Vec::new(),
};
let mut out = Vec::new();
if matches!(verb, Verb::Mv | Verb::Ln) || parsed.link {
for source in sources {
out.extend(source_writes(source, &dest, parsed.symbolic));
}
}
out.push(AnchorWrite::Into {
names: sources
.iter()
.map(|s| source_name(s, parsed.parents))
.collect(),
dest_too: parsed.no_target
|| (verb == Verb::Cp && sources.iter().any(|s| copies_contents(s))),
dest,
});
out
}
fn source_writes(source: &str, dest: &str, symbolic: bool) -> Vec<AnchorWrite> {
let mut out = vec![AnchorWrite::Source(source.to_string())];
if symbolic && !source.starts_with(['/', '~', '$']) {
let dest = dest.trim_end_matches('/');
let parent = match dest.rsplit_once('/') {
Some(("", _)) => "",
Some((parent, _)) => parent,
None => ".",
};
for dir in [dest, parent] {
out.push(AnchorWrite::Source(format!("{dir}/{source}")));
}
}
out
}
fn source_name(source: &str, parents: bool) -> String {
let trimmed = source.trim_end_matches('/');
if parents {
return trimmed.trim_start_matches('/').to_string();
}
trimmed.rsplit('/').next().unwrap_or(trimmed).to_string()
}
fn copies_contents(source: &str) -> bool {
source.ends_with('/') || source.ends_with("/.") || source == "."
}
fn sed_edits(args: &[String]) -> Option<Vec<String>> {
let (mut in_place, mut script_given, mut options) = (false, false, true);
let (mut files, mut suffixes) = (Vec::new(), Vec::new());
let mut i = 0;
while i < args.len() {
let word = &args[i];
i += 1;
if options && word == "--" {
options = false;
} else if options && word.starts_with("--") {
let name = word.split('=').next().unwrap_or(word);
let prefix_of = |long: &str, min: usize| name.len() >= min && long.starts_with(name);
let has_value = word.contains('=');
if prefix_of("--in-place", 3) {
in_place = true;
} else if prefix_of("--expression", 3) || prefix_of("--file", 4) {
script_given = true;
i += usize::from(!has_value);
} else if prefix_of("--line-length", 3) {
i += usize::from(!has_value);
}
} else if options && word.starts_with('-') && word.len() > 1 {
for (at, c) in word.char_indices().skip(1) {
let rest_empty = word.len() == at + c.len_utf8();
match c {
'i' => {
in_place = true;
let suffix = args
.get(i)
.filter(|next| next.is_empty() || next.starts_with('.'));
if let Some(suffix) = suffix.filter(|_| rest_empty) {
suffixes.extend((!suffix.is_empty()).then(|| suffix.clone()));
i += 1;
}
break;
}
'e' | 'f' => {
script_given = true;
i += usize::from(rest_empty);
break;
}
_ => {}
}
}
} else {
files.push(word.clone());
}
}
if !in_place {
return None;
}
if !script_given && !files.is_empty() {
files.remove(0);
}
files.extend(suffixes);
Some(files)
}