From 1a0785d486de8a97087f420f03a3c45e68b956f5 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Tue, 3 Feb 2026 22:30:48 +0000 Subject: [PATCH] fix(install): expand tilde (~) in cache directory paths from config files Previously, Bun would treat `~` as a literal character in cache paths specified in `.npmrc` or `bunfig.toml`, creating directories with `~` in their names (e.g., `/project/~/.dev/npm`) instead of expanding to the user's home directory. This adds a `strings.expandTilde` utility function and uses it when parsing the `cache` option in `.npmrc` and `bunfig.toml` configuration files, matching npm's behavior. Closes #26715 Co-Authored-By: Claude Opus 4.5 --- src/bunfig.zig | 4 +- src/ini.zig | 2 +- src/string/immutable.zig | 26 ++++ test/regression/issue/26715.test.ts | 179 ++++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 test/regression/issue/26715.test.ts diff --git a/src/bunfig.zig b/src/bunfig.zig index bcde33ecf3..e7c9c99479 100644 --- a/src/bunfig.zig +++ b/src/bunfig.zig @@ -696,7 +696,7 @@ pub const Bunfig = struct { } if (cache.asString(allocator)) |value| { - install.cache_directory = value; + install.cache_directory = try bun.strings.expandTilde(allocator, 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 = try bun.strings.expandTilde(allocator, value); } } } diff --git a/src/ini.zig b/src/ini.zig index 23e8b57380..bfac577ab9 100644 --- a/src/ini.zig +++ b/src/ini.zig @@ -999,7 +999,7 @@ pub fn loadNpmrc( if (out.asProperty("cache")) |query| { if (query.expr.asUtf8StringLiteral()) |str| { - install.cache_directory = try allocator.dupe(u8, str); + install.cache_directory = try bun.strings.expandTilde(allocator, str); } else if (query.expr.asBool()) |b| { install.disable_cache = !b; } diff --git a/src/string/immutable.zig b/src/string/immutable.zig index c254666c52..212d73a15d 100644 --- a/src/string/immutable.zig +++ b/src/string/immutable.zig @@ -2383,6 +2383,32 @@ pub const basename = paths_.basename; pub const log = bun.Output.scoped(.STR, .hidden); pub const grapheme = @import("./immutable/grapheme.zig"); + +/// Expands a leading tilde (~) in a path to the user's home directory. +/// Returns the original string if it doesn't start with ~ or if the home directory cannot be determined. +/// The tilde must be followed by a path separator or be the entire string (e.g., "~" or "~/foo"). +pub fn expandTilde(allocator: std.mem.Allocator, path: []const u8) OOM![]const u8 { + if (path.len == 0 or path[0] != '~') { + return path; + } + // Must be just "~" or "~/..." (not "~foo" which means another user's home) + if (path.len > 1 and path[1] != '/' and (Environment.isWindows and path[1] != '\\') == false) { + return path; + } + + const home_dir = bun.env_var.HOME.get() orelse return path; + + if (path.len == 1) { + // Just "~" + return try allocator.dupe(u8, home_dir); + } + // "~/..." - join home with the rest + const rest = path[1..]; // includes the leading / + const result = try allocator.alloc(u8, home_dir.len + rest.len); + @memcpy(result[0..home_dir.len], home_dir); + @memcpy(result[home_dir.len..], rest); + return result; +} pub const CodePoint = i32; const string = []const u8; diff --git a/test/regression/issue/26715.test.ts b/test/regression/issue/26715.test.ts new file mode 100644 index 0000000000..e819698e04 --- /dev/null +++ b/test/regression/issue/26715.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from "bun:test"; +import { rm } from "fs/promises"; +import { bunExe, bunEnv as env, stderrForInstall, tempDirWithFiles } from "harness"; +import { join } from "path"; + +// https://github.com/oven-sh/bun/issues/26715 +// Bun should expand ~ (tilde) to the home directory in cache paths from .npmrc and bunfig.toml + +describe("tilde expansion in cache paths", () => { + test(".npmrc cache path expands tilde to home directory", async () => { + const testDir = tempDirWithFiles("tilde-npmrc-", { + ".npmrc": "cache=~/.bun-test-cache-dir", + "package.json": JSON.stringify({ name: "foo", version: "1.0.0" }), + }); + + // Remove any bunfig.toml that might override the .npmrc setting + await rm(join(testDir, "bunfig.toml"), { force: true }); + + const originalCacheDir = env.BUN_INSTALL_CACHE_DIR; + delete env.BUN_INSTALL_CACHE_DIR; + + try { + const { stdout, stderr, exited } = Bun.spawn({ + cmd: [bunExe(), "pm", "cache"], + cwd: testDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + + const out = (await stdout.text()).trim(); + const err = stderrForInstall(await stderr.text()); + + expect(err).toBeEmpty(); + // Should NOT contain literal "~" - it should be expanded to the home directory + expect(out).not.toContain("/~/"); + expect(out).not.toStartWith("~"); + // Should contain the home directory path + const homeDir = process.env.HOME || process.env.USERPROFILE; + expect(out).toStartWith(homeDir!); + expect(out).toEndWith(".bun-test-cache-dir"); + + expect(await exited).toBe(0); + } finally { + env.BUN_INSTALL_CACHE_DIR = originalCacheDir; + } + }); + + test("bunfig.toml cache string expands tilde to home directory", async () => { + const testDir = tempDirWithFiles("tilde-bunfig-str-", { + "bunfig.toml": '[install]\ncache = "~/.bun-test-cache-dir"', + "package.json": JSON.stringify({ name: "foo", version: "1.0.0" }), + }); + + const originalCacheDir = env.BUN_INSTALL_CACHE_DIR; + delete env.BUN_INSTALL_CACHE_DIR; + + try { + const { stdout, stderr, exited } = Bun.spawn({ + cmd: [bunExe(), "pm", "cache"], + cwd: testDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + + const out = (await stdout.text()).trim(); + const err = stderrForInstall(await stderr.text()); + + expect(err).toBeEmpty(); + expect(out).not.toContain("/~/"); + expect(out).not.toStartWith("~"); + const homeDir = process.env.HOME || process.env.USERPROFILE; + expect(out).toStartWith(homeDir!); + expect(out).toEndWith(".bun-test-cache-dir"); + + expect(await exited).toBe(0); + } finally { + env.BUN_INSTALL_CACHE_DIR = originalCacheDir; + } + }); + + test("bunfig.toml cache.dir expands tilde to home directory", async () => { + const testDir = tempDirWithFiles("tilde-bunfig-dir-", { + "bunfig.toml": '[install.cache]\ndir = "~/.bun-test-cache-dir"', + "package.json": JSON.stringify({ name: "foo", version: "1.0.0" }), + }); + + const originalCacheDir = env.BUN_INSTALL_CACHE_DIR; + delete env.BUN_INSTALL_CACHE_DIR; + + try { + const { stdout, stderr, exited } = Bun.spawn({ + cmd: [bunExe(), "pm", "cache"], + cwd: testDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + + const out = (await stdout.text()).trim(); + const err = stderrForInstall(await stderr.text()); + + expect(err).toBeEmpty(); + expect(out).not.toContain("/~/"); + expect(out).not.toStartWith("~"); + const homeDir = process.env.HOME || process.env.USERPROFILE; + expect(out).toStartWith(homeDir!); + expect(out).toEndWith(".bun-test-cache-dir"); + + expect(await exited).toBe(0); + } finally { + env.BUN_INSTALL_CACHE_DIR = originalCacheDir; + } + }); + + test("paths without tilde are not affected", async () => { + const testDir = tempDirWithFiles("no-tilde-", { + "bunfig.toml": '[install]\ncache = "/tmp/absolute-cache-dir"', + "package.json": JSON.stringify({ name: "foo", version: "1.0.0" }), + }); + + const originalCacheDir = env.BUN_INSTALL_CACHE_DIR; + delete env.BUN_INSTALL_CACHE_DIR; + + try { + const { stdout, stderr, exited } = Bun.spawn({ + cmd: [bunExe(), "pm", "cache"], + cwd: testDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + + const out = (await stdout.text()).trim(); + const err = stderrForInstall(await stderr.text()); + + expect(err).toBeEmpty(); + expect(out).toBe("/tmp/absolute-cache-dir"); + + expect(await exited).toBe(0); + } finally { + env.BUN_INSTALL_CACHE_DIR = originalCacheDir; + } + }); + + test("~username paths are not expanded (only ~ is expanded)", async () => { + // ~username syntax (for other users' home dirs) should not be expanded + // as it would require looking up user info + const testDir = tempDirWithFiles("tilde-user-", { + "bunfig.toml": '[install]\ncache = "~otheruser/cache"', + "package.json": JSON.stringify({ name: "foo", version: "1.0.0" }), + }); + + const originalCacheDir = env.BUN_INSTALL_CACHE_DIR; + delete env.BUN_INSTALL_CACHE_DIR; + + try { + const { stdout, stderr, exited } = Bun.spawn({ + cmd: [bunExe(), "pm", "cache"], + cwd: testDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + + const out = (await stdout.text()).trim(); + const err = stderrForInstall(await stderr.text()); + + expect(err).toBeEmpty(); + // Should still contain ~otheruser since we don't expand that form + expect(out).toContain("~otheruser"); + + expect(await exited).toBe(0); + } finally { + env.BUN_INSTALL_CACHE_DIR = originalCacheDir; + } + }); +});