pub fn split_segments(s: &str) -> Vec<Segment> {
let mut segments = Vec::new();
let mut cur = String::new();
let mut cur_redirects = Vec::new();
let mut cur_spans: Vec<(usize, usize)> = Vec::new();
let mut seg_start = 0;
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_single && 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,
&mut cur_redirects,
&chars[seg_start..i],
seg_start,
&mut cur_spans,
);
seg_start = i + 2;
i += 1; }
'&' if !is_redirection_amp(&chars, i) => {
flush(
&mut segments,
&mut cur,
&mut cur_redirects,
&chars[seg_start..i],
seg_start,
&mut cur_spans,
);
seg_start = i + 1;
}
'>' => {
let prev_is_amp = i > 0 && chars[i - 1] == '&';
let prev_is_lt = i > 0 && chars[i - 1] == '<';
let start = if prev_is_amp || prev_is_lt {
i - 1
} else {
span_start(&chars, seg_start, i)
};
cur.push(c);
let mut j = i + 1;
let truncates = if chars.get(j) == Some(&'>') {
cur.push('>');
j += 1;
false
} else {
true
};
if truncates && chars.get(j) == Some(&'|') {
cur.push('|');
j += 1;
}
if chars.get(j) == Some(&'&') {
cur.push('&');
let end = descriptor(&chars, &mut cur, j + 1);
cur_spans.push((start, end));
i = end;
continue;
}
while j < chars.len() && chars[j].is_whitespace() {
cur.push(chars[j]);
j += 1;
}
if chars.get(j) == Some(&'(') {
i = j;
continue;
}
let (target, end) = target_word(&chars, &mut cur, j);
if !target.is_empty() && !prev_is_amp && !prev_is_lt && !is_sink(&target) {
cur_redirects.push(Overwrite { target, truncates });
}
cur_spans.push((start, end));
i = end;
continue;
}
'|' => {
flush(
&mut segments,
&mut cur,
&mut cur_redirects,
&chars[seg_start..i],
seg_start,
&mut cur_spans,
);
if i + 1 < chars.len() && chars[i + 1] == '|' {
i += 1; }
seg_start = i + 1;
}
';' | '\n' => {
flush(
&mut segments,
&mut cur,
&mut cur_redirects,
&chars[seg_start..i],
seg_start,
&mut cur_spans,
);
seg_start = i + 1;
}
'<' => {
let start = span_start(&chars, seg_start, i);
cur.push(c);
let mut j = i + 1;
if chars.get(j) == Some(&'<') {
cur.push('<');
j += 1;
if chars.get(j) == Some(&'<') {
cur.push('<');
j += 1;
}
} else if chars.get(j) == Some(&'>') {
cur.push('>');
j += 1;
}
if chars.get(j) == Some(&'&') {
cur.push('&');
j = descriptor(&chars, &mut cur, j + 1);
cur_spans.push((start, j));
i = j;
continue;
}
while j < chars.len() && chars[j].is_whitespace() {
cur.push(chars[j]);
j += 1;
}
if chars.get(j) == Some(&'(') {
i = j;
continue;
}
let (_, end) = target_word(&chars, &mut cur, j);
cur_spans.push((start, end));
i = end;
continue;
}
_ => cur.push(c),
}
i += 1;
}
flush(
&mut segments,
&mut cur,
&mut cur_redirects,
&chars[seg_start..],
seg_start,
&mut cur_spans,
);
segments
}
fn span_start(chars: &[char], seg_start: usize, op: usize) -> usize {
let mut k = op;
while k > seg_start && chars[k - 1].is_ascii_digit() {
k -= 1;
}
if k < op && (k == seg_start || chars[k - 1].is_whitespace()) {
k
} else {
op
}
}
fn descriptor(chars: &[char], cur: &mut String, mut j: usize) -> usize {
while j < chars.len() && (chars[j].is_ascii_digit() || chars[j] == '-') {
cur.push(chars[j]);
j += 1;
}
j
}
fn target_word(chars: &[char], cur: &mut String, mut j: usize) -> (String, usize) {
let mut target = String::new();
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 == '\\' && j + 1 < chars.len() => {
cur.push(d);
target.push(d);
j += 1;
}
None if d.is_whitespace() => break,
None if matches!(d, ';' | '\n' | '|') => break,
None if d == '&' && !is_redirection_amp(chars, j) => break,
None => {}
}
cur.push(chars[j]);
target.push(chars[j]);
j += 1;
}
let target: String = target.trim_matches(|c| c == '\'' || c == '"').to_string();
(target, j)
}
const POSIX_SHELLS: [&str; 4] = ["sh", "bash", "dash", "zsh"];
const MAX_WRAP_DEPTH: usize = 4;
pub fn split_segments_deep(s: &str) -> Vec<Segment> {
let mut out = Vec::new();
expand_into(&mut out, split_segments(s), None, 0);
out
}
fn expand_into(out: &mut Vec<Segment>, segments: Vec<Segment>, via: Option<&str>, depth: usize) {
for mut seg in segments {
if let Some(v) = via {
seg.via = Some(v.to_string());
}
seg.scaffold = via.is_some() && harness_scaffold(&seg);
let inner = if depth < MAX_WRAP_DEPTH {
wrapped_command(&seg).or_else(|| wrapped_eval(&seg))
} else {
None
};
seg.wraps = inner.is_some();
out.push(seg);
if let Some((shell, script)) = inner {
let label = if shell == "eval" {
"eval".to_string()
} else {
format!("{shell} -c")
};
expand_into(out, split_segments(&script), Some(&label), depth + 1);
}
}
}
fn harness_scaffold(seg: &Segment) -> bool {
let words: Vec<String> = crate::delete::tokenize_public(seg.command())
.into_iter()
.map(|w| w.trim_start_matches('\\').to_string())
.collect();
let w: Vec<&str> = words.iter().map(String::as_str).collect();
match w.as_slice() {
["shopt", "-u", "extglob"]
| ["setopt", "NO_EXTENDED_GLOB", "NO_BARE_GLOB_QUAL"]
| ["true"]
| ["{", "builtin", "unalias", "--", "unsetenv"]
| ["builtin", "unset", "-f", "--", "unsetenv"]
| ["}"] => true,
["source", path] => is_claude_snapshot(path),
["pwd", "-P"] => {
seg.redirects.len() == 1
&& seg.redirects[0].truncates
&& is_claude_cwd_file(&seg.redirects[0].target)
}
_ => false,
}
}
fn is_claude_snapshot(path: &str) -> bool {
let Some((dir, file)) = path.rsplit_once('/') else {
return false;
};
if !dir.ends_with("/.claude/shell-snapshots") || dir.split('/').any(|c| c == "..") {
return false;
}
let Some(mid) = file
.strip_prefix("snapshot-")
.and_then(|r| r.strip_suffix(".sh"))
else {
return false;
};
let parts: Vec<&str> = mid.splitn(3, '-').collect();
parts.len() == 3
&& parts[0].chars().all(|c| c.is_ascii_alphanumeric())
&& parts[1].chars().all(|c| c.is_ascii_digit())
&& parts[2].chars().all(|c| c.is_ascii_alphanumeric())
&& parts.iter().all(|p| !p.is_empty())
}
fn is_claude_cwd_file(target: &str) -> bool {
target
.strip_prefix("/tmp/claude-")
.and_then(|r| r.strip_suffix("-cwd"))
.is_some_and(|id| !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric()))
}
fn wrapped_eval(seg: &Segment) -> Option<(String, String)> {
let toks = crate::delete::tokenize_detailed(seg.command());
let words: Vec<String> = toks.iter().map(|t| t.text.clone()).collect();
let (head, at) = crate::delete::resolve_head(&words)?;
if head != "eval" || toks.len() != at + 2 {
return None;
}
let arg = &toks[at + 1];
if !arg.single_quoted || arg.text.trim().is_empty() {
return None;
}
Some((head, arg.text.clone()))
}
pub fn context_text(command: &str) -> String {
let segs = split_segments_deep(command);
if segs.len() <= 1 {
return command.to_string();
}
let kept: Vec<&str> = segs
.iter()
.filter(|s| !s.wraps && !s.scaffold)
.map(|s| s.text.as_str())
.collect();
if kept.is_empty() {
return command.to_string();
}
kept.join("; ")
}
fn wrapped_command(seg: &Segment) -> Option<(String, String)> {
let tokens = crate::delete::tokenize_public(seg.command());
let (head, at) = crate::delete::resolve_head(&tokens)?;
if !POSIX_SHELLS.contains(&head.as_str()) {
return None;
}
let mut i = at + 1;
while let Some(tok) = tokens.get(i) {
if tok == "--" || tok == "-" || !tok.starts_with('-') {
return None; }
if tok == "-o" {
i += 2;
continue;
}
if !tok.starts_with("--") && tok[1..].contains('c') {
let mut j = i + 1;
while let Some(t) = tokens.get(j) {
if t == "--" {
j += 1;
break;
}
if t == "-" || !t.starts_with('-') {
break;
}
j += if t == "-o" { 2 } else { 1 };
}
let script = tokens.get(j)?;
if script.trim().is_empty() {
return None;
}
return Some((head, script.clone()));
}
i += 1;
}
None
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Segment {
text: String,
command: String,
pub redirects: Vec<Overwrite>,
pub via: Option<String>,
pub wraps: bool,
pub scaffold: bool,
}
impl Segment {
pub fn command(&self) -> &str {
&self.command
}
}
impl std::ops::Deref for Segment {
type Target = str;
fn deref(&self) -> &str {
&self.text
}
}
impl std::fmt::Display for Segment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.text)
}
}
impl PartialEq<&str> for Segment {
fn eq(&self, other: &&str) -> bool {
self.text == *other
}
}
#[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"
)
}
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<Segment>,
cur: &mut String,
redirects: &mut Vec<Overwrite>,
source: &[char],
seg_start: usize,
spans: &mut Vec<(usize, usize)>,
) {
let t = cur.trim();
if !t.is_empty() {
let command: String = source
.iter()
.enumerate()
.filter(|(k, _)| {
let at = seg_start + k;
!spans.iter().any(|&(a, b)| a <= at && at < b)
})
.map(|(_, c)| *c)
.collect();
segments.push(Segment {
text: t.to_string(),
command: command.trim().to_string(),
redirects: std::mem::take(redirects),
via: None,
wraps: false,
scaffold: false,
});
} else {
redirects.clear();
}
spans.clear();
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::*;
fn redirects(cmd: &str) -> Vec<Overwrite> {
split_segments(cmd)
.into_iter()
.flat_map(|s| s.redirects)
.collect()
}
#[test]
fn truncating_redirects_are_extracted() {
let r = redirects("cat /dev/null > .env");
assert_eq!(r.len(), 1);
assert_eq!(r[0].target, ".env");
assert!(r[0].truncates);
assert_eq!(redirects("ls -la > /etc/hosts")[0].target, "/etc/hosts");
assert_eq!(redirects("echo x >config.json")[0].target, "config.json");
assert_eq!(
redirects(r#"echo x > "my file.txt""#)[0].target,
"my file.txt"
);
}
#[test]
fn appending_is_recorded_but_not_truncating() {
let r = redirects("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!(
redirects(cmd).is_empty(),
"{cmd} redirects a descriptor, it does not truncate a file"
);
}
}
#[test]
fn quoted_redirects_are_text() {
assert!(redirects(r#"echo "a > b""#).is_empty());
assert!(redirects("echo 'x > y'").is_empty());
assert!(redirects(r#"git commit -m "fix > bug""#).is_empty());
}
#[test]
fn the_command_text_leaves_every_redirection_out() {
for (cmd, want) in [
("rm -rf ./cache 2>/dev/null", vec!["rm -rf ./cache"]),
("rm -rf ./cache > /dev/null 2>&1", vec!["rm -rf ./cache"]),
("rm -rf ./cache >log 2>&1", vec!["rm -rf ./cache"]),
("rm -rf ./cache 2>&1 >/dev/null", vec!["rm -rf ./cache"]),
("cat /dev/null > src/main.rs", vec!["cat /dev/null"]),
("sort < in.txt > out.txt", vec!["sort"]),
("cmd &>all.log", vec!["cmd"]),
("cmd &>>all.log", vec!["cmd"]),
("echo a >> log", vec!["echo a"]),
("echo a >| log", vec!["echo a"]),
("cat > \"my file.txt\"", vec!["cat"]),
("rm > early -rf ./cache", vec!["rm -rf ./cache"]),
("cat <<EOF\nbody\nEOF", vec!["cat", "body", "EOF"]),
("cat <<< word", vec!["cat"]),
("exec 3<&0", vec!["exec"]),
("exec 3<> /tmp/f", vec!["exec"]),
("rm -rf ./cache2>log", vec!["rm -rf ./cache2"]),
("tee >(gzip) file", vec!["tee >(gzip) file"]),
("diff <(ls a) <(ls b)", vec!["diff <(ls a) <(ls b)"]),
(
"git status & rm -rf / 2>/dev/null",
vec!["git status", "rm -rf /"],
),
("echo a > x && echo b > y", vec!["echo a", "echo b"]),
("echo \"a > b\"", vec!["echo \"a > b\""]),
] {
let segs = split_segments(cmd);
let got: Vec<&str> = segs.iter().map(|s| s.command()).collect();
assert_eq!(got, want, "{cmd}");
}
}
#[test]
fn the_command_text_changes_nothing_about_the_text_or_the_redirects() {
for (cmd, texts) in [
(
"rm -rf ./cache > /dev/null 2>&1",
vec!["rm -rf ./cache > /dev/null 2>&1"],
),
("sort < in.txt > out.txt", vec!["sort < in.txt > out.txt"]),
("cat <<EOF\nbody\nEOF", vec!["cat <<EOF", "body", "EOF"]),
("exec 3<&0", vec!["exec 3<&0"]),
("tee >(gzip) file", vec!["tee >(gzip) file"]),
("cat > \"my file.txt\"", vec!["cat > \"my file.txt\""]),
("echo \"a > b\"", vec!["echo \"a > b\""]),
("git status & rm -rf /", vec!["git status", "rm -rf /"]),
] {
let segs = split_segments(cmd);
let got: Vec<&str> = segs.iter().map(|s| &**s).collect();
assert_eq!(got, texts, "{cmd}: the text is verbatim");
}
let targets =
|cmd: &str| -> Vec<String> { redirects(cmd).into_iter().map(|o| o.target).collect() };
assert_eq!(targets("rm -rf ./cache >log 2>&1"), vec!["log"]);
assert_eq!(targets("cat > \"my file.txt\""), vec!["my file.txt"]);
assert!(redirects("sort < in.txt").is_empty());
let seg = &split_segments("rm -rf ./cache")[0];
assert_eq!(seg.command(), &**seg);
}
#[test]
fn options_after_dash_c_are_stepped_over() {
for cmd in [
r#"zsh -c -l "rm -rf ./dist""#,
r#"bash -c -e -o pipefail "rm -rf ./dist""#,
r#"sh -c -- "rm -rf ./dist""#,
] {
let segs = split_segments_deep(cmd);
assert!(
segs.iter().any(|s| s.command().contains("rm -rf ./dist")
&& !s.command().starts_with("zsh")
&& !s.command().starts_with("bash")
&& !s.command().starts_with("sh")),
"the inner command must be its own segment for {cmd}: {:?}",
segs.iter()
.map(|s| s.command().to_string())
.collect::<Vec<_>>()
);
}
assert_eq!(split_segments_deep("sh -c -l").len(), 1);
}
#[test]
fn a_posix_shell_c_string_is_read_as_its_own_segments() {
let segs = split_segments_deep(r#"sh -c "cat /dev/null > src/main.rs""#);
let texts: Vec<&str> = segs.iter().map(|s| &**s).collect();
assert_eq!(
texts,
[
r#"sh -c "cat /dev/null > src/main.rs""#,
"cat /dev/null > src/main.rs"
]
);
assert_eq!(segs[0].via, None, "the wrapper was typed at the top level");
assert_eq!(segs[1].via.as_deref(), Some("sh -c"));
assert!(
segs[0].redirects.is_empty(),
"the quoted `>` is text to the wrapper"
);
assert_eq!(segs[1].redirects[0].target, "src/main.rs");
let segs = split_segments_deep(r#"bash -lc "rm -rf ./dist && echo done""#);
let texts: Vec<&str> = segs.iter().map(|s| &**s).collect();
assert_eq!(
texts,
[
r#"bash -lc "rm -rf ./dist && echo done""#,
"rm -rf ./dist",
"echo done"
]
);
assert!(segs[1..]
.iter()
.all(|s| s.via.as_deref() == Some("bash -c")));
for cmd in [
r#"sudo sh -c "rm -rf ./dist""#,
r#"/bin/sh -c "rm -rf ./dist""#,
r#"dash -c "rm -rf ./dist""#,
r#"zsh -ec "rm -rf ./dist""#,
r#"sh -e -c "rm -rf ./dist""#,
r#"bash --norc -c "rm -rf ./dist""#,
r#"bash -o pipefail -c "rm -rf ./dist""#,
"sh -c 'rm -rf ./dist'",
] {
let segs = split_segments_deep(cmd);
assert_eq!(segs.len(), 2, "{cmd}");
assert_eq!(&*segs[1], "rm -rf ./dist", "{cmd}");
}
let segs = split_segments_deep(r#"sh -c "echo hi" > out.log"#);
assert_eq!(segs.len(), 2);
assert_eq!(segs[0].redirects[0].target, "out.log");
assert!(segs[1].redirects.is_empty());
assert_eq!(&*segs[1], "echo hi");
}
#[test]
fn claude_codes_preamble_is_scaffolding_and_its_eval_is_read() {
let bash = r#"bash -c -l "shopt -u extglob 2>/dev/null || true && { \\builtin unalias -- 'unsetenv'; \\builtin unset -f -- 'unsetenv'; } >/dev/null 2>&1 || true && eval 'ls -la /home/dev/proj/ 2>&1; echo \"--- scratch check ---\"; ls -la /home/dev/proj/scratch 2>&1' < /dev/null && pwd -P >| /tmp/claude-c32c-cwd""#;
let segs = split_segments_deep(bash);
let texts: Vec<&str> = segs.iter().map(|s| &**s).collect();
assert_eq!(
texts,
[
bash,
"shopt -u extglob 2>/dev/null",
"true",
r"{ \builtin unalias -- 'unsetenv'",
r"\builtin unset -f -- 'unsetenv'",
"} >/dev/null 2>&1",
"true",
r#"eval 'ls -la /home/dev/proj/ 2>&1; echo "--- scratch check ---"; ls -la /home/dev/proj/scratch 2>&1' < /dev/null"#,
"ls -la /home/dev/proj/ 2>&1",
r#"echo "--- scratch check ---""#,
"ls -la /home/dev/proj/scratch 2>&1",
"pwd -P >| /tmp/claude-c32c-cwd",
]
);
let scaffold: Vec<bool> = segs.iter().map(|s| s.scaffold).collect();
assert_eq!(
scaffold,
[false, true, true, true, true, true, true, false, false, false, false, true]
);
assert!(
segs[0].wraps && segs[7].wraps,
"the shell and the eval are wrappers"
);
assert_eq!(segs[8].via.as_deref(), Some("eval"));
assert_eq!(segs[10].via.as_deref(), Some("eval"));
assert_eq!(
context_text(bash),
r#"ls -la /home/dev/proj/ 2>&1; echo "--- scratch check ---"; ls -la /home/dev/proj/scratch 2>&1"#,
"the context check reads the agent's command, not the preamble"
);
assert!(!context_text(bash).contains("unset -f"));
let zsh = r#"zsh -c -l "setopt NO_EXTENDED_GLOB NO_BARE_GLOB_QUAL 2>/dev/null || true && { \\builtin unalias -- 'unsetenv'; \\builtin unset -f -- 'unsetenv'; } >/dev/null 2>&1 || true && eval 'rm -rf ./scratch' < /dev/null && pwd -P >| /tmp/claude-b1de-cwd""#;
let segs = split_segments_deep(zsh);
let inner: Vec<&str> = segs
.iter()
.filter(|s| !s.wraps && !s.scaffold)
.map(|s| &**s)
.collect();
assert_eq!(inner, ["rm -rf ./scratch"]);
assert_eq!(context_text(zsh), "rm -rf ./scratch");
let sourced = r#"bash -c "source /home/dev/.claude/shell-snapshots/snapshot-bash-1789077199590-d2kylp.sh 2>/dev/null || true && shopt -u extglob 2>/dev/null || true && eval 'git status' < /dev/null && pwd -P >| /tmp/claude-0a1b-cwd""#;
let segs = split_segments_deep(sourced);
assert!(segs[1].scaffold, "sourcing its own snapshot: {}", &*segs[1]);
let inner: Vec<&str> = segs
.iter()
.filter(|s| !s.wraps && !s.scaffold)
.map(|s| &**s)
.collect();
assert_eq!(inner, ["git status"]);
}
#[test]
fn what_the_scaffolding_reading_leaves_alone() {
for top in ["shopt -u extglob", "true", "pwd -P >| /tmp/claude-c32c-cwd"] {
let segs = split_segments_deep(top);
assert!(!segs[0].scaffold, "typed at the top level: {top}");
}
for inside in [
"shopt -u extglob nullglob 2>/dev/null",
"setopt NO_EXTENDED_GLOB 2>/dev/null",
r"\\builtin unset -f -- 'rm'",
"source /home/dev/.bashrc 2>/dev/null",
"source /home/dev/.claude/shell-snapshots/../../.bashrc 2>/dev/null",
"pwd -P >| /home/dev/notes.txt",
"pwd -P >> /tmp/claude-c32c-cwd",
"pwd >| /tmp/claude-c32c-cwd",
] {
let segs = split_segments_deep(&format!(r#"bash -c "{inside}""#));
assert_eq!(segs.len(), 2, "{inside}");
assert!(!segs[1].scaffold, "not the measured form: {inside}");
}
let ok = split_segments_deep(
r#"bash -c "source /Users/x/.claude/shell-snapshots/snapshot-zsh-1-abc.sh 2>/dev/null""#,
);
assert!(ok[1].scaffold);
for not_read in [
r#"eval "$cmd""#,
"eval $cmd",
"eval 'ls' 'more'",
"eval",
"eval ''",
] {
let segs = split_segments_deep(not_read);
assert_eq!(segs.len(), 1, "{not_read}");
assert!(!segs[0].wraps, "{not_read}");
}
let segs = split_segments_deep("eval 'sh -c \"rm -rf ./dist\"'");
let texts: Vec<&str> = segs.iter().map(|s| &**s).collect();
assert_eq!(
texts,
[
"eval 'sh -c \"rm -rf ./dist\"'",
"sh -c \"rm -rf ./dist\"",
"rm -rf ./dist"
]
);
assert_eq!(segs[1].via.as_deref(), Some("eval"));
assert_eq!(segs[2].via.as_deref(), Some("sh -c"));
assert_eq!(context_text("git push --force"), "git push --force");
assert_eq!(context_text("ls && rm -rf x"), "ls; rm -rf x");
}
#[test]
fn what_a_shell_wrapper_reading_leaves_alone() {
for cmd in [
"sh deploy.sh",
"bash ./scripts/clean.sh --force",
"sh -c",
"sh -c \"\"",
r#"fish -c "rm -rf ./dist""#,
r#"ksh -c "rm -rf ./dist""#,
r#"python -c "import shutil; shutil.rmtree('dist')""#,
"sh - script.sh",
"bash --norc script.sh",
] {
let segs = split_segments_deep(cmd);
assert_eq!(segs.len(), 1, "{cmd}");
assert_eq!(segs[0].via, None, "{cmd}");
}
let two = r#"sh -c "bash -c 'rm -rf ./dist'""#;
let segs = split_segments_deep(two);
let texts: Vec<&str> = segs.iter().map(|s| &**s).collect();
assert_eq!(texts, [two, "bash -c 'rm -rf ./dist'", "rm -rf ./dist"]);
assert_eq!(segs[1].via.as_deref(), Some("sh -c"));
assert_eq!(segs[2].via.as_deref(), Some("bash -c"));
let mut deep = "rm -rf ./dist".to_string();
for _ in 0..6 {
deep = format!(
"sh -c \"{}\"",
deep.replace('\\', "\\\\").replace('"', "\\\"")
);
}
let segs = split_segments_deep(&deep);
assert_eq!(
segs.len(),
MAX_WRAP_DEPTH + 1,
"one wrapper per level, then stop"
);
assert_ne!(
&*segs[MAX_WRAP_DEPTH], "rm -rf ./dist",
"the innermost string was not reached"
);
assert_eq!(split_segments(two).len(), 1);
}
#[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!(
redirects(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!(
redirects(cmd).iter().all(|o| !o.target.starts_with('(')),
"{cmd}: a paren is an operator, not a filename"
);
}
assert!(redirects("tee >(gzip -c) < data").is_empty());
}
#[test]
fn escapes_clobber_and_read_write_open() {
assert!(redirects(r"echo a \> b").is_empty());
let r = redirects("cmd >| forced.txt");
assert_eq!(r.len(), 1);
assert_eq!(r[0].target, "forced.txt");
assert!(r[0].truncates);
assert!(redirects("cmd <> rw.txt").is_empty());
}
#[test]
fn an_escaped_quote_does_not_open_a_quote() {
assert_eq!(
split_segments(r#"echo \" ; rm -rf x"#),
vec![r#"echo \""#, "rm -rf x"]
);
assert_eq!(
split_segments(r#"echo "keep \" this; here""#),
vec![r#"echo "keep \" this; here""#]
);
}
#[test]
fn escaped_separators_are_literal() {
assert_eq!(split_segments(r"echo a \; b"), vec![r"echo a \; b"]);
assert_eq!(split_segments(r"echo a\;b"), vec![r"echo a\;b"]);
assert_eq!(
split_segments(r"git commit -m msg \&\& rm x"),
vec![r"git commit -m msg \&\& rm x"]
);
assert_eq!(split_segments("echo a ; b"), vec!["echo a", "b"]);
assert_eq!(split_segments("echo a && rm x"), vec!["echo a", "rm x"]);
let segs = split_segments(r"echo a \; b > out");
assert_eq!(segs.len(), 1);
assert_eq!(segs[0].redirects.len(), 1);
assert_eq!(segs[0].redirects[0].target, "out");
}
#[test]
fn an_escaped_space_keeps_a_target_whole() {
let r = redirects(r"cmd > a\ b.txt");
assert_eq!(r.len(), 1);
assert_eq!(r[0].target, r"a\ b.txt");
}
#[test]
fn a_separator_ends_a_target_and_still_splits() {
let segs = split_segments("cmd > log;next");
assert_eq!(segs, vec!["cmd > log", "next"]);
assert_eq!(segs[0].redirects[0].target, "log");
let segs = split_segments("cmd > a&b");
assert_eq!(segs, vec!["cmd > a", "b"]);
assert_eq!(segs[0].redirects[0].target, "a");
}
#[test]
fn the_predicted_survivors_are_killed() {
let r = redirects("> x");
assert_eq!(r.len(), 1);
assert_eq!(r[0].target, "x");
assert!(r[0].truncates);
assert!(!redirects(">> log").is_empty());
let segs = split_segments("cmd >>| x");
assert_eq!(segs, vec!["cmd >>", "x"]);
assert!(segs.iter().all(|s| s.redirects.is_empty()));
let r = redirects(r"cmd > a\");
assert_eq!(r.len(), 1);
assert_eq!(r[0].target, r"a\");
}
#[test]
fn a_trailing_redirect_with_no_target_pushes_nothing() {
for cmd in ["cmd >", "cmd > ", "cmd >>", "cmd >|", "echo x 2>"] {
assert!(
redirects(cmd).is_empty(),
"{cmd:?} names no target and must push no Overwrite"
);
}
assert!(redirects("echo x \\").is_empty());
}
#[test]
fn several_redirects_in_one_segment() {
let r = redirects("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"]
);
assert_eq!(
split_segments("cmd >| forced.txt"),
vec!["cmd >| forced.txt"]
);
assert_eq!(
split_segments("cmd >| out.txt | grep x"),
vec!["cmd >| out.txt", "grep x"]
);
}
#[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"));
}
#[test]
fn a_quote_of_one_kind_does_not_open_the_other() {
let segs = split_segments("echo \"it's fine\" && rm -rf /tmp/x");
assert_eq!(segs.len(), 2, "{segs:?}");
assert!(segs[1].starts_with("rm -rf"), "{segs:?}");
let segs = split_segments("echo 'say \"hi\"' && rm -rf /tmp/x");
assert_eq!(segs.len(), 2, "{segs:?}");
let targets = redirects("echo \"it's\" > out.txt");
assert_eq!(targets.len(), 1, "{targets:?}");
assert_eq!(targets[0].target, "out.txt");
let targets = redirects("echo 'a \"b\"' > out.txt");
assert_eq!(targets[0].target, "out.txt");
}
#[test]
fn single_quotes_hide_a_substitution_and_double_quotes_do_not() {
assert!(!has_substitution("echo 'a `b` c'"));
assert!(has_substitution("echo \"a `b` c\""));
assert!(!has_substitution("echo 'a $(b) c'"));
assert!(has_substitution("echo \"a $(b) c\""));
}
#[test]
fn an_operator_at_the_very_end_is_not_read_past() {
assert_eq!(split_segments("ls &"), ["ls"]);
assert_eq!(split_segments("ls |"), ["ls"]);
assert_eq!(split_segments("ls &&"), ["ls"]);
assert_eq!(split_segments("ls ||"), ["ls"]);
assert_eq!(split_segments("echo \"a\\"), ["echo \"a\\"]);
assert_eq!(redirects("echo a\\").len(), 0);
assert_eq!(redirects("echo > out.txt\\").len(), 1);
}
#[test]
fn a_segment_may_begin_with_the_operator() {
let targets = redirects("> out.txt");
assert_eq!(targets.len(), 1, "{targets:?}");
assert_eq!(targets[0].target, "out.txt");
assert!(targets[0].truncates);
assert_eq!(redirects("&> log.txt").len(), 0);
assert_eq!(split_segments("&> log.txt"), ["&> log.txt"]);
}
#[test]
fn a_redirect_with_nothing_after_it_has_no_target() {
assert_eq!(redirects("echo >").len(), 0);
assert_eq!(redirects("echo > ").len(), 0);
assert_eq!(redirects("echo >>").len(), 0);
}
#[test]
fn a_quoted_target_keeps_the_spaces_inside_it() {
let targets = redirects("echo > 'my file.txt' && ls");
assert_eq!(targets.len(), 1, "{targets:?}");
assert_eq!(targets[0].target, "my file.txt");
let targets = redirects("echo > \"my file.txt\"");
assert_eq!(targets[0].target, "my file.txt");
}
#[test]
fn an_unbalanced_quote_inside_the_other_kind_changes_nothing() {
let segs = split_segments("echo 'it\"s' && rm -rf /tmp/x");
assert_eq!(segs.len(), 2, "{segs:?}");
assert!(segs[1].starts_with("rm -rf"), "{segs:?}");
let targets = redirects("echo 'a\"b' > out.txt");
assert_eq!(
targets.len(),
1,
"the redirect is outside the quotes: {targets:?}"
);
assert_eq!(targets[0].target, "out.txt");
}
#[test]
fn an_escaped_quote_does_not_close_the_string_it_is_inside() {
let segs = split_segments("echo \"a\\\"b && c\"");
assert_eq!(segs.len(), 1, "{segs:?}");
assert_eq!(
segs[0], "echo \"a\\\"b && c\"",
"the text must survive intact"
);
}
#[test]
fn a_backslash_inside_single_quotes_escapes_nothing() {
let targets = redirects("echo '\\' > out.txt");
assert_eq!(targets.len(), 1, "{targets:?}");
assert_eq!(targets[0].target, "out.txt");
}
#[test]
fn an_operator_pair_is_consumed_exactly_once() {
assert_eq!(split_segments("ls &&> out.txt"), ["ls", "> out.txt"]);
assert_eq!(split_segments("ls |grep x"), ["ls", "grep x"]);
assert_eq!(split_segments("ls ||grep x"), ["ls", "grep x"]);
}
#[test]
fn a_segment_may_begin_with_a_backslash() {
assert_eq!(redirects("\\> out.txt").len(), 0);
assert_eq!(split_segments("\\> out.txt"), ["\\> out.txt"]);
}
#[test]
fn every_sink_spelling_is_a_sink() {
for sink in [
"/dev/null",
"/dev/zero",
"/dev/stdout",
"/dev/stderr",
"/dev/tty",
"/dev/full",
"NUL",
"nul",
"/DEV/NULL",
] {
assert!(
redirects(&format!("echo x > {sink}")).is_empty(),
"{sink} destroys nothing and must not be an overwrite target"
);
}
for real in ["/dev/null.bak", "/dev/nullify", "nulled.txt"] {
assert_eq!(
redirects(&format!("echo x > {real}")).len(),
1,
"{real} is an ordinary file"
);
}
}
}