diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 65c04872fd9b..f4a88f4236b9 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -340,6 +340,14 @@ fn intern_package_json(pkg: PackageJSON) -> core::ptr::NonNull { // surface the bust log. bun_core::define_scoped_log!(debuglog, Resolver, hidden); +#[inline] +fn is_permission_denied_dir_read_error(err: bun_core::Error) -> bool { + err == bun_core::err!("AccessDenied") + || err == bun_core::err!("PermissionDenied") + || err == bun_core::err!("EACCES") + || err == bun_core::err!("EPERM") +} + // PORT NOTE: `Path` in the body is the `'static`-interned variant (paths borrow // DirnameStore/FilenameStore). Alias here so the ~80 bare-`Path` use sites // resolve without a per-site lifetime annotation. @@ -4331,6 +4339,11 @@ impl<'a> Resolver<'a> { if cfg!(debug_assertions) { debug_assert!(queue_slice_len > 0); } + let missing_parent_result = allocators::Result { + index: allocators::NOT_FOUND, + hash: 0, + status: allocators::ItemStatus::NotFound, + }; let open_dir_count = core::cell::Cell::new(0usize); // When this function halts, any item not processed means it's not found. @@ -4365,9 +4378,10 @@ impl<'a> Resolver<'a> { // - fts_open is not the fastest way to read directories. fts actually just uses readdir!! // - remember let mut _safe_path: Option<&'static [u8]> = None; + let mut parent_result = top_parent; // Start at the top. - while queue_slice_len > 0 { + 'queue_loop: while queue_slice_len > 0 { // SAFETY: every slot in `0..queue_slice_len` was `.write()`-initialised above. let mut queue_top = unsafe { queue[queue_slice_len - 1].assume_init_ref() }.clone(); // `unsafe_path` was set to a slice of the threadlocal @@ -4380,6 +4394,7 @@ impl<'a> Resolver<'a> { let queue_top_safe_path: &[u8] = qt_safe_path.slice(); // defer top_parent = queue_top.result — done at end of loop body queue_slice_len -= 1; + let is_target_dir = queue_slice_len == 0; let open_dir: FD = if queue_top.fd.is_valid() { queue_top.fd @@ -4442,6 +4457,10 @@ impl<'a> Resolver<'a> { { return Ok(None); } + if !is_target_dir && is_permission_denied_dir_read_error(err) { + parent_result = missing_parent_result; + continue 'queue_loop; + } let cached_dir_entry_result = rfs!() .entries .get_or_put(queue_top_unsafe_path) @@ -4638,7 +4657,7 @@ impl<'a> Resolver<'a> { let dc = self.dir_cache_mut(); let dir_info_ptr: *mut DirInfo::DirInfo = dc.put(&mut queue_top.result, DirInfo::DirInfo::default())?; - let parent_dir_ptr = dc.at_index(top_parent.index).map(DirInfoRef::from_slot); + let parent_dir_ptr = dc.at_index(parent_result.index).map(DirInfoRef::from_slot); self.dir_info_uncached( dir_info_ptr, @@ -4648,12 +4667,12 @@ impl<'a> Resolver<'a> { queue_top.result, cached_dir_entry_result.index, parent_dir_ptr, - top_parent.index, + parent_result.index, open_dir, None, )?; - top_parent = queue_top.result; + parent_result = queue_top.result; if queue_slice_len == 0 { // SAFETY: `dir_info_ptr` is the BSSMap slot just filled by `dir_info_uncached`. diff --git a/test/cli/resolver.test.ts b/test/cli/resolver.test.ts new file mode 100644 index 000000000000..52ff1014f7c9 --- /dev/null +++ b/test/cli/resolver.test.ts @@ -0,0 +1,262 @@ +import { expect, test } from "bun:test"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "fs"; +import { bunEnv, bunExe, isLinux, tempDir, tempDirWithFiles } from "harness"; +import { dirname, join } from "path"; + +const LANDLOCK_HELPER_SRC = ` +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef __NR_landlock_create_ruleset +#define __NR_landlock_create_ruleset 444 +#endif +#ifndef __NR_landlock_add_rule +#define __NR_landlock_add_rule 445 +#endif +#ifndef __NR_landlock_restrict_self +#define __NR_landlock_restrict_self 446 +#endif + +static int add_path_rule(int ruleset_fd, const char *path, __u64 access) { + int fd = open(path, O_PATH | O_CLOEXEC); + if (fd < 0) return -1; + struct landlock_path_beneath_attr attr = { .allowed_access = access, .parent_fd = fd }; + int ret = syscall(__NR_landlock_add_rule, ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, &attr, 0); + close(fd); + return ret; +} + +int main(int argc, char **argv) { + if (argc < 4) { + fprintf(stderr, "Usage: %s [args...]\\n", argv[0]); + return 1; + } + + const char *allowed_dir = argv[1]; + const char *bun_dir = argv[2]; + + __u64 all_access = + LANDLOCK_ACCESS_FS_EXECUTE | LANDLOCK_ACCESS_FS_WRITE_FILE | LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_READ_DIR | LANDLOCK_ACCESS_FS_REMOVE_DIR | LANDLOCK_ACCESS_FS_REMOVE_FILE | + LANDLOCK_ACCESS_FS_MAKE_CHAR | LANDLOCK_ACCESS_FS_MAKE_DIR | LANDLOCK_ACCESS_FS_MAKE_REG | + LANDLOCK_ACCESS_FS_MAKE_SOCK | LANDLOCK_ACCESS_FS_MAKE_FIFO | LANDLOCK_ACCESS_FS_MAKE_BLOCK | + LANDLOCK_ACCESS_FS_MAKE_SYM; + + struct landlock_ruleset_attr ruleset_attr = { .handled_access_fs = all_access }; + int ruleset_fd = syscall(__NR_landlock_create_ruleset, &ruleset_attr, sizeof(ruleset_attr), 0); + if (ruleset_fd < 0) { + fprintf(stdout, "LANDLOCK_UNSUPPORTED\\n"); + return 0; + } + + __u64 read_exec = LANDLOCK_ACCESS_FS_EXECUTE | LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR; + + add_path_rule(ruleset_fd, allowed_dir, all_access); + add_path_rule(ruleset_fd, bun_dir, read_exec); + add_path_rule(ruleset_fd, "/usr", read_exec); + add_path_rule(ruleset_fd, "/lib", read_exec); + add_path_rule(ruleset_fd, "/lib64", read_exec); + add_path_rule(ruleset_fd, "/etc", LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR); + add_path_rule(ruleset_fd, "/proc", LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR); + add_path_rule(ruleset_fd, "/sys", LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR); + add_path_rule(ruleset_fd, "/dev", read_exec | LANDLOCK_ACCESS_FS_WRITE_FILE); + + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { + fprintf(stdout, "LANDLOCK_UNSUPPORTED\\n"); + return 0; + } + + if (syscall(__NR_landlock_restrict_self, ruleset_fd, 0)) { + fprintf(stdout, "LANDLOCK_UNSUPPORTED\\n"); + return 0; + } + close(ruleset_fd); + + if (strcmp(argv[3], "--self-check") == 0) { + int fd = open("/", O_RDONLY | O_DIRECTORY); + if (fd < 0) { + printf("/:ERR:%d\\n", errno); + } else { + printf("/:OK\\n"); + close(fd); + } + + fd = open(allowed_dir, O_RDONLY | O_DIRECTORY); + if (fd < 0) { + printf("%s:ERR:%d\\n", allowed_dir, errno); + } else { + printf("%s:OK\\n", allowed_dir); + close(fd); + } + return 0; + } + + execvp(argv[3], &argv[3]); + perror("execvp"); + return 1; +} +`; + +function compileLandlockHelper(root: string) { + const helperDir = join(root, "landlock-helper"); + const helperPath = join(helperDir, "landlock_sandbox"); + const srcPath = join(helperDir, "landlock_sandbox.c"); + + mkdirSync(helperDir, { recursive: true }); + writeFileSync(srcPath, LANDLOCK_HELPER_SRC); + + const compiler = process.env.CC || "cc"; + const compile = Bun.spawnSync({ + cmd: [compiler, "-o", helperPath, srcPath], + env: bunEnv, + stderr: "pipe", + stdout: "pipe", + }); + + return { + compileSupported: compile.exitCode === 0 && existsSync(helperPath), + helperPath, + }; +} + +function prepareLandlockFixture(root: string) { + const testBase = join(root, "parent", "project"); + const { compileSupported, helperPath } = compileLandlockHelper(root); + if (!compileSupported) { + return { helperPath, testBase }; + } + + mkdirSync(testBase, { recursive: true }); + + const check = Bun.spawnSync({ + cmd: [helperPath, testBase, dirname(bunExe()), "--self-check"], + env: bunEnv, + cwd: testBase, + stdout: "pipe", + stderr: "pipe", + }); + + const stdout = check.stdout.toString(); + if (!stdout.includes("LANDLOCK_UNSUPPORTED")) { + expect(stdout).toContain("/:ERR:13"); + expect(stdout).toContain(`${testBase}:OK`); + expect(check.exitCode).toBe(0); + } + + return { helperPath, testBase }; +} + +function canUseLandlock() { + if (!isLinux) return false; + + const root = tempDirWithFiles("resolver-landlock-probe", {}); + try { + const testBase = join(root, "parent", "project"); + const { compileSupported, helperPath } = compileLandlockHelper(root); + if (!compileSupported) return false; + + mkdirSync(testBase, { recursive: true }); + const check = Bun.spawnSync({ + cmd: [helperPath, testBase, dirname(bunExe()), "--self-check"], + env: bunEnv, + cwd: testBase, + stdout: "pipe", + stderr: "pipe", + }); + + const stdout = check.stdout.toString(); + return check.exitCode === 0 && stdout.includes("/:ERR:13") && stdout.includes(`${testBase}:OK`); + } finally { + try { + rmSync(root, { recursive: true, force: true }); + } catch {} + } +} + +function runInLandlock(helperPath: string, cwd: string, args: string[]) { + return Bun.spawnSync({ + cmd: [helperPath, cwd, dirname(bunExe()), bunExe(), ...args], + env: bunEnv, + cwd, + stdout: "pipe", + stderr: "pipe", + }); +} + +// https://github.com/oven-sh/bun/issues/28220 +// https://github.com/oven-sh/bun/issues/30859 +// Config resolution should stop inheritance at inaccessible ancestors while +// still allowing the requested project directory's bunfig.toml to be loaded. +test.skipIf(!canUseLandlock())("config resolution works when ancestor directories are inaccessible", async () => { + using root = tempDir("resolver-landlock-ancestors", {}); + const { helperPath, testBase } = prepareLandlockFixture(String(root)); + + writeFileSync( + join(testBase, "bunfig.toml"), + ` +preload = "./preload.ts" +define = { CONFIG_VALUE = "\\"from-bunfig\\"" } + +[test] +preload = "./test-preload.ts" +`, + ); + writeFileSync(join(testBase, "preload.ts"), "globalThis.configPreload = 'from-preload';\n"); + writeFileSync(join(testBase, "test-preload.ts"), "globalThis.configTestPreload = 'from-test-preload';\n"); + writeFileSync( + join(testBase, "index.ts"), + "if (globalThis.configPreload !== 'from-preload') throw new Error('missing bunfig preload'); console.log('preloaded');\n", + ); + writeFileSync( + join(testBase, "sample.test.ts"), + "import { expect, test } from 'bun:test'; test('bunfig test preload', () => expect(globalThis.configTestPreload).toBe('from-test-preload'));\n", + ); + writeFileSync(join(testBase, "build-entry.ts"), "declare const CONFIG_VALUE: string; console.log(CONFIG_VALUE);\n"); + + const direct = runInLandlock(helperPath, testBase, ["index.ts"]); + expect(direct.stdout.toString()).toBe("preloaded\n"); + expect(direct.exitCode).toBe(0); + + const run = runInLandlock(helperPath, testBase, ["run", "index.ts"]); + expect(run.stdout.toString()).toBe("preloaded\n"); + expect(run.exitCode).toBe(0); + + const testResult = runInLandlock(helperPath, testBase, ["test", "sample.test.ts"]); + expect(testResult.stdout.toString() + testResult.stderr.toString()).toContain("1 pass"); + expect(testResult.exitCode).toBe(0); + + const build = runInLandlock(helperPath, testBase, ["build", "build-entry.ts", "--outfile", "bundle.js"]); + expect(build.exitCode).toBe(0); + expect(await Bun.file(join(testBase, "bundle.js")).text()).toContain("from-bunfig"); +}); + +test.skipIf(!canUseLandlock())("resolver still fails when the target directory itself is inaccessible", () => { + using root = tempDir("resolver-landlock-target", {}); + const { helperPath } = prepareLandlockFixture(String(root)); + + const allowedBase = join(String(root), "allowed"); + const blockedBase = join(String(root), "blocked"); + mkdirSync(allowedBase, { recursive: true }); + mkdirSync(blockedBase, { recursive: true }); + writeFileSync(join(blockedBase, "index.ts"), "console.log('should not run');\n"); + + const result = Bun.spawnSync({ + cmd: [helperPath, allowedBase, dirname(bunExe()), bunExe(), "index.ts"], + env: bunEnv, + cwd: blockedBase, + stdout: "pipe", + stderr: "pipe", + }); + + expect(result.stdout.toString()).toBeEmpty(); + expect(result.stderr.toString()).toContain("CouldntReadCurrentDirectory"); + expect(result.exitCode).not.toBe(0); +});