From 3f6394d46b0859ba8130ef662ca546c8e5e9a079 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 10 Oct 2026 17:30:26 +0000 Subject: [PATCH 1/3] Ask the file system for the real path of a name that holds a backslash Off Windows a backslash is a character of a file name like any other, and runtimes differ in what fs.realpathSync does with one. realPathOf is the one place the library asks for a real path: a name without a backslash, and every name on Windows, goes to realpathSync as it is; any other is resolved component by component, split at '/' alone. Dot segments in the given name are folded in text first, as realpathSync folds them. It stands at all eleven call sites, an ESLint rule keeps the raw call out of src/, and the package exports it. --- eslint.config.js | 14 + src/index.ts | 2 + src/sandbox/linux-sandbox-utils.ts | 11 +- src/sandbox/sandbox-utils.ts | 23 +- src/utils/real-path.ts | 59 ++++ test/public-exports.test.ts | 1 + test/sandbox/glob-expand.test.ts | 12 + test/sandbox/mandatory-deny-paths.test.ts | 4 + test/sandbox/names-with-a-backslash.test.ts | 277 ++++++++++++++++ test/utils/real-path.test.ts | 341 ++++++++++++++++++++ 10 files changed, 728 insertions(+), 16 deletions(-) create mode 100644 src/utils/real-path.ts create mode 100644 test/sandbox/names-with-a-backslash.test.ts create mode 100644 test/utils/real-path.test.ts diff --git a/eslint.config.js b/eslint.config.js index 68bb8e3d2..b42d1f472 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -153,5 +153,19 @@ export default [ ], }, }, + { + // Runtimes differ in what realpathSync does with a backslash in a name. + files: ['src/**/*.ts'], + ignores: ['src/utils/real-path.ts'], + rules: { + 'no-restricted-properties': [ + 'error', + ...['realpathSync', 'realpath'].map(property => ({ + property, + message: 'Ask realPathOf (src/utils/real-path.ts) instead.', + })), + ], + }, + }, prettierRecommended, ] diff --git a/src/index.ts b/src/index.ts index 45bac3933..a963a6abc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -127,6 +127,8 @@ export { // Utility functions export { getDefaultWritePaths } from './sandbox/sandbox-utils.js' export { writeRootsOf } from './sandbox/path-entries.js' +// Where the library takes a path to be: for a caller that judges paths itself. +export { realPathOf } from './utils/real-path.js' // Platform utilities export { getWslVersion } from './utils/platform.js' diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 3acfacbc5..772570065 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -1,5 +1,6 @@ import { quote } from '../utils/shell-quote.js' import { logForDebugging } from '../utils/debug.js' +import { realPathOf } from '../utils/real-path.js' import { whichSync } from '../utils/which.js' import { randomBytes } from 'node:crypto' import * as fs from 'fs' @@ -174,7 +175,7 @@ function resolveSymlinkedDenyPath(targetPath: string): string | null { let current = targetPath for (let i = 0; i < MAX_SYMLINK_RESOLUTION_DEPTH; i++) { try { - return fs.realpathSync(current) + return realPathOf(current) } catch { // Some component is missing or dangling — canonicalize manually below. } @@ -192,7 +193,7 @@ function resolveSymlinkedDenyPath(targetPath: string): string | null { remainder.unshift(path.basename(ancestor)) ancestor = parent try { - resolvedAncestor = fs.realpathSync(ancestor) + resolvedAncestor = realPathOf(ancestor) } catch { // Keep walking up. } @@ -359,7 +360,7 @@ export function unreadableDirectories( if ( !stat.isDirectory() || stat.uid !== process.getuid?.() || - fs.realpathSync(dir) !== dir + realPathOf(dir) !== dir ) { break } @@ -1937,7 +1938,7 @@ async function generateFilesystemArgs( let canonical = canonicalFormCache.get(p) if (canonical === undefined) { try { - canonical = retryingTransient(() => fs.realpathSync(p)) + canonical = retryingTransient(() => realPathOf(p)) } catch (err) { canonical = p // vanished or unresolvable: the recorded form stands canonicalFormUnresolved.add(p) @@ -2229,7 +2230,7 @@ async function generateFilesystemArgs( // bwrap follows symlinks, so --bind on a symlink makes the target writable // This could unexpectedly expose paths the user didn't intend to allow try { - const resolvedPath = fs.realpathSync(normalizedPath) + const resolvedPath = realPathOf(normalizedPath) // Trim trailing slashes before comparing: realpathSync never returns // a trailing slash, but normalizedPath may have one, which would cause // a false mismatch and incorrectly treat the path as a symlink. diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index 3cd44f025..0744aa8da 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -3,6 +3,7 @@ import * as path from 'path' import * as fs from 'fs' import { getPlatform } from '../utils/platform.js' import { logForDebugging } from '../utils/debug.js' +import { realPathOf } from '../utils/real-path.js' import type { FilesystemPathEntry } from './sandbox-config.js' /** @@ -85,7 +86,7 @@ export function* properAncestors(absolutePath: string): Generator { */ export function pathSpellings(candidatePath: string): string[] { try { - const resolved = fs.realpathSync(candidatePath) + const resolved = realPathOf(candidatePath) if (resolved !== candidatePath) return [candidatePath, resolved] } catch { // Dangling or vanished: only the spelling names it. @@ -504,11 +505,11 @@ export function normalizePathForSandbox( // tolerates the spelling, and the Linux backend rebuilds its destinations // with path.dirname/join, so that argv was already right. // - // Lexical only, and deliberately not path.normalize/path.resolve: those - // also fold '..', which through a symlinked component aims the rule at a - // different file than the kernel would reach. An absolute spelling's '..' is - // left to realpath below; a relative one was already folded lexically by the - // path.resolve above (pre-existing). + // Lexical only, and deliberately not path.normalize/path.resolve, which + // also fold '..'. An absolute spelling's '..' is left to realPathOf below: + // that folds it in text too, and answers only for a path that is there, so + // one that is not keeps its '..' as written. A relative one was already + // folded lexically by the path.resolve above (pre-existing). if (getPlatform() !== 'windows') { normalizedPath = collapseInteriorSpellings(normalizedPath) } @@ -528,7 +529,7 @@ export function normalizePathForSandbox( // Try to resolve symlinks for the base directory try { - const resolvedBaseDir = fs.realpathSync(baseDir) + const resolvedBaseDir = realPathOf(baseDir) // Validate that resolution stays within expected boundaries if (!isSymlinkOutsideBoundary(baseDir, resolvedBaseDir)) { // Reconstruct the pattern with the resolved directory @@ -555,7 +556,7 @@ export function normalizePathForSandbox( // Resolve symlinks to real paths to avoid bwrap issues // Validate that the resolution stays within expected boundaries try { - const resolvedPath = fs.realpathSync(normalizedPath) + const resolvedPath = realPathOf(normalizedPath) // A symlink pointing outside the expected boundaries (e.g. to a parent // directory) keeps the original path. @@ -1738,7 +1739,7 @@ export function* walkGlobPatternSteps( try { target = onRealPath(realLinkPath, linkPath, p => ({ isDirectory: fs.statSync(p).isDirectory(), - real: fs.realpathSync(p), + real: realPathOf(p), })) } catch (err) { target = isAbsenceErrno(err) ? 'absent' : 'uninspectable' @@ -1779,7 +1780,7 @@ export function* walkGlobPatternSteps( try { // Where it leads can still tell: only a link leads away from its place. const isDirectory = fs.statSync(spelled).isDirectory() - if (fs.realpathSync(spelled) !== path.join(real, name)) type = 'link' + if (realPathOf(spelled) !== path.join(real, name)) type = 'link' else if (isDirectory) type = 'directory' } catch (err) { // Only links make a circle. One that is not there is gone since it was @@ -1807,7 +1808,7 @@ export function* walkGlobPatternSteps( let baseReal = baseDir try { - baseReal = fs.realpathSync(baseDir) + baseReal = realPathOf(baseDir) } catch { // Not there, or a component of it cannot be resolved: list the spelling. } diff --git a/src/utils/real-path.ts b/src/utils/real-path.ts new file mode 100644 index 000000000..f4ba43248 --- /dev/null +++ b/src/utils/real-path.ts @@ -0,0 +1,59 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' + +/** As many links as the kernel follows on the way to one path. */ +const MAX_LINKS = 40 + +/** + * `p` with every symbolic link on the way to it resolved, as + * `fs.realpathSync` gives it, whatever characters its names hold. + * + * Off Windows a backslash is a character of a name like any other, and + * runtimes differ in what `realpathSync` does with one. So the real path of a + * name is asked of the file system component by component, split at `/` + * alone, where the name holds a backslash. Every other name goes to + * `realpathSync` as it is. + * + * A `.`, `..` or `//` in `p` itself is folded in text before anything is + * looked at, which is what `realpathSync` does with them: `link/..` is the + * directory that holds the link. In a link's target they are read as the + * kernel reads them, `..` being the parent of the real directory reached. + * + * An error carries the `code` the kernel gave the call that failed, and + * ELOOP past the number of links the kernel follows. + */ +export function realPathOf(p: string): string { + // What a relative `p` is resolved against is part of the name. + const from = path.isAbsolute(p) ? '' : process.cwd() + if (process.platform === 'win32' || !(from + p).includes('\\')) { + return fs.realpathSync(p) + } + let real = '' + let links = 0 + const rest = path.resolve(from, p).split('/') + for (let name = rest.shift(); name !== undefined; name = rest.shift()) { + if (name === '' || name === '.' || name === '..') { + // Only a directory that can be searched has them: ENOTDIR, EACCES. + fs.lstatSync(`${real}/.`) + if (name === '..') real = real.slice(0, real.lastIndexOf('/')) + continue + } + const next = `${real}/${name}` + if (!fs.lstatSync(next).isSymbolicLink()) { + real = next + continue + } + if (++links > MAX_LINKS) { + throw Object.assign( + new Error( + `ELOOP: too many symbolic links encountered, realpath '${p}'`, + ), + { code: 'ELOOP', syscall: 'realpath', path: p }, + ) + } + const target = fs.readlinkSync(next) + if (target.startsWith('/')) real = '' + rest.unshift(...target.split('/')) + } + return real || '/' +} diff --git a/test/public-exports.test.ts b/test/public-exports.test.ts index 5e0006cdb..31eb18251 100644 --- a/test/public-exports.test.ts +++ b/test/public-exports.test.ts @@ -53,6 +53,7 @@ const PUBLIC_VALUE_EXPORTS = [ 'isUncPath', 'parseWindowsBinShell', 'parseWindowsSandboxError', + 'realPathOf', 'resolveSrtWin', 'restoreWindowsAcl', 'revokeWindowsAcl', diff --git a/test/sandbox/glob-expand.test.ts b/test/sandbox/glob-expand.test.ts index 850af7fdb..1fbe663bb 100644 --- a/test/sandbox/glob-expand.test.ts +++ b/test/sandbox/glob-expand.test.ts @@ -973,6 +973,18 @@ describe.if(!isWindows)('a walk over listings that give no types', () => { }) } + it('leaves a directory with a backslash in its name to be denied whole when lstat fails', () => { + const named = join(data, 'na\\me') + mkdirSync(named) + try { + const { walks } = walked(['**/.env'], { lstatSync: 'EIO' }) + + expect(walks[0]!.unlisted).toEqual([named]) + } finally { + rmSync(named, { recursive: true }) + } + }) + it('asks once for all the patterns handed the same listings', () => { const patterns = ['**/.env', '**/*.txt', '**/deep/**'] const alone = walked(patterns) diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index b5e3a5f47..3090265b2 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -410,6 +410,7 @@ describe.if(isSupportedPlatform)( join(project, 'is-readable'), join(project, 'really-locked'), join(project, 'locked: too'), + join(project, 'locked\\so'), join(project, 'one-thread'), join(project, 'no-prefix'), join(project, 'listed-only/child'), @@ -423,6 +424,7 @@ describe.if(isSupportedPlatform)( const locked = [ join(project, 'really-locked'), join(project, 'locked: too'), + join(project, 'locked\\so'), join(project, 'one-thread'), join(project, 'no-prefix'), join(project, 'a/b/c/too-deep'), @@ -438,6 +440,7 @@ describe.if(isSupportedPlatform)( 'not-there/at-all', 'entered-only/not-there', 'locked: too', + 'locked\\so', 'listed-only/child', ].map( name => `rg: ${project}/${name}: Permission denied (os error 13)`, @@ -474,6 +477,7 @@ describe.if(isSupportedPlatform)( expect(unreadableDirectories(said.join('\n'), project, 3)).toEqual([ join(project, 'really-locked'), join(project, 'locked: too'), + join(project, 'locked\\so'), join(project, 'listed-only'), join(project, 'one-thread'), join(project, 'no-prefix'), diff --git a/test/sandbox/names-with-a-backslash.test.ts b/test/sandbox/names-with-a-backslash.test.ts new file mode 100644 index 000000000..6f4b9e921 --- /dev/null +++ b/test/sandbox/names-with-a-backslash.test.ts @@ -0,0 +1,277 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { wrapCommandWithSandboxMacOS } from '../../src/sandbox/macos-sandbox-utils.js' +import type { SandboxRuntimeConfig } from '../../src/sandbox/sandbox-config.js' +import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' +import { + normalizePathForSandbox, + pathSpellings, +} from '../../src/sandbox/sandbox-utils.js' +import { bwrapCanNamespaceNetwork } from '../helpers/bwrap-namespace.js' +import { isLinux, isMacOS, isWindows } from '../helpers/platform.js' + +/** + * Off Windows a backslash is a character of a file name like any other: + * `a\b` is one name, and `a/b` is another file. A rule that names the one + * applies to it, however it is spelled, and leaves the other alone. + */ + +const APPENDED = 'appended' +const canRun = isMacOS || (isLinux && bwrapCanNamespaceNetwork()) + +let root: string +const savedCwd = process.cwd() + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'backslash-'))) +}) + +afterEach(async () => { + process.chdir(savedCwd) + await SandboxManager.reset() + rmSync(root, { recursive: true, force: true }) +}) + +type Policy = Omit, 'filesystem'> & { + filesystem?: Partial +} + +/** + * Which of `files`, given from `root`, a command in the sandbox can read, or + * append to. Each is made first, holding its own name, unless `absent`. The + * command runs behind an `echo BOOTED`, so a sandbox that failed to start + * cannot read as a command that was refused. + */ +async function reach( + policy: Policy, + does: 'read' | 'append', + files: string[], + absent = false, +): Promise { + for (const file of files) { + mkdirSync(dirname(join(root, file)), { recursive: true }) + if (!absent) writeFileSync(join(root, file), `<${file}>`) + } + await SandboxManager.initialize( + { + network: { allowedDomains: [], deniedDomains: [] }, + ...policy, + filesystem: { + denyRead: [], + allowWrite: [], + denyWrite: [], + ...policy.filesystem, + }, + }, + undefined, + false, + ) + const wrapped = await SandboxManager.wrapWithSandbox( + ['echo BOOTED'] + .concat( + files.map(file => + does === 'read' + ? `cat '${join(root, file)}'` + : `echo ${APPENDED} >> '${join(root, file)}'`, + ), + ) + .join('; '), + ) + const { stdout } = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + timeout: 15_000, + }) + SandboxManager.cleanupAfterCommand() + expect(stdout).toContain('BOOTED') + return files.filter(file => + does === 'read' + ? stdout.includes(`<${file}>`) + : existsSync(join(root, file)) && + readFileSync(join(root, file), 'utf8').includes(APPENDED), + ) +} + +const credential = (path: string, mode: 'deny' | 'mask'): Policy => ({ + network: { allowedDomains: ['localhost'], deniedDomains: [] }, + credentials: { files: [{ path, mode }], allowPlaintextInject: true }, +}) + +/** Each kind of rule as the policy that holds it for `entry`, what a command + * then tries, and whether the rule leaves it `entry` or everything else. */ +const RULES: Array< + [string, (entry: string) => Policy, 'read' | 'append', 'it' | 'the rest'] +> = [ + ['denyRead', e => ({ filesystem: { denyRead: [e] } }), 'read', 'the rest'], + [ + 'a denyRead pattern', + e => ({ filesystem: { denyRead: [`${dirname(e)}/**/${basename(e)}`] } }), + 'read', + 'the rest', + ], + ['a credentials deny', e => credential(e, 'deny'), 'read', 'the rest'], + ['a credentials mask', e => credential(e, 'mask'), 'read', 'the rest'], + [ + 'allowRead', + e => ({ filesystem: { denyRead: [join(root, 'tree')], allowRead: [e] } }), + 'read', + 'it', + ], + [ + 'an allowRead pattern', + e => ({ + filesystem: { + denyRead: [join(root, 'tree')], + allowRead: [e.replace('\\', '*')], + }, + }), + 'read', + 'it', + ], + [ + 'denyWrite', + e => ({ filesystem: { allowWrite: [root], denyWrite: [e] } }), + 'append', + 'the rest', + ], + ['allowWrite', e => ({ filesystem: { allowWrite: [e] } }), 'append', 'it'], +] + +describe.if(canRun)('a rule on a name that holds a backslash', () => { + for (const [rule, policy, does, leaves] of RULES) { + for (const name of ['a\\b', 'c\\d/file']) { + for (const through of ['tree', 'link']) { + // Of the write rules only a deny for bubblewrap is followed through a + // link, whatever the name. + if ( + does === 'append' && + through === 'link' && + !(isLinux && rule === 'denyWrite') + ) { + continue + } + for (const other of [name.replace('\\', '/'), 'plain']) { + it(`${rule} of ${through}/${name}, beside tree/${other}, leaves ${leaves} within reach`, async () => { + symlinkSync('tree', join(root, 'link')) + const files = [`tree/${name}`, `tree/${other}`] + + expect( + await reach(policy(join(root, through, name)), does, files), + ).toEqual([files[leaves === 'it' ? 0 : 1]!]) + }, 60_000) + } + } + } + } + + for (const other of ['c/d/file', 'plain']) { + it(`denyWrite of tree/c\\d/file, which is not there, beside tree/${other}, leaves the rest within reach`, async () => { + expect( + await reach( + { + filesystem: { + allowWrite: [root], + denyWrite: [join(root, 'tree/c\\d/file')], + }, + }, + 'append', + ['tree/c\\d/file', `tree/${other}`], + true, + ), + ).toEqual([`tree/${other}`]) + }, 60_000) + } + + it('keeps the shell start-up file of a working directory with such a name read-only', async () => { + mkdirSync(join(root, 'pro\\ject')) + process.chdir(join(root, 'pro\\ject')) + + expect( + await reach({ filesystem: { allowWrite: [root] } }, 'append', [ + 'pro\\ject/.bashrc', + 'pro\\ject/plain', + 'pro/ject/.bashrc', + ]), + ).toEqual(['pro\\ject/plain', 'pro/ject/.bashrc']) + }, 60_000) + + it('keeps a shell start-up file in a directory with such a name, beneath the working directory, read-only', async () => { + process.chdir(root) + + expect( + await reach({ filesystem: { allowWrite: [root] } }, 'append', [ + 'c\\d/.bashrc', + 'c\\d/plain', + 'c/d/.bashrc', + ]), + ).toEqual(['c\\d/plain']) + }, 60_000) + + // Only bubblewrap is handed what a pattern finds; Seatbelt matches paths. + it.if(isLinux)( + 'denies what a denyRead pattern reaches through a link in a directory with such a name', + async () => { + mkdirSync(join(root, 'tree/c\\d'), { recursive: true }) + symlinkSync(join(root, 'elsewhere'), join(root, 'tree/c\\d/link')) + + expect( + await reach( + { filesystem: { denyRead: [join(root, 'tree/**/file')] } }, + 'read', + ['elsewhere/file', 'elsewhere/plain'], + ), + ).toEqual(['elsewhere/plain']) + }, + 60_000, + ) +}) + +describe.if(!isWindows)('a name that holds a backslash, behind a link', () => { + beforeEach(() => { + mkdirSync(join(root, 'tree/a/b'), { recursive: true }) + mkdirSync(join(root, 'tree/a\\b')) + symlinkSync('tree', join(root, 'link')) + }) + + it('is spelled as written and as it really is', () => { + expect(pathSpellings(join(root, 'link/a\\b'))).toEqual([ + join(root, 'link/a\\b'), + join(root, 'tree/a\\b'), + ]) + }) + + it.each(['', '/**/file'])( + 'is normalized with a parent reference before it folded (%s)', + tail => { + expect(normalizePathForSandbox(`${root}/tree/a/../a\\b${tail}`)).toBe( + `${root}/tree/a\\b${tail}`, + ) + }, + ) + + it('is denied in a Seatbelt profile where it really is', () => { + const profile = wrapCommandWithSandboxMacOS({ + command: 'true', + needsNetworkRestriction: false, + readConfig: { denyOnly: [join(root, 'link/a\\b')] }, + writeConfig: undefined, + }) + + expect(profile).toContain( + `(subpath ${JSON.stringify(join(root, 'tree/a\\b'))})`, + ) + expect(profile).not.toContain(join(root, 'tree/a/b')) + }) +}) diff --git a/test/utils/real-path.test.ts b/test/utils/real-path.test.ts new file mode 100644 index 000000000..808b0c14a --- /dev/null +++ b/test/utils/real-path.test.ts @@ -0,0 +1,341 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + spyOn, +} from 'bun:test' +import * as fc from 'fast-check' +import { spawnSync } from 'node:child_process' +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, relative, resolve } from 'node:path' +import { realPathOf } from '../../src/utils/real-path.js' +import { isWindows } from '../helpers/platform.js' + +/** What a call gives: its value, or the code of what it throws. */ +function answer(call: () => string): string { + try { + return call() + } catch (error) { + return String((error as NodeJS.ErrnoException).code) + } +} + +describe.if(!isWindows)('realPathOf', () => { + let root: string + const savedCwd = process.cwd() + /** As written: `join` would fold what `p` holds. */ + const at = (p: string): string => `${root}/${p}` + + beforeAll(() => { + root = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), 'real-path-'))) + for (const dir of [ + 'a\\b/in', + 'a/b/in', + 'alone\\', + 'plain/in', + 'shut\\/in', + ]) { + fs.mkdirSync(at(dir), { recursive: true }) + } + for (const file of [ + 'a\\b/file', + 'a/b/file', + 'alone\\/file', + 'plain/file', + 'file', + ]) { + fs.writeFileSync(at(file), '') + } + for (const [link, target] of [ + ['to-it', 'a\\b'], + ['to-it-from-the-root', at('a\\b')], + ['a\\b/out', '../plain'], + ['a\\b/up-behind-a-link', 'out/../file'], + ['a\\b/slashes', './/in/.//'], + ['a\\b/nowhere', 'not-there'], + ['a\\b/round', 'round'], + ['a\\b/up-from-a-file', 'file/..'], + ['a\\b/in-a-file', 'file/.'], + ['a\\b/file-as-a-directory', 'file/'], + ['a\\b/up-from-shut', '../shut\\/..'], + ['a\\b/1', 'file'], + ]) { + fs.symlinkSync(target!, at(link!)) + } + for (let i = 2; i <= 41; i++) fs.symlinkSync(`${i - 1}`, at(`a\\b/${i}`)) + fs.chmodSync(at('shut\\'), 0o600) + }) + + afterEach(() => process.chdir(savedCwd)) + + afterAll(() => { + fs.chmodSync(at('shut\\'), 0o700) + fs.rmSync(root, { recursive: true, force: true }) + }) + + it.each([ + // The name itself, with another file where slashes would lead or none. + ['a\\b', 'a\\b'], + ['a\\b/file', 'a\\b/file'], + ['alone\\', 'alone\\'], + ['a/b/file', 'a/b/file'], + // Links to it, from it and in it. + ['to-it/file', 'a\\b/file'], + ['to-it-from-the-root/in', 'a\\b/in'], + ['a\\b/out/file', 'plain/file'], + ['a\\b/40', 'a\\b/file'], + // In a link's target `..` is the parent of where the way has led. + ['a\\b/up-behind-a-link', 'file'], + ['a\\b/slashes', 'a\\b/in'], + // In the name given they are folded in text. + ['a\\b/out/../file', 'a\\b/file'], + ['a\\b/file/../in', 'a\\b/in'], + ['a\\b/not-there/..', 'a\\b'], + ['a\\b/./in//', 'a\\b/in'], + ['a\\b/..', ''], + ['a\\b/file/', 'a\\b/file'], + ])('%s is %s', (given, real) => { + expect(realPathOf(at(given))).toBe(resolve(at(real))) + }) + + it.each([ + ['a\\b/not-there', 'ENOENT'], + ['not\\there', 'ENOENT'], + ['a\\b/nowhere', 'ENOENT'], + ['a\\b/nowhere/in', 'ENOENT'], + ['a\\b/file/in', 'ENOTDIR'], + ['a\\b/up-from-a-file', 'ENOTDIR'], + ['a\\b/in-a-file', 'ENOTDIR'], + ['a\\b/file-as-a-directory', 'ENOTDIR'], + ['a\\b/round', 'ELOOP'], + ['a\\b/round/in', 'ELOOP'], + ['a\\b/41', 'ELOOP'], + [`a\\b/${'n'.repeat(256)}`, 'ENAMETOOLONG'], + ])('%s fails with %s', (given, code) => { + expect(answer(() => realPathOf(at(given)))).toBe(code) + }) + + // root searches a directory of any mode. + it.if(process.getuid?.() !== 0).each(['shut\\/in', 'a\\b/up-from-shut'])( + '%s fails with EACCES', + given => { + expect(answer(() => realPathOf(at(given)))).toBe('EACCES') + }, + ) + + it('says which path had too many links', () => { + expect(() => realPathOf(at('a\\b/round'))).toThrow( + expect.objectContaining({ syscall: 'realpath', path: at('a\\b/round') }), + ) + }) + + it.each([ + ['a\\b', 'file', 'a\\b/file'], + ['a\\b', '.', 'a\\b'], + ['a\\b', '..', ''], + ['a\\b/in', '../out/file', 'plain/file'], + ['alone\\', '.', 'alone\\'], + ['alone\\', 'file', 'alone\\/file'], + ['plain', '../a\\b/file', 'a\\b/file'], + ['plain', 'file', 'plain/file'], + ])('in %s, %s is %s', (cwd, given, real) => { + process.chdir(at(cwd)) + + expect(realPathOf(given)).toBe(resolve(at(real))) + }) + + it('is the root for the root', () => { + fs.symlinkSync('/', at('a\\b/root')) + + expect(realPathOf(at('a\\b/root'))).toBe('/') + expect(realPathOf(at('a\\b/root/..'))).toBe(at('a\\b')) + expect(realPathOf(at('a\\b/root/../..'))).toBe(root) + }) + + describe('what it asks', () => { + const CALLS = ['realpathSync', 'lstatSync', 'readlinkSync'] as const + function asked(given: string): string[] { + const calls: string[] = [] + const spies = CALLS.map(call => { + const real = fs[call] as (...args: unknown[]) => unknown + return spyOn(fs, call).mockImplementation(((...args: unknown[]) => { + calls.push(`${call} ${String(args[0])}`) + return real(...args) + }) as never) + }) + try { + realPathOf(given) + } finally { + spies.forEach(spy => spy.mockRestore()) + } + return calls + } + + it.each(['plain/file', 'to-it/file', 'plain/../plain//in/'])( + 'hands %s, which holds no backslash, to realpathSync as it is', + given => { + expect(asked(at(given))[0]).toBe(`realpathSync ${at(given)}`) + }, + ) + + it('asks about each component once where the name holds one', () => { + const above = [...root.matchAll(/(?<=.)\/|$/g)].map( + cut => `lstatSync ${root.slice(0, cut.index)}`, + ) + + expect(asked(at('a\\b/out/file'))).toEqual([ + 'lstatSync /.', + ...above, + `lstatSync ${at('a\\b')}`, + `lstatSync ${at('a\\b/out')}`, + `readlinkSync ${at('a\\b/out')}`, + `lstatSync ${at('a\\b')}/.`, + `lstatSync ${at('plain')}`, + `lstatSync ${at('plain/file')}`, + ]) + }) + + it('hands every name to realpathSync on Windows, where a backslash separates', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')! + Object.defineProperty(process, 'platform', { value: 'win32' }) + try { + expect(asked(at('to-it'))).toContain(`realpathSync ${at('to-it')}`) + expect(answer(() => asked(at('a\\b')).join())).not.toContain( + 'lstatSync', + ) + } finally { + Object.defineProperty(process, 'platform', platform) + } + }) + }) +}) + +/** + * Trees of directories, files and links whose names hold backslashes, beside + * what the same names lead to with slashes. No file has two names but by a + * link, so its real path is the one path to it that crosses none. + */ +describe.if(!isWindows)('property: realPathOf', () => { + const NAMES = ['a', 'b', 'a\\b', 'b\\', '\\a', '..\\a'] + const segments = (maxLength: number) => + fc.array(fc.constantFrom(...NAMES, ...NAMES, '..', '..', '.', ''), { + maxLength, + }) + /** Where an entry goes, by the index of an earlier one, its name, and what + * it is: a link leads to an earlier entry or along `way`, from the root + * of the tree or from where it lies. */ + const entry = fc.record({ + inside: fc.nat(), + name: fc.constantFrom(...NAMES), + kind: fc.constantFrom('directory', 'directory', 'file', 'link', 'link'), + to: fc.option(fc.nat()), + way: segments(4), + fromRoot: fc.boolean(), + }) + const IN_NODE = ` + import fs from 'node:fs' + const { realPathOf } = await import(process.argv[1]) + const answer = call => { try { return call() } catch (error) { return error.code } } + console.log(JSON.stringify(JSON.parse(fs.readFileSync(0, 'utf8')).map(p => + [answer(() => realPathOf(p)), answer(() => fs.realpathSync(p))])))` + /** The file the kernel finds at `p`, or the code it refuses with. */ + const fileAt = (p: string): string => + answer(() => { + const { dev, ino } = fs.statSync(p) + return `${dev}:${ino}` + }) + const linksOnTheWayTo = (p: string): string[] => + [...p.matchAll(/(?<=.)\/|$/g)] + .map(cut => p.slice(0, cut.index)) + .filter(upTo => fs.lstatSync(upTo).isSymbolicLink()) + + it('leads where the kernel leads and crosses no link, and is what Node gives', () => { + fc.assert( + fc.property( + fc.constantFrom('real-path-', 'real\\path-'), + fc.array(entry, { minLength: 8, maxLength: 24 }), + fc.array(fc.tuple(fc.nat(), segments(3)), { + minLength: 30, + maxLength: 30, + }), + (prefix, entries, questions) => { + const root = fs.mkdtempSync(join(fs.realpathSync(tmpdir()), prefix)) + try { + const directories = [root] + const made = [root] + const pick = (from: string[], n: number) => from[n % from.length]! + const isName = (s: string) => !['', '.', '..'].includes(s) + let upBehindAName = false + for (const { inside, name, kind, to, way, fromRoot } of entries) { + const p = join(pick(directories, inside), name) + if (fs.existsSync(p) || made.includes(p)) continue + if (kind === 'directory') { + fs.mkdirSync(p) + directories.push(p) + } else if (kind === 'file') { + fs.writeFileSync(p, '') + } else { + const led = to === null ? way.join('/') : pick(made, to) + const target = + to === null + ? (fromRoot ? `${root}/` : '') + led + : fromRoot + ? led + : relative(dirname(p), led) + if (target === '') continue + fs.symlinkSync(target, p) + upBehindAName ||= + to === null && + way.some((s, i) => s === '..' && way.slice(0, i).some(isName)) + } + made.push(p) + } + // What a slash after a file's name does is each runtime's own. + const paths = questions.map(([from, way]) => + [pick(made, from), ...way].join('/').replace(/\/+$/, ''), + ) + const { stdout } = spawnSync( + 'node', + [ + '--experimental-strip-types', + '--no-warnings', + '--input-type=module', + '-e', + IN_NODE, + resolve(import.meta.dirname, '../../src/utils/real-path.ts'), + ], + { input: JSON.stringify(paths), encoding: 'utf8' }, + ) + const inNode = JSON.parse(stdout) as string[][] + paths.forEach((p, i) => { + const real = answer(() => realPathOf(p)) + expect([p, real.startsWith('/') ? fileAt(real) : real]).toEqual([ + p, + fileAt(resolve(p)), + ]) + if (real.startsWith('/')) { + expect([p, resolve(real), linksOnTheWayTo(real)]).toEqual([ + p, + real, + [], + ]) + } + const [there, own] = inNode[i]! + expect([p, there]).toEqual([p, real]) + // Node folds a link's target in text, so one that goes up from + // behind a name is not asked of it. + if (!upBehindAName) expect([p, own]).toEqual([p, real]) + }) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }, + ), + { numRuns: 60 }, + ) + }, 120_000) +}) From 282634c6a0fb7627d3fdc22a2d50a21c3d26249b Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 10 Oct 2026 17:37:25 +0000 Subject: [PATCH 2/3] test: expect the real path of a directory with a backslash in its name The case asked its own helper, which goes to fs.realpathSync and falls back to the spelling. Where the temp directory is reached through a link, as on macOS, the walk gives the real path. The expectation is built from the real temp directory. --- test/sandbox/glob-expand.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/sandbox/glob-expand.test.ts b/test/sandbox/glob-expand.test.ts index 1fbe663bb..3442bac14 100644 --- a/test/sandbox/glob-expand.test.ts +++ b/test/sandbox/glob-expand.test.ts @@ -183,7 +183,7 @@ describe('expandGlobPattern', () => { const bsDir = join(RAW_TEST_DIR, 'app\\creds') mkdirSync(bsDir, { recursive: true }) writeFileSync(join(bsDir, 'key.pem'), 'k') - const realBsDir = realPath(bsDir) + const realBsDir = join(TEST_DIR, 'app\\creds') const results = expandGlobPattern(join(bsDir, '*.pem')) expect(results).toContain(join(realBsDir, 'key.pem')) From 8a9f332774f8178375f60ac9c82b36f0cd94a049 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 10 Oct 2026 18:10:03 +0000 Subject: [PATCH 3/3] test: ask Node only where its own reading of a link's target is the kernel's The property compares realPathOf with the kernel, and in a Node child with Node's fs.realpathSync. Runtimes differ in how they read a link's target that goes up from behind a name, and for a name with no backslash realPathOf gives the runtime's answer. So on a tree with such a link the child is asked about names that hold a backslash alone. The child has a time limit, and its error output is part of the expectation. --- test/utils/real-path.test.ts | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/test/utils/real-path.test.ts b/test/utils/real-path.test.ts index 808b0c14a..9ed7fe228 100644 --- a/test/utils/real-path.test.ts +++ b/test/utils/real-path.test.ts @@ -240,8 +240,10 @@ describe.if(!isWindows)('property: realPathOf', () => { import fs from 'node:fs' const { realPathOf } = await import(process.argv[1]) const answer = call => { try { return call() } catch (error) { return error.code } } - console.log(JSON.stringify(JSON.parse(fs.readFileSync(0, 'utf8')).map(p => - [answer(() => realPathOf(p)), answer(() => fs.realpathSync(p))])))` + const { paths, askNode } = JSON.parse(fs.readFileSync(0, 'utf8')) + console.log(JSON.stringify(paths.map(p => [ + (askNode || p.includes('\\\\')) && answer(() => realPathOf(p)), + askNode && answer(() => fs.realpathSync(p))])))` /** The file the kernel finds at `p`, or the code it refuses with. */ const fileAt = (p: string): string => answer(() => { @@ -298,7 +300,11 @@ describe.if(!isWindows)('property: realPathOf', () => { const paths = questions.map(([from, way]) => [pick(made, from), ...way].join('/').replace(/\/+$/, ''), ) - const { stdout } = spawnSync( + // Node folds a link's target in text, so where one goes up from + // behind a name its own answer can differ, or never come. It is + // also what realPathOf gives there for a name with no backslash. + const askNode = !upBehindAName + const { stdout, stderr, error } = spawnSync( 'node', [ '--experimental-strip-types', @@ -308,8 +314,13 @@ describe.if(!isWindows)('property: realPathOf', () => { IN_NODE, resolve(import.meta.dirname, '../../src/utils/real-path.ts'), ], - { input: JSON.stringify(paths), encoding: 'utf8' }, + { + input: JSON.stringify({ paths, askNode }), + encoding: 'utf8', + timeout: 20_000, + }, ) + expect([error, stderr]).toEqual([undefined, '']) const inNode = JSON.parse(stdout) as string[][] paths.forEach((p, i) => { const real = answer(() => realPathOf(p)) @@ -325,10 +336,10 @@ describe.if(!isWindows)('property: realPathOf', () => { ]) } const [there, own] = inNode[i]! - expect([p, there]).toEqual([p, real]) - // Node folds a link's target in text, so one that goes up from - // behind a name is not asked of it. - if (!upBehindAName) expect([p, own]).toEqual([p, real]) + if (askNode || p.includes('\\')) { + expect([p, there]).toEqual([p, real]) + } + if (askNode) expect([p, own]).toEqual([p, real]) }) } finally { fs.rmSync(root, { recursive: true, force: true })