Skip to content
Closed
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
118 changes: 92 additions & 26 deletions src/shell_parser/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2418,6 +2418,13 @@ pub(crate) enum RedirectDirection {
In,
}

pub(crate) enum FdRedirect {
Redirect(ast::RedirectFlags),
NotRedirect,
UnsupportedFd,
UnsupportedInputFd,
}

#[derive(Clone, Copy)]
pub struct BacktrackSnapshot<'bump, const ENCODING: StringEncoding> {
chars: ShellCharIter<'bump, ENCODING>,
Expand Down Expand Up @@ -2974,15 +2981,49 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> {
if self.chars.state != CharState::Normal {
break 'escaped;
}
let snapshot = self.make_snapshot();
if let Some(redirect) = self.eat_redirect(input) {
self.break_word(true)?;
self.tokens.push(Token::Redirect(redirect));
fell_through = true;
// POSIX 2.7: the fd number before a redirect operator must
// be a complete word on its own; mid-word digits are text.
if self.word_start != self.j
|| matches!(
self.last_tok_tag(),
Some(
TokenTag::Var
| TokenTag::VarArgv
| TokenTag::Text
| TokenTag::SingleQuotedText
| TokenTag::DoubleQuotedText
| TokenTag::BraceBegin
| TokenTag::Comma
| TokenTag::BraceEnd
| TokenTag::CmdSubstEnd
| TokenTag::Asterisk
| TokenTag::DoubleAsterisk
Comment thread
robobun marked this conversation as resolved.
)
)
Comment thread
robobun marked this conversation as resolved.
{
break 'escaped;
}
self.backtrack(&snapshot);
break 'escaped;
let snapshot = self.make_snapshot();
match self.eat_redirect(input) {
FdRedirect::Redirect(redirect) => {
self.break_word(true)?;
self.tokens.push(Token::Redirect(redirect));
fell_through = true;
break 'escaped;
}
FdRedirect::NotRedirect => {
self.backtrack(&snapshot);
break 'escaped;
}
FdRedirect::UnsupportedFd => {
self.add_error(b"Redirecting to file descriptors other than 0, 1, and 2 is not supported yet.");
return Ok(());
}
FdRedirect::UnsupportedInputFd => {
self.add_error(b"Redirecting input to file descriptors other than 0 is not supported yet.");
return Ok(());
}
}
}
// Operators
c if c == u32::from(b'|') => {
Expand Down Expand Up @@ -3240,15 +3281,15 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> {
| TokenTag::Comma
| TokenTag::BraceEnd
| TokenTag::CmdSubstEnd
| TokenTag::Asterisk => true,
| TokenTag::Asterisk
| TokenTag::DoubleAsterisk => true,

TokenTag::Pipe
| TokenTag::DoublePipe
| TokenTag::Ampersand
| TokenTag::DoubleAmpersand
| TokenTag::Redirect
| TokenTag::Dollar
| TokenTag::DoubleAsterisk
| TokenTag::Eq
| TokenTag::Semicolon
| TokenTag::Newline
Expand Down Expand Up @@ -3315,21 +3356,36 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> {
}

// TODO Arbitrary file descriptor redirect
fn eat_redirect(&mut self, first: InputChar) -> Option<ast::RedirectFlags> {
let mut flags = ast::RedirectFlags::default();
match first.char {
c if c == u32::from(b'0') => flags |= ast::RedirectFlags::STDIN,
c if c == u32::from(b'1') => flags |= ast::RedirectFlags::STDOUT,
c if c == u32::from(b'2') => flags |= ast::RedirectFlags::STDERR,
// Just allow the std file descriptors for now
_ => return None,
}
fn eat_redirect(&mut self, first: InputChar) -> FdRedirect {
debug_assert!((u32::from(b'0')..=u32::from(b'9')).contains(&first.char));
// Consume the full digit run so multi-digit fds like `10>` are
// recognized as redirects instead of splitting into an argument plus
// `0>`, and so leading zeros (`01>`) still resolve to fd 1.
let mut fd: u32 = first.char - u32::from(b'0');
while let Some(p) = self.peek() {
if p.escaped || !(u32::from(b'0')..=u32::from(b'9')).contains(&p.char) {
break;
}
let _ = self.eat();
fd = fd
.saturating_mul(10)
.saturating_add(p.char - u32::from(b'0'));
}
let mut flags = match fd {
0 => ast::RedirectFlags::STDIN,
1 => ast::RedirectFlags::STDOUT,
2 => ast::RedirectFlags::STDERR,
_ => ast::RedirectFlags::default(),
};
if let Some(input) = self.peek() {
if input.escaped {
return None;
return FdRedirect::NotRedirect;
}
match input.char {
c if c == u32::from(b'>') => {
if flags.is_empty() {
return FdRedirect::UnsupportedFd;
}
let _ = self.eat();
let is_double = self.eat_simple_redirect_operator(RedirectDirection::Out);
if is_double {
Expand All @@ -3347,7 +3403,7 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> {
flags |= ast::RedirectFlags::STDOUT;
flags.remove(ast::RedirectFlags::STDERR);
} else {
return None;
return FdRedirect::NotRedirect;
}
}
c2 if c2 == u32::from(b'2') => {
Expand All @@ -3357,27 +3413,37 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> {
flags |= ast::RedirectFlags::STDERR;
flags.remove(ast::RedirectFlags::STDOUT);
} else {
return None;
return FdRedirect::NotRedirect;
}
}
_ => return None,
_ => return FdRedirect::NotRedirect,
}
}
}
}
Some(flags)
FdRedirect::Redirect(flags)
}
c if c == u32::from(b'<') => {
if flags.is_empty() {
return FdRedirect::UnsupportedFd;
}
// RedirectFlags cannot represent "open fd 1/2 for reading",
// so `1<`/`2<` are rejected rather than silently opening
// the file for writing.
if !flags.stdin() {
return FdRedirect::UnsupportedInputFd;
}
let _ = self.eat();
let is_double = self.eat_simple_redirect_operator(RedirectDirection::In);
if is_double {
flags |= ast::RedirectFlags::APPEND;
}
Some(flags)
FdRedirect::Redirect(flags)
Comment thread
robobun marked this conversation as resolved.
}
_ => None,
_ => FdRedirect::NotRedirect,
}
} else {
None
FdRedirect::NotRedirect
}
}

