Skip to content

SC2096/BRS0009: count redirection operators, not '>' characters (#431) - #434

Open
noahgift wants to merge 3 commits into
mainfrom
fix/431-brs0009-quoted-gt
Open

noahgift wants to merge 3 commits into
mainfrom
fix/431-brs0009-quoted-gt

Conversation

@noahgift

@noahgift noahgift commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Closes #431

The defect

bashrs lint 7.4.2 reports BRS0009 "Multiple stdout redirections specified" on

printf '%s\n' "a <N>m b N>0" >&2

That line has one redirection. SC2096 (BRS0009) counted > characters on the raw line:

  • three regexes (rash/src/linter/rules/sc2096.rs:25-37 on the base);
  • a split('>') counter that skipped a part only when the part before it ended in 2 or & (:126-137);
  • a command splitter that did not know quotes.

It was not in QUOTE_SENSITIVE_RULES, so it read the raw source. There, the > of a string literal looks exactly like an operator. The stderr and append checks were regexes over the same text and had the same defect. To keep heredoc bodies out, the rule skipped every line containing <<, which also hid the redirections on the heredoc's own line.

The fix

  • Masked input. SC2096 joins QUOTE_SENSITIVE_RULES. The dispatcher masks string literals, heredoc bodies and comments before the rule reads a line. That keeps one quoting scanner for the whole linter, not a second one in this rule.
  • Operators from tokens. The rule reads redirection operators: >, >|, >>, >&, <&, &>, &>>, <, <<, <<-, <<< and <>.
    • The descriptor is the all-digit word directly before the operator. In echo file2>out, file2 is an argument.
    • Commands split at |, ;, & and &&.
    • $( ), ( ), backticks and process substitutions are commands of their own.
    • $(( )), (( )), [[ ]], ${...}, \> and comments are stepped over.
  • What counts as a finding.
    • stdout: two truncating redirections of fd 1 in one command.
    • stderr: two 2> or 2>>.
    • append: two appends to the same descriptor.

What the lint gate now accepts and reports differently

This changes what the lint gate accepts, so it needs a sign-off review round from someone other than the author before merge.

No longer reported (false positives removed):

  • A > inside single quotes, double quotes, $'...', a heredoc body or a comment.
  • A > in $(( )), (( )), [[ ]], ${x//>/y} or \>.
  • Redirections of other descriptors, such as exec 3>a 4>b, and redirections across a process substitution or a background &.
  • cmd >> a 2>> b, which appends to two different streams.

No longer reported (a deliberate narrowing):

  • cmd 2> a 2>&1 2> b. A descriptor copy (2>&1, >&2) now ends a run, because a copy can read the earlier target: cmd > a 2>&1 > b really does send stderr to a.

Newly reported (double redirections the character scan missed):

  • cat <<EOF > a > b: the heredoc line used to be skipped.
  • cmd >| a > b.
  • echo file2>out > out2.
  • cmd > a >> b > c: any >> in the command used to switch the stdout check off.

Unchanged: SC2094 has the same split('>') shape over the raw source (sc2094.rs:83). This PR does not change it.

Evidence

  • Fixtures. sc2096_tests_prop_sc2096.rs has one fixture per quoting form: single-quoted, double-quoted, $'...', a heredoc body, and the issue's exact line. Each must give 0 BRS0009, both through lint_shell and through the dispatcher's masked input.

  • Negative control. The same forms still fire when the rule is given the raw source, so the fixtures are able to fail.

  • True positives. Each of these still gives exactly one finding: echo a >x >y, x=$(cmd >a >b), cmd 2>e1 2>e2, echo a >>f1 >>f2 and cmd > a 2> e > b.

  • Guard. quoting_literal_payload_guard.rs gains the BRS0009 payload.

  • Discrimination. With the rule and quoting.rs reverted to the base and the new tests kept:

    • four new lib tests fail; the issue's line gives left: 1, right: 0;
    • the payload guard fails with "BRS0009 (bashrs#431): fired on literal text".

    With the fix restored, 195 sc2096/quoting lib tests and the 4 guard tests pass.

  • CLI check. bashrs lint on 21 small scripts:

    • 15 of the 17 false-positive scripts fire on the base, and none fire with the fix;
    • the 4 true-positive scripts fire once in both builds.
  • Formatting and clippy. cargo fmt --all -- --check and cargo clippy -p bashrs --all-targets -- -D warnings pass.

Full package run, and what it does not cover

cargo test -p bashrs --tests was run on this tree.

  • Lib tests: 15761 passed, 24 failed. All 24 failures are shellcheck_validation_tests, which spawn shellcheck. It was not installed on the machine that ran them (Failed to run shellcheck - is it installed?).
  • Integration tests: all 107 binaries ran, and 106 passed.
    • The one failure is test_property_safe_fixes_preserve_syntax, which also spawns shellcheck.
    • 11 corpus tests in cli_ssc_tests were left out: test_PMAT142_corpus_*, test_PMAT143_corpus_* and test_PMAT147_corpus_*. Each one builds a full corpus report and takes more than ten minutes in a debug build.
  • Not run locally (left to CI): the shellcheck-backed tests, those 11 corpus tests, doctests, nextest, and the other workspace members.

Review

Three review models passed this commit (3/3); none of them is the author's model. One of them noted an overlap that this PR does not change: cmd 2>> e1 2>> e2 gets both the stderr and the append message. Both of the old regexes matched that line too.

🤖 Generated with Claude Code

`bashrs lint` 7.4.2 reported BRS0009 "Multiple stdout redirections
specified" on

    printf '%s\n' "a <N>m b N>0" >&2

which has one redirection. The rule counted `>` characters on the raw
line: three regexes (sc2096.rs:25-37 on the base), a `split('>')`
counter that skipped a part only when the part before it ended in `2`
or `&` (:126-137), and a command splitter that did not know quotes. It
was not in `QUOTE_SENSITIVE_RULES`, so it read the raw source, where
the `>` of a string literal looks exactly like an operator. Its stderr
and append checks were regexes over the same raw text and had the same
defect. To keep heredoc bodies out it skipped every line containing
`<<`, which also hid the redirections on the heredoc's own line.

The fix:

- SC2096 joins `QUOTE_SENSITIVE_RULES`. The dispatcher masks string
  literals, heredoc bodies and comments before the rule sees a line:
  one quoting scanner for the whole linter, not a second one here.
- The rule reads operators from tokens: `>`, `>|`, `>>`, `>&`, `<&`,
  `&>`, `&>>`, `<`, `<<`, `<<-`, `<<<` and `<>`. The descriptor is the
  all-digit word written directly before the operator, so in
  `echo file2>out` the word `file2` is an argument and the `>` is a
  stdout redirection.
- Commands split at `|`, `;`, `&` and `&&`. `$( )`, `( )`, backticks
  and `>( )`/`<( )` are commands of their own. `$(( ))`, `(( ))`,
  `[[ ]]`, `${...}`, `\>` and comments are stepped over, because a `>`
  there is not a redirection in code either.
- A stdout finding is two truncating redirections of fd 1 in one
  command. A stderr finding is two `2>`/`2>>`. An append finding is two
  appends to the SAME descriptor, so `cmd >> a 2>> b` (two streams) is
  no longer reported.
- A descriptor copy (`2>&1`, `>&2`) ends a run, because it can read
  the earlier target: `cmd > a 2>&1 > b` still sends stderr to `a`.
  This also means `cmd 2> a 2>&1 2> b` is no longer reported.

Double redirections the character scan missed now fire:
`cat <<EOF > a > b`, `cmd >| a > b`, `echo file2>out > out2` and
`cmd > a >> b > c` (any `>>` in a command used to switch the stdout
check off).

Tests, in sc2096_tests_prop_sc2096.rs, each run through `lint_shell`
(counting BRS0009) and through the dispatcher's masked input:

- One fixture per quoting form, each 0: single-quoted, double-quoted,
  `$'...'`, a heredoc body, and the issue's exact line. A negative
  control shows the same forms still fire when the rule reads the raw
  source, so the fixtures can fail.
- `>` in code that is not an operator, each 0: `$(( ))`, `(( ))`,
  `[[ ]]`, `\>`, `${x//>/y}`, `exec 3>a 4>b`, a process substitution,
  a background `&`, `>> a 2>> b` and `<>`.
- True positives, each exactly 1: `echo a >x >y`, `x=$(cmd >a >b)`,
  `cmd 2>e1 2>e2`, `echo a >>f1 >>f2` and `cmd > a 2> e > b`.

quoting_literal_payload_guard.rs gains the BRS0009 payload: the issue's
line must not fire, and `echo a >x >y` must.

Shown to discriminate. With sc2096.rs and quoting.rs put back to the
base and the new tests kept, four of the new lib tests fail (the issue
line gives `left: 1, right: 0`) and the payload guard fails with
"BRS0009 (bashrs#431): fired on literal text". With the fix restored,
195 sc2096/quoting lib tests and the 4 guard tests pass. `bashrs lint`
on 21 scripts: 15 of the 17 false-positive scripts fire on the base and
none with the fix; the 4 true-positive scripts fire once in both.

SC2094 has the same `split('>')` shape over the raw source
(sc2094.rs:83) and is unchanged here.

Refs: PMAT-431 #431
Pmat-Ticket: PMAT-431
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@noahgift

noahgift commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

Non-author review plus quorum sign-off, bound to head 508ad600.

  • Clean round: 3 of 3 PASS (gemini-3.1-pro-high, claude-sonnet-5, claude-haiku-4-5). The author model was excluded.
  • Planted control: a test asserting the opposite of BRS0009 false positive: a '>' inside a quoted string is counted as a stdout redirection #431 (brs0009(<issue line>) == 1), committed locally and never pushed. It drew 3 of 3 FAIL, and each lane named the planted lines, so the round was actually measuring this diff.
  • Read by hand: fd_before rejects file2>out, and a Dup (2>&1) ends an override run, so > a 2>&1 > b is not flagged. The masked-source dispatch is the one quoting scanner, not a second one.

Not armed by this reviewer.

🤖 Generated with Claude Code

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BRS0009 false positive: a '>' inside a quoted string is counted as a stdout redirection

1 participant