diff --git a/src/sandbox/read-deny-glob.ts b/src/sandbox/read-deny-glob.ts index 34c83bf08..92f6187b4 100644 --- a/src/sandbox/read-deny-glob.ts +++ b/src/sandbox/read-deny-glob.ts @@ -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, - listings?: GlobWalkListings, + opts: { anchor?: string; listings?: GlobWalkListings } = {}, ): string[] { return finish( expandReadDenyGlobLinuxSteps( globPattern, reExposedPaths, unlistableDirs, - listings, + opts, ), ) } @@ -96,12 +97,13 @@ export function* expandReadDenyGlobLinuxSteps( globPattern: string, reExposedPaths: readonly string[], unlistableDirs?: Set, - listings?: GlobWalkListings, + opts: { anchor?: string; listings?: GlobWalkListings } = {}, ): Steps { 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. diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 7620fab31..4412be9bb 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -1352,7 +1352,7 @@ function getFsReadConfig(): FsReadRestrictionConfig { pattern, reExposedPaths, unlistableDenyDirs, - listings, + { listings }, ), credentialRestrictions.degradeToDenyPaths, ), @@ -1811,7 +1811,7 @@ async function wrapWithSandboxAgain( pattern, reExposedPaths, unlistableDenyDirs, - listings, + { listings }, ), credentialRestrictions.degradeToDenyPaths, ), diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index e17a72854..aa664c7ba 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -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 @@ -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] @@ -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}`, @@ -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 @@ -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()) { diff --git a/test/sandbox/glob-expand.test.ts b/test/sandbox/glob-expand.test.ts index c039b5a9a..3ed93537b 100644 --- a/test/sandbox/glob-expand.test.ts +++ b/test/sandbox/glob-expand.test.ts @@ -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 // ============================================================================ diff --git a/test/sandbox/read-deny-glob.test.ts b/test/sandbox/read-deny-glob.test.ts index 1d36bf4c0..46e0f1aa9 100644 --- a/test/sandbox/read-deny-glob.test.ts +++ b/test/sandbox/read-deny-glob.test.ts @@ -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