diff --git a/tests/helpers-cleanup.test.cjs b/tests/helpers-cleanup.test.cjs index 15d3f29a5..3e63c82e1 100644 --- a/tests/helpers-cleanup.test.cjs +++ b/tests/helpers-cleanup.test.cjs @@ -159,3 +159,29 @@ test('cleanup still removes a real os.tmpdir()-rooted directory (control)', () = assert.strictEqual(fs.existsSync(dir), false, 'os.tmpdir()-rooted directory should be removed'); }); + +// ─── Test 6: realpath'd os.tmpdir() form is not refused (regression) ──────── + +test('cleanup accepts a realpath()d temp dir even when it differs from the raw path (macOS /var -> /private/var)', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cleanup-realpath-')); + const realPath = fs.realpathSync(dir); + + if (realPath !== dir) { + // macOS (and any other symlinked-tmpdir platform): the realpath'd form + // diverges from the raw mkdtempSync() path. This is exactly the shape a + // caller gets from fs.realpathSync() or from process.cwd() after + // chdir-ing into a realpath'd dir — assert the guard does NOT refuse it. + cleanup(realPath); + assert.strictEqual(fs.existsSync(realPath), false, 'realpath()d form should be removed, not refused'); + assert.strictEqual(fs.existsSync(dir), false, 'raw path should also be gone (same directory)'); + } else { + // Linux and any platform with no tmpdir symlink indirection: realpath() + // equals the raw path, so this branch exercises the ordinary path and + // keeps the test meaningful (non-vacuous) on both platforms. + t.after(() => { + if (fs.existsSync(dir)) cleanup(dir); + }); + cleanup(dir); + assert.strictEqual(fs.existsSync(dir), false, 'temp dir should be removed'); + } +}); diff --git a/tests/helpers.cjs b/tests/helpers.cjs index ef5d720e2..6e63de0a8 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -210,13 +210,32 @@ function cleanup(tmpDir) { if (typeof tmpDir !== 'string' || tmpDir.length === 0) return; const target = path.resolve(tmpDir); const cwd = path.resolve(process.cwd()); - const tmpRoot = path.resolve(os.tmpdir()); // isTmpPath was previously computed only inside the catch block below, so it // classified a transient Windows error but was never consulted by the // destructive rmSync call itself — a wrong `target` would still chdir out of // its own tree and get force-deleted. Hoisted above both the chdir and the // rmSync so an out-of-tmpdir path is refused before either can run. - const isTmpPath = target === tmpRoot || target.startsWith(`${tmpRoot}${path.sep}`); + // + // Two acceptable roots, not one: on macOS os.tmpdir() returns a path under + // /var/folders/... but /var is a symlink to /private/var, and path.resolve() + // does not resolve symlinks. A caller that passed the REALPATH'd form of a + // temp dir (e.g. via fs.realpathSync(), or via process.cwd() after chdir-ing + // into a realpath'd dir) would resolve to /private/var/folders/... and get + // wrongly refused by a check against only path.resolve(os.tmpdir()). Build + // the accepted-roots set from both the raw and realpath'd forms of + // os.tmpdir() — realpathSync is wrapped in try/catch because it throws if + // the temp root is momentarily missing, and this guard must never crash + // cleanup() over that. Do NOT realpath `target` itself: cleanup() is + // legitimately called on already-deleted or never-created dirs, and + // realpathSync throws ENOENT on a missing path. + const tmpRoots = [path.resolve(os.tmpdir())]; + try { + const realTmpRoot = fs.realpathSync(os.tmpdir()); + if (!tmpRoots.includes(realTmpRoot)) tmpRoots.push(realTmpRoot); + } catch (_) { /* temp root unreadable — fall back to the resolved form only */ } + const isTmpPath = tmpRoots.some( + (root) => target === root || target.startsWith(`${root}${path.sep}`) + ); if (!isTmpPath) { throw new Error(`cleanup() refused to remove a path outside os.tmpdir(): ${target}`); }