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
102 changes: 56 additions & 46 deletions src/bunfig/arguments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,44 @@ use crate::bunfig::Bunfig;

// ─── bunfig loading ──────────────────────────────────────────────────────────

fn get_home_config_path(buf: &mut PathBuffer) -> Option<&ZStr> {
let paths: [&[u8]; 1] = [b".bunfig.toml"];
/// `None` when `dir/path` does not fit: nothing could be opened at such a path anyway.
fn join_config_path<'buf>(
dir: &[u8],
path: &[u8],
buf: &'buf mut PathBuffer,
) -> Option<&'buf ZStr> {
let max_len = buf.len() - 1;
let len = resolve_path::join_abs_string_buf_checked::<platform::Auto>(
dir,
&mut buf[..max_len],
&[path],
)?
.len();
buf[len] = 0;
Some(ZStr::from_buf(&buf[..], len))
}

if let Some(data_dir) = env_var::XDG_CONFIG_HOME.get() {
return Some(resolve_path::join_abs_string_buf_z::<platform::Auto>(
data_dir, &mut **buf, &paths,
));
}
fn get_home_config_path(buf: &mut PathBuffer) -> Option<&ZStr> {
let dir = env_var::XDG_CONFIG_HOME
.get()
.or_else(|| env_var::HOME.get())?;
join_config_path(dir, b".bunfig.toml", buf)
}

