Compare commits

...

3 Commits

Author SHA1 Message Date
Claude Bot
0a2a7ca77f test: add chmod exit code assertions and missing-file negative test
Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-21 05:40:24 +00:00
Claude
08afcdfed5 skip shell redirect tests on Windows
The tests use #!/bin/sh scripts, chmod +x, and cat which aren't
available on Windows.

https://claude.ai/code/session_01MgsdKCtL4wMRWnUwFAJMck
2026-02-21 04:57:17 +00:00
Claude Bot
d9f728ea9d fix(shell): don't strip trailing digits from command names before redirects
The shell tokenizer incorrectly treated digits at the end of command
names as file descriptor numbers when followed by `<` or `>`. For
example, `./script1<file` was parsed as `./script` with `1<file` (fd
redirect) instead of `./script1` with `<file` (stdin redirect).

The fix checks whether the digit is part of a word already being
accumulated before attempting to parse it as an fd redirect. Per POSIX,
`N<` or `N>` is only a file descriptor redirect when `N` is a
standalone token, not when it's part of a larger word.

Closes #12602

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-20 05:04:59 +00:00
2 changed files with 57 additions and 0 deletions

View File

@@ -2704,6 +2704,11 @@ pub fn NewLexer(comptime encoding: StringEncoding) type {
comptime for ('0'..'9') |c| assertSpecialChar(c);
if (self.chars.state != .Normal) break :escaped;
// Only try to parse a fd redirect (e.g. `2>`) when the digit
// is NOT part of a word already being accumulated.
// Otherwise `./script1<file` would incorrectly strip the
// trailing `1` from the command name. (GH-12602)
if (self.word_start != self.j) break :escaped;
const snapshot = self.make_snapshot();
if (self.eat_redirect(input)) |redirect| {
try self.break_word(true);

View File

@@ -0,0 +1,52 @@
import { $ } from "bun";
import { expect, test } from "bun:test";
import { isWindows, tempDir } from "harness";
// GH-12602: Shell incorrectly strips trailing digits from command names
// when followed by a redirect operator. e.g. `./script1<file` was parsed
// as `./script` with `1<file` (fd redirect), instead of `./script1` with
// `<file` (stdin redirect).
test.skipIf(isWindows)("command name ending in digit followed by redirect is not treated as fd redirect", async () => {
using dir = tempDir("12602", {
"script1": "#!/bin/sh\necho Hello from script1",
"input.txt": "some input",
});
// Make script1 executable
const chmodRes = await $`chmod +x ${dir}/script1`.quiet();
expect(chmodRes.exitCode).toBe(0);
// ./script1<input.txt — the "1" must stay part of the command name
const result = await $`cd ${dir} && ./script1<input.txt`.quiet();
expect(result.text()).toBe("Hello from script1\n");
expect(result.exitCode).toBe(0);
// Redirect from a missing file should fail (the command name must still be correct)
const bad = await $`cd ${dir} && ./script1<missing.txt`.quiet().nothrow();
expect(bad.exitCode).not.toBe(0);
});
test.skipIf(isWindows)("command name ending in '2' followed by redirect is not treated as fd redirect", async () => {
using dir = tempDir("12602-2", {
"script2": "#!/bin/sh\necho Hello from script2",
"input.txt": "some input",
});
const chmodRes = await $`chmod +x ${dir}/script2`.quiet();
expect(chmodRes.exitCode).toBe(0);
const result = await $`cd ${dir} && ./script2<input.txt`.quiet();
expect(result.text()).toBe("Hello from script2\n");
expect(result.exitCode).toBe(0);
});
test.skipIf(isWindows)("standalone digit redirect still works", async () => {
using dir = tempDir("12602-fd", {
"input.txt": "hello from file",
});
// `cat 0<input.txt` — 0 is NOT part of a word, so it should be treated as fd redirect
const result = await $`cd ${dir} && cat 0<input.txt`.quiet();
expect(result.text()).toBe("hello from file");
expect(result.exitCode).toBe(0);
});