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 661530a83..74d1253f7 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 } @@ -1940,7 +1941,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) @@ -2232,7 +2233,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 03f6c688b..e66c51d32 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. @@ -1867,7 +1868,7 @@ export function* walkGlobPatternSteps( linkPath, p => ({ isDirectory: fs.statSync(p).isDirectory(), - real: fs.realpathSync(p), + real: realPathOf(p), }), isAbsenceErrno, () => (unread = true), @@ -1912,7 +1913,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 @@ -1944,7 +1945,7 @@ export function* walkGlobPatternSteps( const baseSpelling = /^[A-Za-z]:$/.test(baseDir) ? `${baseDir}/` : baseDir let baseReal = baseSpelling try { - baseReal = fs.realpathSync(baseSpelling) + baseReal = realPathOf(baseSpelling) } 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 3b4b0d887..5477b8f0c 100644 --- a/test/sandbox/glob-expand.test.ts +++ b/test/sandbox/glob-expand.test.ts @@ -196,7 +196,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')) @@ -1488,6 +1488,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..9ed7fe228 --- /dev/null +++ b/test/utils/real-path.test.ts @@ -0,0 +1,352 @@ +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 } } + 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(() => { + 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(/\/+$/, ''), + ) + // 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', + '--no-warnings', + '--input-type=module', + '-e', + IN_NODE, + resolve(import.meta.dirname, '../../src/utils/real-path.ts'), + ], + { + 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)) + 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]! + 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 }) + } + }, + ), + { numRuns: 60 }, + ) + }, 120_000) +})