From 0fd335f722e612a7d4b233f46e83d5a64fd9fd84 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 05:23:29 +0000 Subject: [PATCH 1/9] shell: export rejects invalid identifiers The export builtin inserted every argument into the exported environment without a check on the name. `export 1abc a-b=5` put `1abc=` and `a-b=5` into the environment of child processes. Validate each name with is_valid_var_name. An invalid word is reported on stderr as "export: ``: not a valid identifier" and is not exported. The valid words are still exported. The exit code is 1 when any word was rejected, like bash. --- src/runtime/shell/builtin/export.rs | 21 ++++++++++++++++++--- src/runtime/shell/mod.rs | 2 +- src/shell_parser/lib.rs | 2 +- src/shell_parser/parse.rs | 2 +- test/js/bun/shell/bunshell.test.ts | 18 ++++++++++++++++++ 5 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/runtime/shell/builtin/export.rs b/src/runtime/shell/builtin/export.rs index 29d6b0e5c71b..984e5f1f47e6 100644 --- a/src/runtime/shell/builtin/export.rs +++ b/src/runtime/shell/builtin/export.rs @@ -1,8 +1,8 @@ -use crate::shell::EnvStr; use crate::shell::builtin::{Builtin, BuiltinState, IoKind}; use crate::shell::interpreter::{Interpreter, NodeId}; use crate::shell::io_writer::{ChildPtr, WriterTag}; use crate::shell::yield_::Yield; +use crate::shell::{EnvStr, is_valid_var_name}; use bun_collections::index_sort; #[derive(Default)] @@ -15,6 +15,7 @@ enum State { #[default] Idle, WaitingIo, + Err, Done, } @@ -25,6 +26,9 @@ impl Export { // No args: print all exported vars. return Self::print_all(interp, cmd); } + // Like bash: every invalid word is reported, the valid ones are still + // exported, and the exit code is 1 if any word was rejected. + let mut errors = Vec::new(); for i in 0..argc { let s = Builtin::of(interp, cmd).arg_bytes(i); if s.is_empty() { @@ -34,6 +38,12 @@ impl Export { Some(eq) => (&s[..eq], &s[eq + 1..]), None => (s, &b""[..]), }; + if !is_valid_var_name(name) { + errors.extend_from_slice(b"export: `"); + errors.extend_from_slice(s); + errors.extend_from_slice(b"`: not a valid identifier\n"); + continue; + } // The argv backing is freed when the Cmd retires, // so the key/value MUST be duplicated into ref-counted storage — // `init_slice` here would leave dangling EnvStr in `export_env`. @@ -45,7 +55,11 @@ impl Export { label.deref(); val.deref(); } - Builtin::done(interp, cmd, 0) + if errors.is_empty() { + return Builtin::done(interp, cmd, 0); + } + Self::state_mut(interp, cmd).state = State::Err; + Builtin::write_failing_error(interp, cmd, &errors, 1) } fn print_all(interp: &Interpreter, cmd: NodeId) -> Yield { @@ -81,7 +95,8 @@ impl Export { _: usize, err: Option, ) -> Yield { + let failed = err.is_some() || matches!(Self::state_mut(interp, cmd).state, State::Err); Self::state_mut(interp, cmd).state = State::Done; - Builtin::done(interp, cmd, err.map_or(0, |_| 1)) + Builtin::done(interp, cmd, if failed { 1 } else { 0 }) } } diff --git a/src/runtime/shell/mod.rs b/src/runtime/shell/mod.rs index 6a509b2fb00e..85a0399fb426 100644 --- a/src/runtime/shell/mod.rs +++ b/src/runtime/shell/mod.rs @@ -132,7 +132,7 @@ pub mod subproc; // ─── shell escaping (canonical impl lives in bun_shell_parser) ─────────────── // Re-export so `crate::shell::*` callers resolve without duplicating the table. -pub use bun_shell_parser::{escape_8bit, needs_escape_utf8_ascii_latin1}; +pub use bun_shell_parser::{escape_8bit, is_valid_var_name, needs_escape_utf8_ascii_latin1}; // ─── AST surface (lifetime-erased aliases over `bun_shell_parser::ast`) ────── // State nodes hold `*const ast::*` raw pointers into the bumpalo-allocated AST diff --git a/src/shell_parser/lib.rs b/src/shell_parser/lib.rs index 5ed358c87c2c..b9c5e0ce0100 100644 --- a/src/shell_parser/lib.rs +++ b/src/shell_parser/lib.rs @@ -20,5 +20,5 @@ pub mod json_fmt; pub use parse::{ JSValueRaw, LexResult, LexerError, ParseError, Parser, ast, escape_8bit, escape_bun_str, - needs_escape_bunstr, needs_escape_utf8_ascii_latin1, + is_valid_var_name, needs_escape_bunstr, needs_escape_utf8_ascii_latin1, }; diff --git a/src/shell_parser/parse.rs b/src/shell_parser/parse.rs index 5c285b84c372..5f6ff3c2357e 100644 --- a/src/shell_parser/parse.rs +++ b/src/shell_parser/parse.rs @@ -3785,7 +3785,7 @@ impl<'a, const ENCODING: StringEncoding> ShellCharIter<'a, ENCODING> { /// - a-zA-Z /// - _ /// - 0-9 (but can't be first char) -pub(crate) fn is_valid_var_name(var_name: &[u8]) -> bool { +pub fn is_valid_var_name(var_name: &[u8]) -> bool { if is_all_ascii(var_name) { return is_valid_var_name_ascii(var_name); } diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts index 2d57ca0587c9..721f66823a16 100644 --- a/test/js/bun/shell/bunshell.test.ts +++ b/test/js/bun/shell/bunshell.test.ts @@ -1398,6 +1398,24 @@ describe("deno_task", () => { TestBuilder.command`export VAR=1 VAR2=testing VAR3="test this out" && echo $VAR $VAR2 $VAR3` .stdout("1 testing test this out\n") .runAsTest("exported vars 2"); + + TestBuilder.command`export 1abc a-b=5 =x ok=1; ${BUN} -e ${"console.log(JSON.stringify([process.env['1abc'], process.env['a-b'], process.env.ok]))"}` + .stdout('[null,null,"1"]\n') + .stderr( + "export: `1abc`: not a valid identifier\n" + + "export: `a-b=5`: not a valid identifier\n" + + "export: `=x`: not a valid identifier\n", + ) + .testMini() + .runAsTest("export rejects invalid identifiers and keeps the valid ones"); + + TestBuilder.command`export 1abc` + .stderr("export: `1abc`: not a valid identifier\n") + .exitCode(1) + .testMini() + .runAsTest("export exits 1 on an invalid identifier"); + + TestBuilder.command`export _ok OK2=1 && echo done`.stdout("done\n").runAsTest("export accepts valid identifiers"); }); describe("pipeline", async () => { From 425b825f31aedb592e16d8abbaead8a5e2932d0b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 05:42:59 +0000 Subject: [PATCH 2/9] shell: export rejects an empty word too An empty argument is not a valid identifier either. Drop the skip so the same check reports it, like bash. --- src/runtime/shell/builtin/export.rs | 3 --- test/js/bun/shell/bunshell.test.ts | 5 +++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/runtime/shell/builtin/export.rs b/src/runtime/shell/builtin/export.rs index 984e5f1f47e6..a9f7cf018998 100644 --- a/src/runtime/shell/builtin/export.rs +++ b/src/runtime/shell/builtin/export.rs @@ -31,9 +31,6 @@ impl Export { let mut errors = Vec::new(); for i in 0..argc { let s = Builtin::of(interp, cmd).arg_bytes(i); - if s.is_empty() { - continue; - } let (name, value) = match bun_core::strings::index_of_char_usize(s, b'=') { Some(eq) => (&s[..eq], &s[eq + 1..]), None => (s, &b""[..]), diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts index 721f66823a16..914c3e146454 100644 --- a/test/js/bun/shell/bunshell.test.ts +++ b/test/js/bun/shell/bunshell.test.ts @@ -1399,12 +1399,13 @@ describe("deno_task", () => { .stdout("1 testing test this out\n") .runAsTest("exported vars 2"); - TestBuilder.command`export 1abc a-b=5 =x ok=1; ${BUN} -e ${"console.log(JSON.stringify([process.env['1abc'], process.env['a-b'], process.env.ok]))"}` + TestBuilder.command`export 1abc a-b=5 =x "" ok=1; ${BUN} -e ${"console.log(JSON.stringify([process.env['1abc'], process.env['a-b'], process.env.ok]))"}` .stdout('[null,null,"1"]\n') .stderr( "export: `1abc`: not a valid identifier\n" + "export: `a-b=5`: not a valid identifier\n" + - "export: `=x`: not a valid identifier\n", + "export: `=x`: not a valid identifier\n" + + "export: ``: not a valid identifier\n", ) .testMini() .runAsTest("export rejects invalid identifiers and keeps the valid ones"); From 1da25baaf1fbb30fe4ec583846dd7afcfd975242 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 05:44:34 +0000 Subject: [PATCH 3/9] shell: drop a redundant comment in the export builtin --- src/runtime/shell/builtin/export.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/runtime/shell/builtin/export.rs b/src/runtime/shell/builtin/export.rs index a9f7cf018998..bdd069f3bb44 100644 --- a/src/runtime/shell/builtin/export.rs +++ b/src/runtime/shell/builtin/export.rs @@ -26,8 +26,6 @@ impl Export { // No args: print all exported vars. return Self::print_all(interp, cmd); } - // Like bash: every invalid word is reported, the valid ones are still - // exported, and the exit code is 1 if any word was rejected. let mut errors = Vec::new(); for i in 0..argc { let s = Builtin::of(interp, cmd).arg_bytes(i); From 710ad934a97a5db5782f20b3e6f064bbd08564c0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 06:01:21 +0000 Subject: [PATCH 4/9] shell: export skips a leading -- before the identifier check POSIX scripts write `export -- NAME=value`. The identifier check would report `--` as an invalid name and exit 1. Treat a leading `--` as the end of options, like bash. --- src/runtime/shell/builtin/export.rs | 6 ++++-- test/js/bun/shell/bunshell.test.ts | 9 +++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/runtime/shell/builtin/export.rs b/src/runtime/shell/builtin/export.rs index bdd069f3bb44..03fa61e5bdc0 100644 --- a/src/runtime/shell/builtin/export.rs +++ b/src/runtime/shell/builtin/export.rs @@ -22,12 +22,14 @@ 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 { + // POSIX end-of-options marker: `export -- NAME=value`. + let start = usize::from(argc > 0 && Builtin::of(interp, cmd).arg_bytes(0) == b"--"); + if start >= argc { // No args: print all exported vars. return Self::print_all(interp, cmd); } let mut errors = Vec::new(); - for i in 0..argc { + for i in start..argc { let s = Builtin::of(interp, cmd).arg_bytes(i); let (name, value) = match bun_core::strings::index_of_char_usize(s, b'=') { Some(eq) => (&s[..eq], &s[eq + 1..]), diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts index 914c3e146454..63bc44a8bd40 100644 --- a/test/js/bun/shell/bunshell.test.ts +++ b/test/js/bun/shell/bunshell.test.ts @@ -1417,6 +1417,15 @@ describe("deno_task", () => { .runAsTest("export exits 1 on an invalid identifier"); TestBuilder.command`export _ok OK2=1 && echo done`.stdout("done\n").runAsTest("export accepts valid identifiers"); + + TestBuilder.command`export -- FOO=bar && echo $FOO && export -- && echo done` + .stdout(stdout => { + expect(stdout).toStartWith("bar\n"); + expect(stdout).toContain("FOO=bar\n"); + expect(stdout).not.toContain("--="); + expect(stdout).toEndWith("done\n"); + }) + .runAsTest("export treats a leading -- as the end of options"); }); describe("pipeline", async () => { From 51ed49570f7939c07163a7354a3ba72293c0c5f6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 06:54:45 +0000 Subject: [PATCH 5/9] shell: cover the exit code of export for an empty word and for mixed arguments --- test/js/bun/shell/bunshell.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts index 63bc44a8bd40..c32b71d475b7 100644 --- a/test/js/bun/shell/bunshell.test.ts +++ b/test/js/bun/shell/bunshell.test.ts @@ -1426,6 +1426,21 @@ describe("deno_task", () => { expect(stdout).toEndWith("done\n"); }) .runAsTest("export treats a leading -- as the end of options"); + + TestBuilder.command`export ""` + .stderr("export: ``: not a valid identifier\n") + .exitCode(1) + .runAsTest("export exits 1 on an empty word"); + + TestBuilder.command`export A=1 2B C=3 3D || echo "failed A=$A C=$C"` + .stdout("failed A=1 C=3\n") + .stderr("export: `2B`: not a valid identifier\nexport: `3D`: not a valid identifier\n") + .runAsTest("export exits 1 when a bare name is invalid and still exports the valid ones"); + + TestBuilder.command`export a-b=5 1FOO=bar OK=1 || echo "failed OK=$OK"` + .stdout("failed OK=1\n") + .stderr("export: `a-b=5`: not a valid identifier\nexport: `1FOO=bar`: not a valid identifier\n") + .runAsTest("export exits 1 when an assignment name is invalid and still exports the valid ones"); }); describe("pipeline", async () => { From 584bdc0960dc5d59fe1c90e4c03c396aa61c591e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:02:35 +0000 Subject: [PATCH 6/9] shell: cover the synchronous stderr path of export with a quiet run --- test/js/bun/shell/bunshell.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts index c32b71d475b7..ad7fe61cd79b 100644 --- a/test/js/bun/shell/bunshell.test.ts +++ b/test/js/bun/shell/bunshell.test.ts @@ -1416,6 +1416,14 @@ describe("deno_task", () => { .testMini() .runAsTest("export exits 1 on an invalid identifier"); + // `.quiet()` keeps stderr as an in-memory buffer, so the builtin takes the + // synchronous write path instead of the async fd write the other runs use. + TestBuilder.command`export 1abc` + .stderr("export: `1abc`: not a valid identifier\n") + .exitCode(1) + .quiet() + .runAsTest("export exits 1 on an invalid identifier with quiet output"); + TestBuilder.command`export _ok OK2=1 && echo done`.stdout("done\n").runAsTest("export accepts valid identifiers"); TestBuilder.command`export -- FOO=bar && echo $FOO && export -- && echo done` From 510edee36b3e2c29cdbe5b3eda49c5a845a1a997 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:36:45 +0000 Subject: [PATCH 7/9] shell: consume the write error in Export::on_io_writer_chunk clippy denies needless_pass_by_value on the err argument. A write error keeps precedence over the identifier failure. --- src/runtime/shell/builtin/export.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/runtime/shell/builtin/export.rs b/src/runtime/shell/builtin/export.rs index 03fa61e5bdc0..f862d61ed568 100644 --- a/src/runtime/shell/builtin/export.rs +++ b/src/runtime/shell/builtin/export.rs @@ -2,7 +2,7 @@ use crate::shell::builtin::{Builtin, BuiltinState, IoKind}; use crate::shell::interpreter::{Interpreter, NodeId}; use crate::shell::io_writer::{ChildPtr, WriterTag}; use crate::shell::yield_::Yield; -use crate::shell::{EnvStr, is_valid_var_name}; +use crate::shell::{EnvStr, ExitCode, is_valid_var_name}; use bun_collections::index_sort; #[derive(Default)] @@ -92,8 +92,15 @@ impl Export { _: usize, err: Option, ) -> Yield { - let failed = err.is_some() || matches!(Self::state_mut(interp, cmd).state, State::Err); + let failed = matches!(Self::state_mut(interp, cmd).state, State::Err); Self::state_mut(interp, cmd).state = State::Done; - Builtin::done(interp, cmd, if failed { 1 } else { 0 }) + Builtin::done( + interp, + cmd, + match err { + Some(_err) => 1, + None => ExitCode::from(failed), + }, + ) } } From 06ef49dbc5783049828211eb9b0cf6821e274fbe Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:30:01 +0000 Subject: [PATCH 8/9] shell: export refuses invalid bare names, an empty name and a leading option A first word that starts with `-` (other than `--`) refuses the whole command, so `export -n NAME` no longer stores an empty value in NAME. A word without `=` must be an identifier, and a word must not start with `=`. A refused word prints one line, the command exits 1, and the `NAME=value` operands around it are still exported. After a refused word, a bare NAME in the same command is left alone. The name of a `NAME=value` operand is not checked, as before. An empty word is skipped again, as before. --- src/runtime/shell/builtin/export.rs | 58 ++++++--- src/runtime/shell/mod.rs | 4 +- test/js/bun/shell/bunshell.test.ts | 185 +++++++++++++++++++++------- 3 files changed, 184 insertions(+), 63 deletions(-) diff --git a/src/runtime/shell/builtin/export.rs b/src/runtime/shell/builtin/export.rs index bab029c2706f..5bf696bfcdf7 100644 --- a/src/runtime/shell/builtin/export.rs +++ b/src/runtime/shell/builtin/export.rs @@ -2,8 +2,9 @@ use crate::shell::builtin::{Builtin, BuiltinState, IoKind}; use crate::shell::interpreter::{Interpreter, NodeId}; use crate::shell::io_writer::{ChildPtr, WriterTag}; use crate::shell::yield_::Yield; -use crate::shell::{EnvStr, ExitCode, is_valid_var_name}; +use crate::shell::{EnvStr, ExitCode}; use bun_collections::index_sort; +use bun_shell_parser::is_valid_var_name; #[derive(Default)] pub(crate) struct Export { @@ -22,25 +23,39 @@ enum State { impl Export { pub(crate) fn start(interp: &Interpreter, cmd: NodeId) -> Yield { let argc = Builtin::of(interp, cmd).args_slice().len(); - // POSIX end-of-options marker: `export -- NAME=value`. - let start = usize::from(argc > 0 && Builtin::of(interp, cmd).arg_bytes(0) == b"--"); - if start >= argc { - // No args: print all exported vars. - return Self::print_all(interp, cmd); - } + let mut has_operand = false; let mut errors = Vec::new(); - for i in start..argc { + for i in 0..argc { let s = Builtin::of(interp, cmd).arg_bytes(i); - let (name, value) = match bun_core::strings::index_of_char_usize(s, b'=') { - Some(eq) => (&s[..eq], &s[eq + 1..]), - None => (s, &b""[..]), - }; - if !is_valid_var_name(name) { - errors.extend_from_slice(b"export: `"); - errors.extend_from_slice(s); - errors.extend_from_slice(b"`: not a valid identifier\n"); + if i == 0 && s.len() > 1 && s[0] == b'-' { + if s == b"--" { + continue; + } + // No option is supported. `export -n NAME` must not reach NAME. + let end = bun_core::strings::index_of_char_usize(s, b'=').unwrap_or(s.len()); + let msg = [b"export: ".as_slice(), &s[..end], b": invalid option\n"].concat(); + return Self::fail(interp, cmd, &msg); + } + has_operand = true; + if s.is_empty() { continue; } + let (name, value) = match bun_core::strings::index_of_char_usize(s, b'=') { + Some(eq) if eq > 0 => (&s[..eq], &s[eq + 1..]), + None if is_valid_var_name(s) => { + // A bare NAME stores an empty value (#45371). Do not do that after a refused word. + if !errors.is_empty() { + continue; + } + (s, &b""[..]) + } + refused => { + errors.extend_from_slice(b"export: `"); + errors.extend_from_slice(if refused.is_some() { b"=" } else { s }); + errors.extend_from_slice(b"`: not a valid identifier\n"); + continue; + } + }; // The argv backing is freed when the Cmd retires, // so the key/value MUST be duplicated into ref-counted storage — // `init_slice` here would leave dangling EnvStr in `export_env`. @@ -55,11 +70,20 @@ impl Export { label.deref(); val.deref(); } + if !has_operand { + // No operand: print all exported vars. + return Self::print_all(interp, cmd); + } if errors.is_empty() { return Builtin::done(interp, cmd, 0); } + Self::fail(interp, cmd, &errors) + } + + /// Write `msg` to stderr, then finish with exit code 1. + fn fail(interp: &Interpreter, cmd: NodeId, msg: &[u8]) -> Yield { Self::state_mut(interp, cmd).state = State::Err; - Builtin::write_failing_error(interp, cmd, &errors, 1) + Builtin::write_failing_error(interp, cmd, msg, 1) } fn print_all(interp: &Interpreter, cmd: NodeId) -> Yield { diff --git a/src/runtime/shell/mod.rs b/src/runtime/shell/mod.rs index 432a9872cc4e..c7402b5448b9 100644 --- a/src/runtime/shell/mod.rs +++ b/src/runtime/shell/mod.rs @@ -130,9 +130,7 @@ pub(crate) mod subproc; // ─── shell escaping (canonical impl lives in bun_shell_parser) ─────────────── // Re-export so `crate::shell::*` callers resolve without duplicating the table. -pub(crate) use bun_shell_parser::{ - escape_8bit, is_valid_var_name, needs_escape_utf8_ascii_latin1, -}; +pub(crate) use bun_shell_parser::{escape_8bit, needs_escape_utf8_ascii_latin1}; // ─── AST surface (lifetime-erased aliases over `bun_shell_parser::ast`) ────── // State nodes hold `*const ast::*` raw pointers into the bumpalo-allocated AST diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts index 34af99ba87d2..3fe97c6e308a 100644 --- a/test/js/bun/shell/bunshell.test.ts +++ b/test/js/bun/shell/bunshell.test.ts @@ -1669,56 +1669,155 @@ describe("deno_task", () => { .stdout("SPLIT=[hello]\n") .runAsTest("export splits a non-assignment word"); - TestBuilder.command`export 1abc a-b=5 =x "" ok=1; ${BUN} -e ${"console.log(JSON.stringify([process.env['1abc'], process.env['a-b'], process.env.ok]))"}` - .stdout('[null,null,"1"]\n') - .stderr( - "export: `1abc`: not a valid identifier\n" + - "export: `a-b=5`: not a valid identifier\n" + - "export: `=x`: not a valid identifier\n" + - "export: ``: not a valid identifier\n", - ) - .testMini() - .runAsTest("export rejects invalid identifiers and keeps the valid ones"); + describe("export refuses a name that is not an identifier", () => { + const notValid = (name: string) => `export: \`${name}\`: not a valid identifier\n`; + const invalidOption = (word: string) => `export: ${word}: invalid option\n`; + const env = { A: "one" }; + + // [script, stderr, exit code, the exported variables after the script] + const rows: [string, string, number, Record][] = [ + ["export a-b", notValid("a-b"), 1, { A: "one" }], + ["export 1a", notValid("1a"), 1, { A: "one" }], + ['export "a b"', notValid("a b"), 1, { A: "one" }], + ["export a.b", notValid("a.b"), 1, { A: "one" }], + ["export é", notValid("é"), 1, { A: "one" }], + ["export aé", notValid("aé"), 1, { A: "one" }], + ["export -", notValid("-"), 1, { A: "one" }], + ["X=a-b; export $X", notValid("a-b"), 1, { A: "one" }], + ["export $(echo a-b)", notValid("a-b"), 1, { A: "one" }], + ["export b{-c,=1}", notValid("b-c"), 1, { A: "one", b: "1" }], + ['"export" a-b', notValid("a-b"), 1, { A: "one" }], + ["E=export; $E a-b", notValid("a-b"), 1, { A: "one" }], + ["(export a-b)", notValid("a-b"), 1, { A: "one" }], + ["export a-b | cat", notValid("a-b"), 0, { A: "one" }], + ["export a-b 2> /dev/null", "", 1, { A: "one" }], + + // An empty name is refused. The message does not have the value. + ["export =x", notValid("="), 1, { A: "one" }], + ["export $UNSET=1", notValid("="), 1, { A: "one" }], + ['export =$(echo "x B=2")', notValid("="), 1, { A: "one", B: "2" }], + + // A refused word does not stop the `NAME=value` operands around it. + ["export B=1 a-b D=2", notValid("a-b"), 1, { A: "one", B: "1", D: "2" }], + ["export a-b A=", notValid("a-b"), 1, { A: "" }], + + // A bare NAME stores an empty value. After a refused word it is left alone. + ["export a-b A", notValid("a-b"), 1, { A: "one" }], + ["export B=1 -n A", notValid("-n"), 1, { A: "one", B: "1" }], + ["X=5; export a-b X", notValid("a-b"), 1, { A: "one" }], + + // `export` has no options. A leading option refuses the whole command. + ["export -x", invalidOption("-x"), 1, { A: "one" }], + ["export -n A", invalidOption("-n"), 1, { A: "one" }], + ["export -x B=1", invalidOption("-x"), 1, { A: "one" }], + ["export -X=1", invalidOption("-X"), 1, { A: "one" }], + ["export -- B=1", "", 0, { A: "one", B: "1" }], + ["export -- -n A", notValid("-n"), 1, { A: "one" }], + + // Valid input. The name of a `NAME=value` operand is not checked, only that it is not empty. + ["export _b=1 B2=2", "", 0, { A: "one", _b: "1", B2: "2" }], + ["export B= C=a=b", "", 0, { A: "one", B: "", C: "a=b" }], + ["export a-b=1 a.b=c=d", "", 0, { A: "one", "a-b": "1", "a.b": "c=d" }], + ['export "" B=1', "", 0, { A: "one", B: "1" }], + ['export ""', "", 0, { A: "one" }], + ['export -- ""', "", 0, { A: "one" }], + ]; + + test.each(rows)("%s", async (script, stderr, exitCode, exported) => { + // Without `.quiet()` stderr is a file descriptor: the builtin finishes + // in the callback of the write. With `.quiet()` stderr is a buffer. + for (const quiet of [false, true]) { + const shell = $`${{ raw: script }}`.env(env).nothrow(); + const result = await (quiet ? shell.quiet() : shell); + expect({ + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + exitCode: result.exitCode, + }).toEqual({ stdout: "", stderr, exitCode }); + } - TestBuilder.command`export 1abc` - .stderr("export: `1abc`: not a valid identifier\n") - .exitCode(1) - .testMini() - .runAsTest("export exits 1 on an invalid identifier"); + // `export` with no operand prints each exported variable as `NAME=value`. + // PWD is in the list because this file calls `$.cwd()`. + const { stdout } = await $`${{ raw: script }}; export`.env(env).nothrow().quiet(); + const { PWD, ...listed } = Object.fromEntries( + stdout + .toString() + .split("\n") + .filter(line => line.length > 0) + .map(line => [line.slice(0, line.indexOf("=")), line.slice(line.indexOf("=") + 1)]), + ); + expect(listed).toEqual(exported); + }); - // `.quiet()` keeps stderr as an in-memory buffer, so the builtin takes the - // synchronous write path instead of the async fd write the other runs use. - TestBuilder.command`export 1abc` - .stderr("export: `1abc`: not a valid identifier\n") - .exitCode(1) - .quiet() - .runAsTest("export exits 1 on an invalid identifier with quiet output"); + test("a child process does not get a refused name", async () => { + const printEnv = `console.log(JSON.stringify(["a-b", "a b", "-n", "A", "D"].map(name => [name, process.env[name]])))`; + const { stdout, stderr } = await $`export a-b D=1 ${"a b"}; export -n A; ${BUN} -e ${printEnv}` + .env({ ...bunEnv, A: "one" }) + .nothrow() + .quiet(); + expect(stderr.toString()).toBe(notValid("a-b") + notValid("a b") + invalidOption("-n")); + expect(JSON.parse(stdout.toString())).toEqual([ + ["a-b", null], + ["a b", null], + ["-n", null], + ["A", "one"], + ["D", "1"], + ]); + }); - TestBuilder.command`export _ok OK2=1 && echo done`.stdout("done\n").runAsTest("export accepts valid identifiers"); + test("the message follows a redirect of stderr", async () => { + using dir = tempDir("export-stderr", {}); + const toStdout = await $`export a-b 2>&1`.env(env).nothrow().quiet(); + expect({ + stdout: toStdout.stdout.toString(), + stderr: toStdout.stderr.toString(), + exitCode: toStdout.exitCode, + }).toEqual({ stdout: notValid("a-b"), stderr: "", exitCode: 1 }); + + const substituted = await $`echo "$(export a-b 2>&1)"`.env(env).nothrow().quiet(); + expect(substituted.stdout.toString()).toBe(notValid("a-b")); + + const toFile = await $`export a-b 2> err.txt`.env(env).cwd(String(dir)).nothrow().quiet(); + expect(await Bun.file(join(String(dir), "err.txt")).text()).toBe(notValid("a-b")); + expect(toFile.exitCode).toBe(1); + }); - TestBuilder.command`export -- FOO=bar && echo $FOO && export -- && echo done` - .stdout(stdout => { - expect(stdout).toStartWith("bar\n"); - expect(stdout).toContain("FOO=bar\n"); - expect(stdout).not.toContain("--="); - expect(stdout).toEndWith("done\n"); - }) - .runAsTest("export treats a leading -- as the end of options"); + test("an empty word from a JavaScript value is skipped", async () => { + const { stdout, stderr } = await $`export ${""} B=1 && echo $B`.env(env).quiet(); + expect({ stdout: stdout.toString(), stderr: stderr.toString() }).toEqual({ stdout: "1\n", stderr: "" }); + }); - TestBuilder.command`export ""` - .stderr("export: ``: not a valid identifier\n") - .exitCode(1) - .runAsTest("export exits 1 on an empty word"); + test("`export --` prints the exported variables", async () => { + const { stdout, stderr } = await $`export --`.env(env).quiet(); + expect(stdout.toString()).toStartWith("A=one\n"); + expect(stderr.toString()).toBe(""); + }); + + TestBuilder.command`export 1abc` + .stderr(notValid("1abc")) + .exitCode(1) + .testMini({ onlyMini: true }) + .runAsTest("in a script that `bun run` executes"); + + TestBuilder.command`export A=1 2B C=3 3D || echo "failed A=$A C=$C"` + .stdout("failed A=1 C=3\n") + .stderr(notValid("2B") + notValid("3D")) + .runAsTest("the next command of an || list runs"); - TestBuilder.command`export A=1 2B C=3 3D || echo "failed A=$A C=$C"` - .stdout("failed A=1 C=3\n") - .stderr("export: `2B`: not a valid identifier\nexport: `3D`: not a valid identifier\n") - .runAsTest("export exits 1 when a bare name is invalid and still exports the valid ones"); + TestBuilder.command`export NODE_ENV = test && echo ran` + .stderr(notValid("=")) + .exitCode(1) + .runAsTest("spaces around = stop an && list"); - TestBuilder.command`export a-b=5 1FOO=bar OK=1 || echo "failed OK=$OK"` - .stdout("failed OK=1\n") - .stderr("export: `a-b=5`: not a valid identifier\nexport: `1FOO=bar`: not a valid identifier\n") - .runAsTest("export exits 1 when an assignment name is invalid and still exports the valid ones"); + TestBuilder.command`B=2 export -x B; echo "[$B]"` + .stdout("[]\n") + .stderr(invalidOption("-x")) + .runAsTest("a refused option does not apply a prefix assignment"); + + TestBuilder.command`export _ok OK2=1 && echo done` + .stdout("done\n") + .runAsTest("a valid bare name and a valid assignment exit 0"); + }); }); describe("pipeline", async () => { From 04e82fdb90bccf8fa8be866af47a78543d16c6f5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:05:03 +0000 Subject: [PATCH 9/9] shell: pin the result of a bare export name before a refused word --- test/js/bun/shell/bunshell.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts index 3fe97c6e308a..99c77225653c 100644 --- a/test/js/bun/shell/bunshell.test.ts +++ b/test/js/bun/shell/bunshell.test.ts @@ -1705,6 +1705,8 @@ describe("deno_task", () => { ["export a-b A", notValid("a-b"), 1, { A: "one" }], ["export B=1 -n A", notValid("-n"), 1, { A: "one", B: "1" }], ["X=5; export a-b X", notValid("a-b"), 1, { A: "one" }], + // Before a refused word it is stored, as it is without one. + ["export A a-b", notValid("a-b"), 1, { A: "" }], // `export` has no options. A leading option refuses the whole command. ["export -x", invalidOption("-x"), 1, { A: "one" }],