diff --git a/src/runtime/shell/Builtin.rs b/src/runtime/shell/Builtin.rs index 983e0a83b166..42cf692c025c 100644 --- a/src/runtime/shell/Builtin.rs +++ b/src/runtime/shell/Builtin.rs @@ -463,6 +463,13 @@ impl Builtin { &self.args } + /// Index of the first operand, skipping a leading `--` end-of-options + /// delimiter (POSIX Utility Syntax Guideline 10). + #[inline] + pub fn operand_start(&self) -> usize { + (!self.args.is_empty() && self.arg_bytes(0) == b"--") as usize + } + /// Borrow `argv[1..][idx]` as `&[u8]` (NUL excluded). /// /// Every entry in `self.args` borrows into the owning `Cmd`'s diff --git a/src/runtime/shell/builtin/basename.rs b/src/runtime/shell/builtin/basename.rs index 221c95717cde..638bdea84701 100644 --- a/src/runtime/shell/builtin/basename.rs +++ b/src/runtime/shell/builtin/basename.rs @@ -22,11 +22,12 @@ impl Basename { let buf = { let bltn = Builtin::of(interp, cmd); let argc = bltn.args_slice().len(); - if argc == 0 { + let start = bltn.operand_start(); + if start >= argc { return Self::fail(interp, cmd, Kind::Basename.usage_string()); } let mut buf = Vec::new(); - for i in 0..argc { + for i in start..argc { buf.extend_from_slice(bun_paths::resolve_path::basename(bltn.arg_bytes(i))); buf.push(b'\n'); } diff --git a/src/runtime/shell/builtin/cd.rs b/src/runtime/shell/builtin/cd.rs index daed43368ec1..888a52e436c8 100644 --- a/src/runtime/shell/builtin/cd.rs +++ b/src/runtime/shell/builtin/cd.rs @@ -23,7 +23,8 @@ enum State { impl Cd { pub(crate) fn start(interp: &Interpreter, cmd: NodeId) -> Yield { let args = Builtin::of(interp, cmd).args_slice(); - if args.len() > 1 { + let skip = Builtin::of(interp, cmd).operand_start(); + if args.len() - skip > 1 { return Self::write_stderr_non_blocking( interp, cmd, @@ -31,8 +32,8 @@ impl Cd { ); } - if args.len() == 1 { - let first_arg = Builtin::of(interp, cmd).arg_bytes(0); + if args.len() - skip == 1 { + let first_arg = Builtin::of(interp, cmd).arg_bytes(skip); if first_arg == b"-" { let prev = Builtin::shell(interp, cmd).prev_cwd().to_vec(); if let Err(err) = interp.as_cmd_mut(cmd).base.shell_mut().change_prev_cwd() { diff --git a/src/runtime/shell/builtin/dirname.rs b/src/runtime/shell/builtin/dirname.rs index 4edfdb2db591..964a4c920e0d 100644 --- a/src/runtime/shell/builtin/dirname.rs +++ b/src/runtime/shell/builtin/dirname.rs @@ -21,13 +21,14 @@ impl Dirname { pub(crate) fn start(interp: &Interpreter, cmd: NodeId) -> Yield { let bltn = Builtin::of(interp, cmd); let argc = bltn.args_slice().len(); - if argc == 0 { + let start = bltn.operand_start(); + if start >= argc { return Self::fail(interp, cmd, b"usage: dirname string\n"); } let stdout_needs_io = bltn.stdout.needs_io(); let mut buf = Vec::new(); - for i in 0..argc { + for i in start..argc { let path = bltn.arg_bytes(i); let dir = bun_paths::resolve_path::dirname::(path); let dir: &[u8] = if dir.is_empty() { b"." } else { dir }; diff --git a/src/runtime/shell/builtin/exit.rs b/src/runtime/shell/builtin/exit.rs index 767cbe4b2bd7..0178b82f532d 100644 --- a/src/runtime/shell/builtin/exit.rs +++ b/src/runtime/shell/builtin/exit.rs @@ -18,10 +18,11 @@ enum State { impl Exit { pub(crate) fn start(interp: &Interpreter, cmd: NodeId) -> Yield { let bltn = Builtin::of(interp, cmd); - let code: crate::shell::ExitCode = match bltn.args_slice().len() { + let start = bltn.operand_start(); + let code: crate::shell::ExitCode = match bltn.args_slice().len() - start { 0 => 0, 1 => { - let s = bltn.arg_bytes(0); + let s = bltn.arg_bytes(start); match parse_exit_code(s) { Some(c) => c, None => { diff --git a/src/runtime/shell/builtin/export.rs b/src/runtime/shell/builtin/export.rs index 4e7d74c65cf6..734ee145dba2 100644 --- a/src/runtime/shell/builtin/export.rs +++ b/src/runtime/shell/builtin/export.rs @@ -20,11 +20,12 @@ enum State { impl Export { pub(crate) fn start(interp: &Interpreter, cmd: NodeId) -> Yield { let argc = Builtin::of(interp, cmd).args_slice().len(); - if argc == 0 { + let start = Builtin::of(interp, cmd).operand_start(); + if start >= argc { // No args: print all exported vars. return Self::print_all(interp, cmd); } - for i in 0..argc { + for i in start..argc { let s = Builtin::of(interp, cmd).arg_bytes(i); if s.is_empty() { continue; diff --git a/src/runtime/shell/builtin/ls.rs b/src/runtime/shell/builtin/ls.rs index 978adf9b9d8d..70b1b2336292 100644 --- a/src/runtime/shell/builtin/ls.rs +++ b/src/runtime/shell/builtin/ls.rs @@ -244,6 +244,10 @@ impl Ls { let mut idx = 0usize; while idx < argc { let flag = Builtin::of(interp, cmd).arg_bytes(idx); + if flag == b"--" { + idx += 1; + return Ok(if idx < argc { Some(idx) } else { None }); + } match Self::parse_flag(&mut Self::state_mut(interp, cmd).opts, flag) { ParseFlag::Done => return Ok(Some(idx)), ParseFlag::ContinueParsing => {} diff --git a/src/runtime/shell/builtin/mv.rs b/src/runtime/shell/builtin/mv.rs index 9393e20798bc..eb330a3af242 100644 --- a/src/runtime/shell/builtin/mv.rs +++ b/src/runtime/shell/builtin/mv.rs @@ -350,23 +350,24 @@ impl Mv { let mut idx = 0usize; while idx < argc { let flag = Builtin::of(interp, cmd).arg_bytes(idx); + if flag == b"--" { + idx += 1; + break; + } match Self::parse_flag(&mut Self::state_mut(interp, cmd).opts, flag) { - MvFlag::Done => { - let filepath_args = argc - idx; - if filepath_args < 2 { - return Err(MvParseError::ShowUsage); - } - let me = Self::state_mut(interp, cmd); - me.args.sources_start = idx; - me.args.target_idx = argc - 1; - return Ok(()); - } - MvFlag::ContinueParsing => {} + MvFlag::Done => break, + MvFlag::ContinueParsing => idx += 1, MvFlag::IllegalOption(s) => return Err(MvParseError::IllegalOption(s)), } - idx += 1; } - Err(MvParseError::ShowUsage) + let filepath_args = argc - idx; + if filepath_args < 2 { + return Err(MvParseError::ShowUsage); + } + let me = Self::state_mut(interp, cmd); + me.args.sources_start = idx; + me.args.target_idx = argc - 1; + Ok(()) } fn parse_flag(opts: &mut Opts, flag: &[u8]) -> MvFlag { diff --git a/src/runtime/shell/builtin/pwd.rs b/src/runtime/shell/builtin/pwd.rs index 354c7e9682ef..b3e879b499b7 100644 --- a/src/runtime/shell/builtin/pwd.rs +++ b/src/runtime/shell/builtin/pwd.rs @@ -27,7 +27,8 @@ enum WaitKind { impl Pwd { pub(crate) fn start(interp: &Interpreter, cmd: NodeId) -> Yield { - if !Builtin::of(interp, cmd).args_slice().is_empty() { + let bltn = Builtin::of(interp, cmd); + if bltn.args_slice().len() > bltn.operand_start() { let msg: &[u8] = b"pwd: too many arguments\n"; if let Some(safeguard) = Builtin::of(interp, cmd).stderr.needs_io() { Self::state_mut(interp, cmd).state = State::WaitingIo { diff --git a/src/runtime/shell/builtin/rm.rs b/src/runtime/shell/builtin/rm.rs index daa85de3cea2..f0f925fc173c 100644 --- a/src/runtime/shell/builtin/rm.rs +++ b/src/runtime/shell/builtin/rm.rs @@ -149,7 +149,13 @@ impl Rm { } let arg = Builtin::of(interp, cmd).arg_bytes(idx as usize).to_vec(); - match Self::parse_flag(&mut Self::state_mut(interp, cmd).opts, &arg) { + let is_end_of_options = arg == b"--"; + let parsed = if is_end_of_options { + RmParseFlag::Done + } else { + Self::parse_flag(&mut Self::state_mut(interp, cmd).opts, &arg) + }; + match parsed { RmParseFlag::ContinueParsing => { if let RmState::ParseOpts { idx: i, .. } = &mut Self::state_mut(interp, cmd).state @@ -174,7 +180,11 @@ impl Rm { return Self::write_err_literal(interp, cmd, idx, buf); } - let args_start = idx as usize; + let args_start = idx as usize + is_end_of_options as usize; + if args_start >= argc { + let usage = Kind::Rm.usage_string(); + return Self::write_err_literal(interp, cmd, idx, usage); + } // Check that none of the paths will delete the root. { diff --git a/src/runtime/shell/builtin/seq.rs b/src/runtime/shell/builtin/seq.rs index a383c157f13f..5b63aaf15616 100644 --- a/src/runtime/shell/builtin/seq.rs +++ b/src/runtime/shell/builtin/seq.rs @@ -89,6 +89,9 @@ impl Seq { idx += 1; continue; } + if arg == b"--" { + idx += 1; + } break; } diff --git a/src/runtime/shell/builtin/which.rs b/src/runtime/shell/builtin/which.rs index 0a395138006d..983633a3b532 100644 --- a/src/runtime/shell/builtin/which.rs +++ b/src/runtime/shell/builtin/which.rs @@ -32,7 +32,8 @@ pub enum State { impl Which { pub(crate) fn start(interp: &Interpreter, cmd: NodeId) -> Yield { let argc = Builtin::of(interp, cmd).args_slice().len(); - if argc == 0 { + let start = Builtin::of(interp, cmd).operand_start(); + if start >= argc { if let Some(safeguard) = Builtin::of(interp, cmd).stdout.needs_io() { Self::state_mut(interp, cmd).state = State::OneArg; let child = ChildPtr::new(cmd, WriterTag::Builtin); @@ -49,7 +50,7 @@ impl Which { // captured buffer, then finish. let (path_env, cwd) = Self::path_and_cwd(interp, cmd); let mut had_not_found = false; - for i in 0..argc { + for i in start..argc { let arg = Self::arg(interp, cmd, i); match Self::resolve(&path_env, &cwd, &arg) { Some(resolved) => { @@ -79,7 +80,7 @@ impl Which { } Self::state_mut(interp, cmd).state = State::MultiArgs { - arg_idx: 0, + arg_idx: start, had_not_found: false, waiting_write: false, }; diff --git a/src/runtime/shell/builtin/yes.rs b/src/runtime/shell/builtin/yes.rs index f8627dc5cc06..381b4624ab46 100644 --- a/src/runtime/shell/builtin/yes.rs +++ b/src/runtime/shell/builtin/yes.rs @@ -33,12 +33,13 @@ impl Yes { pub(crate) fn start(interp: &Interpreter, cmd: NodeId) -> Yield { // Build one copy of the output line. let argc = Builtin::of(interp, cmd).args_slice().len(); + let start = Builtin::of(interp, cmd).operand_start(); let mut one = Vec::new(); - if argc == 0 { + if start >= argc { one.extend_from_slice(b"y\n"); } else { - for i in 0..argc { - if i > 0 { + for i in start..argc { + if i > start { one.push(b' '); } one.extend_from_slice(Builtin::of(interp, cmd).arg_bytes(i)); diff --git a/src/runtime/shell/interpreter.rs b/src/runtime/shell/interpreter.rs index e9bbdcab23f4..5e6efba5c065 100644 --- a/src/runtime/shell/interpreter.rs +++ b/src/runtime/shell/interpreter.rs @@ -2577,6 +2577,10 @@ pub fn parse_flags<'a, O: FlagParser>( while idx < args.len() { // SAFETY: argv entries are NUL-terminated C strings (see Builtin::init). let flag = unsafe { bun_core::ffi::cstr(args[idx]) }.to_bytes(); + if flag == b"--" { + let rest = &args[idx + 1..]; + return Ok(if rest.is_empty() { None } else { Some(rest) }); + } match parse_one_flag(opts, flag) { ParseFlagResult::Done => return Ok(Some(&args[idx..])), ParseFlagResult::ContinueParsing => {} diff --git a/test/js/bun/shell/commands/double-dash.test.ts b/test/js/bun/shell/commands/double-dash.test.ts new file mode 100644 index 000000000000..5ebfeb8f964f --- /dev/null +++ b/test/js/bun/shell/commands/double-dash.test.ts @@ -0,0 +1,228 @@ +// POSIX Utility Syntax Guideline 10: `--` ends option parsing; any following +// arguments are operands even if they begin with `-`. +import { $ } from "bun"; +import { describe, expect, test } from "bun:test"; +import { createTestBuilder } from "../test_builder"; +import { sortedShellOutput } from "../util"; +const TestBuilder = createTestBuilder(import.meta.path); + +$.nothrow(); + +describe("-- end-of-options delimiter", () => { + describe("rm", () => { + TestBuilder.command`touch a; rm -- a` + .ensureTempDir() + .stderr("") + .exitCode(0) + .doesNotExist("a") + .runAsTest("rm -- file"); + + TestBuilder.command`touch a; rm -v -- a` + .ensureTempDir() + .stdout("a\n") + .stderr("") + .exitCode(0) + .doesNotExist("a") + .runAsTest("rm -v -- file applies flag before --"); + + TestBuilder.command`touch ./-f; rm -- -f` + .ensureTempDir() + .stderr("") + .exitCode(0) + .doesNotExist("-f") + .runAsTest("rm -- -f treats -f as an operand"); + + TestBuilder.command`rm --` + .ensureTempDir() + .stderr("usage: rm [-f | -i] [-dIPRrvWx] file ...\n unlink [--] file\n") + .exitCode(1) + .runAsTest("rm -- with no operands shows usage"); + }); + + describe("mv", () => { + TestBuilder.command`echo hi > a; mv -- a b` + .ensureTempDir() + .stderr("") + .exitCode(0) + .doesNotExist("a") + .fileEquals("b", "hi\n") + .runAsTest("mv -- src dst"); + + TestBuilder.command`echo hi > ./-n; mv -- -n out` + .ensureTempDir() + .stderr("") + .exitCode(0) + .doesNotExist("-n") + .fileEquals("out", "hi\n") + .runAsTest("mv -- -n out treats -n as an operand"); + + TestBuilder.command`mv -- a` + .ensureTempDir() + .stderr("usage: mv [-f | -i | -n] [-hv] source target\n mv [-f | -i | -n] [-v] source ... directory\n") + .exitCode(1) + .runAsTest("mv -- with one operand shows usage"); + }); + + describe("mkdir", () => { + TestBuilder.command`mkdir -- d; ls`.ensureTempDir().stdout("d\n").stderr("").exitCode(0).runAsTest("mkdir -- dir"); + + TestBuilder.command`mkdir -p -- a/b; ls a` + .ensureTempDir() + .stdout("b\n") + .stderr("") + .exitCode(0) + .runAsTest("mkdir -p -- nested applies flag before --"); + + TestBuilder.command`mkdir -- -p; ls` + .ensureTempDir() + .stdout("-p\n") + .stderr("") + .exitCode(0) + .runAsTest("mkdir -- -p treats -p as an operand"); + }); + + describe("touch", () => { + TestBuilder.command`touch -- t; ls`.ensureTempDir().stdout("t\n").stderr("").exitCode(0).runAsTest("touch -- file"); + + TestBuilder.command`touch -- -a; ls` + .ensureTempDir() + .stdout("-a\n") + .stderr("") + .exitCode(0) + .runAsTest("touch -- -a treats -a as an operand"); + }); + + describe("ls", () => { + TestBuilder.command`touch x; ls --` + .ensureTempDir() + .stdout("x\n") + .stderr("") + .exitCode(0) + .runAsTest("ls -- with no operands lists cwd"); + + TestBuilder.command`touch a b; ls -- a b` + .ensureTempDir() + .stdout(str => expect(sortedShellOutput(str)).toEqual(["a", "b"])) + .stderr("") + .exitCode(0) + .runAsTest("ls -- file file"); + + TestBuilder.command`touch ./-a; ls -- -a` + .ensureTempDir() + .stdout("-a\n") + .stderr("") + .exitCode(0) + .runAsTest("ls -- -a treats -a as an operand"); + }); + + describe("seq", () => { + TestBuilder.command`seq -- 2`.stdout("1\n2\n").stderr("").exitCode(0).runAsTest("seq -- 2"); + + TestBuilder.command`seq -s , -- 3`.stdout("1,2,3,").stderr("").exitCode(0).runAsTest("seq -s , -- 3"); + + TestBuilder.command`seq --` + .stderr("usage: seq [-w] [-f format] [-s string] [-t string] [first [incr]] last\n") + .exitCode(1) + .runAsTest("seq -- with no operands shows usage"); + }); + + describe("cd", () => { + TestBuilder.command`mkdir sub; cd -- sub && echo ok` + .ensureTempDir() + .stdout("ok\n") + .stderr("") + .exitCode(0) + .runAsTest("cd -- dir"); + + TestBuilder.command`mkdir ./-sub; cd -- -sub && echo ok` + .ensureTempDir() + .stdout("ok\n") + .stderr("") + .exitCode(0) + .runAsTest("cd -- -sub treats -sub as an operand"); + + TestBuilder.command`cd -- && echo ok`.stdout("ok\n").stderr("").exitCode(0).runAsTest("cd -- with no operand"); + }); + + describe("basename", () => { + TestBuilder.command`basename -- /a/b`.stdout("b\n").stderr("").exitCode(0).runAsTest("basename -- path"); + + TestBuilder.command`basename -- -name`.stdout("-name\n").stderr("").exitCode(0).runAsTest("basename -- -name"); + + TestBuilder.command`basename --` + .stderr("usage: basename string\n") + .exitCode(1) + .runAsTest("basename -- with no operand shows usage"); + }); + + describe("dirname", () => { + TestBuilder.command`dirname -- /a/b`.stdout("/a\n").stderr("").exitCode(0).runAsTest("dirname -- path"); + + TestBuilder.command`dirname -- -dir/x`.stdout("-dir\n").stderr("").exitCode(0).runAsTest("dirname -- -dir/x"); + + TestBuilder.command`dirname --` + .stderr("usage: dirname string\n") + .exitCode(1) + .runAsTest("dirname -- with no operand shows usage"); + }); + + describe("which", () => { + TestBuilder.command`which -- bun_nope_not_a_thing` + .stdout(str => { + expect(str).not.toContain("--"); + expect(str).toContain("bun_nope_not_a_thing not found\n"); + }) + .stderr("") + .exitCode(1) + .runAsTest("which -- name does not treat -- as an operand"); + + TestBuilder.command`which -- -nope` + .stdout(str => { + expect(str).not.toContain("-- not found"); + expect(str).toContain("-nope not found\n"); + }) + .stderr("") + .exitCode(1) + .runAsTest("which -- -nope treats -nope as an operand"); + }); + + describe("yes", () => { + test("yes -- outputs 'y'", async () => { + const buffer = Buffer.alloc(6); + await $`yes -- > ${buffer}`; + expect(buffer.toString()).toEqual("y\ny\ny\n"); + }); + + test("yes -- -n outputs '-n'", async () => { + const buffer = Buffer.alloc(6); + await $`yes -- -n > ${buffer}`; + expect(buffer.toString()).toEqual("-n\n-n\n"); + }); + }); + + describe("pwd", () => { + TestBuilder.command`pwd --` + .ensureTempDir() + .stdout("$TEMP_DIR\n") + .stderr("") + .exitCode(0) + .runAsTest("pwd -- prints cwd"); + }); + + describe("exit", () => { + TestBuilder.command`exit -- 5`.stderr("").exitCode(5).runAsTest("exit -- 5"); + + TestBuilder.command`exit --`.stderr("").exitCode(0).runAsTest("exit -- with no operand exits 0"); + }); + + describe("export", () => { + TestBuilder.command`export -- FOO=bar && export --` + .stdout(str => { + expect(str).toContain("FOO=bar\n"); + expect(str).not.toContain("--="); + }) + .stderr("") + .exitCode(0) + .runAsTest("export -- name=value skips -- and does not create a var named --"); + }); +});