diff --git a/src/lib/bun-runtime-preflight.ts b/src/lib/bun-runtime-preflight.ts index 19b9ca3ca09..3f3e7f6a68b 100644 --- a/src/lib/bun-runtime-preflight.ts +++ b/src/lib/bun-runtime-preflight.ts @@ -19,9 +19,10 @@ export class RuntimePreflightError extends Error { } } -type DirectoryIdentity = { dev: number; ino: number; isDirectory(): boolean; isSymbolicLink(): boolean }; +// bigint: NTFS file IDs exceed 2^53, where adjacent IDs are equal as numbers. +type DirectoryIdentity = { dev: bigint; ino: bigint; isDirectory(): boolean; isSymbolicLink(): boolean }; export interface RuntimePreflightFs { - lstatSync: (path: string) => DirectoryIdentity; + lstatSync: (path: string, options: { bigint: true }) => DirectoryIdentity; mkdirSync: (path: string, mode: number) => unknown; rmdirSync: (path: string) => void; readdirSync: (path: string) => readonly string[]; @@ -44,7 +45,7 @@ export interface RuntimePreflightDeps { const probeFs: RuntimePreflightFs = { lstatSync, mkdirSync, rmdirSync, readdirSync }; function sameDirectory(fs: RuntimePreflightFs, path: string, owned: DirectoryIdentity): boolean { - const current = fs.lstatSync(path); + const current = fs.lstatSync(path, { bigint: true }); return current.isDirectory() && !current.isSymbolicLink() && current.dev === owned.dev && current.ino === owned.ino; } @@ -59,7 +60,7 @@ function removeOwnedEmptyDirectory(fs: RuntimePreflightFs, path: string, owned: function prepareRoot(fs: RuntimePreflightFs, root: string, rootWasAbsent?: boolean): void { try { if (rootWasAbsent === undefined) { - try { fs.lstatSync(root); rootWasAbsent = false; } + try { fs.lstatSync(root, { bigint: true }); rootWasAbsent = false; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; rootWasAbsent = true; @@ -73,7 +74,7 @@ function prepareRoot(fs: RuntimePreflightFs, root: string, rootWasAbsent?: boole // Use a concurrently created root, subject to the directory check below. } } - const current = fs.lstatSync(root); + const current = fs.lstatSync(root, { bigint: true }); if (!current.isDirectory() || current.isSymbolicLink()) throw new RuntimePreflightError("create"); } catch { throw new RuntimePreflightError("create"); @@ -83,7 +84,7 @@ function prepareRoot(fs: RuntimePreflightFs, root: string, rootWasAbsent?: boole function probeInProcess(fs: RuntimePreflightFs, path: string): void { let owned: DirectoryIdentity | undefined; try { - try { fs.mkdirSync(path, 0o700); owned = fs.lstatSync(path); } + try { fs.mkdirSync(path, 0o700); owned = fs.lstatSync(path, { bigint: true }); } catch { throw new RuntimePreflightError("create"); } try { fs.rmdirSync(path); } catch { throw new RuntimePreflightError("remove"); } @@ -96,10 +97,10 @@ function probeChild(runtime: DurableBunRuntime, path: string, nonce: string, spa // The child emits only this invocation's nonce and a fixed result, never paths/errors. const script = `const fs = require("node:fs"); const path = ${JSON.stringify(path)}; let result = "create", owned; -try { fs.mkdirSync(path, 0o700); owned = fs.lstatSync(path); result = "remove"; +try { fs.mkdirSync(path, 0o700); owned = fs.lstatSync(path, { bigint: true }); result = "remove"; fs.rmdirSync(path); result = "ok"; } catch { /* result already names the failed step; details never leave the child */ } finally { - if (owned) { try { const now = fs.lstatSync(path); + if (owned) { try { const now = fs.lstatSync(path, { bigint: true }); if (now.isDirectory() && !now.isSymbolicLink() && now.dev === owned.dev && now.ino === owned.ino && fs.readdirSync(path).length === 0) fs.rmdirSync(path); } catch { /* leave any probe entry that cannot be proven ours */ } } diff --git a/tests/lib/bun-runtime-preflight.test.ts b/tests/lib/bun-runtime-preflight.test.ts index 353302af64d..0c7dea985ff 100644 --- a/tests/lib/bun-runtime-preflight.test.ts +++ b/tests/lib/bun-runtime-preflight.test.ts @@ -171,18 +171,44 @@ describe("selected Bun write preflight", () => { test("a replacement empty root with a different inode survives refusal", () => { const parent = temporary(); const root = join(parent, "replaced"); - let originalIno = 0; + let originalIno = 0n; refusal(() => assertSelectedRuntimeWritable(runtime, root, { ...windows, rootWasAbsent: true, spawnSync: (() => { - originalIno = lstatSync(root).ino; + originalIno = lstatSync(root, { bigint: true }).ino; renameSync(root, join(parent, "old")); mkdirSync(root); return { status: 1, stdout: "nonce:create" }; }) as typeof spawnSync, }), "create"); - expect(lstatSync(root).ino).not.toBe(originalIno); + // NTFS file IDs exceed 2^53; as numbers, adjacent IDs compare equal. + expect(lstatSync(root, { bigint: true }).ino).not.toBe(originalIno); expect(readdirSync(root)).toEqual([]); }); + test("a replacement probe whose file ID differs only above 2^53 is never removed", () => { + const root = temporary(); + const probe = join(root, ".ocx-runtime-probe-nonce"); + let replaced = false; + let removals = 0; + // Adjacent IDs above 2^53 collapse to one Number: the owned probe and its replacement. + const lstat = ((path: string, options?: { bigint?: boolean }) => { + const real = lstatSync(path, { bigint: true }); + const ino = path === probe ? (replaced ? 2n ** 53n : 2n ** 53n + 1n) : real.ino; + return { + dev: options?.bigint ? real.dev : Number(real.dev), ino: options?.bigint ? ino : Number(ino), + isDirectory: () => real.isDirectory(), isSymbolicLink: () => real.isSymbolicLink(), + }; + }) as RuntimePreflightFs["lstatSync"]; + refusal(() => assertSelectedRuntimeWritable(standalone, root, { + ...windows, fs: { ...fs, lstatSync: lstat, rmdirSync: path => { + if (removals++ > 0) { rmdirSync(path); return; } + renameSync(path, join(root, "owned-probe")); mkdirSync(path); replaced = true; + throw new Error("denied once"); + } }, + }), "remove"); + expect(existsSync(probe)).toBe(true); + expect(removals).toBe(1); + }); + test("refusal never inspects emptiness or deletes the config root", () => { const root = join(temporary(), "root"); const cleanup: string[] = []; diff --git a/tests/providers/cc-switch-import.test.ts b/tests/providers/cc-switch-import.test.ts index 185f1a7ab85..106475de1bd 100644 --- a/tests/providers/cc-switch-import.test.ts +++ b/tests/providers/cc-switch-import.test.ts @@ -1006,7 +1006,10 @@ describe("CC-Switch keyless provider import", () => { try { writer.run("PRAGMA journal_mode = WAL;"); writer.run("CREATE TABLE providers (id TEXT NOT NULL, app_type TEXT NOT NULL, name TEXT NOT NULL, settings_config TEXT NOT NULL, sort_index INTEGER, PRIMARY KEY (id, app_type))"); - writer.prepare("INSERT INTO providers VALUES (?1, ?2, ?3, ?4, ?5)").run( + // writer.run finalizes its statement; an unfinalized prepare() keeps the db, -wal and -shm + // open past close(), and Windows then refuses to delete the temp tree (EBUSY). + writer.run( + "INSERT INTO providers VALUES (?1, ?2, ?3, ?4, ?5)", "existing-prov", "openai", "Existing", @@ -1017,10 +1020,12 @@ describe("CC-Switch keyless provider import", () => { // Disable auto-checkpoint so subsequent UPDATE and INSERT stay exclusively in live-wal.db-wal. writer.run("PRAGMA wal_autocheckpoint = 0;"); - writer.prepare("UPDATE providers SET settings_config = ?1 WHERE id = 'existing-prov'").run( + writer.run( + "UPDATE providers SET settings_config = ?1 WHERE id = 'existing-prov'", JSON.stringify({ baseUrl: "https://updated.acme.example/v1" }), ); - writer.prepare("INSERT INTO providers VALUES (?1, ?2, ?3, ?4, ?5)").run( + writer.run( + "INSERT INTO providers VALUES (?1, ?2, ?3, ?4, ?5)", "uncheckpointed-prov", "claude", "Uncheckpointed",