if let Some(home_dir) = env_var::HOME.get() {
return Some(resolve_path::join_abs_string_buf_z::<platform::Auto>(
home_dir, &mut **buf, &paths,
));
fn unreadable_config(
auto_loaded: bool,
err: &bun_sys::Error,
config_path: &[u8],
) -> Result<(), crate::Error> {
if auto_loaded {
return Ok(());
}

None
bun_core::pretty_errorln!(
"{}\nwhile reading config \"{}\"",
err,
BStr::new(config_path),
);
Global::exit(1);
}

fn load_bunfig(
Expand All @@ -44,17 +66,7 @@ fn load_bunfig(
let source =
match bun_ast::to_source(config_path, bun_ast::ToSourceOptions { convert_bom: true }) {
Ok(s) => s,
Err(err) => {
if auto_loaded {
return Ok(());
}
bun_core::pretty_errorln!(
"{}\nwhile reading config \"{}\"",
err,
BStr::new(config_path.as_bytes()),
);
Global::exit(1);
}
Err(err) => return unreadable_config(auto_loaded, &err, config_path.as_bytes()),
};

bun_ast::stmt::data::Store::create();
Expand Down Expand Up @@ -187,11 +199,12 @@ pub fn load_config(
if config_path_.is_empty() {
return Ok(());
}
let config_path_len: usize;
if config_path_[0] == b'/' {
config_buf[..config_path_.len()].copy_from_slice(config_path_);
config_buf[config_path_.len()] = 0;
config_path_len = config_path_.len();
let config_path: Option<&ZStr> = if config_path_[0] == b'/' {
if config_path_.len() < config_buf.len() {
Some(resolve_path::z(config_path_, &mut config_buf))
} else {
None
}
} else {
if ctx.args.absolute_working_dir.is_none() {
let mut secondbuf = PathBuffer::uninit();
Expand All @@ -202,23 +215,20 @@ pub fn load_config(
ctx.args.absolute_working_dir = Some(Box::<[u8]>::from(&secondbuf[..cwd_len]));
}

// Reshaped for borrowck: `join_abs_string_buf` ties the
// returned slice's lifetime to both `cwd` (borrowed from `ctx.args`)
// and `config_buf`. We only need the length to NUL-terminate and
// re-wrap, so capture `joined.len()` and drop the `ctx` borrow before
// the `&mut ctx` call below.
config_path_len = {
let awd: &[u8] = ctx.args.absolute_working_dir.as_deref().unwrap();
let parts: [&[u8]; 2] = [awd, config_path_];
let joined =
resolve_path::join_abs_string_buf::<platform::Auto>(awd, &mut *config_buf, &parts);
joined.len()
};
config_buf[config_path_len] = 0;
}
// SAFETY: `config_buf[config_path_len] == 0` (written above on both arms);
// `config_buf` outlives the call.
let config_path = ZStr::from_buf(&config_buf[..], config_path_len);
join_config_path(
ctx.args.absolute_working_dir.as_deref().unwrap(),
config_path_,
&mut config_buf,
)
};
let Some(config_path) = config_path else {
return unreadable_config(
auto_loaded,
&bun_sys::Error::from_code(bun_sys::E::ENAMETOOLONG, bun_sys::Tag::open)
.with_path(config_path_),
config_path_,
);
};

if let Err(err) = load_config_path(cmd, auto_loaded, config_path, ctx) {
report_bunfig_load_failure(ctx.log, err);
Expand Down
215 changes: 214 additions & 1 deletion test/config/bunfig/bunfig-errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
import { bunEnv, bunExe, isWindows, tempDir } from "harness";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join, relative } from "node:path";

describe.concurrent("bunfig.toml type-mismatch error messages", () => {
const cases: [config: string, expected: string][] = [
Expand Down Expand Up @@ -30,3 +32,214 @@ describe.concurrent("bunfig.toml type-mismatch error messages", () => {
expect(exitCode).not.toBe(0);
});
});

// bun builds every config path in a stack buffer of MAX_PATH_BYTES (the platform's
// PATH_MAX, see bun_core), so the longest path it can hold is MAX_PATH_BYTES - 1
// bytes plus the NUL terminator. Paths that do not fit used to overflow the buffer
// (a panic at startup); they must be treated like any other unreadable config.
//
// On Windows the buffer is ~96 KiB, longer than any path, argument or environment
// variable the OS accepts, so the overflow cannot be reached there.
describe.concurrent.skipIf(isWindows)("config paths that do not fit in a path buffer", () => {
const MAX_PATH_BYTES = process.platform === "linux" || process.platform === "android" ? 4096 : 1024;
// A TOML syntax error: every command fails to load it, and the error names the
// path the config was loaded from.
const INVALID_BUNFIG = "[install\n";
const SEGMENT = Buffer.alloc(200, "d").toString();

/** An absolute path below `root` whose UTF-8 encoding is exactly `length` bytes long. */
function pathOfLength(root: string, length: number): string {
let path = root;
// Leave room for the final component, which has to stay under NAME_MAX (255).
while (length - Buffer.byteLength(path) > 256) path = join(path, SEGMENT);
path = join(path, Buffer.alloc(length - Buffer.byteLength(path) - 1, "L").toString());
expect(Buffer.byteLength(path)).toBe(length);
return path;
}

/** Writes an invalid config at `configPath` and returns it. */
function invalidConfigAt(configPath: string): string {
mkdirSync(dirname(configPath), { recursive: true });
writeFileSync(configPath, INVALID_BUNFIG);
return configPath;
}

async function runBun(args: string[], cwd: string, env: Record<string, string | undefined> = {}) {
await using proc = Bun.spawn({
cmd: [bunExe(), ...args],
env: { ...bunEnv, ...env },
cwd,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
}
type Result = Awaited<ReturnType<typeof runBun>>;

function expectLoadedFrom({ stdout, stderr, exitCode }: Result, configPath: string) {
expect(stderr).toContain(`at ${configPath}:`);
expect(stderr).toContain("failed to load bunfig");
expect(stdout).toBe("");
expect(exitCode).toBe(1);
}

function expectNameTooLong({ stdout, stderr, exitCode }: Result, configArg: string) {
expect(stderr.replaceAll(configArg, "<config>")).toBe(
'ENAMETOOLONG: <config>: File name too long (open())\nwhile reading config "<config>"\n',
);
expect(stdout).toBe("");
expect(exitCode).toBe(1);
}

describe("bunfig.toml auto-loaded from the working directory", () => {
const PRINT_CWD_BYTES = "console.log(Buffer.byteLength(process.cwd()))";

test("is loaded when its path is exactly MAX_PATH_BYTES - 1 bytes", async () => {
using dir = tempDir("bunfig-long-cwd", {});
const cwd = pathOfLength(String(dir), MAX_PATH_BYTES - "/bunfig.toml".length - 1);
const config = invalidConfigAt(join(cwd, "bunfig.toml"));
expect(Buffer.byteLength(config)).toBe(MAX_PATH_BYTES - 1);

expectLoadedFrom(await runBun(["-e", PRINT_CWD_BYTES], cwd), config);
});

// No bunfig.toml exists in these directories: its path would be too long to
// create, and the path is built (and used to overflow) before it is opened.
const skippedCases: [configPathWouldBe: string, cwdBytes: number][] = [
["exactly MAX_PATH_BYTES bytes, leaving no room for the NUL", MAX_PATH_BYTES - "/bunfig.toml".length],
["longer than the buffer", MAX_PATH_BYTES - 1],
];

test.each(skippedCases)("bun -e still runs when the bunfig.toml path would be %s", async (_, cwdBytes) => {
using dir = tempDir("bunfig-long-cwd", {});
const cwd = pathOfLength(String(dir), cwdBytes);
mkdirSync(cwd, { recursive: true });

expect(await runBun(["-e", PRINT_CWD_BYTES], cwd)).toEqual({
stdout: `${cwdBytes}\n`,
stderr: "",
exitCode: 0,
});
});

test("bun <file> still runs when the bunfig.toml path does not fit", async () => {
using dir = tempDir("bunfig-long-cwd", {});
const cwdBytes = MAX_PATH_BYTES - "/bunfig.toml".length;
const cwd = pathOfLength(String(dir), cwdBytes);
mkdirSync(cwd, { recursive: true });
writeFileSync(join(cwd, "x.cjs"), PRINT_CWD_BYTES);

expect(await runBun(["x.cjs"], cwd)).toEqual({
stdout: `${cwdBytes}\n`,
stderr: "",
exitCode: 0,
});
});
});

describe("--config=<relative path>", () => {
test("is loaded when the resolved path is exactly MAX_PATH_BYTES - 1 bytes", async () => {
using dir = tempDir("bunfig-long-config", {});
const config = invalidConfigAt(pathOfLength(String(dir), MAX_PATH_BYTES - 1));

expectLoadedFrom(await runBun([`--config=${relative(String(dir), config)}`, "-e", "1"], String(dir)), config);
});

test("fails with ENAMETOOLONG when the resolved path is MAX_PATH_BYTES bytes", async () => {
using dir = tempDir("bunfig-long-config", {});
const configArg = relative(String(dir), pathOfLength(String(dir), MAX_PATH_BYTES));

expectNameTooLong(await runBun([`--config=${configArg}`, "-e", "1"], String(dir)), configArg);
});

test("fails with ENAMETOOLONG when the argument alone is longer than the buffer", async () => {
using dir = tempDir("bunfig-long-config", {});
const configArg = Buffer.alloc(MAX_PATH_BYTES + 1000, "a").toString();

expectNameTooLong(await runBun([`--config=${configArg}`, "-e", "1"], String(dir)), configArg);
});

test("is loaded when a path longer than the buffer normalizes to one that fits", async () => {
using dir = tempDir("bunfig-long-config", { "bunfig.toml": INVALID_BUNFIG });
const hop = "x/../";
const hops = Buffer.alloc(Math.ceil(MAX_PATH_BYTES / hop.length) * hop.length, hop).toString();
const configArg = hops + "bunfig.toml";
expect(configArg.length).toBeGreaterThan(MAX_PATH_BYTES);

expectLoadedFrom(
await runBun([`--config=${configArg}`, "-e", "1"], String(dir)),
join(String(dir), "bunfig.toml"),
);
});
});

describe("--config=<absolute path>", () => {
test("is loaded when the path is exactly MAX_PATH_BYTES - 1 bytes", async () => {
using dir = tempDir("bunfig-long-config", {});
const config = invalidConfigAt(pathOfLength(String(dir), MAX_PATH_BYTES - 1));

expectLoadedFrom(await runBun([`--config=${config}`, "-e", "1"], String(dir)), config);
});

const tooLongCases: [pathIs: string, configArgBelow: (root: string) => string][] = [
["exactly MAX_PATH_BYTES bytes", root => pathOfLength(root, MAX_PATH_BYTES)],
["longer than the buffer", () => "/" + Buffer.alloc(MAX_PATH_BYTES + 1000, "a").toString()],
];

test.each(tooLongCases)("fails with ENAMETOOLONG when the path is %s", async (_, configArgBelow) => {
using dir = tempDir("bunfig-long-config", {});
const configArg = configArgBelow(String(dir));

expectNameTooLong(await runBun([`--config=${configArg}`, "-e", "1"], String(dir)), configArg);
});
});

// Install commands also read $XDG_CONFIG_HOME/.bunfig.toml (or $HOME/.bunfig.toml).
// `bun pm cache` prints the cache directory once that config has been handled.
describe("global .bunfig.toml", () => {
const PACKAGE_JSON = { "package.json": JSON.stringify({ name: "bunfig-global-test" }) };
// `bun pm cache` creates the directory it prints, and silently falls back to
// node_modules/.cache when it cannot, so it has to live inside the temp dir.
const cacheDirIn = (dir: string) => join(dir, "install-cache");
async function pmCache(dir: string, env: Record<string, string | undefined>): Promise<Result> {
const result = await runBun(["pm", "cache"], dir, { BUN_INSTALL_CACHE_DIR: cacheDirIn(dir), ...env });
return { ...result, stdout: result.stdout.trim() };
}
/** The global config was skipped and the command went on to print the cache directory. */
const printedCacheDir = (dir: string): Result => ({ stdout: cacheDirIn(dir), stderr: "", exitCode: 0 });

test("is loaded when its path is exactly MAX_PATH_BYTES - 1 bytes", async () => {
using dir = tempDir("bunfig-long-global", PACKAGE_JSON);
const configHome = pathOfLength(String(dir), MAX_PATH_BYTES - "/.bunfig.toml".length - 1);
const config = invalidConfigAt(join(configHome, ".bunfig.toml"));
expect(Buffer.byteLength(config)).toBe(MAX_PATH_BYTES - 1);

expectLoadedFrom(await pmCache(String(dir), { XDG_CONFIG_HOME: configHome }), config);
});

// The directories never exist; only the length of the variable matters. The
// longest value used here still leaves room for the "/.npmrc" that install
// commands append to the same variables afterwards.
const skippedCases: [configPathWouldBe: string, configHomeLength: number][] = [
["exactly MAX_PATH_BYTES bytes, leaving no room for the NUL", MAX_PATH_BYTES - "/.bunfig.toml".length],
["longer than the buffer", MAX_PATH_BYTES - "/.npmrc".length - 1],
];

test.each(skippedCases)("is skipped when its $XDG_CONFIG_HOME path would be %s", async (_, configHomeLength) => {
using dir = tempDir("bunfig-long-global", PACKAGE_JSON);
const configHome = pathOfLength(String(dir), configHomeLength);

expect(await pmCache(String(dir), { XDG_CONFIG_HOME: configHome })).toEqual(printedCacheDir(String(dir)));
});

test("is skipped when its $HOME path would be exactly MAX_PATH_BYTES bytes", async () => {
using dir = tempDir("bunfig-long-global", PACKAGE_JSON);
const home = pathOfLength(String(dir), MAX_PATH_BYTES - "/.bunfig.toml".length);

expect(await pmCache(String(dir), { HOME: home, XDG_CONFIG_HOME: undefined })).toEqual(
printedCacheDir(String(dir)),
);
});
});
});