diff --git a/README.md b/README.md index 7a96720d2..1a7af0546 100644 --- a/README.md +++ b/README.md @@ -530,6 +530,7 @@ Uses two different patterns: - `filesystem.denyRead` - Array of paths to deny read access. Empty array = full read access. - `filesystem.allowRead` - Array of paths to re-allow read access within denied regions (takes precedence over denyRead). **Note:** this is the opposite of write, where `denyWrite` takes precedence over `allowWrite`. +- `filesystem.denyReadGlobBudget` - Optional, Linux only: `{ "maxEntries": 20000000, "timeoutMs": 60000 }` (the defaults). What expanding the globs of one read configuration may spend before the wrap is refused; see "Path Syntax (Linux)". **Write restrictions** (allow-only pattern) - all writes denied by default: @@ -564,6 +565,7 @@ bubblewrap binds concrete paths, so glob support is narrower than on macOS: - A directory matched by a `denyRead` pattern ending in `/**` that holds at least one entry when the command is wrapped becomes one tmpfs mount, like a directory listed in `denyRead` literally: inside the sandbox it is an EMPTY WRITABLE directory, so a command that used to write through a read-denied `build/` still writes, into the tmpfs, and loses that output when the command exits. A file added to the directory on the host afterwards is hidden too. A matched directory that is empty when the command is wrapped gets no mount (a matched symlink to a directory always gets one, on the directory it leads to). An `allowRead` beneath a mounted directory is bound back over the tmpfs, but each entry beneath it that the pattern matches keeps its own mask: under a `/**` pattern that is every entry there, so only what is created beneath the `allowRead` later is readable. - A directory the expansion cannot list is denied as a whole, and nothing is bound back beneath the mount that hides it, `allowRead` and `allowWrite` paths included: what the pattern matches under them cannot be found. So is an entry that may be a directory for all the expansion can learn: one the file system lists without a type and that cannot be looked at either. A `denyRead` entry that cannot be inspected (its parent directory is readable but not searchable, say), or that leads to `/`, hides the nearest directory above it instead, in the same way. - Symlinked directories are descended. A directory is listed once for each way the pattern can carry on beneath it, however many links lead to it, so the cost of the expansion follows the size of the tree and the length of the pattern, not the number or length of the names its links offer; what is found through a link is reported, and denied, where it really is. A link that leads back up the tree (to the directory holding it or above, or to the pattern's starting directory or above) is not descended. A `**` written against other text (`**.pem`, `a**/x`) and a bracket expression that can match `/` span directories as they do on macOS, through symlinked directories too. Every `denyRead` mount goes where the path really is (bubblewrap 0.12 and later refuse to mount on a symlink), so an entry reached through a symlink is denied under every name that leads to it; a link back up the tree is not descended, and denies what it reaches only when it is itself a match, which denies all of it, as a literal deny of the link would. A link that resolves to nothing is skipped. A link whose target cannot be looked at (a directory above the target is not searchable, say) is not followed: when the pattern would continue through it rather than match it, nothing is denied for it and what lies behind it is not masked. A link of that kind that is itself a match is still denied, under its own spelling. `allowRead` globs are not expanded through symlinks: they match the link itself. +- A read configuration's patterns (`allowRead`, `denyRead`, globs among `credentials.files`) are expanded on one shared budget: 20,000,000 directory entries read from disk and 60 seconds of wall-clock time, which a cold cache or busy host spends sooner. `filesystem.denyReadGlobBudget` (`maxEntries`, `timeoutMs`) changes it, in the configuration or in one wrap's `customConfig`, which replaces only the limit it names; anything but positive integers under those two keys fails the wrap. A directory several `denyRead` patterns walk is read and counted once, and kept until the last is expanded (100 to 200 bytes an entry). Past the budget, `wrapWithSandbox()`, `wrapWithSandboxArgv()` and `getFsReadConfig()` fail with a `LinuxSandboxProfileError`, `.code` `deny_glob_too_large`: a deny list cut short would leave readable what it should hide. `initialize()` and `updateConfig()` expand nothing and nothing is kept between wraps, so each wrap is refused only after spending the budget again. The message names the remedies; its facts are also on `.cause`, a `GlobWalkBudgetError` (type exported): `pattern` (the one being expanded, not always the large one), `directory`, `exhausted` (`'entries'` or `'time'`), `entries`, `elapsedMs`, `maxEntries`, `timeoutMs`. Time is checked at every directory and entry, so only one blocking call (a dead network mount) or one slow match can overrun it. - An `allowRead` or `allowWrite` path is bound back over a denied directory only where it really is, so no directory shows under a second name inside the sandbox. - `denyRead: ["/"]` denies each directory in `/` (`/proc`, `/dev` and `/sys` aside); a symlink there (`/bin`, `/lib` on a usr-merged system) gets no mount of its own, because what it leads to is denied together with the directory that holds it. @@ -1191,7 +1193,7 @@ Filesystem restrictions are enforced at the OS level: - The string must be run while the process that produced it is alive, and before the runtime cleans up after that command (`cleanupAfterCommand()`), which is when the profile is released. - The profile needs a directory that takes an `O_TMPFILE` file — `os.tmpdir()`, else `/dev/shm` — and a readable `/proc/self/fd`. Without them an over-long profile is refused at wrap time with the reason; there is no fallback to a named file. Profiles that fit the command line do not use any of this. - bubblewrap parses at most 9000 arguments (about 3000 mounts). A profile past that, or a command too long for one argument by itself, fails at wrap time with an error. The [mandatory deny paths](#mandatory-deny-paths-auto-protected-files) count too, and no list in the configuration holds them: a git repository directly inside a writable working directory takes four mounts (its hooks, its config, and the two directories above them), so some 730 repositories reach the limit by themselves. The error says how many of the arguments are theirs. There are fewer when the command runs from a directory with fewer repositories in it, or when `allowWrite` names fewer of them; a lower `mandatoryDenySearchDepth` leaves the deeper ones unprotected. -- Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart; branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in. A wrap that threw has already released what it held: do not call `cleanupAfterCommand()` for it, or a sandbox still running loses its mount points. +- Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart; branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in, and `deny_glob_too_large`, which says the read patterns could not be expanded within their budget (see "Path Syntax (Linux)"). A wrap that threw has already released what it held: do not call `cleanupAfterCommand()` for it, or a sandbox still running loses its mount points. ### Mandatory Deny Paths (Auto-Protected Files) diff --git a/src/index.ts b/src/index.ts index f3f9f7874..399a56c0e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -57,6 +57,7 @@ export { LinuxSandboxProfileError, type LinuxSandboxProfileErrorCode, } from './sandbox/linux-sandbox-utils.js' +export type { GlobWalkBudgetError } from './sandbox/sandbox-utils.js' // Windows install/status API export { diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 66f9db747..04b86aa3d 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -972,6 +972,12 @@ export type LinuxSandboxProfileErrorCode = | 'args_file_unavailable' /** The line does not fit one shell argument even with the mounts in a file. */ | 'command_too_long' + /** + * The read patterns could not be expanded within their budget, so which + * paths to hide is not known. The `GlobWalkBudgetError` on `.cause` names + * the pattern, the directory being listed and what had been spent. + */ + | 'deny_glob_too_large' /** * Thrown when a Linux bubblewrap profile cannot be run on this host: what the diff --git a/src/sandbox/read-deny-glob.ts b/src/sandbox/read-deny-glob.ts index 51bce7f20..58e81ac0b 100644 --- a/src/sandbox/read-deny-glob.ts +++ b/src/sandbox/read-deny-glob.ts @@ -1,6 +1,6 @@ import { logForDebugging } from '../utils/debug.js' import { - type GlobWalkListings, + type GlobWalkOptions, isAtOrUnder, normalizePathForSandbox, pathSpellings, @@ -59,6 +59,11 @@ function collapseReadDenyLocations({ return kept } +type ReadDenyGlobOptions = Pick< + GlobWalkOptions, + 'anchor' | 'budget' | 'listings' +> + /** * Expand a read-deny glob into the paths bwrap should mount over, collapsed * against `reExposedPaths` (the caller's allowRead and allowWrite entries). @@ -74,13 +79,14 @@ function collapseReadDenyLocations({ * pattern matches under an allowed path in there was never found, and would * come back unmasked. * @param opts.anchor - as in `ExpandGlobOptions.anchor`. + * @param opts.budget - as in `GlobWalkOptions.budget`. * @param opts.listings - one map for all the patterns of a configuration. */ export function expandReadDenyGlobLinux( globPattern: string, reExposedPaths: readonly string[], unlistableDirs?: Set, - opts: { anchor?: string; listings?: GlobWalkListings } = {}, + opts: ReadDenyGlobOptions = {}, ): string[] { return finish( expandReadDenyGlobLinuxSteps( @@ -97,13 +103,12 @@ export function* expandReadDenyGlobLinuxSteps( globPattern: string, reExposedPaths: readonly string[], unlistableDirs?: Set, - opts: { anchor?: string; listings?: GlobWalkListings } = {}, + opts: ReadDenyGlobOptions = {}, ): Steps { const walk = yield* walkGlobPatternSteps(globPattern, { withDirectoryForm: true, followSymlinkedDirectories: true, - anchor: opts.anchor, - listings: opts.listings, + ...opts, }) // 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-config.ts b/src/sandbox/sandbox-config.ts index b8930db2b..5cd26bbd0 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -976,6 +976,18 @@ export const FilesystemConfigSchema = z.object({ 'Paths to re-allow reading within denied regions (takes precedence over denyRead). ' + 'Use with denyRead to deny a broad region then allow back specific subdirectories.', ), + denyReadGlobBudget: z + .object({ + maxEntries: z.number().int().positive().optional(), + timeoutMs: z.number().int().positive().optional(), + }) + .strict() + .optional() + .describe( + 'Linux: what expanding the globs of one read configuration may spend, all of them together, ' + + 'before the wrap is refused: directory entries read (default 20,000,000) and ' + + 'milliseconds (default 60,000).', + ), allowWrite: z .array(filesystemPathEntrySchema) .describe('Paths allowed for writing'), diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 9738c78dd..b1f425225 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -48,6 +48,7 @@ import type { SandboxRuntimeConfig, SeccompConfig, } from './sandbox-config.js' +import { FilesystemConfigSchema } from './sandbox-config.js' import type { SandboxAskCallback, CredentialRestrictionConfig, @@ -64,6 +65,7 @@ import { type SandboxDependencyCheck, cleanupBwrapMountPoints, linuxGetCwdMandatoryDenyPaths, + LinuxSandboxProfileError, } from './linux-sandbox-utils.js' import { expandReadDenyGlobLinuxSteps } from './read-deny-glob.js' import { @@ -127,9 +129,12 @@ import { globPatternBaseDir, normalizePathForSandbox, removeTrailingGlobSuffix, + GlobWalkBudgetError, walkGlobPatternSteps, workingDirectory, + type GlobWalkBudget, type GlobWalkListings, + newGlobWalkBudget, type Steps, finish, finishInTurns, @@ -2197,11 +2202,8 @@ function* resolveReadPathEntries( !literal.has(p) && containsGlobChars(stripped) ) { - for (const found of yield* withOtherReadings( - p, - stripped, - kind, - expandGlob, + for (const found of yield* withinBudget( + withOtherReadings(p, stripped, kind, expandGlob), )) { resolved.add(found) } @@ -2238,11 +2240,49 @@ function stripWriteGlobs( }) } +/** + * What the glob walks of one read configuration may spend between them, on + * Linux, its clock starting now: `filesystem.denyReadGlobBudget`, a wrap's own + * limits over the session's one by one. Checked here, because only the CLI + * runs the schema and a limit that is no number would never be reached. + */ +function readGlobBudget( + ...limits: Array +): GlobWalkBudget { + const [session, own] = limits.map(given => + FilesystemConfigSchema.shape.denyReadGlobBudget.parse(given, { + path: ['filesystem', 'denyReadGlobBudget'], + }), + ) + return newGlobWalkBudget({ + maxEntries: own?.maxEntries ?? session?.maxEntries, + timeoutMs: own?.timeoutMs ?? session?.timeoutMs, + }) +} + +/** `walk`, with a budget that runs out in it told as + * {@link LinuxSandboxProfileError} `deny_glob_too_large`. */ +function* withinBudget(walk: Steps): Steps { + try { + return yield* walk + } catch (error) { + if (!(error instanceof GlobWalkBudgetError)) throw error + throw new LinuxSandboxProfileError( + 'deny_glob_too_large', + `${error.message}; the allowRead, denyRead and credentials.files patterns of one configuration share the budget: ` + + `narrow the pattern, remove or move what it walks into, or raise filesystem.denyReadGlobBudget`, + error, + ) + } +} + function* expandAllowReadGlob( + budget: GlobWalkBudget, pattern: string, anchor?: string, ): Steps { - const expanded = (yield* walkGlobPatternSteps(pattern, { anchor })).matches + const expanded = (yield* walkGlobPatternSteps(pattern, { anchor, budget })) + .matches logForDebugging( `[Sandbox] Expanded allowRead glob pattern "${pattern}" to ${expanded.length} paths on Linux`, ) @@ -2270,11 +2310,12 @@ function getFsReadConfig(): FsReadRestrictionConfig { ) // allowRead (re-allow within denied regions) is resolved first: the // denyRead glob expansion collapses against it. + const budget = readGlobBudget(config.filesystem.denyReadGlobBudget) const allowPaths = finish( resolveReadPathEntries( 'allow', config.filesystem.allowRead ?? [], - expandAllowReadGlob, + (pattern, anchor) => expandAllowReadGlob(budget, pattern, anchor), ), ) const reExposedPaths = reExposedBy( @@ -2293,7 +2334,7 @@ function getFsReadConfig(): FsReadRestrictionConfig { pattern, reExposedPaths, unlistableDenyDirs, - { anchor, listings }, + { anchor, budget, listings }, ), ), ) @@ -2847,13 +2888,17 @@ async function wrapWithSandboxOnce( // allowRead is resolved first: on Linux a denyRead glob's expansion is // collapsed against the paths that re-expose contents under a denied // directory (allowRead + allowWrite), so both must be final here. + const budget = readGlobBudget( + config?.filesystem.denyReadGlobBudget, + customConfig?.filesystem?.denyReadGlobBudget, + ) const expandedAllowRead = await walked( resolveReadPathEntries( 'allow', customConfig?.filesystem?.allowRead ?? config?.filesystem.allowRead ?? [], - expandAllowReadGlob, + (pattern, anchor) => expandAllowReadGlob(budget, pattern, anchor), ), ) if (expandedAllowRead === REPLACED) return startOver() @@ -2885,7 +2930,7 @@ async function wrapWithSandboxOnce( pattern, reExposedPaths, unlistableDenyDirs, - { anchor, listings }, + { anchor, budget, listings }, ), ), ) @@ -3916,6 +3961,8 @@ export interface ISandboxManager { }, options?: CheckDependenciesOptions, ): Promise + /** On Linux it expands the `denyRead` globs, and throws + * {@link LinuxSandboxProfileError} `deny_glob_too_large` as the wrap does. */ getFsReadConfig(): FsReadRestrictionConfig getFsWriteConfig(): FsWriteRestrictionConfig getNetworkRestrictionConfig(): NetworkRestrictionConfig diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index c85cbbdb9..48506ed2d 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -1252,6 +1252,67 @@ export interface ExpandGlobOptions { anchor?: string } +/** + * What the walks handed it may spend between them: one count and one clock, + * so the patterns of one configuration cannot each spend a full one. + */ +export interface GlobWalkBudget { + /** The directory entries the walks may read, together. */ + readonly maxEntries: number + /** How long they may take, together, in milliseconds. */ + readonly timeoutMs: number + /** When the budget was made, as a `performance.now()` reading. */ + readonly startedAt: number + /** The directory entries read so far. */ + entries: number +} + +/** A budget nothing has been spent from, its clock starting now. */ +export function newGlobWalkBudget( + limits: { maxEntries?: number; timeoutMs?: number } = {}, +): GlobWalkBudget { + return { + maxEntries: limits.maxEntries ?? 20_000_000, + timeoutMs: limits.timeoutMs ?? 60_000, + startedAt: performance.now(), + entries: 0, + } +} + +/** + * Thrown by {@link walkGlobPattern} when its budget runs out. What it had + * found is not handed back: a deny list cut short is a path left readable. + */ +export class GlobWalkBudgetError extends Error { + /** Which limit was reached. */ + readonly exhausted: 'entries' | 'time' + /** The entries read under the budget, by this walk and those before. */ + readonly entries: number + /** Milliseconds since the budget was made. */ + readonly elapsedMs: number + readonly maxEntries: number + readonly timeoutMs: number + constructor( + /** The pattern being walked, as the caller wrote it. */ + readonly pattern: string, + /** The directory the walk was listing. */ + readonly directory: string, + budget: GlobWalkBudget, + ) { + const exhausted = budget.entries > budget.maxEntries ? 'entries' : 'time' + const elapsedMs = Math.round(performance.now() - budget.startedAt) + super( + `Pattern "${pattern}" could not be expanded: the ${exhausted} ran out with ${budget.entries} of ${budget.maxEntries} directory entries read after ${elapsedMs} of ${budget.timeoutMs} ms, while listing ${directory}`, + ) + this.name = 'GlobWalkBudgetError' + this.exhausted = exhausted + this.entries = budget.entries + this.elapsedMs = elapsedMs + this.maxEntries = budget.maxEntries + this.timeoutMs = budget.timeoutMs + } +} + /** What the walk reads of a listed entry. */ type GlobWalkEntry = Pick @@ -1263,6 +1324,7 @@ export type GlobWalkListings = Map export type GlobWalkOptions = ExpandGlobOptions & { withDirectoryForm?: boolean followSymlinkedDirectories?: boolean + budget?: GlobWalkBudget /** Handed to every walk of one configuration, so that patterns with a * base in common list each directory once between them. */ listings?: GlobWalkListings @@ -1610,6 +1672,9 @@ export function toForwardSlashes(s: string): string { * `globPath` without its trailing `/**`, with the symlinks seen recorded. * With `followSymlinkedDirectories` it also lists through a symlinked * directory and reports every match where it really lives. + * + * With a `budget` it throws {@link GlobWalkBudgetError} once that is spent. + * One step is not cut short: a filesystem call that blocks, or a slow match. */ export function walkGlobPattern( globPath: string, @@ -1719,6 +1784,22 @@ export function* walkGlobPatternSteps( const records = new Map() const listings: GlobWalkListings = opts.listings ?? new Map() const pending: Frame[] = [] + /** Counts `read` more entries of `dir` read from disk, and throws once the + * budget is spent. An entry another pattern has read already costs time + * alone, so the tree that fits does not shrink with every pattern written + * over it. The clock is read every time: what one entry costs is not + * bounded, so a stride would not bound the time. */ + const spend = (dir: string, read: 0 | 1): void => { + const budget = opts.budget + if (budget === undefined) return + budget.entries += read + if ( + budget.entries > budget.maxEntries || + performance.now() - budget.startedAt >= budget.timeoutMs + ) { + throw new GlobWalkBudgetError(globPath, dir, budget) + } + } const codeOf = (err: unknown): string | undefined => (err as NodeJS.ErrnoException | undefined)?.code /** @@ -1746,7 +1827,7 @@ export function* walkGlobPatternSteps( try { return call(real) } catch (first) { - if (short === real) throw first + if (short === real || first instanceof GlobWalkBudgetError) throw first let answer: T try { answer = call(short) @@ -1819,6 +1900,7 @@ export function* walkGlobPatternSteps( ) { return entry } + spend(real, 0) const { name } = entry const spelled = path.join(listedAs, name) let type: 'directory' | 'link' | 'other' = 'other' @@ -1886,6 +1968,8 @@ export function* walkGlobPatternSteps( const fresh = frame.positions.filter(p => !record.listedFor.has(p)) if (fresh.length === 0) continue yield + // After the step, in which time can have passed, and before the listing. + spend(dir, 0) // Once. What one pattern denies is denied for the whole configuration, so // what the short name listed is kept for the next pattern like any other. const denyWhole = (): void => { @@ -1895,8 +1979,8 @@ export function* walkGlobPatternSteps( if (real !== dir) walk.realOf.set(dir, real) } let entries: GlobWalkEntry[] + const known = listings.get(real) try { - const known = listings.get(real) if (known instanceof Error) throw known entries = known ?? @@ -1912,6 +1996,8 @@ export function* walkGlobPatternSteps( ) listings.set(real, entries) } catch (err) { + // Not a directory that could not be listed. + if (err instanceof GlobWalkBudgetError) throw err const errorCode = codeOf(err) logForDebugging( `[Sandbox] Error listing ${dir} for glob pattern ${globPath}: ${err}`, @@ -1935,6 +2021,7 @@ export function* walkGlobPatternSteps( } for (const p of fresh) record.listedFor.add(p) for (const entry of entries) { + spend(dir, known === undefined ? 1 : 0) // Nearly every entry of a large tree: a plain file the pattern does not // match, which nothing below records. A pattern that splits is matched // by name, so no path need be spelled to find that out. diff --git a/test/sandbox/read-deny-glob-bound.test.ts b/test/sandbox/read-deny-glob-bound.test.ts new file mode 100644 index 000000000..b92ab10d3 --- /dev/null +++ b/test/sandbox/read-deny-glob-bound.test.ts @@ -0,0 +1,407 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + mock, + spyOn, +} from 'bun:test' +// The namespace the library binds, so the code under test sees a spy on it. +import * as fs from 'fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expandReadDenyGlobLinux } from '../../src/sandbox/read-deny-glob.js' +import { + FilesystemConfigSchema, + type SandboxRuntimeConfig, +} from '../../src/sandbox/sandbox-config.js' +import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' +import { + type GlobWalkListings, + newGlobWalkBudget, + walkGlobPattern, +} from '../../src/sandbox/sandbox-utils.js' +import { isLinux, isWindows } from '../helpers/platform.js' + +const DIRECTORIES = 6 +/** What a planted tree holds: its directories, and the four files in each. */ +const ENTRIES = DIRECTORIES * 5 +let ROOT: string +let TREE: string +/** Matches the `.env` in each directory of TREE. */ +let PATTERN: string + +/** `ROOT/`: d0 to d5, a `.env` and three other files in each. */ +function plantTree(name: string): string { + for (let d = 0; d < DIRECTORIES; d++) { + fs.mkdirSync(join(ROOT, name, `d${d}`), { recursive: true }) + for (const file of ['.env', 'a', 'b', 'c']) { + fs.writeFileSync(join(ROOT, name, `d${d}`, file), '') + } + } + return join(ROOT, name) +} + +/** Has the clock move a millisecond each time it is read. */ +function tickingClock(): void { + let now = 0 + spyOn(performance, 'now').mockImplementation(() => ++now) +} + +/** Has every listing answer what `listed` makes of it. */ +function listingsThrough( + listed: (dir: string, entries: fs.Dirent[]) => unknown[], +): void { + const { readdirSync } = fs + spyOn(fs, 'readdirSync').mockImplementation((( + ...args: Parameters + ) => + listed( + String(args[0]), + readdirSync(...args) as unknown as fs.Dirent[], + )) as unknown as typeof readdirSync) +} + +/** What `run` throws or rejects with: undefined when it does neither. */ +async function refusalOf(run: () => unknown): Promise { + try { + await run() + return undefined + } catch (error) { + return error + } +} + +beforeAll(() => { + ROOT = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), 'deny-glob-budget-'))) + TREE = plantTree('tree') + PATTERN = join(TREE, '**/.env') +}) + +afterEach(() => { + mock.restore() +}) + +afterAll(() => { + fs.rmSync(ROOT, { recursive: true, force: true }) +}) + +describe.if(!isWindows)('the budget of a glob walk', () => { + const expand = (pattern: string, maxEntries: number): string[] => + expandReadDenyGlobLinux(pattern, [], undefined, { + budget: newGlobWalkBudget({ maxEntries }), + }) + + it('is charged for every entry read from disk, matched or not, and not for one another walk has read', () => { + const budget = newGlobWalkBudget() + const listings: GlobWalkListings = new Map() + for (const pattern of [PATTERN, join(TREE, '**/a')]) { + walkGlobPattern(pattern, { budget, listings }) + expect(budget.entries).toBe(ENTRIES) + } + // Not handed what they read, so it reads the tree again. + walkGlobPattern(PATTERN, { budget }) + expect(budget.entries).toBe(2 * ENTRIES) + }) + + it('throws at the entry past maxEntries, and says where it was', async () => { + // Exactly enough is enough. + expect(expand(PATTERN, ENTRIES)).toHaveLength(DIRECTORIES) + + expect(await refusalOf(() => expand(PATTERN, ENTRIES - 1))).toMatchObject({ + name: 'GlobWalkBudgetError', + message: expect.stringContaining(PATTERN), + pattern: PATTERN, + directory: expect.stringContaining(TREE + '/'), + exhausted: 'entries', + entries: ENTRIES, + elapsedMs: expect.any(Number), + maxEntries: ENTRIES - 1, + timeoutMs: 60_000, + }) + }) + + it('is charged for what links lead to, one after another, named where it really is', async () => { + // linked/out -> ../hop and hop/on -> ../tree: an entry each, and the tree. + // A command allowed to write there can make the way as long as it likes. + fs.mkdirSync(join(ROOT, 'linked')) + fs.mkdirSync(join(ROOT, 'hop')) + fs.symlinkSync(join('..', 'hop'), join(ROOT, 'linked', 'out')) + fs.symlinkSync(join('..', 'tree'), join(ROOT, 'hop', 'on')) + const pattern = join(ROOT, 'linked', '**/.env') + + expect(expand(pattern, 2 + ENTRIES)).toEqual(expand(PATTERN, ENTRIES)) + expect(await refusalOf(() => expand(pattern, 1 + ENTRIES))).toMatchObject({ + pattern, + directory: expect.stringContaining(TREE + '/'), + }) + }) + + // The budget is made at 1 ms, so the reading at n ms finds n - 1 gone: at 2 + // the tree is about to be listed, from 3 to 8 its entries are met, at 9 and + // from 10 to 13 the same for a directory in it, and at 14 for the next. + it.each([ + ['before the first listing', 1, false, /tree$/, 0], + ['at an entry, not at every so many', 4, false, /tree$/, 3], + ['at an entry another pattern has read', 4, true, /tree$/, 0], + ['after a step, before the next listing', 13, false, /tree\/d\d$/, 6 + 4], + ])( + 'throws once the time is up: %s', + async (_when, timeoutMs, read, directory, entries) => { + const listings: GlobWalkListings = new Map() + if (read) walkGlobPattern(join(TREE, '**/a'), { listings }) + tickingClock() + const budget = newGlobWalkBudget({ timeoutMs }) + + expect( + await refusalOf(() => walkGlobPattern(PATTERN, { budget, listings })), + ).toMatchObject({ + exhausted: 'time', + directory: expect.stringMatching(directory), + entries, + }) + }, + ) + + it('throws before an entry whose type has to be asked for, and not as a directory that could not be listed', async () => { + // Listed the way some NFS and FUSE mounts list: names, and no types. + const untyped: ProxyHandler = { + get: ({ name }, key) => (key === 'name' ? name : () => false), + } + const listed: string[] = [] + listingsThrough((dir, entries) => { + listed.push(dir) + return entries.map(e => new Proxy(e, untyped)) + }) + const asked = spyOn(fs, 'lstatSync') + tickingClock() + const budget = newGlobWalkBudget({ timeoutMs: 4 }) + + expect( + await refusalOf(() => walkGlobPattern(PATTERN, { budget })), + ).toMatchObject({ exhausted: 'time', directory: TREE, entries: 0 }) + expect(asked).toHaveBeenCalledTimes(2) + + // Nor as a real path that could not be read, which is asked for again by + // a shorter name that led to it. + fs.mkdirSync(join(ROOT, 'v')) + fs.symlinkSync(TREE, join(ROOT, 'v', 'l')) + listed.length = 0 + expect( + await refusalOf(() => + expandReadDenyGlobLinux(join(ROOT, 'v', '**/.env'), [], undefined, { + budget: newGlobWalkBudget({ timeoutMs: 6 }), + }), + ), + ).toMatchObject({ exhausted: 'time', directory: TREE }) + expect(listed).toEqual([join(ROOT, 'v'), TREE]) + }) +}) + +describe.if(isLinux)('the budget of a read configuration', () => { + function configOf( + denyReadGlobBudget: object | undefined, + lists: { denyRead?: string[]; allowRead?: string[] } = {}, + credentials?: SandboxRuntimeConfig['credentials'], + ): SandboxRuntimeConfig { + return { + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { + denyRead: [], + allowWrite: [], + denyWrite: [], + ...lists, + denyReadGlobBudget, + }, + credentials, + } + } + + afterAll(() => SandboxManager.reset()) + + describe.each([ + ['getFsReadConfig()', () => SandboxManager.getFsReadConfig()], + ['wrapWithSandbox()', () => SandboxManager.wrapWithSandbox('true')], + ['wrapWithSandboxArgv()', () => SandboxManager.wrapWithSandboxArgv('true')], + ])('worked out by %s', (_way, workOut) => { + /** What refuses it, if anything does, in a session of that configuration. */ + const refusalUnder = ( + ...config: Parameters + ): Promise => { + SandboxManager.updateConfig(configOf(...config)) + return refusalOf(workOut) + } + + it.each([ + [{ maxEntries: ENTRIES - 1 }, 'entries'], + [{ timeoutMs: 1 }, 'time'], + ])('is refused past %o', async (limits, exhausted) => { + const held = { maxEntries: 20_000_000, timeoutMs: 60_000, ...limits } + tickingClock() + + const refusal = await refusalUnder(limits, { denyRead: [PATTERN] }) + + expect(refusal).toMatchObject({ + name: 'LinuxSandboxProfileError', + code: 'deny_glob_too_large', + cause: { + name: 'GlobWalkBudgetError', + pattern: PATTERN, + exhausted, + ...held, + }, + }) + for (const part of [ + `"${PATTERN}"`, + `the ${exhausted} ran out`, + `of ${held.maxEntries} directory entries`, + `of ${held.timeoutMs} ms`, + `while listing ${TREE}`, + 'narrow the pattern', + 'or raise filesystem.denyReadGlobBudget', + ]) { + expect(String(refusal)).toContain(part) + } + }) + + it.each<[string, string, (pattern: string) => [object, object?]]>([ + ['allowRead', 'tree', p => [{ allowRead: [p] }]], + [ + 'credentials.files', + 'tree', + p => [{}, { files: [{ path: p, mode: 'deny' }] }], + ], + // Walked beneath an anchor: the folder, brackets and all, is a name. + ['denyRead', 'pk[g]s', p => [{ denyRead: [p] }]], + ])( + 'holds a pattern of %s, in %s, to it', + async (_list, folder, listing) => { + const pattern = join(plantTree(folder), '**/.env') + + expect( + await refusalUnder({ maxEntries: 1000 }, ...listing(pattern)), + ).toBeUndefined() + expect( + await refusalUnder({ maxEntries: ENTRIES - 1 }, ...listing(pattern)), + ).toMatchObject({ code: 'deny_glob_too_large', cause: { pattern } }) + }, + ) + + it('has one for all its patterns, charged once for a tree that two denyRead patterns go through', async () => { + const limits = { maxEntries: ENTRIES } + const other = join(plantTree('other'), '**/.env') + + expect( + await refusalUnder(limits, { denyRead: [PATTERN, join(TREE, '**/a')] }), + ).toBeUndefined() + for (const lists of [ + { denyRead: [PATTERN, other] }, + { allowRead: [PATTERN, other] }, + { allowRead: [PATTERN], denyRead: [other] }, + ]) { + // The pattern that was being expanded when the budget ran out. + expect(await refusalUnder(limits, lists)).toMatchObject({ + cause: { pattern: other, entries: ENTRIES + 1 }, + }) + } + }) + + // NaN would never be reached, and a misspelt key would leave the default. + it.each([{ maxEntries: NaN }, { timeoutMs: '1000' }, { maxEntires: 1 }])( + 'fails on limits that no schema has seen: %o', + async limits => { + expect(await refusalUnder(limits)).toMatchObject({ + message: expect.stringMatching(/filesystem[^]*denyReadGlobBudget/), + }) + }, + ) + }) + + it('takes the limits of one wrap over those of the session, initialized or updated, one by one', async () => { + const lists = { denyRead: [PATTERN] } + const refusalWith = (limits?: object): Promise => { + const own = configOf(limits, lists) + return refusalOf(() => + SandboxManager.wrapWithSandbox('true', undefined, own), + ) + } + await SandboxManager.reset() + await SandboxManager.initialize( + configOf({ maxEntries: ENTRIES - 1, timeoutMs: 1234 }, lists), + ) + + expect(await refusalWith()).toMatchObject({ + cause: { maxEntries: ENTRIES - 1, timeoutMs: 1234 }, + }) + expect( + await refusalWith({ maxEntries: ENTRIES - 2, timeoutMs: undefined }), + ).toMatchObject({ cause: { maxEntries: ENTRIES - 2, timeoutMs: 1234 } }) + expect(await refusalWith({ maxEntries: ENTRIES })).toBeUndefined() + + SandboxManager.updateConfig(configOf({ maxEntries: ENTRIES }, lists)) + expect(await refusalWith()).toBeUndefined() + expect(await refusalWith({ maxEntries: ENTRIES - 1 })).toMatchObject({ + cause: { maxEntries: ENTRIES - 1, timeoutMs: 60_000 }, + }) + }) + + // The allowRead walk lists every directory, and then the denyRead walk does. + it.each([ + ['allowRead', 2], + ['denyRead', DIRECTORIES + 1 + 2], + ])( + 'starts over under the budget of a configuration that arrives during the %s walk, with nothing spent', + async (_walk, arrivesAt) => { + const lists = { allowRead: [join(TREE, '**/a')], denyRead: [PATTERN] } + const refusalUnderLate = (maxEntries: number): Promise => { + mock.restore() + SandboxManager.updateConfig(configOf(undefined, lists)) + let listed = 0 + listingsThrough((dir, entries) => { + if (dir.startsWith(TREE) && ++listed === arrivesAt) { + SandboxManager.updateConfig(configOf({ maxEntries }, lists)) + } + return entries + }) + return refusalOf(() => SandboxManager.wrapWithSandbox('true')) + } + + expect(await refusalUnderLate(ENTRIES - 1)).toMatchObject({ + cause: { maxEntries: ENTRIES - 1 }, + }) + // What the two walks of one attempt read, and no more. + expect(await refusalUnderLate(2 * ENTRIES)).toBeUndefined() + }, + ) +}) + +describe('filesystem.denyReadGlobBudget in the schema', () => { + it.each([ + [true, undefined], + [true, { maxEntries: 1 }], + [true, { timeoutMs: 120_000 }], + [true, { maxEntries: 50_000_000, timeoutMs: 1 }], + [false, { maxEntries: 0 }], + [false, { maxEntries: 1.5 }], + [false, { maxEntries: '1000' }], + [false, { maxEntries: Infinity }], + [false, { maxEntires: 1 }], + [false, { timeoutMs: 0 }], + [false, { timeoutMs: 0.5 }], + [false, 1000], + ])( + 'takes positive integers, either or both, and nothing else: %p for %o', + (taken, budget) => { + expect( + FilesystemConfigSchema.safeParse({ + denyRead: [], + allowWrite: [], + denyWrite: [], + denyReadGlobBudget: budget, + }).success, + ).toBe(taken) + }, + ) +})