pub fn split_segments(s: &str) -> Vec<String> {
let mut segments = Vec::new();
let mut cur = String::new();
let chars: Vec<char> = s.chars().collect();
let mut i = 0;
let (mut in_single, mut in_double) = (false, false);
while i < chars.len() {
let c = chars[i];
match c {
'\'' if !in_double => {
in_single = !in_single;
cur.push(c);
}
'"' if !in_single => {
in_double = !in_double;
cur.push(c);
}
'\\' if in_double && i + 1 < chars.len() => {
cur.push(c);
cur.push(chars[i + 1]);
i += 1;
}
_ if in_single || in_double => cur.push(c),
'&' if i + 1 < chars.len() && chars[i + 1] == '&' => {
flush(&mut segments, &mut cur);
i += 1; }
'&' if !is_redirection_amp(&chars, i) => flush(&mut segments, &mut cur),
'|' => {
flush(&mut segments, &mut cur);
if i + 1 < chars.len() && chars[i + 1] == '|' {
i += 1; }
}
';' | '\n' => flush(&mut segments, &mut cur),
_ => cur.push(c),
}
i += 1;
}
flush(&mut segments, &mut cur);
segments
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Overwrite {
pub target: String,
pub truncates: bool,
}
fn is_sink(target: &str) -> bool {
matches!(
target.to_ascii_lowercase().as_str(),
"/dev/null"
| "/dev/zero"
| "/dev/stdout"
| "/dev/stderr"
| "/dev/tty"
| "/dev/full"
| "nul"
)
}
pub fn redirect_targets(segment: &str) -> Vec<Overwrite> {
let chars: Vec<char> = segment.chars().collect();
let mut out = Vec::new();
let (mut in_single, mut in_double) = (false, false);
let mut i = 0;
while i < chars.len() {
let c = chars[i];
match c {
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
'\\' if !in_single && i + 1 < chars.len() => i += 1,
_ if in_single || in_double => {}
'>' => {
let prev_is_amp = i > 0 && chars[i - 1] == '&';
let prev_is_lt = i > 0 && chars[i - 1] == '<';
let mut j = i + 1;
let truncates = if chars.get(j) == Some(&'>') {
j += 1;
false
} else {
true
};
if truncates && chars.get(j) == Some(&'|') {
j += 1;
}
if chars.get(j) == Some(&'&') {
i = j + 1;
continue;
}
while j < chars.len() && chars[j].is_whitespace() {
j += 1;
}
if chars.get(j) == Some(&'(') {
i = j;
continue;
}
let start = j;
let mut quote: Option<char> = None;
while j < chars.len() {
let d = chars[j];
match quote {
Some(q) if d == q => quote = None,
Some(_) => {}
None if d == '\'' || d == '"' => quote = Some(d),
None if d.is_whitespace() => break,
None => {}
}
j += 1;
}
let target: String = chars[start..j]
.iter()
.collect::<String>()
.trim_matches(|c| c == '\'' || c == '"')
.to_string();
if !target.is_empty() && !prev_is_amp && !prev_is_lt && !is_sink(&target) {
out.push(Overwrite { target, truncates });
}
i = j;
continue;
}
_ => {}
}
i += 1;
}
out
}
fn is_redirection_amp(chars: &[char], i: usize) -> bool {
let prev_is_redirect = i > 0 && matches!(chars[i - 1], '>' | '<');
let next_is_redirect = chars.get(i + 1) == Some(&'>');
prev_is_redirect || next_is_redirect
}
fn flush(segments: &mut Vec<String>, cur: &mut String) {
let t = cur.trim();
if !t.is_empty() {
segments.push(t.to_string());
}
cur.clear();
}
pub fn has_substitution(s: &str) -> bool {
let chars: Vec<char> = s.chars().collect();
let mut in_single = false;
let mut i = 0;
while i < chars.len() {
match chars[i] {
'\'' => in_single = !in_single,
'`' if !in_single => return true,
'$' if !in_single && chars.get(i + 1) == Some(&'(') => return true,
_ => {}
}
i += 1;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncating_redirects_are_extracted() {
let r = redirect_targets("cat /dev/null > .env");
assert_eq!(r.len(), 1);
assert_eq!(r[0].target, ".env");
assert!(r[0].truncates);
assert_eq!(
redirect_targets("ls -la > /etc/hosts")[0].target,
"/etc/hosts"
);
assert_eq!(
redirect_targets("echo x >config.json")[0].target,
"config.json"
);
assert_eq!(
redirect_targets(r#"echo x > "my file.txt""#)[0].target,
"my file.txt"
);
}
#[test]
fn appending_is_recorded_but_not_truncating() {
let r = redirect_targets("echo entry >> app.log");
assert_eq!(r.len(), 1);
assert_eq!(r[0].target, "app.log");
assert!(!r[0].truncates, ">> appends, it does not destroy");
}
#[test]
fn descriptor_redirects_are_not_file_targets() {
for cmd in [
"make 2>&1",
"cmd >&2",
"cmd <&-",
"cmd &> log",
"cmd &>> log",
"make 2>&1 | tee out",
] {
assert!(
redirect_targets(cmd).is_empty(),
"{cmd} redirects a descriptor, it does not truncate a file"
);
}
}
#[test]
fn quoted_redirects_are_text() {
assert!(redirect_targets(r#"echo "a > b""#).is_empty());
assert!(redirect_targets("echo 'x > y'").is_empty());
assert!(redirect_targets(r#"git commit -m "fix > bug""#).is_empty());
}
#[test]
fn sinks_are_not_targets() {
for cmd in [
"cargo test > /dev/null",
"cmd 2> /dev/null",
"make >/dev/null 2>&1",
"cat big > /dev/zero",
"echo x > NUL",
] {
assert!(
redirect_targets(cmd).is_empty(),
"{cmd} truncates a sink, not a file"
);
}
}
#[test]
fn process_substitution_is_not_a_target() {
for cmd in ["tee >(gzip -c) < data", "diff x >(sort)", "cmd > >(bar)"] {
assert!(
redirect_targets(cmd)
.iter()
.all(|o| !o.target.starts_with('(')),
"{cmd}: a paren is an operator, not a filename"
);
}
assert!(redirect_targets("tee >(gzip -c) < data").is_empty());
}
#[test]
fn escapes_clobber_and_read_write_open() {
assert!(redirect_targets(r"echo a \> b").is_empty());
let r = redirect_targets("cmd >| forced.txt");
assert_eq!(r.len(), 1);
assert_eq!(r[0].target, "forced.txt");
assert!(r[0].truncates);
assert!(redirect_targets("cmd <> rw.txt").is_empty());
}
#[test]
fn several_redirects_in_one_segment() {
let r = redirect_targets("cmd > out.txt 2> err.txt");
assert_eq!(r.len(), 2);
assert_eq!(r[0].target, "out.txt");
assert_eq!(r[1].target, "err.txt");
}
#[test]
fn splits_the_field_report_command() {
let cmd = r#"git status && echo "---" && git branch -vv && git log --oneline -5"#;
let seg = split_segments(cmd);
assert_eq!(
seg,
vec![
"git status",
r#"echo "---""#,
"git branch -vv",
"git log --oneline -5"
]
);
}
#[test]
fn splits_all_operators() {
assert_eq!(
split_segments("a; b | c || d && e"),
vec!["a", "b", "c", "d", "e"]
);
}
#[test]
fn quotes_protect_operators() {
assert_eq!(split_segments("echo 'a && b'"), vec!["echo 'a && b'"]);
assert_eq!(split_segments(r#"echo "x; y""#), vec![r#"echo "x; y""#]);
}
#[test]
fn redirections_survive() {
assert_eq!(split_segments("cmd 2>&1"), vec!["cmd 2>&1"]);
assert_eq!(split_segments("cmd >&2"), vec!["cmd >&2"]);
assert_eq!(split_segments("cmd &> log"), vec!["cmd &> log"]);
assert_eq!(split_segments("cmd &>> log"), vec!["cmd &>> log"]);
assert_eq!(split_segments("cmd <&-"), vec!["cmd <&-"]);
assert_eq!(
split_segments("make 2>&1 | tee log"),
vec!["make 2>&1", "tee log"]
);
}
#[test]
fn a_lone_ampersand_splits() {
assert_eq!(
split_segments("git status & rm -rf /"),
vec!["git status", "rm -rf /"]
);
assert_eq!(split_segments("ls & rm -rf /"), vec!["ls", "rm -rf /"]);
assert_eq!(split_segments("npm run dev &"), vec!["npm run dev"]);
assert_eq!(split_segments("a & b & c"), vec!["a", "b", "c"]);
assert_eq!(
split_segments("echo hi&rm -rf /"),
vec!["echo hi", "rm -rf /"]
);
}
#[test]
fn newlines_split_too() {
assert_eq!(
split_segments("git status\nrm -rf /"),
vec!["git status", "rm -rf /"]
);
}
#[test]
fn substitution_detected() {
assert!(has_substitution("echo $(rm -rf /)"));
assert!(has_substitution("echo `whoami`"));
assert!(!has_substitution("echo '$(safe)'"));
assert!(!has_substitution("git status"));
}
}