From 66eb29e014f68211242768bd1b21a56818345421 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 28 Sep 2026 18:09:30 +0000 Subject: [PATCH 1/2] Let a glob be walked beneath a directory taken as the name it is The glob dialect has no escape, so a pattern cannot be aimed beneath a directory whose own name holds `[`, `*` or `?`: those characters are read as pattern. `walkGlobPattern`, `expandGlobPattern` and `expandReadDenyGlobLinux` take an `anchor`, a leading run of the path that is a name on disk. Only what follows it is compiled and matched. Without an anchor nothing changes, and nothing passes one yet. --- src/sandbox/read-deny-glob.ts | 3 + src/sandbox/sandbox-utils.ts | 39 +++++++++-- test/sandbox/glob-expand.test.ts | 105 ++++++++++++++++++++++++++++ test/sandbox/read-deny-glob.test.ts | 43 ++++++++++++ 4 files changed, 184 insertions(+), 6 deletions(-) diff --git a/src/sandbox/read-deny-glob.ts b/src/sandbox/read-deny-glob.ts index 342805143..717017035 100644 --- a/src/sandbox/read-deny-glob.ts +++ b/src/sandbox/read-deny-glob.ts @@ -70,15 +70,18 @@ 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 opts.anchor - as in `ExpandGlobOptions.anchor`. */ export function expandReadDenyGlobLinux( globPattern: string, reExposedPaths: readonly string[], unlistableDirs?: Set, + opts: { anchor?: string } = {}, ): string[] { const walk = walkGlobPattern(globPattern, { withDirectoryForm: true, followSymlinkedDirectories: true, + anchor: opts.anchor, }) // 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-utils.ts b/src/sandbox/sandbox-utils.ts index 23859d190..cf4e00d1a 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -1135,6 +1135,13 @@ 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. + */ + anchor?: string } /** What one recursive walk of a glob's base directory found; see {@link walkGlobPattern}. */ @@ -1456,7 +1463,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] @@ -1500,9 +1508,19 @@ export function walkGlobPattern( realOf: new Map(), } - const normalizedPattern = toForwardSlashes(normalizePathForSandbox(globPath)) - const baseDir = globPatternBaseDir(normalizedPattern) - if (baseDir === '' || baseDir === '/') { + // 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) + const patternBaseDir = globPatternBaseDir(normalizedPattern) + const baseDirBelowAnchor = patternBaseDir === '/' ? '' : patternBaseDir + const baseDir = + anchor === undefined ? patternBaseDir : anchor + baseDirBelowAnchor + if (anchor === undefined && (baseDir === '' || baseDir === '/')) { logForDebugging( `[Sandbox] Glob pattern has no literal directory to start from, skipping: ${globPath}`, { level: 'warn' }, @@ -1606,7 +1624,9 @@ export function walkGlobPattern( 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 @@ -1638,7 +1658,14 @@ export function walkGlobPattern( for (const entry of entries) { 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 ec0625c9f..b62efa7d3 100644 --- a/test/sandbox/glob-expand.test.ts +++ b/test/sandbox/glob-expand.test.ts @@ -718,6 +718,111 @@ 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('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 From 7a8df45299d8027800655ca7703d911a7f0a9c04 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 28 Sep 2026 19:34:43 +0000 Subject: [PATCH 2/2] Glob walk: throw for an anchor the pattern does not lie beneath The tail was cut off the pattern by the anchor's length alone, so a wrong anchor of the right length walked another directory and handed back what the tail matched there. An anchor that is empty or the root, that the pattern does not start with, or that ends inside a name now throws a TypeError. Thrown, not skipped: this feeds deny lists, and a skipped deny is lost. An anchored base can then never be the root, so the refusal of a pattern with no literal directory needs no exception for anchors. --- src/sandbox/sandbox-utils.ts | 18 ++++++++++++++++-- test/sandbox/glob-expand.test.ts | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index cf4e00d1a..03e9ba6f8 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -1139,7 +1139,9 @@ export interface ExpandGlobOptions { * 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. + * 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 } @@ -1516,11 +1518,23 @@ export function walkGlobPattern( 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 (anchor === undefined && (baseDir === '' || baseDir === '/')) { + if (baseDir === '' || baseDir === '/') { logForDebugging( `[Sandbox] Glob pattern has no literal directory to start from, skipping: ${globPath}`, { level: 'warn' }, diff --git a/test/sandbox/glob-expand.test.ts b/test/sandbox/glob-expand.test.ts index b62efa7d3..2bb6f4b0f 100644 --- a/test/sandbox/glob-expand.test.ts +++ b/test/sandbox/glob-expand.test.ts @@ -784,6 +784,24 @@ describe.if(!isWindows)('a pattern walked beneath an anchor', () => { ) }) + 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*')