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
12 changes: 7 additions & 5 deletions src/sandbox/read-deny-glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,20 +73,21 @@ function collapseReadDenyLocations({
* covering it. The Linux wrapper binds nothing back beneath one: what the
* pattern matches under an allowed path in there was never found, and would
* come back unmasked.
* @param listings - one map for all the patterns of a configuration.
* @param opts.anchor - as in `ExpandGlobOptions.anchor`.
* @param opts.listings - one map for all the patterns of a configuration.
*/
export function expandReadDenyGlobLinux(
globPattern: string,
reExposedPaths: readonly string[],
unlistableDirs?: Set<string>,
listings?: GlobWalkListings,
opts: { anchor?: string; listings?: GlobWalkListings } = {},
): string[] {
return finish(
expandReadDenyGlobLinuxSteps(
globPattern,
reExposedPaths,
unlistableDirs,
listings,
opts,
),
)
}
Expand All @@ -96,12 +97,13 @@ export function* expandReadDenyGlobLinuxSteps(
globPattern: string,
reExposedPaths: readonly string[],
unlistableDirs?: Set<string>,
listings?: GlobWalkListings,
opts: { anchor?: string; listings?: GlobWalkListings } = {},
): Steps<string[]> {
const walk = yield* walkGlobPatternSteps(globPattern, {
withDirectoryForm: true,
followSymlinkedDirectories: true,
listings,
anchor: opts.anchor,
listings: opts.listings,
})
// Where a path the walk reported really lives: the denyRead loop mounts an
// entry there, whatever spelling named it.
Expand Down
4 changes: 2 additions & 2 deletions src/sandbox/sandbox-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1352,7 +1352,7 @@ function getFsReadConfig(): FsReadRestrictionConfig {
pattern,
reExposedPaths,
unlistableDenyDirs,
listings,
{ listings },
),
credentialRestrictions.degradeToDenyPaths,
),
Expand Down Expand Up @@ -1811,7 +1811,7 @@ async function wrapWithSandboxAgain(
pattern,
reExposedPaths,
unlistableDenyDirs,
listings,
{ listings },
),
credentialRestrictions.degradeToDenyPaths,
),
Expand Down
51 changes: 46 additions & 5 deletions src/sandbox/sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1135,6 +1135,15 @@ export interface ExpandGlobOptions {
* don't need it).
*/
caseInsensitive?: boolean
/**
* A directory the pattern is walked beneath, taken as the name it is: a
* leading run of `globPath` that exists on disk and may itself hold `[`,
* `*` or `?`. Only what follows it is pattern. The glob dialect has no
* escape, so this is the one way to say where such a name ends. A
* `TypeError` is thrown for one that is empty or the root, or that
* `globPath` does not start with, up to a separator.
*/
anchor?: string
}

/** Successful listings, by real directory: a second position, or a second
Expand Down Expand Up @@ -1504,7 +1513,8 @@ function globPositions(
* component), which {@link walkGlobPattern} refuses to expand.
*
* @param normalizedPattern - a pattern already through
* {@link normalizePathForSandbox} (and, on Windows, {@link toForwardSlashes})
* {@link normalizePathForSandbox} (and, on Windows, {@link toForwardSlashes}),
* or the tail of one that is walked beneath an anchor
*/
export function globPatternBaseDir(normalizedPattern: string): string {
const staticPrefix = normalizedPattern.split(/[*?[\]]/)[0]
Expand Down Expand Up @@ -1553,8 +1563,30 @@ export function* walkGlobPatternSteps(
realOf: new Map(),
}

const normalizedPattern = toForwardSlashes(normalizePathForSandbox(globPath))
const baseDir = globPatternBaseDir(normalizedPattern)
// Beneath an anchor the pattern is the tail alone, so no character of the
// anchor is compiled; the walk starts at the anchor plus the tail's base.
const anchor =
opts.anchor === undefined ? undefined : toForwardSlashes(opts.anchor)
const normalizedPattern =
anchor === undefined
? toForwardSlashes(normalizePathForSandbox(globPath))
: toForwardSlashes(globPath).slice(anchor.length)
// The tail is cut by length, so a wrong anchor would walk another directory
// and hand back its matches. Thrown, not skipped: a skipped deny is lost.
if (
anchor !== undefined &&
(!/[^/]/.test(anchor) ||
!toForwardSlashes(globPath).startsWith(anchor) ||
!(anchor.endsWith('/') || /^(\/|$)/.test(normalizedPattern)))
) {
throw new TypeError(
`Glob pattern ${globPath} does not lie beneath the anchor ${opts.anchor}`,
)
}
const patternBaseDir = globPatternBaseDir(normalizedPattern)
const baseDirBelowAnchor = patternBaseDir === '/' ? '' : patternBaseDir
const baseDir =
anchor === undefined ? patternBaseDir : anchor + baseDirBelowAnchor
if (baseDir === '' || baseDir === '/') {
logForDebugging(
`[Sandbox] Glob pattern has no literal directory to start from, skipping: ${globPath}`,
Expand Down Expand Up @@ -1657,7 +1689,9 @@ export function* walkGlobPatternSteps(
dir: baseDir,
real: baseReal,
short: baseDir.length < baseReal.length ? baseDir : baseReal,
positions: baseDir.split('/').reduce(positions.next, positions.start),
positions: (anchor === undefined ? baseDir : baseDirBelowAnchor)
.split('/')
.reduce(positions.next, positions.start),
})
for (let frame = pending.pop(); frame !== undefined; frame = pending.pop()) {
const { dir, real } = frame
Expand Down Expand Up @@ -1701,7 +1735,14 @@ export function* walkGlobPatternSteps(
}
const fullPath = path.join(dir, entry.name)
const realPath = path.join(real, entry.name)
const candidate = toForwardSlashes(fullPath)
// A pattern that does not split is matched by its whole spelling, and
// beneath an anchor that is the spelling from the anchor on. It is never
// listed through a link, so every path it sees starts with the anchor.
const spelled = toForwardSlashes(fullPath)
const candidate =
anchor !== undefined && spelled.startsWith(anchor)
? spelled.slice(anchor.length)
: spelled
const isMatch = positions.matches(fresh, entry.name, candidate)
if (isMatch) walk.matches.push(fullPath)
if (entry.isDirectory()) {
Expand Down
123 changes: 123 additions & 0 deletions test/sandbox/glob-expand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,129 @@ describe.if(!isWindows)('walkGlobPattern', () => {
})
})

// ============================================================================
// A pattern walked beneath an anchor
// ============================================================================

/**
* The glob dialect has no escape, so a directory whose own name holds `[`,
* `*` or `?` cannot be spelled inside a pattern: `anchor` says where the name
* ends and the pattern starts.
*/
describe.if(!isWindows)('a pattern walked beneath an anchor', () => {
let root: string
let ordinary: string
const named = ['[WIP] project', 'build*', 'notes (draft?)']

beforeAll(() => {
root = realPath(mkdtempSync(join(tmpdir(), 'glob-anchor-')))
ordinary = join(root, 'ordinary')
// `buildX` and `W` are what `build*` and `[WIP]` match as patterns.
for (const base of [...named, 'ordinary', 'buildX', 'W project']) {
mkdirSync(join(root, base, 'deep', 'er'), { recursive: true })
for (const file of ['a]x', 'deep/a]x', 'deep/er/b]x', 'deep/.env']) {
writeFileSync(join(root, base, file), '')
}
}
})

afterAll(() => {
rmSync(root, { recursive: true, force: true })
})

const beneath = (base: string, found: string[]): string[] =>
found.map(match => match.slice(base.length)).sort()

describe.each(named)('a directory named %p', name => {
it.each(['/**/.env', '/deep/*', '/de*/.env', '/**/er/*', '/*/*]x'])(
'finds for %p what the same tail finds beneath an ordinary directory',
tail => {
const anchor = join(root, name)
const expected = beneath(ordinary, expandGlobPattern(ordinary + tail))
expect(expected.length).toBeGreaterThan(0)
expect(
beneath(anchor, expandGlobPattern(anchor + tail, { anchor })),
).toEqual(expected)
},
)

it.each([
['/**/[a*]x', ['/a]x', '/deep/a]x']],
['/[a*]x', ['/a]x']],
['/deep/[a*]x', ['/deep/a]x']],
])(
'matches %p, which cannot be split, by its spelling from the anchor on',
(tail, expected) => {
// A wildcard inside a bracket expression: the walk matches such a
// pattern against whole paths, which beneath an anchor start at it.
const anchor = join(root, name)
expect(beneath(ordinary, expandGlobPattern(ordinary + tail))).toEqual(
expected,
)
expect(
beneath(anchor, expandGlobPattern(anchor + tail, { anchor })),
).toEqual(expected)
},
)
})

it.each([
[
'another directory of the same length',
(a: string) => a.replace(/.$/, '_'),
],
['a longer path than the pattern', (a: string) => `${a}/deep/.env/more`],
['a name cut short', (a: string) => a.slice(0, -1)],
['the root', () => '/'],
['nothing', () => ''],
])('throws for an anchor that is %s', (_what, wrong) => {
// The tail is cut by length: taken as it comes, a wrong anchor would walk
// another directory and hand back what the tail matches there.
const anchor = join(root, '[WIP] project')
expect(() =>
expandGlobPattern(`${anchor}/deep/.env`, { anchor: wrong(anchor) }),
).toThrow(TypeError)
})

it('reads no character of the anchor as pattern', () => {
// Without the anchor the same strings are patterns from end to end.
const starred = join(root, 'build*')
expect(expandGlobPattern(`${starred}/deep/.env`).sort()).toEqual([
join(root, 'build*', 'deep', '.env'),
join(root, 'buildX', 'deep', '.env'),
])
expect(
expandGlobPattern(`${starred}/deep/.e*`, { anchor: starred }),
).toEqual([join(root, 'build*', 'deep', '.env')])
const bracketed = join(root, '[WIP] project')
expect(expandGlobPattern(`${bracketed}/deep/.e*`)).toEqual([
join(root, 'W project', 'deep', '.env'),
])
expect(
expandGlobPattern(`${bracketed}/deep/.e*`, { anchor: bracketed }),
).toEqual([join(bracketed, 'deep', '.env')])
})

it('keeps a trailing separator, with which a pattern matches nothing', () => {
const anchor = join(root, '[WIP] project')
expect(expandGlobPattern(`${ordinary}/*/`)).toEqual([])
expect(expandGlobPattern(`${anchor}/*/`, { anchor })).toEqual([])
})

it('starts at the anchor plus the directory the tail itself starts from', () => {
const anchor = join(root, '[WIP] project')
expect(walkGlobPattern(`${anchor}/deep/*`, { anchor }).baseLocation).toBe(
join(anchor, 'deep'),
)
const walk = walkGlobPattern(`${anchor}/**/er/**`, {
anchor,
withDirectoryForm: true,
})
expect(walk.baseLocation).toBe(anchor)
expect(walk.directoryMatches).toEqual([join(anchor, 'deep', 'er')])
})
})

// ============================================================================
// expandTilde — `~\` form is Windows-only
// ============================================================================
Expand Down
43 changes: 43 additions & 0 deletions test/sandbox/read-deny-glob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,49 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (collapse)', () => {
})
})

