Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions crates/jcode-command-risk/src/assess_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -723,3 +723,88 @@ fn issue_1753_inert_text_stays_safe() {
assert!(level(command).runs_immediately(), "{command:?}");
}
}

#[test]
fn issue_932_a_leading_cd_moves_where_relative_targets_resolve() {
// A relative delete after `cd` resolves against the directory the shell is
// actually in, not the tool call's working directory. Without the tracking
// these looked like ordinary deletes inside the workspace.
assert_eq!(level("cd /etc && rm -rf passwd"), RiskLevel::Catastrophic);
assert_eq!(level("cd /etc; rm -rf passwd"), RiskLevel::Catastrophic);
assert_eq!(
level("cd / && cd etc && rm -rf passwd"),
RiskLevel::Catastrophic
);
assert_eq!(level("cd ~/.ssh && rm -rf *"), RiskLevel::Catastrophic);
// `cd` with no operand goes home.
assert_eq!(level("cd && rm -rf Documents"), RiskLevel::Catastrophic);

// A temp-directory cleanup must not be attributed to the workspace.
let assessment = assess("cd /tmp && rm -rf build-xyz", &ctx());
assert!(
assessment
.findings
.iter()
.all(|finding| finding.target.as_deref() != Some("/home/u/proj/build-xyz")),
"{:?}",
assessment.findings
);
assert!(
assessment.level.runs_immediately(),
"{:?}",
assessment.findings
);

// A destination that cannot be resolved statically is not assumed to be
// the workspace either.
let assessment = assess("cd $UNKNOWN_DIR && rm -rf build-xyz", &ctx());
assert!(
assessment
.findings
.iter()
.all(|finding| finding.target.as_deref() != Some("/home/u/proj/build-xyz")),
"{:?}",
assessment.findings
);
}

#[test]
fn issue_932_redirect_syntax_is_not_a_delete_target() {
let bare = assess("rm -rf build-xyz", &ctx());
for command in [
"rm -rf build-xyz 2>/dev/null",
"rm -rf build-xyz 2> /dev/null",
"rm -rf build-xyz >/dev/null 2>&1",
"rm -rf build-xyz 2>>/dev/null",
"rm -rf build-xyz &>/dev/null",
"rm -rf build-xyz < /dev/null",
] {
let assessment = assess(command, &ctx());
assert_eq!(
assessment.level, bare.level,
"{command:?}: {:?}",
assessment.findings
);
// Neither the descriptor number nor the destination is an operand.
assert!(
assessment
.findings
.iter()
.all(|finding| finding.target.as_deref() != Some("/home/u/proj/2")),
"{command:?}: {:?}",
assessment.findings
);
assert!(
assessment.findings.len() <= bare.findings.len(),
"{command:?}: {:?}",
assessment.findings
);
}

// A space between the number and the operator makes it a real operand,
// which the shell would pass to the command.
assert_eq!(level("rm -rf build-xyz 2 >/dev/null"), RiskLevel::Low);

// Truncating a protected path through a redirect is still catastrophic.
assert_eq!(level("echo pwned > /etc/passwd"), RiskLevel::Catastrophic);
}
87 changes: 84 additions & 3 deletions crates/jcode-command-risk/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,28 @@ pub fn assess(command: &str, ctx: &RiskContext) -> RiskAssessment {
});
}

