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
2 changes: 2 additions & 0 deletions docs/guides/install/custom-registry.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ registry = "https://registry.npmjs.org"

Your `bunfig.toml` can reference environment variables. `bun install` automatically loads environment variables from `.env.production.local`, `.env.local`, `.env.production`, and `.env`, regardless of `NODE_ENV`. It does not read `.env.development` or `.env.test`. See [Environment variables](/runtime/environment-variables).

`bun install` does not take proxy settings (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`) or `NODE_TLS_REJECT_UNAUTHORIZED` from these files. The `git` that it runs for a git dependency sees none of their variables, such as `GIT_SSH_COMMAND` or `SSH_AUTH_SOCK`. Set those in the environment that runs `bun install`.

```toml bunfig.toml icon="settings"
[install]
registry = { url = "https://registry.npmjs.org", token = "$npm_token" }
Expand Down
2 changes: 2 additions & 0 deletions docs/guides/install/registry-scope.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ To configure a registry for a particular npm scope:

Your `bunfig.toml` can reference environment variables. `bun install` automatically loads environment variables from `.env.production.local`, `.env.local`, `.env.production`, and `.env`, regardless of `NODE_ENV`. It does not read `.env.development` or `.env.test`. See [Environment variables](/runtime/environment-variables).

`bun install` does not take proxy settings (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`) or `NODE_TLS_REJECT_UNAUTHORIZED` from these files. The `git` that it runs for a git dependency sees none of their variables, such as `GIT_SSH_COMMAND` or `SSH_AUTH_SOCK`. Set those in the environment that runs `bun install`.

```toml bunfig.toml icon="settings"
[install.scopes]
"@myorg3" = { token = "$npm_token", url = "https://registry.myorg.com/" }
Expand Down
81 changes: 76 additions & 5 deletions src/install/PackageManager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,10 @@ pub struct PackageManager {
// Set once in `init()`/`init_with_runtime()` to the process-singleton
// `DotEnv.Loader` (leaked allocation; outlives the manager). `BackRef`
// encapsulates the liveness invariant so `env()` is a safe accessor.
// Holds the project's `.env*` files too: git, proxy and TLS settings come from `tooling_env()`.
pub env: Option<bun_ptr::BackRef<dot_env::Loader, bun_ptr::Mut>>,
/// The process environment with no `.env*` file merged in. `None` for the runtime's auto-install.
pub(crate) process_env: Option<dot_env::Loader>,
pub progress: Progress,
pub(crate) downloads_node: Option<*mut ProgressNode>, // BORROW_FIELD — points into self.progress
pub scripts_node: Option<NonNull<ProgressNode>>, // points to a caller stack-local Progress node; only valid while that caller frame is live
Expand Down Expand Up @@ -886,14 +889,51 @@ impl PackageManager {
Ok(unsafe { &mut *ptr })
}

/// The environment of the git children and of the proxy / TLS settings.
pub(crate) fn tooling_env(&self) -> &dot_env::Loader {
self.process_env.as_ref().unwrap_or_else(|| self.env())
}

pub fn http_proxy(&self, url: &URL<'_>) -> Option<URL<'static>> {
// `env_mut()` yields an unbounded `&'a Loader` (process-lifetime
// singleton), so the returned `URL<'_>` borrows for `'static`.
self.env_mut().get_http_proxy_for(url)
// SAFETY: the manager and the loader behind `env` are leaked
// process-lifetime singletons, and `process_env` is never written
// after `init()`.
let env: &'static dot_env::Loader =
unsafe { bun_ptr::detach_lifetime_ref(self.tooling_env()) };
env.get_http_proxy_for(url)
Comment on lines 897 to +903

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 pre-existing, not blocking: Pre-existing (memory safety): a script that writes a proxy variable to process.env while an auto-install request is pending can make the HTTP thread dial a freed buffer. http_proxy() at src/install/PackageManager.rs:902 detaches the loader reference to 'static, but on the runtime path tooling_env() is the VM loader, whose map value Bun__setEnvValue replaces and frees (src/runtime/api/BunObject.rs:2051, :2071). Fix: make the install's requests own their proxy bytes, as fetch() does with url_proxy_buffer, so the URL stored in NetworkTask.rs:666 and :887 never borrows the env map, and state that invariant in the SAFETY comment instead of "process_env is never written after init()", which does not cover the env() fallback.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A bun app.ts run without node_modules imports a package that auto-install must fetch; the resolver enters init_with_runtime_once, which sets process_env to None (src/install/PackageManager.rs:2584), so tooling_env() (:893-895) returns env(), the VM's transpiler loader. http_proxy() (:897-903) laundered that reference to 'static and returns a URL<'static> whose hostname slice points into the HashTableValue Box of the HTTPS_PROXY entry (src/dotenv/env_loader.rs:325-331). NetworkTask::for_manifest and for_tarball store it in the AsyncHTTP (src/install/NetworkTask.rs:666, :887); the HTTP thread dials url.hostname from it (src/http/HTTPThread.rs:506). Auto-install is asynchronous in the runtime, so JS can run process.env.HTTPS_PROXY = other meanwhile; Bun__setEnvValue puts a new Box or removes the entry (src/runtime/api/BunObject.rs:2051, :2071), freeing the bytes the pending request still points at. The base branch had the same borrow through env_mut(); the diff rewrites the function and adds a SAFETY comment whose stated invariant covers only process_env, not this fallback.

Verification: pre-existing (memory safety; the base reaches the same freed bytes by the same route). JS writes a proxy variable to process.env while an auto-install request is in flight. Bun__setEnvValue (src/runtime/api/BunObject.rs:2047-2071) writes vm.transpiler.env_mut().map, freeing the Box<[u8]> the HTTP thread's client.http_proxy borrows. The SAFETY comment does not cover the runtime path.

}

pub fn tls_reject_unauthorized(&self) -> bool {
self.env().get_tls_reject_unauthorized()
self.tooling_env().get_tls_reject_unauthorized()
}

/// After a failed request or git command: names, once, the settings that only `.env*` has.
pub fn note_dotenv_only_vars(&self, kind: ProcessOnlyEnv) {
static NOTED: [AtomicBool; 2] = [AtomicBool::new(false), AtomicBool::new(false)];
let Some(process_env) = &self.process_env else {
return;
};
if NOTED[kind as usize].swap(true, Ordering::SeqCst) {
return;
}
let mut names: Vec<u8> = Vec::new();
for key in self.env().map.map.keys() {
if kind.reads(key) && process_env.get(key).is_none() {
if !names.is_empty() {
names.extend_from_slice(b", ");
}
names.extend_from_slice(key);
}
}
if names.is_empty() {
return;
}
bun_core::note!(
"bun {} reads <b>{}<r> from the environment only, not from .env files.",
<&'static str>::from(self.subcommand),
bstr::BStr::new(&names),
);
Output::flush();
}

pub(crate) fn fail_root_resolution(
Expand Down Expand Up @@ -1326,6 +1366,33 @@ fn http_thread_on_init_error(err: http::InitError, opts: &http::http_thread::Ini
// allocate / get singleton
// ──────────────────────────────────────────────────────────────────────────

/// The settings that `PackageManager::process_env` serves.
#[derive(Clone, Copy)]
pub enum ProcessOnlyEnv {
Network,
Git,
}

impl ProcessOnlyEnv {
fn reads(self, key: &[u8]) -> bool {
match self {
Self::Network => [
b"http_proxy" as &[u8],
b"HTTP_PROXY",
b"https_proxy",
b"HTTPS_PROXY",
b"all_proxy",
b"ALL_PROXY",
b"no_proxy",
b"NO_PROXY",
b"NODE_TLS_REJECT_UNAUTHORIZED",
Comment thread
robobun marked this conversation as resolved.
]
.contains(&key),
Self::Git => key.starts_with(b"GIT_") || key.starts_with(b"SSH_"),
}
Comment thread
robobun marked this conversation as resolved.
}
}

fn allocate_package_manager() {
// Uninitialized memory, abort-on-OOM. The init() functions below write the full struct via
// `core::ptr::write` (no Drop on the uninit bytes).
Expand Down Expand Up @@ -1893,6 +1960,8 @@ pub fn init(
};

env.load_process()?;
let mut process_env = dot_env::Loader::init();
process_env.load_process()?;
// Copy the listing's basenames out under `entries_mutex`; `.data` must
// only be probed while the lock is held.
let env_probe_keys = {
Expand Down Expand Up @@ -2051,6 +2120,7 @@ pub fn init(
// reads. `BackRef` stores a raw pointer —
// ending the reborrow here does not alias the later uses.
wr!(env, Some(bun_ptr::BackRef::new_mut(&mut *env)));
wr!(process_env, Some(process_env));
wr!(
thread_pool,
ThreadPool::init(thread_pool::Config {
Expand Down Expand Up @@ -2314,7 +2384,7 @@ pub fn init(
}

// If any HTTP proxy is set, use a diferent limit
if env.has_http_proxy() {
if mgr_ref.tooling_env().has_http_proxy() {
break 'brk DEFAULT_MAX_SIMULTANEOUS_REQUESTS_FOR_BUN_INSTALL_FOR_PROXIES;
}

Expand Down Expand Up @@ -2511,6 +2581,7 @@ fn init_with_runtime_once(
// reads. `BackRef` stores a raw pointer —
// ending the reborrow here does not alias the later uses.
wr!(env, Some(bun_ptr::BackRef::new_mut(&mut *env)));
wr!(process_env, None);
wr!(
thread_pool,
ThreadPool::init(thread_pool::Config {
Expand Down
2 changes: 1 addition & 1 deletion src/install/PackageManager/install_with_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ pub fn install_with_manager(

// Start resolving DNS for the default registry immediately.
// Unless you're behind a proxy.
if !manager.env().has_http_proxy()
if !manager.tooling_env().has_http_proxy()
&& manager.options.offline != crate::package_manager_real::options::OfflineMode::Offline
{
// And don't try to resolve DNS if it's an IP address.
Expand Down
4 changes: 3 additions & 1 deletion src/install/PackageManager/runTasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ use bun_install::{
};
// Import the *module* under the `Task` name so `Task::Id` resolves as a path.
use super::{
Command, PackageInstaller, PackageManager, ProgressStrings, Subcommand, TaskCallbackList,
Command, PackageInstaller, PackageManager, ProcessOnlyEnv, ProgressStrings, Subcommand,
TaskCallbackList,
};
use super::{directories, enqueue};
use crate::dependency::Behavior;
Expand Down Expand Up @@ -1977,6 +1978,7 @@ fn throttle_after_network_error(manager: &PackageManager, has_network_error: &mu
if core::mem::replace(has_network_error, true) {
return;
}
manager.note_dotenv_only_vars(ProcessOnlyEnv::Network);
let min = manager.options.min_simultaneous_requests;
let max = AsyncHTTP::max_simultaneous_requests().load(Ordering::Relaxed);
if max > min {
Expand Down
10 changes: 7 additions & 3 deletions src/install/git_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ use bun_sys::windows::libuv as uv;
use bun_threading::thread_pool as ThreadPool;

use crate::install::{ExtractData, ExtractDataJson};
use crate::package_manager_real::ProcessOnlyEnv;
use crate::package_manager_task::{self as Task, Tag};
use crate::repository::{GitEnv, Repository, RepositoryExt as _, is_safe_resolved_tag};
use crate::{Error, PackageManager};
Expand Down Expand Up @@ -593,7 +594,7 @@ impl GitSubprocess {
/// Spawns `git <args>` with stdout and stderr captured. On `Ok` the child
/// may already have exited and freed `this`.
fn spawn(this: ThisPtr<Self>, args: &[&[u8]]) -> Result<(), Error> {
let env = GitEnv::get(this.manager().env_mut());
let env = GitEnv::get(this.manager().tooling_env());
let Some(git) = &env.git else {
this.log_error(format_args!(
"\"git\" is not installed (needed for \"{}\")",
Expand Down Expand Up @@ -776,8 +777,11 @@ impl GitSubprocess {
&& strings::contains(stderr, b"not")
&& strings::contains(stderr, b"found"))
|| strings::contains(stderr, b"does not exist"));
if !ok && !not_found {
this.report_failure(status, stderr);
if !ok {
if !not_found {
this.report_failure(status, stderr);
}
this.manager().note_dotenv_only_vars(ProcessOnlyEnv::Git);
Comment on lines +780 to +784

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) Users whose install succeeds can still get the new note: bun install reads GIT_SSH_COMMAND ... from the environment only line, which the PR says never happens. git_runner.rs:784 prints it for every failed git command, including the https attempt that precedes the ssh retry for a git+ssh:// dependency (urls are popped https first, git_runner.rs:451-497). runTasks.rs:1990 likewise prints the network note on a transient download failure that the retry then recovers. Fix: print the note only when the failure is terminal, i.e. in the branches that call Self::fail/log_error after the URL forms are exhausted, and for network errors only when the task is not re-queued for retry; this covers both sites.

Why this was flagged

A project .env sets any GIT_* or SSH_* variable and package.json has a private git+ssh:// dependency. spawn_clone pops the https form first (src/install/git_runner.rs:451-454 push ssh then https, :496 pops). Without credentials git fails on https with a non-"not found" error, so on_command_exit enters if !ok at src/install/git_runner.rs:780 and calls note_dotenv_only_vars(ProcessOnlyEnv::Git) at :784 before the Step::Clone arm at :800 retries the ssh form, which succeeds. The user sees the note on an install that exits 0; the base branch prints only the pre-existing git stderr for the first attempt. The same happens at src/install/PackageManager/runTasks.rs:1990: throttle_after_network_error runs on the first download_failed even when retry re-queues the task and it then succeeds, so a project with HTTP_PROXY in .env and one connection reset sees the network note on a successful install. Nothing gates the note on the failure being final.

Verification: nit. A project .env* sets a GIT_*/SSH_* name the process env lacks, and a git+ssh:// dependency's https form fails with a non-"not found" error. The block at src/install/git_runner.rs:780-785 runs note_dotenv_only_vars(ProcessOnlyEnv::Git) for every !ok exit, before the Step::Clone arm at :796-809 retries the ssh URL. The ssh fallback succeeds, the install exits 0, yet the note is printed.

}
let name = BStr::new(&this.name);
match step {
Expand Down
4 changes: 2 additions & 2 deletions src/install/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,8 +314,8 @@ pub use tarball_stream::TarballStream;
// `package_manager_real` so `crate::PackageManager` and
// `package_manager_real::PackageManager` are the SAME type.
pub use package_manager_real::{
AsyncNetworkTaskQueue, CommandLineArguments, PackageManager, PatchTaskQueue, RootPackageId,
Subcommand,
AsyncNetworkTaskQueue, CommandLineArguments, PackageManager, PatchTaskQueue, ProcessOnlyEnv,
RootPackageId, Subcommand,
};

// ──────────────────────────────────────────────────────────────────────────
Expand Down
9 changes: 5 additions & 4 deletions src/install/repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ impl SloppyGlobalGitConfig {
pub use bun_install_types::resolver_hooks::Repository;

/// The environment every `git` the install spawns runs with (see `GitEnv::get`).
/// Built from `PackageManager::tooling_env`: the `.env*` files that `bun install` loads must not reach git.
pub(crate) struct GitEnv {
/// `KEY=VALUE\0` array for spawn.
pub(crate) envp: bun_dotenv::NullDelimitedEnvMap,
Expand All @@ -147,21 +148,21 @@ pub(crate) struct GitEnv {
static GIT_ENV: bun_core::RacyCell<Option<GitEnv>> = bun_core::RacyCell::new(None);

impl GitEnv {
pub(crate) fn get(loader: &mut bun_dotenv::Loader) -> &'static GitEnv {
pub(crate) fn get(tooling_env: &bun_dotenv::Loader) -> &'static GitEnv {
let slot = GIT_ENV.get();
// SAFETY: only the install thread reaches this. The slot is written
// once, before any reference into it exists, and never reassigned.
unsafe {
if (*slot).is_none() {
*slot = Some(Self::init(loader));
*slot = Some(Self::init(tooling_env));
}
(*slot).as_ref().unwrap()
}
}

fn init(loader: &mut bun_dotenv::Loader) -> GitEnv {
fn init(tooling_env: &bun_dotenv::Loader) -> GitEnv {
// No prompts by default: the install's own output would hide them.
let mut map = bun_core::handle_oom(loader.map.clone_with_allocator());
let mut map = bun_core::handle_oom(tooling_env.map.clone_with_allocator());

if map.get(b"GIT_ASKPASS").is_none() {
let config = SloppyGlobalGitConfig::get();
Expand Down
10 changes: 8 additions & 2 deletions src/runtime/cli/audit_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ use bun_install::lockfile::reachable;
use bun_install::package_manager_real::command_line_arguments::AuditLevel;
use bun_install::package_manager_real::{ROOT_PACKAGE_JSON_PATH, install_with_manager};
use bun_install::resolution::Tag as ResolutionTag;
use bun_install::{CommandLineArguments, LogLevel, PackageManager, PackageNameHash, Subcommand};
use bun_install::{
CommandLineArguments, LogLevel, PackageManager, PackageNameHash, ProcessOnlyEnv, Subcommand,
};
use bun_libdeflate_sys::libdeflate;
use bun_parsers::json as bun_json;
use bun_url::URL;
Expand Down Expand Up @@ -754,6 +756,7 @@ fn send_audit_request(
http_proxy,
http::FetchRedirect::Follow,
);
req.client.flags.reject_unauthorized = pm.tls_reject_unauthorized();
let reason = match req.send_sync(&mut response_buf) {
Ok(res) if res.status_code() >= 400 => SkipReason::Status(res.status_code()),
Ok(_) => {
Expand All @@ -764,7 +767,10 @@ fn send_audit_request(
}
SkipReason::NotJson
}
Err(err) => SkipReason::Send(err.name()),
Err(err) => {
pm.note_dotenv_only_vars(ProcessOnlyEnv::Network);
Comment thread
robobun marked this conversation as resolved.
SkipReason::Send(err.name())
}
};

if !registry.is_default {
Expand Down
3 changes: 2 additions & 1 deletion src/runtime/cli/pm_diff_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use bun_install::dependency;
use bun_install::lockfile::LoadResult;
use bun_install::lockfile::package::PackageColumns as _;
use bun_install::npm::{self, PackageManifest};
use bun_install::{PackageManager, resolution};
use bun_install::{PackageManager, ProcessOnlyEnv, resolution};
use bun_libarchive::lib::{ArchiveIterator, IteratorResult as ArchiveIterResult};
use bun_semver as Semver;
use bun_sys::{Fd, FdExt as _, dir_iterator as DirIterator};
Expand Down Expand Up @@ -903,6 +903,7 @@ fn registry_get(
Err(err) => {
Status::clear();
Output::err(err, "GET {} failed", (BStr::new(&display_url),));
pm.note_dotenv_only_vars(ProcessOnlyEnv::Network);
Global::exit(1);
}
};
Expand Down
3 changes: 2 additions & 1 deletion src/runtime/cli/pm_view_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ use bun_core::fmt as bun_fmt;
use bun_core::strings;
use bun_core::{Global, Output, prettyln};
use bun_http as http;
use bun_install::PackageManager;
use bun_install::dependency;
use bun_install::npm::{self, PackageManifest};
use bun_install::{PackageManager, ProcessOnlyEnv};
use bun_js_parser as ast;
use bun_js_printer as JSPrinter;
use bun_parsers::json as JSON;
Expand Down Expand Up @@ -136,6 +136,7 @@ pub(crate) fn view(
Ok(r) => r,
Err(err) => {
Output::err(err, "view request failed to send", ());
manager.note_dotenv_only_vars(ProcessOnlyEnv::Network);
Global::crash();
}
};
Expand Down
37 changes: 35 additions & 2 deletions test/cli/install/bun-audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
normalizeBunSnapshot,
runBunInstall,
tempDir,
tls,
} from "harness";
import { join } from "node:path";
import { resolveBulkAdvisoryFixture } from "./registry/fixtures/audit/audit-fixtures";
Expand Down Expand Up @@ -247,10 +248,11 @@ async function reinstall(dir: string, pkgJson: object) {
await runBunInstall(installEnv(dir), dir);
}

async function run(dir: string, args: string[], root: string = dir) {
// An `undefined` value in `extraEnv` removes that variable from the environment.
async function run(dir: string, args: string[], root: string = dir, extraEnv: Record<string, string | undefined> = {}) {
await using proc = Bun.spawn({
cmd: [bunExe(), ...args],
env: installEnv(root),
env: { ...installEnv(root), ...extraEnv },
cwd: dir,
stdout: "pipe",
stderr: "pipe",
Expand Down Expand Up @@ -1231,6 +1233,37 @@ describe("`bun audit` report", () => {
expect(await lock(dir)).toBe(lockBefore);
});

// `bun install`, `bun info` and `bun pm diff` read this variable for their requests in the same way.
test.concurrent(
"NODE_TLS_REJECT_UNAUTHORIZED=0 applies from the environment and not from the project's .env",
async () => {
// `tls` is self-signed, so a client that checks certificates rejects it.
await using server = Bun.serve({ port: 0, tls, fetch: () => Response.json({}) });
using dir = tempDir("audit-self-signed-", {
"package.json": JSON.stringify({ name: "foo", dependencies: { leaf: "1.0.0" } }),
"bun.lock": JSON.stringify({
lockfileVersion: 1,
configVersion: 1,
workspaces: { "": { name: "foo", dependencies: { leaf: "1.0.0" } } },
packages: { leaf: ["leaf@1.0.0", "", {}, ""] },
}),
".env": "NODE_TLS_REJECT_UNAUTHORIZED=0\n",
});
await writeBunfig(String(dir), `https://localhost:${server.port}/`);

const fromDotenv = await run(String(dir), ["audit"], String(dir), { NODE_TLS_REJECT_UNAUTHORIZED: undefined });
expect(fromDotenv.stderr).toContain("DEPTH_ZERO_SELF_SIGNED_CERT");
expect(fromDotenv.stderr).toContain(
"note: bun audit reads NODE_TLS_REJECT_UNAUTHORIZED from the environment only, not from .env files.",
);
expect(fromDotenv.exitCode).toBe(1);

const fromRealEnv = await run(String(dir), ["audit"], String(dir), { NODE_TLS_REJECT_UNAUTHORIZED: "0" });
expect(normalizeBunSnapshot(fromRealEnv.stdout)).toBe(AUDIT_HEADER + noVulnerabilities(1));
expect(fromRealEnv.exitCode).toBe(0);
},
);

test.concurrent("a scoped registry that refuses the connection is skipped", async () => {
await using scoped = startRegistry({});
await using server = startRegistry({});
Expand Down
Loading
Loading