Skip to content

test: reject a -a/-o that ends the expression - #14395

Open
Socialpranker wants to merge 1 commit into
uutils:mainfrom
Socialpranker:test-trailing-boolop
Open

test: reject a -a/-o that ends the expression#14395
Socialpranker wants to merge 1 commit into
uutils:mainfrom
Socialpranker:test-trailing-boolop

Conversation

@Socialpranker

Copy link
Copy Markdown
Contributor

A -a or -o at the end of an expression was silently swallowed:

$ test x -a; echo $?          # GNU
test: missing argument after '-a'
2
$ test x -a; echo $?          # uutils, before
0

$ test x -o; echo $?          # GNU: 2, uutils before: 0
$ test x -a y -a; echo $?     # GNU: 2, uutils before: 0
$ test '(' x ')' -a; echo $?  # GNU: 2, uutils before: 0
$ test ! x -a; echo $?        # GNU: 2, uutils before: 0

That is the same diagnostic uutils already produces for the other operators
that run out of an operand (test x =, test 1 -gt), and the exit status
2 is what --help reserves for a malformed expression:

Exit status is 0 if EXPRESSION is true, 1 if EXPRESSION is false or
missing, and 2 if an error occurred.

Returning 0 here is the worst possible answer: a truncated -a chain — an
unquoted empty variable is the usual way to get one — reads as true.

The fix

maybe_boolop turned a BOOLOP found at the end of the token stream into a
literal string. That is correct only when the BOOLOP has no operand on
either side, which is the one-argument test -a (a string-length test of
"-a") and the same case nested inside another BOOLOP, test -o -o. Once
an expression has been parsed the trailing BOOLOP joins it to nothing, so
maybe_boolop now takes whether a left operand exists and reports
MissingArgument in that case. expr passes what it knows; the recursive
call and the one in bang pass true, since both have just parsed a term.

No new error kind, no new message: ParseErrorKind::MissingArgument and
its diagnostics entry already exist and are what the other operators use.

This also un-ignores test_string_length_and_nothing, which was marked
#[ignore = "GNU considers this an error"] for exactly this gap.

Not in scope

test -a x -a and test ! -a x still differ from GNU in which token the
message names ('x': binary operator expected vs. the one printed here).
That is issue #6203 — the parse stack does not record enough to tell those
inputs apart — and is untouched by this change: they exited 0/1 before and
still do not match, except that the first now at least exits 2.

How the GNU behavior was established

By running the installed GNU coreutils 9.11 binary (Homebrew, gtest) as a
black box over 26 combinations of -a/-o in leading, medial and trailing
position, with and without !, parentheses, unary operators and empty
operands, and diffing exit status and stderr against uutils. I did not read
GNU coreutils source.

Testing

  • New tests in tests/by-util/test_test.rs:
    test_boolop_without_right_operand (both operators, empty left operand,
    and after a chain, a parenthesized group and !) and
    test_lone_boolop_is_a_string (guards the literal fallback that must
    stay: test -a, test ! -a, test -n -a), plus the un-ignored
    test_string_length_and_nothing. Mutation-checked: reverting
    parser.rs alone makes two of them fail.
  • cargo test --features test --test tests test_test: 107 passed, 0
    failed, 3 ignored (105/0/4 before).
  • cargo clippy -p uu_test --all-targets -- -D warnings: clean.
  • cargo fmt --check: clean.
  • Differential A/B against GNU coreutils 9.11 over 713 invocations across
    28 utilities: mismatches 79 -> 78, the test bucket 1 -> 0, no other
    bucket moved.

Disclosure

Prepared with AI assistance (Claude Opus 5, via Claude Code), per the AI
policy in CONTRIBUTING.md. Every GNU behavior quoted above came from
running the installed binary, not from reading GPL source. All testing was
run locally.

`test x -a` and `test x -o` exited 0 instead of failing with the usual
missing-operand diagnostic: the parser turned any BOOLOP found at the end
of the token stream into a literal string. That fallback is only right when
the BOOLOP has no operand on either side, as in the one-argument
`test -a`; once an expression has been parsed the trailing BOOLOP joins it
to nothing, which is an error.
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

GNU testsuite comparison:

Skip an intermittent issue tests/tail/tail-n0f (fails in this run but passes in the 'main' branch)
Skipping an intermittent issue tests/cut/bounded-memory (passes in this run but fails in the 'main' branch)
Skipping an intermittent issue tests/rm/isatty (passes in this run but fails in the 'main' branch)
Skipping an intermittent issue tests/tail/follow-name (passes in this run but fails in the 'main' branch)

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.

1 participant