Expand Down
58 changes: 58 additions & 0 deletions test/js/bun/shell/bunshell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1485,6 +1485,64 @@ describe("deno_task", () => {
// .exitCode(1)
// .run();

describe("fd number is only recognized as its own word (POSIX 2.7)", () => {
// A trailing digit in a word must stay part of that word, not become an fd.
TestBuilder.command`echo z1>test.txt`.fileEquals("test.txt", "z1\n").runAsTest("echo z1>f writes z1");
TestBuilder.command`echo z2>test.txt`.fileEquals("test.txt", "z2\n").runAsTest("echo z2>f writes z2");
TestBuilder.command`echo z0>test.txt`.fileEquals("test.txt", "z0\n").runAsTest("echo z0>f writes z0");
TestBuilder.command`echo abc123>test.txt`
.fileEquals("test.txt", "abc123\n")
.runAsTest("echo abc123>f writes abc123");
TestBuilder.command`echo ${{ raw: '"z"1>test.txt' }}`
.fileEquals("test.txt", "z1\n")
.runAsTest("digit after quoted text is not an fd");
// https://github.com/oven-sh/bun/issues/12602
TestBuilder.command`echo PASS > script1 && echo FAIL > script && cat ./script1<script`
.ensureTempDir()
.stdout("PASS\n")
.fileEquals("script", "FAIL\n")
.runAsTest("./script1<file keeps the trailing 1 in the word");
TestBuilder.command`echo ${{ raw: "$(echo z)1" }}>test.txt`
.fileEquals("test.txt", "z1\n")
.runAsTest("digit after command substitution is not an fd");
TestBuilder.command`echo z1>>test.txt`.fileEquals("test.txt", "z1\n").runAsTest("echo z1>>f writes z1");

// A standalone digit before the operator is still an fd.
TestBuilder.command`echo z 1> test.txt`
.fileEquals("test.txt", "z\n")
.runAsTest("standalone 1> still redirects stdout");
TestBuilder.command`echo z 2> test.txt`
.stdout("z\n")
.fileEquals("test.txt", "")
.runAsTest("standalone 2> still redirects stderr");
TestBuilder.command`echo z 1>test.txt`
.fileEquals("test.txt", "z\n")
.runAsTest("standalone 1>file (no space) redirects stdout");

// fd numbers outside 0/1/2 aren't supported yet but must be recognized
// as redirects, not silently treated as arguments.
test.each([
["echo z 3> test.txt", "Redirecting to file descriptors other than 0, 1, and 2 is not supported yet."],
["echo z 10> test.txt", "Redirecting to file descriptors other than 0, 1, and 2 is not supported yet."],
["echo z 3< test.txt", "Redirecting to file descriptors other than 0, 1, and 2 is not supported yet."],
["echo z 3>> test.txt", "Redirecting to file descriptors other than 0, 1, and 2 is not supported yet."],
["echo z 1< test.txt", "Redirecting input to file descriptors other than 0 is not supported yet."],
["echo z 2< test.txt", "Redirecting input to file descriptors other than 0 is not supported yet."],
])("`%s` is a redirect (unsupported fd)", async (script, expected) => {
let message = "";
try {
await $`${{ raw: script }}`.cwd(TestBuilder.tmpdir()).quiet();
} catch (e) {
message = (e as Error).message;
}
expect(message).toBe(expected);
});

// A digit word not followed by a redirect operator is a plain argument.
TestBuilder.command`echo 42 >test.txt`.fileEquals("test.txt", "42\n").runAsTest("echo 42 >f writes 42");
TestBuilder.command`echo 3 z`.stdout("3 z\n").runAsTest("digit arg with no redirect");
});

// /dev/null
TestBuilder.command`BUN_TEST_VAR=1 ${BUN} -e 'console.log(1); console.error(5)' 2> /dev/null`
.stdout("1\n")
Expand Down
Loading
Loading