// A `cd` earlier in the same command line decides where later relative
// paths resolve (#932). Without that, `cd /etc && rm -rf passwd` was judged
// as a delete inside the workspace while the shell would remove
// `/etc/passwd`. Subshell parentheses are flattened away by the tokenizer,
// so a `cd` inside `( ... )` over-approximates the rest of the line; that
// direction only ever costs a confirmation.
let mut cwd = ctx.working_dir.clone();
for segment in tokenize::split_segments(command) {
assess_segment(&segment, ctx, &mut findings);
let segment_ctx = RiskContext {
working_dir: cwd.clone(),
..ctx.clone()
};
match directory_change(&segment, &segment_ctx) {
DirectoryChange::None => {}
DirectoryChange::Known(dir) => cwd = Some(dir),
DirectoryChange::Unknown => cwd = None,
Comment on lines +229 to +232

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Scoped directory changes hide deletes

If the shell starts in /etc, (cd /tmp); rm -rf passwd still targets /etc/passwd: the subshell does not change the parent’s directory. A failed cd /missing-directory; rm -rf passwd also leaves the deletion in /etc. The new directory tracking instead resolves these targets under /tmp or /missing-directory and returns Low, so the gate allows commands the base blocked as Catastrophic. This protected-file denial bypass must be fixed before merging: preserve shell scope and account for paths where cd fails before replacing the directory used for later commands.

How this was verified: Both commands changed from denied to allowed in executed assessment checks, and disposable Bash equivalents deleted the original-directory file rather than the predicted target.

Artifacts

Executed Rust harness for public API checks and disposable deletions

  • The compiled harness calls assess and gate for the exact reports and executes only disposable Bash equivalents, asserting both the verdicts and actual deletion targets.

Executed runner for isolated base and PR comparisons

  • The runner exports each pinned revision’s leaf sources, compiles the same Rust harness, and captures real command output with exit codes, making the comparison reproducible.

Base results showing protected deletions denied

  • The base run captures Catastrophic and Deny for both reported commands plus disposable shell deletion assertions, showing the original policy blocked these targets.

PR results showing protected deletions allowed

  • The PR run captures Low and Allow for both reported commands while disposable Bash executions delete the original fixtures, confirming the safety regression.

Relevant source lines and unchanged tracked-source status

  • Executed git and numbered-source commands capture the PR revision, cwd propagation, gate behavior, and working-tree status, locating the defect without modifying shared source.

Executed comparison and safe-shell runner

  • The executed Python runner builds both revisions, captures their behavior, and runs a disposable shell analogue, making the verification reproducible.

Executed candidate-specific Rust harness

  • The same Rust harness checks assessment, gate, and consumer refusal on both revisions, isolating the claimed regression.

Production refusal function compiled into the harness

  • The runner extracts and verifies identical production refusal-function source from both revisions, exercising the real decision logic rather than a rewritten gate.

Base assessment and gate results

  • Compiled base and executed the harness with DEST=/etc and assessment cwd=/etc, showing the candidate was Catastrophic and denied.

HEAD assessment and gate results

  • Compiled HEAD and executed the identical harness inputs, showing the candidate becomes Low and allowed while both controls remain denied.

Disposable-directory shell execution

  • Ran the exact candidate shell string with DEST pointing only to a disposable directory, confirming passwd is removed and the neighboring sentinel survives.

Relevant source lines and evidence hashes

  • Captured numbered production source, revision identity, repository status, and evidence hashes, tracing the allowed verdict to the cwd-clearing change without tracked source edits.

View artifacts

TREX Ran code and verified through TREX

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/jcode-command-risk/src/lib.rs
Line: 229-232

Comment:
**Scoped directory changes hide deletes**

If the shell starts in `/etc`, `(cd /tmp); rm -rf passwd` still targets `/etc/passwd`: the subshell does not change the parent’s directory. A failed `cd /missing-directory; rm -rf passwd` also leaves the deletion in `/etc`. The new directory tracking instead resolves these targets under `/tmp` or `/missing-directory` and returns `Low`, so the gate allows commands the base blocked as `Catastrophic`. This protected-file denial bypass must be fixed before merging: preserve shell scope and account for paths where `cd` fails before replacing the directory used for later commands.

**How this was verified:** Both commands changed from denied to allowed in executed assessment checks, and disposable Bash equivalents deleted the original-directory file rather than the predicted target.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Unknown directories hide protected targets

Clearing working_dir does not escalate later relative targets. If the shell starts in /etc and DEST=/etc, cd $DEST && rm -rf passwd now returns Low and is allowed without justification; the base identified /etc/passwd and blocked it as Catastrophic. With no tracked directory, paths::expand() leaves passwd relative, and target classification treats it as a concrete, unprotected path. This denial bypass must be fixed before merging: carry the unknown-directory state into target checks and require confirmation when a destructive relative target’s location cannot be established.

How this was verified: Executed checks changed the command from Catastrophic/Deny to Low/Allow, and the production refusal function returned no refusal on HEAD.

Artifacts

Executed comparison and safe-shell runner

  • The executed Python runner builds both revisions, captures their behavior, and runs a disposable shell analogue, making the verification reproducible.

Executed candidate-specific Rust harness

  • The same Rust harness checks assessment, gate, and consumer refusal on both revisions, isolating the claimed regression.

Production refusal function compiled into the harness

  • The runner extracts and verifies identical production refusal-function source from both revisions, exercising the real decision logic rather than a rewritten gate.

Base assessment and gate results

  • Compiled base and executed the harness with DEST=/etc and assessment cwd=/etc, showing the candidate was Catastrophic and denied.

HEAD assessment and gate results

  • Compiled HEAD and executed the identical harness inputs, showing the candidate becomes Low and allowed while both controls remain denied.

Disposable-directory shell execution

  • Ran the exact candidate shell string with DEST pointing only to a disposable directory, confirming passwd is removed and the neighboring sentinel survives.

Relevant source lines and evidence hashes

  • Captured numbered production source, revision identity, repository status, and evidence hashes, tracing the allowed verdict to the cwd-clearing change without tracked source edits.

View artifacts

TREX Ran code and verified through TREX

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/jcode-command-risk/src/lib.rs
Line: 232

Comment:
**Unknown directories hide protected targets**

Clearing `working_dir` does not escalate later relative targets. If the shell starts in `/etc` and `DEST=/etc`, `cd $DEST && rm -rf passwd` now returns `Low` and is allowed without justification; the base identified `/etc/passwd` and blocked it as `Catastrophic`. With no tracked directory, `paths::expand()` leaves `passwd` relative, and target classification treats it as a concrete, unprotected path. This denial bypass must be fixed before merging: carry the unknown-directory state into target checks and require confirmation when a destructive relative target’s location cannot be established.

**How this was verified:** Executed checks changed the command from Catastrophic/Deny to Low/Allow, and the production refusal function returned no refusal on HEAD.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

}
let segment_ctx = RiskContext {
working_dir: cwd.clone(),
..ctx.clone()
};
assess_segment(&segment, &segment_ctx, &mut findings);
Comment on lines +234 to +238

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Redirects use the wrong directory

Redirects on cd are checked against the destination directory instead of where the shell opens them. If the shell starts in /etc, cd /tmp > passwd opens and truncates /etc/passwd before cd runs, provided the shell has write permission. The new ordering checks /tmp/passwd and returns Safe/Allow, where the base returned Catastrophic/Deny. This protected-write denial bypass must be fixed before merging: assess the current segment with its incoming directory, then update the directory for later segments.

How this was verified: Executed assessment checks reproduced Safe/Allow on HEAD, and a disposable Bash execution truncated the original-directory file before changing directories.

Artifacts

Executed Rust harness for public assessment and gate

  • Compiled and executed against both revisions, this harness assesses the candidate and two controls without sending any protected-path command to a shell.

Executed independent comparison and disposable shell harness

  • Executed to extract and compile each revision in unique temporary paths, run the public APIs, and verify shell redirection ordering using disposable sentinel files.

Base execution denies the relative redirect

  • Captured the base public API run and disposable Bash analogue, showing Catastrophic/Deny and truncation in the original directory.

HEAD execution allows the same relative redirect

  • Captured the HEAD public API run and disposable Bash analogue, showing Safe/Allow despite Bash truncating the original-directory file.

Exact numbered assessment and gate source

  • Captured the revision, working-tree status, and numbered base and HEAD source lines, locating the premature cwd update and resulting allow branch.

View artifacts

TREX Ran code and verified through TREX

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/jcode-command-risk/src/lib.rs
Line: 234-238

Comment:
**Redirects use the wrong directory**

Redirects on `cd` are checked against the destination directory instead of where the shell opens them. If the shell starts in `/etc`, `cd /tmp > passwd` opens and truncates `/etc/passwd` before `cd` runs, provided the shell has write permission. The new ordering checks `/tmp/passwd` and returns `Safe`/`Allow`, where the base returned `Catastrophic`/`Deny`. This protected-write denial bypass must be fixed before merging: assess the current segment with its incoming directory, then update the directory for later segments.

**How this was verified:** Executed assessment checks reproduced Safe/Allow on HEAD, and a disposable Bash execution truncated the original-directory file before changing directories.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

}

