Skip to content
Open
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
35 changes: 27 additions & 8 deletions src/runtime/shell/builtin/export.rs
Original file line number Diff line number Diff line change
@@ -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, ExitCode, is_valid_var_name};
use bun_collections::index_sort;

#[derive(Default)]
Expand All @@ -15,25 +15,32 @@ enum State {
#[default]
Idle,
WaitingIo,
Err,
Done,
}

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);
}
for i in 0..argc {
let mut errors = Vec::new();
for i in start..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""[..]),
};
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;
}
Comment thread
robobun marked this conversation as resolved.
// 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`.
Expand All @@ -45,7 +52,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 {
Expand Down Expand Up @@ -81,7 +92,15 @@ impl Export {
_: usize,
err: Option<bun_sys::SystemError>,
) -> Yield {
let failed = 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,
match err {
Some(_err) => 1,
None => ExitCode::from(failed),
},
)
}
}
2 changes: 1 addition & 1 deletion src/runtime/shell/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/shell_parser/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
2 changes: 1 addition & 1 deletion src/shell_parser/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
51 changes: 51 additions & 0 deletions test/js/bun/shell/bunshell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1398,6 +1398,57 @@ 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" +
"export: ``: 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");

// `.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`
.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");

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 () => {
Expand Down
Loading