describe.if(!isWindows)('expandReadDenyGlobLinux (beneath an anchor)', () => {
let ROOT: string
let PROJECT: string

beforeAll(() => {
ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-anchor-')))
// A directory a pattern reads as a character class, and `W project`,
// which is what that class matches.
PROJECT = join(ROOT, '[WIP] project')
for (const base of [PROJECT, join(ROOT, 'W project')]) {
mkdirSync(join(base, 'pkg', 'build', 'sub'), { recursive: true })
writeFileSync(join(base, 'pkg', 'build', 'sub', '1.out'), '')
writeFileSync(join(base, 'pkg', '.env'), '')
}
mkdirSync(join(ROOT, 'elsewhere'))
writeFileSync(join(ROOT, 'elsewhere', '.env'), '')
symlinkSync(join(ROOT, 'elsewhere'), join(PROJECT, 'link'))
})

afterAll(() => {
rmSync(ROOT, { recursive: true, force: true })
})

it('walks the pattern beneath the directory of that name and no other', () => {
expect(expandReadDenyGlobLinux(`${PROJECT}/**/build/**`, [])).toEqual([
join(ROOT, 'W project', 'pkg', 'build'),
])
expect(
expandReadDenyGlobLinux(`${PROJECT}/**/build/**`, [], undefined, {
anchor: PROJECT,
}),
).toEqual([join(PROJECT, 'pkg', 'build')])
})

it('lists a match found through a link beneath the anchor where it really lives', () => {
expect(
expandReadDenyGlobLinux(`${PROJECT}/**/.env`, [], undefined, {
anchor: PROJECT,
}),
).toEqual([join(PROJECT, 'pkg', '.env'), join(ROOT, 'elsewhere', '.env')])
})
})

describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => {
let ROOT: string
let OUTSIDE: string
Expand Down
Loading