// `"$(...)"`, backticks and unquoted heredoc bodies run commands the
Expand All @@ -235,6 +255,67 @@ pub fn assess(command: &str, ctx: &RiskContext) -> RiskAssessment {
RiskAssessment::from_findings(findings)
}

/// What a segment does to the shell's working directory.
enum DirectoryChange {
/// Not a `cd`.
None,
/// A `cd` whose destination can be resolved statically.
Known(std::path::PathBuf),
/// A `cd` whose destination depends on runtime state (`cd -`, `cd $VAR`).
Unknown,
}

/// Track `cd` so later relative operands resolve where the shell will be.
fn directory_change(tokens: &[Token], ctx: &RiskContext) -> DirectoryChange {
let mut rest = tokens;
while rest.len() > 1 && SHELL_CONTROL_PREFIXES.contains(&rest[0].text.as_str()) {
rest = &rest[1..];
}
// Leading `VAR=value` assignments prefix a command without changing it.
while rest.len() > 1 && rest[0].text.contains('=') && !rest[0].is_flag() {
rest = &rest[1..];
}
if rest.first().map(Token::basename).as_deref() != Some("cd") {
return DirectoryChange::None;
}

let mut destination: Option<&str> = None;
for token in rest.iter().skip(1) {
if token.is_operator || token.is_redirect_target {
continue;
}
// `cd -P /x`, `cd -- /x`: options precede the destination.
if destination.is_none() && (token.is_flag() || token.text == "--") {
continue;
}
if destination.is_some() {
// `cd a b` is an error in every shell, so the directory did not
// change in a way worth guessing at.
return DirectoryChange::Unknown;
}
destination = Some(token.text.as_str());
}

let Some(destination) = destination else {
// A bare `cd` goes home.
return match &ctx.home_dir {
Some(home) => DirectoryChange::Known(home.clone()),
None => DirectoryChange::Unknown,
};
};
// `cd -` uses OLDPWD, and substitutions or globs need the shell's own
// expansion before the directory is known.
if destination == "-" || destination.contains(['$', '`', '*', '?']) {
return DirectoryChange::Unknown;
}
let resolved = paths::expand(destination, ctx);
if resolved.is_absolute() {
DirectoryChange::Known(resolved)
} else {
DirectoryChange::Unknown
}
}

