diff --git a/src/bunfig.zig b/src/bunfig.zig index bcde33ecf3..a43d12c559 100644 --- a/src/bunfig.zig +++ b/src/bunfig.zig @@ -670,13 +670,13 @@ pub const Bunfig = struct { if (install_obj.get("globalDir")) |dir| { if (dir.asString(allocator)) |value| { - install.global_dir = value; + install.global_dir = bun.path.expandTilde(allocator, value) orelse value; } } if (install_obj.get("globalBinDir")) |dir| { if (dir.asString(allocator)) |value| { - install.global_bin_dir = value; + install.global_bin_dir = bun.path.expandTilde(allocator, value) orelse value; } } @@ -696,7 +696,7 @@ pub const Bunfig = struct { } if (cache.asString(allocator)) |value| { - install.cache_directory = value; + install.cache_directory = bun.path.expandTilde(allocator, value) orelse value; break :load; } @@ -715,7 +715,7 @@ pub const Bunfig = struct { if (cache.get("dir")) |directory| { if (directory.asString(allocator)) |value| { - install.cache_directory = value; + install.cache_directory = bun.path.expandTilde(allocator, value) orelse value; } } } diff --git a/src/resolver/resolve_path.zig b/src/resolver/resolve_path.zig index 61155c109f..d661b82c29 100644 --- a/src/resolver/resolve_path.zig +++ b/src/resolver/resolve_path.zig @@ -2066,6 +2066,44 @@ pub fn posixToPlatformInPlace(comptime T: type, path_buffer: []T) void { } } +/// Expands a leading tilde (`~`) in a path to the user's home directory. +/// Returns the original path if it doesn't start with `~` or if the home directory +/// cannot be determined. +/// +/// Examples: +/// - `~/foo/bar` -> `/home/user/foo/bar` +/// - `~` -> `/home/user` +/// - `/absolute/path` -> `/absolute/path` (unchanged) +/// - `relative/path` -> `relative/path` (unchanged) +pub fn expandTilde(allocator: std.mem.Allocator, path: []const u8) ?[]const u8 { + // Check if path starts with ~ + if (path.len == 0 or path[0] != '~') { + return null; // No expansion needed + } + + // Handle ~ or ~/... + // We only expand ~ followed by nothing or a path separator + if (path.len == 1 or isSepAny(path[1])) { + const home_dir = bun.env_var.HOME.get() orelse return null; + + if (path.len == 1) { + // Just "~" + return allocator.dupe(u8, home_dir) catch return null; + } + + // "~/..." - join home_dir with the rest of the path (skip ~/) + const rest = path[2..]; // Skip ~/ + const result = allocator.alloc(u8, home_dir.len + 1 + rest.len) catch return null; + @memcpy(result[0..home_dir.len], home_dir); + result[home_dir.len] = '/'; + @memcpy(result[home_dir.len + 1 ..], rest); + return result; + } + + // ~username expansion is not supported, return null + return null; +} + const Fs = @import("../fs.zig"); const std = @import("std"); diff --git a/test/regression/issue/25766.test.ts b/test/regression/issue/25766.test.ts new file mode 100644 index 0000000000..97e9a68607 --- /dev/null +++ b/test/regression/issue/25766.test.ts @@ -0,0 +1,145 @@ +// https://github.com/oven-sh/bun/issues/25766 +// Tilde expansion (~) should work in bunfig.toml path settings +// +// When users specify paths like `globalBinDir = "~/.bun/bin"` in their bunfig.toml, +// Bun should expand the `~` to $HOME. Without the fix, it creates a literal folder named `~`. + +import { describe, expect, test } from "bun:test"; +import { existsSync } from "fs"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { join } from "path"; + +describe("bunfig.toml tilde expansion", () => { + test("globalBinDir with tilde expands to home directory", async () => { + // Create a fake home directory and a bunfig that uses tilde for globalBinDir + using dir = tempDir("issue-25766-bin", { + "fake-home/.bun/bin/.gitkeep": "", + "package.json": JSON.stringify({ + name: "test-pkg", + version: "1.0.0", + }), + "bunfig.toml": `[install] +globalBinDir = "~/.bun/bin" +`, + }); + + const fakeHome = join(String(dir), "fake-home"); + + // Use `bun link` which triggers the openGlobalBinDir and openGlobalDir code paths + await using proc = Bun.spawn({ + cmd: [bunExe(), "link"], + cwd: String(dir), + env: { + ...bunEnv, + HOME: fakeHome, + }, + stdout: "pipe", + stderr: "pipe", + }); + + await proc.exited; + + // The key assertion: a literal "~" directory should NOT be created in cwd + // If the bug exists, it would create a directory literally named "~" + const literalTildeDir = join(String(dir), "~"); + expect(existsSync(literalTildeDir)).toBe(false); + }); + + test("globalDir with tilde expands to home directory", async () => { + using dir = tempDir("issue-25766-global", { + "fake-home/.bun/install/global/.gitkeep": "", + "package.json": JSON.stringify({ + name: "test-pkg", + version: "1.0.0", + }), + "bunfig.toml": `[install] +globalDir = "~/.bun/install/global" +`, + }); + + const fakeHome = join(String(dir), "fake-home"); + + // Use `bun link` which triggers the openGlobalDir code path + await using proc = Bun.spawn({ + cmd: [bunExe(), "link"], + cwd: String(dir), + env: { + ...bunEnv, + HOME: fakeHome, + }, + stdout: "pipe", + stderr: "pipe", + }); + + await proc.exited; + + // No literal "~" directory should be created + const literalTildeDir = join(String(dir), "~"); + expect(existsSync(literalTildeDir)).toBe(false); + }); + + test("cache.dir with tilde expands to home directory", async () => { + using dir = tempDir("issue-25766-cache", { + "fake-home/.bun/install/cache/.gitkeep": "", + "package.json": JSON.stringify({ + name: "test-pkg", + version: "1.0.0", + }), + "bunfig.toml": `[install.cache] +dir = "~/.bun/install/cache" +`, + }); + + const fakeHome = join(String(dir), "fake-home"); + + // Regular install should trigger cache directory access + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: { + ...bunEnv, + HOME: fakeHome, + }, + stdout: "pipe", + stderr: "pipe", + }); + + await proc.exited; + + // No literal "~" directory should be created + const literalTildeDir = join(String(dir), "~"); + expect(existsSync(literalTildeDir)).toBe(false); + }); + + test("cache shorthand with tilde expands to home directory", async () => { + using dir = tempDir("issue-25766-cache-short", { + "fake-home/.bun/install/cache/.gitkeep": "", + "package.json": JSON.stringify({ + name: "test-pkg", + version: "1.0.0", + }), + "bunfig.toml": `[install] +cache = "~/.bun/install/cache" +`, + }); + + const fakeHome = join(String(dir), "fake-home"); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: { + ...bunEnv, + HOME: fakeHome, + }, + stdout: "pipe", + stderr: "pipe", + }); + + await proc.exited; + + // No literal "~" directory should be created + const literalTildeDir = join(String(dir), "~"); + expect(existsSync(literalTildeDir)).toBe(false); + }); +});