From 9a7d7c1a93e9ebaf38c4136ea5410f11043d29eb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:13:09 +0000 Subject: [PATCH 1/5] shell: reassigning an exported var updates the child environment --- src/runtime/shell/interpreter.rs | 13 ++++++++++++- test/js/bun/shell/bunshell.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/runtime/shell/interpreter.rs b/src/runtime/shell/interpreter.rs index 693b33fa798c..cdff21f441ec 100644 --- a/src/runtime/shell/interpreter.rs +++ b/src/runtime/shell/interpreter.rs @@ -2141,7 +2141,18 @@ impl ShellExecEnv { ) { match assign_ctx { AssignCtx::Cmd => self.cmd_local_env.insert(label, value), - AssignCtx::Shell => self.shell_env.insert(label, value), + AssignCtx::Shell => { + // POSIX: a plain assignment to a name that already carries the + // export attribute keeps it exported, so children must see the + // new value. Update the exported binding in place rather than + // shadowing it with a shell-local entry. + if let Some(existing) = self.export_env.get(label) { + existing.deref(); + self.export_env.insert(label, value); + } else { + self.shell_env.insert(label, value); + } + } AssignCtx::Exported => self.export_env.insert(label, value), } } diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts index 8082ef57b818..dfa92ad18643 100644 --- a/test/js/bun/shell/bunshell.test.ts +++ b/test/js/bun/shell/bunshell.test.ts @@ -999,6 +999,29 @@ booga" expect(procEnv).toEqual({ ...bunEnv, BUN_TEST_VAR: "1", FOO: "bar" }); }); + + // POSIX: a plain assignment to a name that already carries the export + // attribute keeps it exported, so the new value must reach child processes, + // not just the shell's own `$VAR` view. + test("reassigning an exported var updates both the shell and the child", async () => { + const env = { ...bunEnv, OUTER: "fromenv" }; + const code = "process.stdout.write('child=' + (process.env.OUTER ?? ''))"; + const { stdout } = await $`OUTER=changed; echo shell=$OUTER; ${BUN} -e ${code}`.env(env); + expect(stdout.toString()).toBe("shell=changed\nchild=changed"); + }); + + test("append idiom on an exported var reaches the child", async () => { + const env = { ...bunEnv, OUTER: "fromenv" }; + const code = "process.stdout.write(process.env.OUTER ?? '')"; + const { stdout } = await $`OUTER="$OUTER+more"; ${BUN} -e ${code}`.env(env); + expect(stdout.toString()).toBe("fromenv+more"); + }); + + test("bare assignment to a non-exported name stays shell-local", async () => { + const code = "process.stdout.write(process.env.NEWV ?? '')"; + const { stdout } = await $`NEWV=nv; echo shell=$NEWV; ${BUN} -e ${code}`.env(bunEnv); + expect(stdout.toString()).toBe("shell=nv\n"); + }); }); describe("cd & pwd", () => { From f3db27c3802fd1cd9ff11c754d5595db3955c0af Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:31:58 +0000 Subject: [PATCH 2/5] shell: clear shell-local shadow when reassignment routes to exported env --- src/runtime/shell/EnvMap.rs | 8 ++++++++ src/runtime/shell/interpreter.rs | 4 ++++ test/js/bun/shell/bunshell.test.ts | 9 +++++++++ 3 files changed, 21 insertions(+) diff --git a/src/runtime/shell/EnvMap.rs b/src/runtime/shell/EnvMap.rs index 53693524e626..efe99d567a25 100644 --- a/src/runtime/shell/EnvMap.rs +++ b/src/runtime/shell/EnvMap.rs @@ -108,6 +108,14 @@ impl EnvMap { Some(val) } + /// Removes `key` if present, dereffing the stored key and value it owned. + pub fn remove(&mut self, key: EnvStr) { + if let Some((k, v)) = self.map.fetch_swap_remove(&key) { + k.deref(); + v.deref(); + } + } + pub fn clone(&self) -> EnvMap { let new = EnvMap { map: self.map.clone().expect("OOM"), diff --git a/src/runtime/shell/interpreter.rs b/src/runtime/shell/interpreter.rs index cdff21f441ec..9ad2d371913f 100644 --- a/src/runtime/shell/interpreter.rs +++ b/src/runtime/shell/interpreter.rs @@ -2148,6 +2148,10 @@ impl ShellExecEnv { // shadowing it with a shell-local entry. if let Some(existing) = self.export_env.get(label) { existing.deref(); + // Drop any shell-local entry so it can't shadow the exported + // value: `$VAR` expansion consults `shell_env` before + // `export_env`. + self.shell_env.remove(label); self.export_env.insert(label, value); } else { self.shell_env.insert(label, value); diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts index dfa92ad18643..c66135504065 100644 --- a/test/js/bun/shell/bunshell.test.ts +++ b/test/js/bun/shell/bunshell.test.ts @@ -1022,6 +1022,15 @@ booga" const { stdout } = await $`NEWV=nv; echo shell=$NEWV; ${BUN} -e ${code}`.env(bunEnv); expect(stdout.toString()).toBe("shell=nv\n"); }); + + // A shell-local var that is later exported and then reassigned must not keep + // a stale shell-local entry shadowing the exported value: `$VAR` expansion + // checks shell_env before export_env. + test("bare reassignment after export clears the shell-local shadow", async () => { + const code = "process.stdout.write('child=' + (process.env.FOO ?? ''))"; + const { stdout } = await $`FOO=a; export FOO=b; FOO=c; echo shell=$FOO; ${BUN} -e ${code}`.env(bunEnv); + expect(stdout.toString()).toBe("shell=c\nchild=c"); + }); }); describe("cd & pwd", () => { From b2561c92f336f2bf2ee19f05484f18f04b08e18f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:02:24 +0000 Subject: [PATCH 3/5] shell: export builtin keeps values and clears the shell-local shadow --- src/runtime/shell/builtin/export.rs | 39 ++++++++++++++++++++++++----- test/js/bun/shell/bunshell.test.ts | 18 +++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/runtime/shell/builtin/export.rs b/src/runtime/shell/builtin/export.rs index 4e7d74c65cf6..d96e8abcd4b9 100644 --- a/src/runtime/shell/builtin/export.rs +++ b/src/runtime/shell/builtin/export.rs @@ -29,20 +29,47 @@ impl Export { if s.is_empty() { continue; } - let (name, value) = match s.iter().position(|&b| b == b'=') { - Some(eq) => (&s[..eq], &s[eq + 1..]), - None => (s, &b""[..]), + let eq = s.iter().position(|&b| b == b'='); + let name = match eq { + Some(eq) => &s[..eq], + None => s, }; // 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`. let label = EnvStr::dupe_ref_counted(name); - let val = EnvStr::dupe_ref_counted(value); let shell = interp.as_cmd(cmd).base.shell; // SAFETY: shell env outlives the Cmd node. - unsafe { (*shell).export_env.insert(label, val) }; + unsafe { + match eq { + Some(eq) => { + let val = EnvStr::dupe_ref_counted(&s[eq + 1..]); + // A `NAME=value` shell-local entry must not shadow the + // exported binding: `$VAR` expansion checks `shell_env` + // before `export_env`. + (*shell).shell_env.remove(label); + (*shell).export_env.insert(label, val); + val.deref(); + } + // `export NAME` gives NAME the export attribute while keeping + // its current value: promote a shell-local value rather than + // blanking it, and leave an already-exported value untouched. + None => { + if let Some(existing) = (*shell).shell_env.get(label) { + (*shell).shell_env.remove(label); + (*shell).export_env.insert(label, existing); + existing.deref(); + } else if let Some(existing) = (*shell).export_env.get(label) { + existing.deref(); + } else { + let val = EnvStr::dupe_ref_counted(b""); + (*shell).export_env.insert(label, val); + val.deref(); + } + } + } + } label.deref(); - val.deref(); } Builtin::done(interp, cmd, 0) } diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts index c66135504065..ab74c597dbfa 100644 --- a/test/js/bun/shell/bunshell.test.ts +++ b/test/js/bun/shell/bunshell.test.ts @@ -1031,6 +1031,24 @@ booga" const { stdout } = await $`FOO=a; export FOO=b; FOO=c; echo shell=$FOO; ${BUN} -e ${code}`.env(bunEnv); expect(stdout.toString()).toBe("shell=c\nchild=c"); }); + + test("export NAME=value overrides a prior shell-local value", async () => { + const code = "process.stdout.write('child=' + (process.env.FOO ?? ''))"; + const { stdout } = await $`FOO=a; export FOO=b; echo shell=$FOO; ${BUN} -e ${code}`.env(bunEnv); + expect(stdout.toString()).toBe("shell=b\nchild=b"); + }); + + test("export NAME promotes an existing shell-local value", async () => { + const code = "process.stdout.write('child=' + (process.env.FOO ?? ''))"; + const { stdout } = await $`FOO=a; export FOO; echo shell=$FOO; ${BUN} -e ${code}`.env(bunEnv); + expect(stdout.toString()).toBe("shell=a\nchild=a"); + }); + + test("export NAME leaves an already-exported value intact", async () => { + const code = "process.stdout.write('child=' + (process.env.FOO ?? ''))"; + const { stdout } = await $`export FOO=b; export FOO; echo shell=$FOO; ${BUN} -e ${code}`.env(bunEnv); + expect(stdout.toString()).toBe("shell=b\nchild=b"); + }); }); describe("cd & pwd", () => { From 99929fcc04defb7676b3acea36c8e2c0bcd40af4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:36:10 +0000 Subject: [PATCH 4/5] ci: retrigger From 7bb237c5e1cb6472ed8b32b0dcdf1a417cea0737 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:04:58 +0000 Subject: [PATCH 5/5] test: run the new shell env tests concurrently --- test/js/bun/shell/bunshell.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts index ab74c597dbfa..18730075b1a7 100644 --- a/test/js/bun/shell/bunshell.test.ts +++ b/test/js/bun/shell/bunshell.test.ts @@ -1003,21 +1003,21 @@ booga" // POSIX: a plain assignment to a name that already carries the export // attribute keeps it exported, so the new value must reach child processes, // not just the shell's own `$VAR` view. - test("reassigning an exported var updates both the shell and the child", async () => { + test.concurrent("reassigning an exported var updates both the shell and the child", async () => { const env = { ...bunEnv, OUTER: "fromenv" }; const code = "process.stdout.write('child=' + (process.env.OUTER ?? ''))"; const { stdout } = await $`OUTER=changed; echo shell=$OUTER; ${BUN} -e ${code}`.env(env); expect(stdout.toString()).toBe("shell=changed\nchild=changed"); }); - test("append idiom on an exported var reaches the child", async () => { + test.concurrent("append idiom on an exported var reaches the child", async () => { const env = { ...bunEnv, OUTER: "fromenv" }; const code = "process.stdout.write(process.env.OUTER ?? '')"; const { stdout } = await $`OUTER="$OUTER+more"; ${BUN} -e ${code}`.env(env); expect(stdout.toString()).toBe("fromenv+more"); }); - test("bare assignment to a non-exported name stays shell-local", async () => { + test.concurrent("bare assignment to a non-exported name stays shell-local", async () => { const code = "process.stdout.write(process.env.NEWV ?? '')"; const { stdout } = await $`NEWV=nv; echo shell=$NEWV; ${BUN} -e ${code}`.env(bunEnv); expect(stdout.toString()).toBe("shell=nv\n"); @@ -1026,25 +1026,25 @@ booga" // A shell-local var that is later exported and then reassigned must not keep // a stale shell-local entry shadowing the exported value: `$VAR` expansion // checks shell_env before export_env. - test("bare reassignment after export clears the shell-local shadow", async () => { + test.concurrent("bare reassignment after export clears the shell-local shadow", async () => { const code = "process.stdout.write('child=' + (process.env.FOO ?? ''))"; const { stdout } = await $`FOO=a; export FOO=b; FOO=c; echo shell=$FOO; ${BUN} -e ${code}`.env(bunEnv); expect(stdout.toString()).toBe("shell=c\nchild=c"); }); - test("export NAME=value overrides a prior shell-local value", async () => { + test.concurrent("export NAME=value overrides a prior shell-local value", async () => { const code = "process.stdout.write('child=' + (process.env.FOO ?? ''))"; const { stdout } = await $`FOO=a; export FOO=b; echo shell=$FOO; ${BUN} -e ${code}`.env(bunEnv); expect(stdout.toString()).toBe("shell=b\nchild=b"); }); - test("export NAME promotes an existing shell-local value", async () => { + test.concurrent("export NAME promotes an existing shell-local value", async () => { const code = "process.stdout.write('child=' + (process.env.FOO ?? ''))"; const { stdout } = await $`FOO=a; export FOO; echo shell=$FOO; ${BUN} -e ${code}`.env(bunEnv); expect(stdout.toString()).toBe("shell=a\nchild=a"); }); - test("export NAME leaves an already-exported value intact", async () => { + test.concurrent("export NAME leaves an already-exported value intact", async () => { const code = "process.stdout.write('child=' + (process.env.FOO ?? ''))"; const { stdout } = await $`export FOO=b; export FOO; echo shell=$FOO; ${BUN} -e ${code}`.env(bunEnv); expect(stdout.toString()).toBe("shell=b\nchild=b");