fn assess_segment(tokens: &[Token], ctx: &RiskContext, findings: &mut Vec<RiskFinding>) {
// Shell redirects happen even when a wrapper only prints information.
for target in tokens.iter().filter(|t| t.is_truncating_redirect_target) {
Expand Down Expand Up @@ -389,7 +470,7 @@ fn assess_segment(tokens: &[Token], ctx: &RiskContext, findings: &mut Vec<RiskFi
let targets: Vec<&Token> = tokens
.iter()
.skip(1)
.filter(|t| !t.is_flag() && !t.is_operator && !t.is_truncating_redirect_target)
.filter(|t| !t.is_flag() && !t.is_operator && !t.is_redirect_target)
.collect();

// A destructive command fed by a pipe takes its operands from the previous
Expand Down Expand Up @@ -449,7 +530,7 @@ fn assess_find(tokens: &[Token], ctx: &RiskContext, findings: &mut Vec<RiskFindi
.skip(1)
.skip_while(|t| matches!(t.text.as_str(), "-H" | "-L" | "-P" | "--"))
.take_while(|t| !t.is_flag() && !t.is_operator && !matches!(t.text.as_str(), "!" | "("))
.filter(|t| !t.is_truncating_redirect_target)
.filter(|t| !t.is_redirect_target)
.collect();
let classify_roots = |findings: &mut Vec<RiskFinding>| {
for raw in roots
Expand Down
64 changes: 57 additions & 7 deletions crates/jcode-command-risk/src/tokenize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ pub struct Token {
pub receives_pipe: bool,
/// True for `>` / `>|` redirect destinations, which are truncated on open.
pub is_truncating_redirect_target: bool,
/// True for every redirect destination (`>`, `>>`, `<`, `&>`). These are
/// never operands of the command, so they are excluded from target
/// collection; only the truncating subset is also classified as a write
/// (#932: `rm -rf x 2>/dev/null` reported `/dev/null` as a delete target).
pub is_redirect_target: bool,
/// True for control operators like `&&`, which are never path targets.
pub is_operator: bool,
}
Expand All @@ -24,6 +29,7 @@ impl Token {
text: text.into(),
receives_pipe: false,
is_truncating_redirect_target: false,
is_redirect_target: false,
is_operator: false,
}
}
Expand Down Expand Up @@ -99,22 +105,40 @@ pub fn tokenize(command: &str) -> Vec<Token> {
let mut has_content = false;
let mut chars = command.chars().peekable();
let mut pending_redirect = false;
let mut pending_truncating = false;

macro_rules! flush {
() => {
() => {{
if has_content {
let mut token = Token::word(std::mem::take(&mut current));
if pending_redirect {
token.is_redirect_target = true;
token.is_truncating_redirect_target = pending_truncating;
}
#[allow(unused_assignments)]
{
if pending_redirect {
token.is_truncating_redirect_target = true;
pending_redirect = false;
}
has_content = false;
}
tokens.push(token);
#[allow(unused_assignments)]
{
pending_redirect = false;
pending_truncating = false;
}
}
}};
}

// A redirect operator with no destination word leaves nothing to mark, and
// the flag must not carry over across a command boundary to a later word.
macro_rules! clear_redirect {
() => {{
#[allow(unused_assignments)]
{
pending_redirect = false;
pending_truncating = false;
}
};
}};
}

while let Some(c) = chars.next() {
Expand Down Expand Up @@ -154,12 +178,14 @@ pub fn tokenize(command: &str) -> Vec<Token> {
' ' | '\t' => flush!(),
'\n' | ';' => {
flush!();
clear_redirect!();
let mut op = Token::word(c.to_string());
op.is_operator = true;
tokens.push(op);
}
'&' | '|' => {
flush!();
clear_redirect!();
let mut text = c.to_string();
if chars.peek() == Some(&c) {
text.push(c);
Expand All @@ -171,16 +197,19 @@ pub fn tokenize(command: &str) -> Vec<Token> {
}
'(' | ')' => {
flush!();
clear_redirect!();
let mut op = Token::word(c.to_string());
op.is_operator = true;
tokens.push(op);
}
'>' => {
drop_fd_prefix(&mut current, &mut has_content);
flush!();
// `>>` appends and does not truncate, so it is far less
// destructive; only a single `>` clobbers.
if chars.peek() == Some(&'>') {
chars.next();
pending_redirect = true;
} else if chars.peek() == Some(&'&') {
// `>&N` / `>&-` duplicate or close a file descriptor and
// never name a file to truncate. Consume the FD word.
Expand All @@ -197,15 +226,21 @@ pub fn tokenize(command: &str) -> Vec<Token> {
if fd.is_empty() {
// `>& file` (bash: redirect stdout+stderr) truncates.
pending_redirect = true;
pending_truncating = true;
}
} else {
if chars.peek() == Some(&'|') {
chars.next();
}
pending_redirect = true;
pending_truncating = true;
}
}
'<' => flush!(),
'<' => {
drop_fd_prefix(&mut current, &mut has_content);
flush!();
pending_redirect = true;
}
_ => {
has_content = true;
current.push(c);
Expand All @@ -217,6 +252,21 @@ pub fn tokenize(command: &str) -> Vec<Token> {
tokens
}

/// Drop a bare file-descriptor number that prefixes a redirect (`2>/dev/null`).
///
/// The number is redirect syntax, not an operand, but it was emitted as an
/// ordinary word and then collected as a delete target: `rm -rf x 2>/dev/null`
/// reported a recursive delete of `.../2` (#932). A descriptor prefix has to be
/// adjacent to the operator, and a space flushes the word first, so only the
/// adjacent spelling reaches here. `rm -rf x 2 >/dev/null` really does name a
/// file called `2` and is left alone.
fn drop_fd_prefix(current: &mut String, has_content: &mut bool) {
if *has_content && current.chars().all(|c| c.is_ascii_digit()) {
current.clear();
*has_content = false;
Comment on lines +263 to +266

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Quoted numeric filenames disappear

drop_fd_prefix() also removes quoted or escaped numeric filenames. For example, rm -rf "2">/dev/null deletes the workspace directory named 2, but the checker drops that argument and requests a justification retry because no target remains. Executed checks show the same regression for escaped and single-quoted spellings: Low/Allow becomes Confirm/Reflect. This is a non-blocking usability issue that adds an unnecessary retry and omits the actual filename from the findings. Only discard adjacent numeric prefixes that were entirely unquoted and unescaped.

Artifacts

Complete Python validation driver

  • The executed driver independently compiles both revisions and runs disposable-directory Bash checks, providing the complete reproducible procedure.

Complete Rust public API harness

  • The executed Rust source calls tokenizer, assessment, and gate APIs for five spellings and asserts revision-specific results, reproducing the operand-loss regression.

Base revision behavior and Bash deletion checks

  • The base library preserves quoted and escaped numeric operands while Bash deletes the disposable numeric paths, establishing the working baseline.

PR revision behavior and Bash deletion checks

  • The PR library loses quoted and escaped numeric operands and requests reflection while Bash still deletes those paths, confirming the regression.

Exact tokenizer, assessment, and gate source lines

  • A numbered source capture records the descriptor-dropping helper, no-target escalation, gate mapping, and repository status, locating the defect without shared source edits.

View artifacts

TREX Ran code and verified through TREX

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/jcode-command-risk/src/tokenize.rs
Line: 263-266

Comment:
**Quoted numeric filenames disappear**

`drop_fd_prefix()` also removes quoted or escaped numeric filenames. For example, `rm -rf "2">/dev/null` deletes the workspace directory named `2`, but the checker drops that argument and requests a justification retry because no target remains. Executed checks show the same regression for escaped and single-quoted spellings: `Low`/`Allow` becomes `Confirm`/`Reflect`. This is a non-blocking usability issue that adds an unnecessary retry and omits the actual filename from the findings. Only discard adjacent numeric prefixes that were entirely unquoted and unescaped.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

}
}

/// Remove heredoc payloads before tokenizing shell source.
///
/// A heredoc body is input data for the command on the declaration line, not
Expand Down
39 changes: 39 additions & 0 deletions crates/jcode-command-risk/src/tokenize_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,42 @@ fn parentheses_split_subshell_and_command_substitution_bodies() {
vec![vec!["x=$"], vec!["rm", "-rf", "~"], vec!["echo", "ok"]]
);
}

#[test]
fn redirect_destinations_are_marked_and_fd_prefixes_are_not_words() {
// Every destination is a redirect target, so it is never an operand.
for (command, destination) in [
("rm -rf x 2>/dev/null", "/dev/null"),
("rm -rf x 2> /dev/null", "/dev/null"),
("rm -rf x >> log.txt", "log.txt"),
("rm -rf x < /dev/null", "/dev/null"),
("rm -rf x &>/dev/null", "/dev/null"),
] {
let tokens = tokenize(command);
assert!(
tokens
.iter()
.any(|t| t.is_redirect_target && t.text == destination),
"{command}: {tokens:?}"
);
assert!(
!tokens.iter().any(|t| t.text == "2"),
"{command} keeps the fd prefix as a word: {tokens:?}"
);
}

// An input redirect is a destination but never truncates.
let input = tokenize("rm -rf x < /dev/null");
assert!(
!input
.iter()
.any(|t| t.is_truncating_redirect_target && t.text == "/dev/null")
);

// A space before the operator makes the number a real argument.
assert!(
tokenize("rm -rf x 2 >/dev/null")
.iter()
.any(|t| t.text == "2" && !t.is_redirect_target)
);
}
Loading