Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
11 changes: 6 additions & 5 deletions src/sandbox/linux-sandbox-utils.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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.
}
Expand All @@ -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.
}
Expand Down Expand Up @@ -359,7 +360,7 @@ export function unreadableDirectories(
if (
!stat.isDirectory() ||
stat.uid !== process.getuid?.() ||
fs.realpathSync(dir) !== dir
realPathOf(dir) !== dir
) {
break
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
23 changes: 12 additions & 11 deletions src/sandbox/sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -85,7 +86,7 @@ export function* properAncestors(absolutePath: string): Generator<string> {
*/
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.
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -1867,7 +1868,7 @@ export function* walkGlobPatternSteps(
linkPath,
p => ({
isDirectory: fs.statSync(p).isDirectory(),
real: fs.realpathSync(p),
real: realPathOf(p),
}),
isAbsenceErrno,
() => (unread = true),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
}
Expand Down
59 changes: 59 additions & 0 deletions src/utils/real-path.ts
Original file line number Diff line number Diff line change
@@ -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 || '/'
}
1 change: 1 addition & 0 deletions test/public-exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const PUBLIC_VALUE_EXPORTS = [
'isUncPath',
'parseWindowsBinShell',
'parseWindowsSandboxError',
'realPathOf',
'resolveSrtWin',
'restoreWindowsAcl',
'revokeWindowsAcl',
Expand Down
14 changes: 13 additions & 1 deletion test/sandbox/glob-expand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions test/sandbox/mandatory-deny-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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'),
Expand All @@ -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)`,
Expand Down Expand Up @@ -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'),
Expand Down
Loading
Loading