From de9aad77efb770dbbb9ea1eca8586553b2444126 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Fri, 25 Sep 2026 20:23:04 +0000 Subject: [PATCH 01/17] linux: a read-deny glob stays in its own tree, and its walk has a budget bubblewrap has no patterns, so a denyRead entry with glob syntax is expanded on the host into the paths to mount over, on every wrap. That walk listed through any symbolic link to a directory, wherever it led; the only link it declined was one leading back up the tree. With a link from a project to /usr and `**/.env` denied, each expansion listed 57,000 entries: 260 to 320 ms per command warm, seconds cold, and no limit for a larger target. A sandboxed command can plant such a link. A link whose target lies outside the pattern's tree is no longer listed through. The tree is the pattern's literal starting directory and what is under it, and a link is judged by where it resolves. Links that stay inside are followed as before, and a link whose own name matches still masks what it leads to. What this gives up is a file the pattern matches only by a name that passes through such a link: macOS never covered it, since patterns are matched against resolved paths there. Where a matched link hides a directory outside the tree that the pattern would have carried on into, nothing is bound back beneath it: it is reported through unlistableDenyDirs like a directory the walk cannot list. Unlisted, an allowed path beneath it would otherwise come back with nothing masked. The walk takes a budget, shared by all the denyRead patterns of one configuration: 2,000,000 directory entries and 10 seconds, the clock read before each listing and at each entry. Out of budget it throws and hands nothing back, and the wrap is refused with LinuxSandboxProfileError `deny_glob_too_large`, the walk's error as its cause. getFsReadConfig() throws the same. A deny list cut short would leave readable what the pattern was written to hide. Each expansion logs one line under SRT_DEBUG: milliseconds, matches, mounts, directories listed, entries looked at, links left unfollowed. --- src/sandbox/linux-sandbox-utils.ts | 17 +++- src/sandbox/read-deny-glob.ts | 85 +++++++++++++---- src/sandbox/sandbox-manager.ts | 49 +++++++++- src/sandbox/sandbox-schemas.ts | 5 +- src/sandbox/sandbox-utils.ts | 148 ++++++++++++++++++++++++++++- 5 files changed, 272 insertions(+), 32 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 4579a9424..5a88eed3a 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -750,11 +750,19 @@ 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 `denyRead` patterns could not be expanded within their budget of + * directory entries and time, so which paths to hide is not known. The + * error 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 * configuration expands to is past a limit, or, for `command_too_long` and - * `nul_in_path`, what the caller passed in is. The command was not run and no + * `nul_in_path`, what the caller passed in is; for `deny_glob_too_large` the + * profile could not be worked out at all. The command was not run and no * profile file stays open, so do not run the per-command cleanup * (`cleanupAfterCommand()`, `cleanupBwrapMountPoints()`) for a wrap that * threw: it would release a second time, and a sandbox still running would @@ -2607,7 +2615,8 @@ async function generateFilesystemArgs( } return hidden } - // Entries a glob expansion produced for a directory it could not list. + // Entries a glob expansion produced for a directory it did not list: one + // it could not, or one a matched link leads to out of the pattern's tree. // What the pattern matches beneath an allowed path in there was never // found, so binding that path back over the tmpfs would show every one of // those matches unmasked. @@ -2668,7 +2677,7 @@ async function generateFilesystemArgs( if (isDirectory) { // A stand-in for an entry this wrap cannot vouch for hides everything // beneath it: nothing under it can be vouched for either. So does a - // directory a glob expansion could not enumerate. + // directory a glob expansion did not enumerate. if (isStandIn) { logForDebugging( `[Sandbox Linux] Read deny path ${normalizedPath} cannot be mounted where it names; hiding ${landing} instead`, @@ -2680,7 +2689,7 @@ async function generateFilesystemArgs( unlistableDenyDirs.has(landing) if (unlistable) { logForDebugging( - `[Sandbox Linux] Read-denied directory could not be listed when the glob was expanded; restoring nothing beneath it: ${landing}`, + `[Sandbox Linux] Read-denied directory was not listed when the glob was expanded; restoring nothing beneath it: ${landing}`, { level: 'warn' }, ) } diff --git a/src/sandbox/read-deny-glob.ts b/src/sandbox/read-deny-glob.ts index 342805143..5cd3f3282 100644 --- a/src/sandbox/read-deny-glob.ts +++ b/src/sandbox/read-deny-glob.ts @@ -1,5 +1,6 @@ import { logForDebugging } from '../utils/debug.js' import { + type GlobWalkBudget, isAtOrUnder, normalizePathForSandbox, pathSpellings, @@ -60,26 +61,60 @@ function collapseReadDenyLocations({ * Expand a read-deny glob into the paths bwrap should mount over, collapsed * against `reExposedPaths` (the caller's allowRead and allowWrite entries). * A pattern ending in `/**` also takes its directory form, so - * `**\/build/**` yields one mount per `build/` directory. A match reached - * through a symlink is listed where it really lives, and a directory the walk - * could not list is denied whole. Sorted, so an ancestor precedes its + * `**\/build/**` yields one mount per `build/` directory. A directory the + * walk could not list is denied whole. Sorted, so an ancestor precedes its * descendants. * + * The pattern covers the tree under its literal directory, by every name + * that leads around inside it: a match reached through a symlink that stays + * in the tree is listed where it really lives. The tree is the pattern's + * own, the directory written before its first wildcard, and not the project + * it may lie in: `/src/**\/.env` covers `/src`, so + * `src/shared -> ../shared` leads out of it. A symlinked directory that + * leads out of the tree is not listed through. One that itself matches still + * denies what it leads to, whole; where the pattern would carry on into that + * directory, it is handed back through `unlistableDirs` like one that could + * not be listed: what the pattern matches in there was not looked for. One + * that does not match denies nothing, and a file that only a listing through + * it would have found is not denied. That is not only a file out there: a + * link out there can lead back into the tree, and a file in the tree that + * the pattern matches by no name but the one through both links is not + * found either. + * + * Throws {@link GlobWalkBudgetError} when `budget` runs out before the walk + * is done. There is no shorter list to fall back on: a caller that cannot + * have the whole expansion must not run the command. + * * @param unlistableDirs - receives the returned locations that hide something - * the walk could not enumerate, whether by being that directory or by - * 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. + * the walk did not enumerate, whether by being that directory or by + * covering it: a directory it could not list, or one out of the tree that a + * matched link denies and the pattern would carry on into. 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.budget - what this expansion and the others handed the same + * object may spend between them. + * @param opts.unfollowedLinks - receives each symlinked directory that was + * not listed through because it leads out of the pattern's tree, with the + * directory it leads to. */ export function expandReadDenyGlobLinux( globPattern: string, reExposedPaths: readonly string[], unlistableDirs?: Set, + opts: { + budget?: GlobWalkBudget + unfollowedLinks?: Map + } = {}, ): string[] { + const startedAt = performance.now() const walk = walkGlobPattern(globPattern, { withDirectoryForm: true, followSymlinkedDirectories: true, + budget: opts.budget, }) + for (const [link, target] of walk.unfollowedLinks) { + opts.unfollowedLinks?.set(link, target) + } // Where a path the walk reported really lives: the denyRead loop mounts an // entry there, whatever spelling named it. const locationOf = (p: string): string => walk.realOf.get(p) ?? p @@ -95,9 +130,9 @@ export function expandReadDenyGlobLinux( const holdMatches = new Set(walk.matches.map(m => parentOf(locationOf(m)))) for (const dir of walk.directoryMatches) { // A directory-form match that is a symlink counts in its own right: - // one the walk did not list through (a link back up the tree, or a - // further name for a directory already listed) has no match beneath - // it, yet denies everything it reaches. + // one the walk did not list through (a link back up the tree or out + // of it, or a further name for a directory already listed) has no + // match beneath it, yet denies everything it reaches. if (holdMatches.has(locationOf(dir)) || walk.symlinks.has(dir)) { candidates.add(dir) } @@ -164,11 +199,20 @@ export function expandReadDenyGlobLinux( reExposedPaths: reExposed, }) - // Which mounts stand for something the walk could not enumerate: the - // unlistable directory itself when it survived the collapse, otherwise the - // kept ancestor that hides it. - for (const unlisted of walk.unlisted) { - const location = locationOf(unlisted) + // Which mounts stand for something the walk did not enumerate: a directory + // it could not list, or one a matched link leads to out of the tree, which + // is denied whole and was not listed. The directory itself when it + // survived the collapse, otherwise the kept ancestor that hides it. A link + // out of the tree that is no match denies nothing: where it leads is not + // a location, and nothing is reported for it, even where the mount of + // another match happens to hide it. + const unenumerated = [ + ...walk.unlisted.map(locationOf), + ...[...walk.unfollowedLinks.values()].filter(target => + locations.has(target), + ), + ] + for (const location of unenumerated) { if (mounts.has(location)) { unlistableDirs?.add(location) continue @@ -181,8 +225,17 @@ export function expandReadDenyGlobLinux( } } + // One line for the whole expansion, with what it cost: a caller that times + // its wraps takes the numbers from here. + const [firstUnfollowed] = walk.unfollowedLinks logForDebugging( - `[Sandbox Linux] Expanded denyRead glob "${globPattern}": ${walk.matches.length} matches -> ${mounts.size} mounts`, + `[Sandbox Linux] Expanded denyRead glob "${globPattern}" in ${Math.round(performance.now() - startedAt)} ms: ` + + `${walk.matches.length} matches -> ${mounts.size} mounts; ` + + `directories listed: ${walk.directoriesListed}, entries looked at: ${walk.entriesExamined}, ` + + `links out of the tree left unfollowed: ${walk.unfollowedLinks.size}` + + (firstUnfollowed + ? ` (first: ${firstUnfollowed[0]} -> ${firstUnfollowed[1]})` + : ''), ) for (const mount of mounts) { // A matched link decides what is hidden for the whole sandbox: a diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 91daa448a..d07adda9c 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -48,6 +48,7 @@ import { type SandboxDependencyCheck, cleanupBwrapMountPoints, linuxGetCwdMandatoryDenyPaths, + LinuxSandboxProfileError, } from './linux-sandbox-utils.js' import { expandReadDenyGlobLinux } from './read-deny-glob.js' import { @@ -85,6 +86,8 @@ import { normalizePathForSandbox, removeTrailingGlobSuffix, expandGlobPattern, + GlobWalkBudgetError, + newGlobWalkBudget, attributionKeyFor, decodeSandboxedCommand, encodeSandboxedCommand, @@ -1305,12 +1308,50 @@ function expandAllowReadGlob(pattern: string): string[] { return expanded } +/** + * The expansion of every denyRead glob of one read configuration, on Linux. + * The patterns share one budget of directory entries and time, so a + * configuration costs at most that however many patterns it holds. When the + * budget runs out the returned function throws + * {@link LinuxSandboxProfileError} `deny_glob_too_large`: there is no deny + * list to go on with, since one cut short leaves readable what the pattern + * was written to hide. + */ +function readDenyGlobExpander( + reExposedPaths: readonly string[], + unlistableDenyDirs: Set, +): (pattern: string) => string[] { + const budget = newGlobWalkBudget() + return pattern => { + try { + return expandReadDenyGlobLinux( + pattern, + reExposedPaths, + unlistableDenyDirs, + { budget }, + ) + } catch (error) { + if (!(error instanceof GlobWalkBudgetError)) throw error + throw new LinuxSandboxProfileError( + 'deny_glob_too_large', + `denyRead pattern "${pattern}" could not be expanded: the denyRead patterns of one configuration share ` + + `${error.maxEntries} directory entries and ${error.timeoutMs} ms, and the ${error.exhausted} ran out ` + + `with ${error.entries} entries looked at after ${error.elapsedMs} ms, while listing ${error.directory}; ` + + `narrow the pattern, or remove or move what it walks into`, + error, + ) + } + } +} + /** * The read policy of the initialized config, for inspection and display. * On Linux, denyRead globs are collapsed to covering directory mounts against * this config's allowRead and {@link getFsWriteConfig}'s allowOnly, so * `denyOnly` is only sound alongside that write config and must not be handed - * to wrapCommandWithSandboxLinux with a different one. + * to wrapCommandWithSandboxLinux with a different one. Throws + * {@link LinuxSandboxProfileError} `deny_glob_too_large` when the globs + * cannot be expanded within their budget, as the wrap does. */ function getFsReadConfig(): FsReadRestrictionConfig { if (!config || config.filesystem.disabled) { @@ -1333,8 +1374,7 @@ function getFsReadConfig(): FsReadRestrictionConfig { const unlistableDenyDirs = new Set() const denyPaths = resolveReadPathEntries( unionDenyReadPaths(config.filesystem.denyRead, credentialRestrictions), - pattern => - expandReadDenyGlobLinux(pattern, reExposedPaths, unlistableDenyDirs), + readDenyGlobExpander(reExposedPaths, unlistableDenyDirs), credentialRestrictions.degradeToDenyPaths, ) @@ -1722,8 +1762,7 @@ async function wrapWithSandbox( customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead ?? [], credentialRestrictions, ), - pattern => - expandReadDenyGlobLinux(pattern, reExposedPaths, unlistableDenyDirs), + readDenyGlobExpander(reExposedPaths, unlistableDenyDirs), credentialRestrictions.degradeToDenyPaths, ) readConfig = { diff --git a/src/sandbox/sandbox-schemas.ts b/src/sandbox/sandbox-schemas.ts index ce4243109..ac2e92ed3 100644 --- a/src/sandbox/sandbox-schemas.ts +++ b/src/sandbox/sandbox-schemas.ts @@ -21,8 +21,9 @@ export interface FsReadRestrictionConfig { denyOnly: string[] allowWithinDeny?: string[] /** - * The `denyOnly` entries that stand for a directory a glob expansion could - * not list. Nothing is bound back beneath one — neither an + * The `denyOnly` entries that stand for a directory a glob expansion did + * not list: one it could not, or one a matched symlink leads to out of the + * pattern's tree. Nothing is bound back beneath one — neither an * `allowWithinDeny` path nor an allowed write path — because what the * pattern matches under such a path was never found and would come back * unmasked. Linux only: the other backends match globs natively. diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index 23859d190..af9000166 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -1137,6 +1137,87 @@ export interface ExpandGlobOptions { caseInsensitive?: boolean } +/** + * The most directory entries the read-deny expansions of one read + * configuration look at, all its patterns together. + */ +export const GLOB_WALK_MAX_ENTRIES = 2_000_000 + +/** The longest they take, all together, in milliseconds. */ +export const GLOB_WALK_TIMEOUT_MS = 10_000 + +/** + * What the walks handed it may spend between them. One budget is meant to be + * shared: every walk draws on the same count and the same deadline, so the + * patterns of one configuration cannot each spend a full one. + */ +export interface GlobWalkBudget { + /** The directory entries the walks may look at, together. */ + readonly maxEntries: number + /** When the budget was made, as a `performance.now()` reading. */ + readonly startedAt: number + /** When the walks must be done, the same way. */ + readonly deadline: number + /** The directory entries looked at so far. */ + entries: number +} + +/** A budget nothing has been spent from, its clock starting now. */ +export function newGlobWalkBudget( + limits: { maxEntries?: number; timeoutMs?: number } = {}, +): GlobWalkBudget { + const startedAt = performance.now() + return { + maxEntries: limits.maxEntries ?? GLOB_WALK_MAX_ENTRIES, + startedAt, + deadline: startedAt + (limits.timeoutMs ?? GLOB_WALK_TIMEOUT_MS), + entries: 0, + } +} + +/** + * Thrown by {@link walkGlobPattern} when its budget runs out. What the walk + * had found by then is not handed back: for a deny expansion, a list cut + * short is a path left readable. + */ +export class GlobWalkBudgetError extends Error { + /** The pattern being walked, as the caller wrote it. */ + readonly pattern: string + /** The directory the walk was listing. */ + readonly directory: string + /** Which limit was reached. */ + readonly exhausted: 'entries' | 'time' + /** The directory entries looked at under the budget, by this walk and the + * ones before it. */ + readonly entries: number + /** Milliseconds since the budget was made. */ + readonly elapsedMs: number + /** The entries the budget allowed. */ + readonly maxEntries: number + /** The milliseconds it allowed. */ + readonly timeoutMs: number + constructor( + pattern: string, + directory: string, + exhausted: 'entries' | 'time', + budget: GlobWalkBudget, + ) { + const elapsedMs = Math.round(performance.now() - budget.startedAt) + const timeoutMs = Math.round(budget.deadline - budget.startedAt) + super( + `Glob pattern ${pattern} was not walked to the end: ${budget.entries} directory entries looked at in ${elapsedMs} ms (the budget is ${budget.maxEntries} entries and ${timeoutMs} ms), stopped while listing ${directory}`, + ) + this.name = 'GlobWalkBudgetError' + this.pattern = pattern + this.directory = directory + this.exhausted = exhausted + this.entries = budget.entries + this.elapsedMs = elapsedMs + this.maxEntries = budget.maxEntries + this.timeoutMs = timeoutMs + } +} + /** What one recursive walk of a glob's base directory found; see {@link walkGlobPattern}. */ export interface GlobWalk { /** Where the walk started, with symlinks resolved (the spelling itself @@ -1166,6 +1247,16 @@ export interface GlobWalk { * what was found through a symlinked directory, which is reported where * it really lives to begin with. */ realOf: Map + /** With `followSymlinkedDirectories`: the symlinked directories the + * pattern could match beneath that were not listed through because they + * lead out of the tree, each with the directory it leads to. One that is + * itself a match is in `matches` or `directoryMatches` all the same. */ + unfollowedLinks: Map + /** How many directories were listed. */ + directoriesListed: number + /** How many directory entries were looked at: each entry once for every + * listing of its directory the pattern called for. */ + entriesExamined: number } /** @@ -1481,13 +1572,24 @@ export function toForwardSlashes(s: string): string { * static prefix, filtered by `globPath` and, with `withDirectoryForm`, by * `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. + * directory that stays inside the tree and reports every match where it + * really lives. The tree is the walk's base, the pattern's static prefix, + * and what lies beneath it, wherever a project around it begins. A link that + * leaves the tree is not listed through: what it leads to is a tree the + * pattern does not name, of any size, and a command allowed to write beneath + * the base can plant one. It is still a match under its own name. + * + * With a `budget` the walk throws {@link GlobWalkBudgetError} once the budget + * is spent, and returns nothing. The clock is read before each listing and + * at each entry, so what is not cut short is one step: a filesystem call + * that blocks, or a name that is slow to match. */ export function walkGlobPattern( globPath: string, opts: ExpandGlobOptions & { withDirectoryForm?: boolean followSymlinkedDirectories?: boolean + budget?: GlobWalkBudget } = {}, ): GlobWalk { const walk: GlobWalk = { @@ -1498,6 +1600,9 @@ export function walkGlobPattern( uninspectableLinks: new Set(), unlisted: [], realOf: new Map(), + unfollowedLinks: new Map(), + directoriesListed: 0, + entriesExamined: 0, } const normalizedPattern = toForwardSlashes(normalizePathForSandbox(globPath)) @@ -1554,6 +1659,25 @@ export function walkGlobPattern( * same entries. */ const listings = new Map() const pending: Frame[] = [] + /** Counts one more entry of `dir` looked at, or none before a listing, and + * throws once the budget is spent. The clock is read every time, before + * each listing and at each entry: what an entry costs is not bounded (a + * name can be slow to match, a link slow to resolve), so a stride of + * entries between readings would be no bound on the time either. Called + * outside every `try` here, so a spent budget is never taken for a + * directory that could not be listed. */ + const spend = (dir: string, entries: 0 | 1): void => { + walk.entriesExamined += entries + const budget = opts.budget + if (budget === undefined) return + budget.entries += entries + if (budget.entries > budget.maxEntries) { + throw new GlobWalkBudgetError(globPath, dir, 'entries', budget) + } + if (performance.now() >= budget.deadline) { + throw new GlobWalkBudgetError(globPath, dir, 'time', budget) + } + } /** A filesystem call on a real path, and on a shorter name for it when that * fails. The real path crosses no link, so a long chain of them cannot * fail the call (ELOOP). */ @@ -1617,6 +1741,7 @@ export function walkGlobPattern( const fresh = frame.positions.filter(p => !listed.has(p)) if (fresh.length === 0) continue for (const p of fresh) listed.add(p) + spend(dir, 0) let entries = listings.get(real) try { entries ??= onRealPath(real, frame.short, p => @@ -1636,6 +1761,7 @@ export function walkGlobPattern( continue } for (const entry of entries) { + spend(dir, 1) const fullPath = path.join(dir, entry.name) const realPath = path.join(real, entry.name) const candidate = toForwardSlashes(fullPath) @@ -1668,10 +1794,10 @@ export function walkGlobPattern( } walk.symlinks.add(fullPath) // Only the read-deny expansion lists through a symlinked directory: it - // has to cover what the pattern reaches by every name. The allowRead - // expansion and the Windows ACL stamp take the link itself and stop - // there, as the allow bind and the ACL they feed do — Windows does not - // follow reparse points at all. + // has to cover what the pattern reaches inside its tree by every name + // that leads there. The allowRead expansion and the Windows ACL stamp + // take the link itself and stop there, as the allow bind and the ACL + // they feed do — Windows does not follow reparse points at all. if (!opts.followSymlinkedDirectories) continue const isDirectoryFormCandidate = opts.withDirectoryForm === true && @@ -1713,6 +1839,17 @@ export function walkGlobPattern( ) continue } + // Neither is a link that leaves the tree, judged by where it resolves + // and not by how it is written. Whatever it matched by its own name is + // recorded above and still denies the target; only the listing of a + // tree the pattern does not name is left out. + if (!isAtOrUnder(target.real, baseReal)) { + walk.unfollowedLinks.set(fullPath, target.real) + logForDebugging( + `[Sandbox] Not following symlink ${fullPath} -> ${target.real} for glob pattern ${globPath}: it leads out of ${baseReal}`, + ) + continue + } pending.push({ dir: target.real, real: target.real, @@ -1722,5 +1859,6 @@ export function walkGlobPattern( } } + walk.directoriesListed = listings.size return walk } From 883cf6a550fb219b804f28d6b73c5d3449ccd30a Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Fri, 25 Sep 2026 20:23:04 +0000 Subject: [PATCH 02/17] test: the tree a read-deny glob stays in, and the budget of its walk Ten existing cases pinned the listing through a link out of the tree. Five now expect the rule. Five are about something else (a chain of links, a path too long to name, `**` written against text, a bracket expression, a carve-out): they keep their expectations under a base widened to hold the link, and assert the original pattern under the new rule beside it. None is removed. New cases pin the rule from both sides: a link out is never listed, by a count of listings, and a link inside the tree that is the only route to a match is still followed. A link is judged by where it resolves; the tree is the pattern's and not the project's; a matched link still masks its target. Nothing is bound back beneath an unlisted target, shown by a real bubblewrap run in which the file beneath the allowed path is unreadable under both names. The budget: the exact entry boundary, the deadline before and during the walk and inside a small directory, one budget shared by the patterns of a wrap, never a partial result. At the manager the wrap rejects and returns no command, the getter throws the same, and the fields of the cause are what a caller words its own message from. --- test/sandbox/glob-expand.test.ts | 78 +- test/sandbox/read-deny-glob-bound.test.ts | 1315 +++++++++++++++++++++ test/sandbox/read-deny-glob.test.ts | 107 +- 3 files changed, 1472 insertions(+), 28 deletions(-) create mode 100644 test/sandbox/read-deny-glob-bound.test.ts diff --git a/test/sandbox/glob-expand.test.ts b/test/sandbox/glob-expand.test.ts index ec0625c9f..c656e4550 100644 --- a/test/sandbox/glob-expand.test.ts +++ b/test/sandbox/glob-expand.test.ts @@ -234,12 +234,30 @@ describe.if(!isWindows)('walkGlobPattern', () => { expect(plain.matches).not.toContain(join(link, 'outside.out')) expect(plain.realOf.get(link)).toBeUndefined() + // With the option the link is resolved, so that a deny lands on what + // it leads to. It leads out of a/, where the pattern starts, so what + // lies behind it is not listed under either name. const followed = walkGlobPattern(join(RAW_BASE, 'a', '**/build/**'), { followSymlinkedDirectories: true, }) - // What is found through the link is reported where it really is. - expect(followed.matches).toContain(join(BASE, 'elsewhere', 'outside.out')) + expect(followed.matches).toContain(link) + expect(followed.realOf.get(link)).toBe(join(BASE, 'elsewhere')) + expect(followed.matches).not.toContain( + join(BASE, 'elsewhere', 'outside.out'), + ) expect(followed.matches).not.toContain(join(link, 'outside.out')) + expect([...followed.unfollowedLinks]).toEqual([ + [link, join(BASE, 'elsewhere')], + ]) + + // Started where both ends of the link are, what is found through it is + // reported where it really is. + const inside = walkGlobPattern(join(RAW_BASE, '**/build/**'), { + followSymlinkedDirectories: true, + }) + expect(inside.matches).toContain(join(BASE, 'elsewhere', 'outside.out')) + expect(inside.matches).not.toContain(join(link, 'outside.out')) + expect([...inside.unfollowedLinks]).toEqual([]) } finally { rmSync(join(BASE, 'elsewhere', 'outside.out')) } @@ -475,6 +493,8 @@ describe.if(!isWindows)('walkGlobPattern', () => { // crosses every link before it. A walk that carried that spelling would // match each entry against a longer and longer path; what is found // through a link is carried and reported by its real path instead. + // `d0*` names d0 alone and starts the walk at root, which holds the whole + // chain: every other directory is reached through the links only. const root = realPath(mkdtempSync(join(tmpdir(), 'glob-walk-chain-'))) const links = 300 try { @@ -486,7 +506,7 @@ describe.if(!isWindows)('walkGlobPattern', () => { symlinkSync(join('..', `d${i + 1}`), join(root, `d${i}`, 'next')) } - const walk = walkGlobPattern(join(root, 'd0', '**/*.pem'), { + const walk = walkGlobPattern(join(root, 'd0*', '**/*.pem'), { followSymlinkedDirectories: true, }) @@ -500,6 +520,16 @@ describe.if(!isWindows)('walkGlobPattern', () => { for (const seen of walk.symlinks) { expect(seen.length).toBeLessThanOrEqual(longest) } + + // Started at d0, the first link already leaves the tree, and the + // chain is not entered. + const fromFirst = walkGlobPattern(join(root, 'd0', '**/*.pem'), { + followSymlinkedDirectories: true, + }) + expect(fromFirst.matches).toEqual([join(root, 'd0', 'id.pem')]) + expect([...fromFirst.unfollowedLinks]).toEqual([ + [join(root, 'd0', 'next'), join(root, 'd1')], + ]) } finally { rmSync(root, { recursive: true, force: true }) } @@ -511,6 +541,8 @@ describe.if(!isWindows)('walkGlobPattern', () => { // deep is a real directory whose path is a few bytes short of PATH_MAX, // so deep/key.pem cannot be named by its real path at all, only through // base/s. The link is a match, and what it leads to has to be found. + // `bas*` names base alone and starts the walk at root, which holds deep + // as well. const root = realPath(mkdtempSync(join(tmpdir(), 'glob-walk-long-'))) let deep = join(root, 'deep') while (deep.length < 4090) { @@ -525,7 +557,7 @@ describe.if(!isWindows)('walkGlobPattern', () => { symlinkSync(join(root, 'secret.txt'), viaLink) expect(join(deep, 'key.pem').length).toBeGreaterThan(4095) - const walk = walkGlobPattern(join(root, 'base', '*/*.pem'), { + const walk = walkGlobPattern(join(root, 'bas*', '*/*.pem'), { followSymlinkedDirectories: true, }) @@ -533,6 +565,15 @@ describe.if(!isWindows)('walkGlobPattern', () => { expect(walk.matches.map(m => walk.realOf.get(m))).toEqual([ join(root, 'secret.txt'), ]) + + // Started at base, base/s leaves the tree and is not listed through. + const fromBase = walkGlobPattern(join(root, 'base', '*/*.pem'), { + followSymlinkedDirectories: true, + }) + expect(fromBase.matches).toEqual([]) + expect([...fromBase.unfollowedLinks]).toEqual([ + [join(root, 'base', 's'), deep], + ]) } finally { rmSync(viaLink, { force: true }) rmSync(root, { recursive: true, force: true }) @@ -625,7 +666,9 @@ describe.if(!isWindows)('walkGlobPattern', () => { // globToRegex lets `**.pem` and `ce**/x.pem` span directories. Each is // two patterns the walk can follow a name at a time (`*.pem` or // `*` / `**` / `*.pem`), so what they reach through a link is found like - // any other match: here certs leads out of the pattern's base. + // any other match: here certs leads to a directory beside proj, which + // the patterns name in no other way. `pro*` names proj alone and starts + // the walk at root, which holds both. const root = realPath(mkdtempSync(join(tmpdir(), 'glob-walk-glued-'))) try { mkdirSync(join(root, 'proj')) @@ -635,13 +678,14 @@ describe.if(!isWindows)('walkGlobPattern', () => { writeFileSync(join(root, 'proj', 'z.pem'), 'KEY') symlinkSync(join('..', 'outside'), join(root, 'proj', 'certs')) - const found = (pattern: string): string[] => { - const walk = walkGlobPattern(join(root, 'proj', pattern), { + const foundFrom = (start: string, pattern: string): string[] => { + const walk = walkGlobPattern(join(root, start, pattern), { followSymlinkedDirectories: true, }) expect(walk.unlisted).toEqual([]) return walk.matches.map(m => walk.realOf.get(m) ?? m).sort() } + const found = (pattern: string): string[] => foundFrom('pro*', pattern) expect(found('**.pem')).toEqual([ join(root, 'outside', 'deep', 'y.pem'), @@ -657,6 +701,11 @@ describe.if(!isWindows)('walkGlobPattern', () => { join(root, 'outside', 'deep', 'y.pem'), ]) expect(found('***/x.pem')).toEqual([join(root, 'outside', 'x.pem')]) + + // Started at proj, certs leads out of the tree and is not listed + // through, however the pattern spans directories. + expect(foundFrom('proj', '**.pem')).toEqual([join(root, 'proj', 'z.pem')]) + expect(foundFrom('proj', 'ce**/x.pem')).toEqual([]) } finally { rmSync(root, { recursive: true, force: true }) } @@ -664,7 +713,8 @@ describe.if(!isWindows)('walkGlobPattern', () => { it('follows a bracket expression that can match a separator', () => { // `[s/]` is an `s` within a name or a separator between two, and both - // readings are followed, through a link as well. + // readings are followed, through a link as well. `pro*` names proj alone + // and starts the walk at root, which holds the link's target too. const root = realPath(mkdtempSync(join(tmpdir(), 'glob-walk-bracket-'))) try { mkdirSync(join(root, 'proj')) @@ -673,7 +723,7 @@ describe.if(!isWindows)('walkGlobPattern', () => { writeFileSync(join(root, 'outside', 'certsx.pem'), 'KEY') symlinkSync(join('..', 'outside'), join(root, 'proj', 'lnk')) - const walk = walkGlobPattern(join(root, 'proj', '*/cert[s/]x.pem'), { + const walk = walkGlobPattern(join(root, 'pro*', '*/cert[s/]x.pem'), { followSymlinkedDirectories: true, }) @@ -681,6 +731,16 @@ describe.if(!isWindows)('walkGlobPattern', () => { join(root, 'outside', 'cert', 'x.pem'), join(root, 'outside', 'certsx.pem'), ]) + + // Started at proj, the link leaves the tree and neither reading is + // followed through it. + const fromProj = walkGlobPattern(join(root, 'proj', '*/cert[s/]x.pem'), { + followSymlinkedDirectories: true, + }) + expect(fromProj.matches).toEqual([]) + expect([...fromProj.unfollowedLinks.keys()]).toEqual([ + join(root, 'proj', 'lnk'), + ]) } finally { rmSync(root, { recursive: true, force: true }) } 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..bd9995db9 --- /dev/null +++ b/test/sandbox/read-deny-glob-bound.test.ts @@ -0,0 +1,1315 @@ +import { describe, it, expect, beforeAll, afterAll, spyOn } from 'bun:test' +// The namespaces the library binds, so a spy on one is seen by the code under +// test. +import * as fs from 'fs' +import * as sandboxUtils from '../../src/sandbox/sandbox-utils.js' +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { spawnSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expandReadDenyGlobLinux } from '../../src/sandbox/read-deny-glob.js' +import { + GLOB_WALK_MAX_ENTRIES, + GLOB_WALK_TIMEOUT_MS, + GlobWalkBudgetError, + expandGlobPattern, + newGlobWalkBudget, + walkGlobPattern, +} from '../../src/sandbox/sandbox-utils.js' +import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' +import { LinuxSandboxProfileError } from '../../src/sandbox/linux-sandbox-utils.js' +import { isLinux, isWindows } from '../helpers/platform.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { countMounts } from '../helpers/bwrap-argv.js' + +/** Every directory `fn` had listed, in order, one entry per listing. */ +function listedDuring(fn: () => T): { result: T; listed: string[] } { + const listed: string[] = [] + const readdirSync = fs.readdirSync + const spy = spyOn(fs, 'readdirSync').mockImplementation((( + ...args: Parameters + ) => { + listed.push(String(args[0])) + return readdirSync(...args) + }) as typeof fs.readdirSync) + try { + return { result: fn(), listed } + } finally { + spy.mockRestore() + } +} + +/** + * `root/start/next -> ../pool/d1`, `pool/d1/next -> ../d2` and so on, each + * `pool/d` holding a `.env`: `links` directories that nothing but the link + * before them leads to from `start`. + */ +function plantChain(root: string, links: number): void { + mkdirSync(join(root, 'start'), { recursive: true }) + mkdirSync(join(root, 'pool')) + symlinkSync(join('..', 'pool', 'd1'), join(root, 'start', 'next')) + for (let i = 1; i <= links; i++) { + mkdirSync(join(root, 'pool', `d${i}`)) + writeFileSync(join(root, 'pool', `d${i}`, '.env'), '') + if (i < links) { + symlinkSync(join('..', `d${i + 1}`), join(root, 'pool', `d${i}`, 'next')) + } + } +} + +/** What `fn` throws, or undefined when it returns. */ +function thrownBy(fn: () => unknown): unknown { + try { + fn() + } catch (error) { + return error + } + return undefined +} + +describe.if(!isWindows)('a read-deny glob and a link out of its tree', () => { + let ROOT: string + let PROJ: string + let OUTSIDE: string + + beforeAll(() => { + ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-bound-'))) + PROJ = join(ROOT, 'proj') + OUTSIDE = join(ROOT, 'outside') + mkdirSync(join(PROJ, 'src'), { recursive: true }) + mkdirSync(join(PROJ, 'sub')) + writeFileSync(join(PROJ, '.env'), 'A=1') + writeFileSync(join(PROJ, 'src', '.env'), 'B=2') + // What a pattern that starts at proj must not go looking for: each of + // these matches `**/.env` by name. + mkdirSync(join(OUTSIDE, 'deep'), { recursive: true }) + writeFileSync(join(OUTSIDE, '.env'), 'C=3') + writeFileSync(join(OUTSIDE, 'deep', '.env'), 'D=4') + }) + + afterAll(() => { + rmSync(ROOT, { recursive: true, force: true }) + }) + + /** A project of its own beside OUTSIDE, for a case that plants links. */ + function project(name: string): string { + const proj = join(ROOT, name) + mkdirSync(join(proj, 'src'), { recursive: true }) + mkdirSync(join(proj, 'sub')) + writeFileSync(join(proj, '.env'), 'A=1') + writeFileSync(join(proj, 'src', '.env'), 'B=2') + return proj + } + + it('does not list what a link out of the tree leads to', () => { + const proj = project('p-out') + symlinkSync(join('..', 'outside'), join(proj, 'rel')) + symlinkSync(OUTSIDE, join(proj, 'abs')) + // A directory beside the tree whose path begins with the tree's own. + const sibling = project('p-out-sibling') + symlinkSync(sibling, join(proj, 'sib')) + + const unfollowedLinks = new Map() + const { result: mounts, listed } = listedDuring(() => + expandReadDenyGlobLinux(join(proj, '**/.env'), [], undefined, { + unfollowedLinks, + }), + ) + + // Only what is in the tree, and neither .env behind the links. + expect(mounts).toEqual([join(proj, '.env'), join(proj, 'src', '.env')]) + // Not read, under any name. + expect(listed.sort()).toEqual( + [proj, join(proj, 'src'), join(proj, 'sub')].sort(), + ) + // Each link that was left is handed back with where it leads. + const left: [string, string][] = [ + [join(proj, 'abs'), OUTSIDE], + [join(proj, 'rel'), OUTSIDE], + [join(proj, 'sib'), sibling], + ] + expect([...unfollowedLinks].sort()).toEqual(left) + expect( + [ + ...walkGlobPattern(join(proj, '**/.env'), { + followSymlinkedDirectories: true, + }).unfollowedLinks, + ].sort(), + ).toEqual(left) + }) + + it('does not report a link back up the tree as one that left it', () => { + // up -> .. leads above the directory the pattern starts from. That is + // the older rule's link: not listed through, and not one of these. + const proj = project('p-up') + symlinkSync('..', join(proj, 'src', 'up')) + symlinkSync(ROOT, join(proj, 'top')) + + const unfollowedLinks = new Map() + const { result: mounts, listed } = listedDuring(() => + expandReadDenyGlobLinux(join(proj, '**/.env'), [], undefined, { + unfollowedLinks, + }), + ) + + expect(mounts).toEqual([join(proj, '.env'), join(proj, 'src', '.env')]) + expect(listed.sort()).toEqual( + [proj, join(proj, 'src'), join(proj, 'sub')].sort(), + ) + expect([...unfollowedLinks]).toEqual([]) + }) + + it('judges a link by where it resolves, not by how it is written', () => { + const proj = project('p-spelled') + // Written from inside the tree, it still leads out of it. + symlinkSync('./sub/../../outside', join(proj, 'dotted')) + // So does one that names a path inside the tree which is itself a link + // out of it. + symlinkSync(join('..', '..', 'outside'), join(proj, 'sub', 'exit')) + symlinkSync(join('sub', 'exit'), join(proj, 'hop')) + // Written out of the tree and back, it leads to a directory inside it. + symlinkSync(join('..', 'p-spelled', 'src'), join(proj, 'back')) + + const unfollowedLinks = new Map() + const { result: mounts, listed } = listedDuring(() => + expandReadDenyGlobLinux(join(proj, '*/.env'), [], undefined, { + unfollowedLinks, + }), + ) + + expect(mounts).toEqual([join(proj, 'src', '.env')]) + expect(listed.filter(dir => dir.startsWith(OUTSIDE))).toEqual([]) + expect([...unfollowedLinks].sort()).toEqual([ + [join(proj, 'dotted'), OUTSIDE], + [join(proj, 'hop'), OUTSIDE], + ]) + // `back` is the only name `b*` matches, so src/.env is found through it. + expect(expandReadDenyGlobLinux(join(proj, 'b*/.env'), [])).toEqual([ + join(proj, 'src', '.env'), + ]) + }) + + it('still finds a match behind a link that stays inside the tree, once, where it really is', () => { + const proj = project('p-in') + symlinkSync('src', join(proj, 'alias')) + + const unfollowedLinks = new Map() + // `a*` matches the link alone: the file is reached through it. + expect( + expandReadDenyGlobLinux(join(proj, 'a*/.env'), [], undefined, { + unfollowedLinks, + }), + ).toEqual([join(proj, 'src', '.env')]) + // `**` reaches it by both names, and it is listed and mounted once. + const { result: mounts, listed } = listedDuring(() => + expandReadDenyGlobLinux(join(proj, '**/.env'), [], undefined, { + unfollowedLinks, + }), + ) + expect(mounts).toEqual([join(proj, '.env'), join(proj, 'src', '.env')]) + expect(listed.filter(dir => dir === join(proj, 'src'))).toHaveLength(1) + expect([...unfollowedLinks]).toEqual([]) + }) + + it('finds a file that only a link inside the tree leads to', () => { + // tlink -> sub, and `t*` matches the link and not sub: the link is the + // only way the pattern has to sub/.env, so a walk that followed no link + // would find nothing. + const proj = join(ROOT, 'p-route') + mkdirSync(join(proj, 'sub'), { recursive: true }) + writeFileSync(join(proj, 'sub', '.env'), 'A=1') + symlinkSync(join(proj, 'sub'), join(proj, 'tlink')) + + const unfollowedLinks = new Map() + expect( + expandReadDenyGlobLinux(join(proj, 't*/**/.env'), [], undefined, { + unfollowedLinks, + }), + ).toEqual([join(proj, 'sub', '.env')]) + expect([...unfollowedLinks]).toEqual([]) + }) + + it('takes the tree to be the directory the pattern starts from, not the one above it', () => { + // src/shared -> ../shared: the pattern that starts at src does not reach + // shared/.env, and the one that starts at the project does. + const proj = join(ROOT, 'p-base') + mkdirSync(join(proj, 'src'), { recursive: true }) + mkdirSync(join(proj, 'shared')) + writeFileSync(join(proj, 'src', '.env'), 'A=1') + writeFileSync(join(proj, 'shared', '.env'), 'B=2') + symlinkSync(join('..', 'shared'), join(proj, 'src', 'shared')) + + const unfollowedLinks = new Map() + expect( + expandReadDenyGlobLinux(join(proj, 'src', '**/.env'), [], undefined, { + unfollowedLinks, + }), + ).toEqual([join(proj, 'src', '.env')]) + expect([...unfollowedLinks]).toEqual([ + [join(proj, 'src', 'shared'), join(proj, 'shared')], + ]) + expect(expandReadDenyGlobLinux(join(proj, '**/.env'), [])).toEqual([ + join(proj, 'shared', '.env'), + join(proj, 'src', '.env'), + ]) + }) + + it('takes the tree to be where the pattern really starts, when it starts from a link', () => { + // entry -> p-aliased: the pattern is spelled through the link, and the + // tree is the directory it leads to. A link inside that directory stays + // inside the tree, and one out of it leaves. + const proj = project('p-aliased') + const entry = join(ROOT, 'entry') + symlinkSync(proj, entry) + symlinkSync('src', join(proj, 'alias')) + symlinkSync(OUTSIDE, join(proj, 'away')) + + const unfollowedLinks = new Map() + expect( + expandReadDenyGlobLinux(join(entry, 'a*/.env'), [], undefined, { + unfollowedLinks, + }), + ).toEqual([join(proj, 'src', '.env')]) + expect([...unfollowedLinks]).toEqual([[join(entry, 'away'), OUTSIDE]]) + }) + + it('still denies what a link leads to when the link itself matches', () => { + const proj = project('p-own') + mkdirSync(join(OUTSIDE, 'vault', 'inner'), { recursive: true }) + writeFileSync(join(OUTSIDE, 'vault', 'inner', 'id.pem'), 'KEY') + writeFileSync(join(OUTSIDE, 'key.pem'), 'KEY') + mkdirSync(join(proj, 'certs')) + symlinkSync(join(OUTSIDE, 'key.pem'), join(proj, 'certs', 'key.pem')) + symlinkSync(join(OUTSIDE, 'vault'), join(proj, 'certs', 'vault.pem')) + + const unfollowedLinks = new Map() + const { result: mounts, listed } = listedDuring(() => + expandReadDenyGlobLinux(join(proj, 'certs', '*.pem'), [], undefined, { + unfollowedLinks, + }), + ) + + // A file and a directory, each denied where the link leads. + expect(mounts).toEqual([join(OUTSIDE, 'key.pem'), join(OUTSIDE, 'vault')]) + expect(listed).toEqual([join(proj, 'certs')]) + // The pattern has nothing to match beneath `*.pem`, so there was nothing + // to follow and nothing is reported as left. + expect([...unfollowedLinks]).toEqual([]) + + // The same with a pattern that carries on beneath the link: the target + // is denied as a whole and not listed. + const beneath = listedDuring(() => + expandReadDenyGlobLinux(join(proj, 'certs', '**/*'), [], undefined, { + unfollowedLinks, + }), + ) + expect(beneath.result).toEqual([ + join(OUTSIDE, 'key.pem'), + join(OUTSIDE, 'vault'), + ]) + expect(beneath.listed).toEqual([join(proj, 'certs')]) + expect([...unfollowedLinks]).toEqual([ + [join(proj, 'certs', 'vault.pem'), join(OUTSIDE, 'vault')], + ]) + }) + + it('denies the target of a directory-form link out of the tree as a whole, without listing it', () => { + const proj = project('p-form') + const outsideBuild = join(OUTSIDE, 'artifacts', 'build') + mkdirSync(join(outsideBuild, 'nested'), { recursive: true }) + writeFileSync(join(outsideBuild, '1.out'), '') + writeFileSync(join(outsideBuild, 'nested', '2.out'), '') + mkdirSync(join(proj, 'pkg', 'build'), { recursive: true }) + writeFileSync(join(proj, 'pkg', 'build', '0.out'), '') + mkdirSync(join(proj, 'linked')) + symlinkSync(outsideBuild, join(proj, 'linked', 'build')) + + const unfollowedLinks = new Map() + const { result: mounts, listed } = listedDuring(() => + expandReadDenyGlobLinux(join(proj, '**/build/**'), [], undefined, { + unfollowedLinks, + }), + ) + + expect(mounts).toEqual([outsideBuild, join(proj, 'pkg', 'build')]) + expect(listed.filter(dir => dir.startsWith(OUTSIDE))).toEqual([]) + expect([...unfollowedLinks]).toEqual([ + [join(proj, 'linked', 'build'), outsideBuild], + ]) + }) + + /** + * `/proj` beside `/store`, with `proj/pkg/build -> store/build` + * and `proj/certs/vault.pem -> store/vault`. Each target holds `public`, + * the allowed path, with a file in it that the patterns here match. + */ + function plantStore(name: string): { top: string; proj: string } { + const top = join(ROOT, name) + const proj = join(top, 'proj') + for (const dir of ['build', 'vault']) { + mkdirSync(join(top, 'store', dir, 'public'), { recursive: true }) + writeFileSync(join(top, 'store', dir, 'public', 'id.pem'), 'KEY') + } + mkdirSync(join(proj, 'pkg'), { recursive: true }) + mkdirSync(join(proj, 'certs')) + symlinkSync(join(top, 'store', 'build'), join(proj, 'pkg', 'build')) + symlinkSync(join(top, 'store', 'vault'), join(proj, 'certs', 'vault.pem')) + return { top, proj } + } + + it('hands back what a matched link out of the tree leads to as a directory to bind nothing back beneath', () => { + // The target is denied as a whole and was not listed, so id.pem beneath + // the allowed path was never found: bound back, the path would show it. + const { top, proj } = plantStore('p-closed') + const build = join(top, 'store', 'build') + const vault = join(top, 'store', 'vault') + + // A link that matches in the directory form, and one by its own name. + for (const [pattern, target] of [ + [join(proj, '**/build/**'), build], + [join(proj, 'certs', '**/*.pem'), vault], + ] as const) { + const unlistable = new Set() + const { result: mounts, listed } = listedDuring(() => + expandReadDenyGlobLinux(pattern, [join(target, 'public')], unlistable), + ) + + expect(mounts).toEqual([target]) + expect(listed.filter(dir => dir.startsWith(join(top, 'store')))).toEqual( + [], + ) + expect([...unlistable]).toEqual([target]) + } + + // Started above both ends of the link, the target is listed: the allowed + // path is bound back, and what the pattern matches beneath it keeps its + // own mount. + const unlistable = new Set() + expect( + expandReadDenyGlobLinux( + join(top, '**/build/**'), + [join(build, 'public')], + unlistable, + ), + ).toEqual([build, join(build, 'public'), join(build, 'public', 'id.pem')]) + expect([...unlistable]).toEqual([]) + }) + + it('hands back nothing for a link that only passes through, or that the pattern ends at', () => { + const { top, proj } = plantStore('p-through') + const vault = join(top, 'store', 'vault') + writeFileSync(join(proj, 'id.pem'), 'KEY') + + // Neither link is a match: nothing out there is denied, so there is + // nothing for an allowed path to be bound back over. + const passedThrough = new Set() + const unfollowedLinks = new Map() + expect( + expandReadDenyGlobLinux( + join(proj, '**/id.pem'), + [join(vault, 'public')], + passedThrough, + { unfollowedLinks }, + ), + ).toEqual([join(proj, 'id.pem')]) + expect([...unfollowedLinks.keys()].sort()).toEqual([ + join(proj, 'certs', 'vault.pem'), + join(proj, 'pkg', 'build'), + ]) + expect([...passedThrough]).toEqual([]) + + // The link is a match and the pattern has nothing to match beneath it: + // no listing was called for, so nothing is missing from the deny, and + // the allowed path comes back as under a literal deny of the link. + const endedAt = new Set() + expect( + expandReadDenyGlobLinux( + join(proj, 'certs', '*.pem'), + [join(vault, 'public')], + endedAt, + ), + ).toEqual([vault]) + expect([...endedAt]).toEqual([]) + }) + + it('hands back nothing for a link that passes through to beneath what another link denies', () => { + // a/x.pem -> out is a match the pattern ends at: out is denied as a + // whole, as a literal deny of the link would deny it, with nothing to + // list there. b -> out/sub only passes through. It is no match, so it + // reports nothing, although a mount hides where it leads: the allowed + // path out/sub is bound back, and k.pem beneath it, which only a + // listing through b would have found, gets no mount of its own. + const top = join(ROOT, 'p-beneath') + const proj = join(top, 'proj') + const out = join(top, 'out') + mkdirSync(join(out, 'sub'), { recursive: true }) + writeFileSync(join(out, 'sub', 'k.pem'), 'KEY') + mkdirSync(join(proj, 'a'), { recursive: true }) + symlinkSync(out, join(proj, 'a', 'x.pem')) + symlinkSync(join(out, 'sub'), join(proj, 'b')) + + const unlistable = new Set() + const unfollowedLinks = new Map() + expect( + expandReadDenyGlobLinux( + join(proj, '*/*.pem'), + [join(out, 'sub')], + unlistable, + { unfollowedLinks }, + ), + ).toEqual([out]) + expect([...unfollowedLinks]).toEqual([[join(proj, 'b'), join(out, 'sub')]]) + expect([...unlistable]).toEqual([]) + }) + + it('hands back the mount that hides what a matched link out of the tree leads to', () => { + // up/build -> top leads back up, and denies top as a whole. a/build -> + // top/store leaves the tree beside it: store is denied too and was not + // listed, and the mount that hides it is top's. + const top = join(ROOT, 'p-hidden') + const proj = join(top, 'proj') + mkdirSync(join(top, 'store', 'public'), { recursive: true }) + writeFileSync(join(top, 'store', 'public', 'ok.txt'), 'PUBLIC') + mkdirSync(join(proj, 'up'), { recursive: true }) + mkdirSync(join(proj, 'a')) + symlinkSync(top, join(proj, 'up', 'build')) + symlinkSync(join(top, 'store'), join(proj, 'a', 'build')) + + const unlistable = new Set() + expect( + expandReadDenyGlobLinux( + join(proj, '**/build/**'), + [join(top, 'store', 'public')], + unlistable, + ), + ).toEqual([top]) + expect([...unlistable]).toEqual([top]) + }) + + it('does not find a file in the tree by a name that leaves the tree and comes back', () => { + // leave -> ../out/hop is not listed through, so back -> ../../proj/deep + // out there is never met. `*/*/.env` matches deep/.env as + // leave/back/.env and by no other name: the file is inside the tree, + // and is not denied. + const top = join(ROOT, 'p-return') + const proj = join(top, 'proj') + const hop = join(top, 'out', 'hop') + mkdirSync(join(proj, 'deep'), { recursive: true }) + mkdirSync(hop, { recursive: true }) + writeFileSync(join(proj, 'deep', '.env'), 'A=1') + symlinkSync(join('..', 'out', 'hop'), join(proj, 'leave')) + symlinkSync(join('..', '..', 'proj', 'deep'), join(hop, 'back')) + + const unfollowedLinks = new Map() + const { result: mounts, listed } = listedDuring(() => + expandReadDenyGlobLinux(join(proj, '*/*/.env'), [], undefined, { + unfollowedLinks, + }), + ) + + expect(mounts).toEqual([]) + expect(listed.filter(dir => dir.startsWith(join(top, 'out')))).toEqual([]) + expect([...unfollowedLinks]).toEqual([[join(proj, 'leave'), hop]]) + // A pattern that matches the file by a name inside the tree denies it. + expect(expandReadDenyGlobLinux(join(proj, '*/.env'), [])).toEqual([ + join(proj, 'deep', '.env'), + ]) + + // The same where the link out there has the name the pattern asks for: + // src/vendor -> ../vendor and vendor/secrets -> ../src/data, so + // `**/secrets/*.pem` matches data/key.pem as vendor/secrets/key.pem. + const src = join(ROOT, 'p-named', 'src') + const vendor = join(ROOT, 'p-named', 'vendor') + mkdirSync(join(src, 'data'), { recursive: true }) + mkdirSync(vendor) + writeFileSync(join(src, 'data', 'key.pem'), 'KEY') + symlinkSync(join('..', 'vendor'), join(src, 'vendor')) + symlinkSync(join('..', 'src', 'data'), join(vendor, 'secrets')) + + expect(expandReadDenyGlobLinux(join(src, '**/secrets/*.pem'), [])).toEqual( + [], + ) + expect(expandReadDenyGlobLinux(join(src, '**/*.pem'), [])).toEqual([ + join(src, 'data', 'key.pem'), + ]) + }) + + it('costs no listing of a large tree a link leads out to', () => { + const proj = project('p-cost') + const big = join(ROOT, 'big') + for (let d = 0; d < 30; d++) { + mkdirSync(join(big, `d${d}`), { recursive: true }) + for (let f = 0; f < 100; f++) { + writeFileSync(join(big, `d${d}`, f === 0 ? '.env' : `f${f}`), '') + } + } + const pattern = join(proj, '**/.env') + const without = listedDuring(() => + walkGlobPattern(pattern, { followSymlinkedDirectories: true }), + ) + + symlinkSync(big, join(proj, 'bigtree')) + const withLink = listedDuring(() => + walkGlobPattern(pattern, { followSymlinkedDirectories: true }), + ) + + // The same listings, and one entry more to look at: the link itself. + expect(withLink.listed.sort()).toEqual(without.listed.sort()) + expect(withLink.listed).toHaveLength(3) + expect(withLink.result.directoriesListed).toBe(3) + expect(withLink.result.entriesExamined).toBe( + without.result.entriesExamined + 1, + ) + expect(withLink.result.matches).toEqual(without.result.matches) + expect([...withLink.result.unfollowedLinks]).toEqual([ + [join(proj, 'bigtree'), big], + ]) + // The tree is there to be found by a pattern that names it. + expect(expandGlobPattern(join(big, '**/.env'))).toHaveLength(30) + }) + + it('still leaves a link alone without the link option', () => { + // What the allowRead expansion and the Windows ACL stamp get: the link + // as a match of its own, nothing resolved and nothing reported. + const proj = project('p-plain') + symlinkSync(OUTSIDE, join(proj, 'out')) + + const walk = walkGlobPattern(join(proj, '*')) + + expect(walk.matches).toContain(join(proj, 'out')) + expect(walk.realOf.get(join(proj, 'out'))).toBeUndefined() + expect([...walk.unfollowedLinks]).toEqual([]) + }) +}) + +describe.if(!isWindows)('the budget of a glob walk', () => { + let ROOT: string + /** Twelve directories of four files, one of them a match: 60 entries. */ + let TREE: string + let TREE_ENTRIES: number + + function plantTree(name: string): string { + const tree = join(ROOT, name) + for (let d = 0; d < 12; d++) { + mkdirSync(join(tree, `d${d}`), { recursive: true }) + for (const f of ['.env', 'a', 'b', 'c']) { + writeFileSync(join(tree, `d${d}`, f), '') + } + } + return tree + } + + beforeAll(() => { + ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-budget-'))) + TREE = plantTree('tree') + TREE_ENTRIES = walkGlobPattern(join(TREE, '**/.env')).entriesExamined + }) + + afterAll(() => { + rmSync(ROOT, { recursive: true, force: true }) + }) + + it('defaults to two million entries and ten seconds', () => { + expect(GLOB_WALK_MAX_ENTRIES).toBe(2_000_000) + expect(GLOB_WALK_TIMEOUT_MS).toBe(10_000) + const budget = newGlobWalkBudget() + expect(budget.maxEntries).toBe(2_000_000) + // Rounded: the deadline is a clock reading plus ten seconds, and a + // floating-point sum less the reading need not be ten seconds exactly. + expect(Math.round(budget.deadline - budget.startedAt)).toBe(10_000) + expect(budget.entries).toBe(0) + }) + + it('counts every entry it looks at, with or without a budget', () => { + expect(TREE_ENTRIES).toBe(12 + 12 * 4) + const budget = newGlobWalkBudget() + const walk = walkGlobPattern(join(TREE, '**/.env'), { budget }) + expect(walk.entriesExamined).toBe(TREE_ENTRIES) + expect(walk.directoriesListed).toBe(13) + expect(budget.entries).toBe(TREE_ENTRIES) + }) + + it('throws when the tree holds more entries than the budget, and hands nothing back', () => { + const pattern = join(TREE, '**/.env') + // Exactly enough is enough. + expect( + expandReadDenyGlobLinux(pattern, [], undefined, { + budget: newGlobWalkBudget({ maxEntries: TREE_ENTRIES }), + }), + ).toHaveLength(12) + + const budget = newGlobWalkBudget({ maxEntries: TREE_ENTRIES - 1 }) + let mounts: string[] | undefined + const error = thrownBy(() => { + mounts = expandReadDenyGlobLinux(pattern, [], undefined, { budget }) + }) + + expect(mounts).toBeUndefined() + expect(error).toBeInstanceOf(GlobWalkBudgetError) + const budgetError = error as GlobWalkBudgetError + expect(budgetError.exhausted).toBe('entries') + expect(budgetError.pattern).toBe(pattern) + // The directory it was in when the count ran out, which is one of the + // tree's own. + expect(budgetError.directory.startsWith(TREE + '/')).toBe(true) + expect(budgetError.entries).toBe(TREE_ENTRIES) + expect(budgetError.maxEntries).toBe(TREE_ENTRIES - 1) + expect(budgetError.elapsedMs).toBeGreaterThanOrEqual(0) + expect(budgetError.message).toContain(pattern) + expect(budgetError.message).toContain(budgetError.directory) + // The walk itself throws the same, whoever calls it. + expect(() => + walkGlobPattern(pattern, { + budget: newGlobWalkBudget({ maxEntries: 5 }), + }), + ).toThrow(GlobWalkBudgetError) + }) + + it('throws when the deadline has passed before it starts', () => { + const pattern = join(TREE, '**/.env') + let mounts: string[] | undefined + const error = thrownBy(() => { + mounts = expandReadDenyGlobLinux(pattern, [], undefined, { + budget: newGlobWalkBudget({ timeoutMs: 0 }), + }) + }) + + expect(mounts).toBeUndefined() + expect(error).toBeInstanceOf(GlobWalkBudgetError) + const budgetError = error as GlobWalkBudgetError + expect(budgetError.exhausted).toBe('time') + expect(budgetError.pattern).toBe(pattern) + expect(budgetError.directory).toBe(TREE) + expect(budgetError.entries).toBe(0) + expect(budgetError.timeoutMs).toBe(0) + }) + + it('throws when the deadline passes while it walks', () => { + // A clock that moves a second each time it is read, and the walk reads + // it before each listing and at each entry. The budget is made at 1 s; + // the tree and its twelve entries take the readings up to 14 s, and each + // of the first two directories beneath it five more, up to 24 s. The + // next reading, before the third is listed, is past the deadline. + let now = 0 + const clock = spyOn(performance, 'now').mockImplementation( + () => (now += 1000), + ) + try { + const pattern = join(TREE, '**/.env') + const budget = newGlobWalkBudget({ timeoutMs: 23_500 }) + const error = thrownBy(() => walkGlobPattern(pattern, { budget })) + + expect(error).toBeInstanceOf(GlobWalkBudgetError) + const budgetError = error as GlobWalkBudgetError + expect(budgetError.exhausted).toBe('time') + expect(budgetError.directory.startsWith(TREE + '/')).toBe(true) + expect(budgetError.entries).toBe(12 + 2 * 4) + } finally { + clock.mockRestore() + } + }) + + it('throws inside a directory of few entries, at the entry the deadline passes on', () => { + // The clock is read at every entry, not every so many: a name can be + // slow to match, and a directory of forty such names must not be walked + // to its end long after the time is up. One reading a second again: the + // budget is made at 1 s, the listing is at 2 s, and the fourth entry, at + // 6 s, is past the deadline. + const few = join(ROOT, 'few') + mkdirSync(few) + for (let f = 0; f < 40; f++) writeFileSync(join(few, `f${f}`), '') + let now = 0 + const clock = spyOn(performance, 'now').mockImplementation( + () => (now += 1000), + ) + try { + const budget = newGlobWalkBudget({ timeoutMs: 4500 }) + const error = thrownBy(() => + walkGlobPattern(join(few, '*.env'), { budget }), + ) + + expect(error).toBeInstanceOf(GlobWalkBudgetError) + const budgetError = error as GlobWalkBudgetError + expect(budgetError.exhausted).toBe('time') + expect(budgetError.directory).toBe(few) + expect(budgetError.entries).toBe(4) + } finally { + clock.mockRestore() + } + }) + + it('is spent by every expansion it is handed to', () => { + const second = plantTree('second') + const budget = newGlobWalkBudget({ maxEntries: TREE_ENTRIES + 10 }) + + expect( + expandReadDenyGlobLinux(join(TREE, '**/.env'), [], undefined, { + budget, + }), + ).toHaveLength(12) + expect(budget.entries).toBe(TREE_ENTRIES) + + // The second tree fits a budget of its own, and does not fit what the + // first expansion left of this one. + expect( + expandReadDenyGlobLinux(join(second, '**/.env'), [], undefined, { + budget: newGlobWalkBudget({ maxEntries: TREE_ENTRIES + 10 }), + }), + ).toHaveLength(12) + const error = thrownBy(() => + expandReadDenyGlobLinux(join(second, '**/.env'), [], undefined, { + budget, + }), + ) + expect(error).toBeInstanceOf(GlobWalkBudgetError) + expect((error as GlobWalkBudgetError).pattern).toBe(join(second, '**/.env')) + expect((error as GlobWalkBudgetError).entries).toBe(TREE_ENTRIES + 11) + }) + + it('throws on a tree that only links inside it lead through', () => { + // start/next -> ../pool/d1, d1/next -> ../d2, …, all beneath the + // directory the pattern starts from, so every link is followed: a tree a + // command allowed to write there can make as long as it likes. `start*` + // names start alone, so nothing but the links leads into pool. + const chain = join(ROOT, 'chain') + const links = 40 + plantChain(chain, links) + const pattern = join(chain, 'start*', '**/.env') + const whole = walkGlobPattern(pattern, { + followSymlinkedDirectories: true, + }) + expect(whole.matches).toHaveLength(links) + expect([...whole.unfollowedLinks]).toEqual([]) + // chain holds start and pool; every other entry was met through a link. + expect(whole.entriesExamined).toBe(2 + 2 * links) + + let mounts: string[] | undefined + const error = thrownBy(() => { + mounts = expandReadDenyGlobLinux(pattern, [], undefined, { + budget: newGlobWalkBudget({ maxEntries: whole.entriesExamined - 1 }), + }) + }) + + expect(mounts).toBeUndefined() + expect(error).toBeInstanceOf(GlobWalkBudgetError) + expect((error as GlobWalkBudgetError).directory).toBe( + join(chain, 'pool', `d${links}`), + ) + }) + + it('does not take a spent budget for a directory that could not be listed', () => { + // An error from the listing itself marks the directory to be denied as + // a whole; the budget's error is not one of those, whether the entries + // ran out or the time did. The time is looked at right before each + // listing: taken for a listing that failed, it would deny the tree as a + // whole and hand that back. + for (const limits of [{ maxEntries: 20 }, { timeoutMs: 0 }]) { + const unlistable = new Set() + let mounts: string[] | undefined + const error = thrownBy(() => { + mounts = expandReadDenyGlobLinux( + join(TREE, '**/.env'), + [], + unlistable, + { budget: newGlobWalkBudget(limits) }, + ) + }) + + expect(error).toBeInstanceOf(GlobWalkBudgetError) + expect(mounts).toBeUndefined() + expect([...unlistable]).toEqual([]) + } + }) +}) + +describe.if(isLinux)('a read-deny glob past its budget, at the manager', () => { + let ROOT: string + let PROJ: string + let OTHER: string + const newBudget = sandboxUtils.newGlobWalkBudget + + /** Has the manager make its budgets with these limits instead. */ + function withBudgetOf( + limits: { maxEntries?: number; timeoutMs?: number }, + fn: (made: () => number) => Promise, + ): Promise { + const spy = spyOn(sandboxUtils, 'newGlobWalkBudget').mockImplementation( + () => newBudget(limits), + ) + return fn(() => spy.mock.calls.length).finally(() => spy.mockRestore()) + } + + function plantProject(name: string): string { + const proj = join(ROOT, name) + for (let d = 0; d < 10; d++) { + mkdirSync(join(proj, `pkg${d}`), { recursive: true }) + for (const f of ['.env', 'index.js', 'readme.md']) { + writeFileSync(join(proj, `pkg${d}`, f), '') + } + } + return proj + } + + function filesystemOf(denyRead: string[]) { + return { filesystem: { denyRead, allowWrite: [], denyWrite: [] } } + } + + /** The wrapped command, or what the wrap threw: never both. */ + async function wrapOf( + denyRead: string[], + ): Promise<{ wrapped?: string; error?: unknown }> { + try { + return { + wrapped: await SandboxManager.wrapWithSandbox( + 'echo hello', + undefined, + filesystemOf(denyRead), + ), + } + } catch (error) { + return { error } + } + } + + beforeAll(() => { + ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-manager-'))) + PROJ = plantProject('proj') + OTHER = plantProject('other') + }) + + afterAll(async () => { + await SandboxManager.reset() + rmSync(ROOT, { recursive: true, force: true }) + }) + + it('wraps an ordinary project with the default budget', async () => { + const { wrapped, error } = await wrapOf([join(PROJ, '**/.env')]) + + expect(error).toBeUndefined() + for (let d = 0; d < 10; d++) { + expect(wrapped).toContain( + `--ro-bind /dev/null ${join(PROJ, `pkg${d}`, '.env')}`, + ) + } + }) + + it('refuses the wrap, and returns no command, when the tree is past the budget', async () => { + const pattern = join(PROJ, '**/.env') + const { wrapped, error } = await withBudgetOf({ maxEntries: 25 }, () => + wrapOf([pattern]), + ) + + expect(wrapped).toBeUndefined() + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + const profileError = error as LinuxSandboxProfileError + expect(profileError.code).toBe('deny_glob_too_large') + expect(profileError.message).toContain(`"${pattern}"`) + expect(profileError.message).toContain('25 directory entries') + expect(profileError.message).toContain('narrow the pattern') + expect(profileError.cause).toBeInstanceOf(GlobWalkBudgetError) + const cause = profileError.cause as GlobWalkBudgetError + expect(cause.exhausted).toBe('entries') + expect(profileError.message).toContain(cause.directory) + // What a caller builds its own message from, read off `.cause` as plain + // fields: it needs neither the class nor the text of the message. + const fields: Record = { ...cause } + expect(fields).toEqual({ + name: 'GlobWalkBudgetError', + pattern, + directory: expect.stringMatching(new RegExp(`^${PROJ}(/|$)`)), + exhausted: 'entries', + entries: 26, + elapsedMs: expect.any(Number), + maxEntries: 25, + timeoutMs: 10_000, + }) + // A rejected promise, which is how every caller of the wrap meets it. + await withBudgetOf({ maxEntries: 25 }, async () => { + // eslint-disable-next-line @typescript-eslint/await-thenable -- bun:test types .rejects.toThrow() as void; the await is required at runtime + await expect( + SandboxManager.wrapWithSandbox( + 'echo hello', + undefined, + filesystemOf([pattern]), + ), + ).rejects.toThrow(LinuxSandboxProfileError) + }) + }) + + it('refuses the wrap when the time is up', async () => { + const { wrapped, error } = await withBudgetOf({ timeoutMs: 0 }, () => + wrapOf([join(PROJ, '**/.env')]), + ) + + expect(wrapped).toBeUndefined() + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe('deny_glob_too_large') + expect((error as LinuxSandboxProfileError).message).toContain( + 'the time ran out', + ) + }) + + it('gives the patterns of one wrap one budget between them', async () => { + const first = join(PROJ, '**/.env') + const second = join(OTHER, '**/.env') + // Each tree is 40 entries: either fits 60, and the two do not. + await withBudgetOf({ maxEntries: 60 }, async made => { + expect((await wrapOf([first])).error).toBeUndefined() + expect((await wrapOf([second])).error).toBeUndefined() + expect(made()).toBe(2) + + const { wrapped, error } = await wrapOf([first, second]) + + expect(made()).toBe(3) + expect(wrapped).toBeUndefined() + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_glob_too_large', + ) + // The pattern that was being expanded when the budget ran out. + expect((error as LinuxSandboxProfileError).message).toContain( + `"${second}"`, + ) + }) + }) + + it('refuses the wrap over a tree that only links inside it lead through', async () => { + const chain = join(ROOT, 'chain') + plantChain(chain, 30) + const pattern = join(chain, 'start*', '**/.env') + expect((await wrapOf([pattern])).wrapped).toContain( + `--ro-bind /dev/null ${join(chain, 'pool', 'd30', '.env')}`, + ) + + // chain itself holds two entries: the rest of the twenty go on what the + // links lead to. + const { wrapped, error } = await withBudgetOf({ maxEntries: 20 }, () => + wrapOf([pattern]), + ) + + expect(wrapped).toBeUndefined() + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe('deny_glob_too_large') + expect( + ((error as LinuxSandboxProfileError).cause as GlobWalkBudgetError) + .directory, + ).toStartWith(join(chain, 'pool') + '/') + }) + + it('does not spend the budget on a tree a link leads out to', async () => { + const proj = join(ROOT, 'linked') + mkdirSync(join(proj, 'src'), { recursive: true }) + writeFileSync(join(proj, '.env'), '') + symlinkSync(OTHER, join(proj, 'bigtree')) + + // Three entries in the tree; OTHER holds forty. + const { wrapped, error } = await withBudgetOf({ maxEntries: 3 }, () => + wrapOf([join(proj, '**/.env')]), + ) + + expect(error).toBeUndefined() + expect(wrapped).toContain(`--ro-bind /dev/null ${join(proj, '.env')}`) + expect(wrapped).not.toContain(join(OTHER, 'pkg0', '.env')) + }) + + it('refuses the read configuration the same way', async () => { + const pattern = join(PROJ, '**/.env') + await SandboxManager.reset() + await SandboxManager.initialize({ + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { denyRead: [pattern], allowWrite: [], denyWrite: [] }, + }) + try { + expect(SandboxManager.getFsReadConfig().denyOnly).toHaveLength(10) + + const error = await withBudgetOf({ maxEntries: 25 }, async () => + thrownBy(() => SandboxManager.getFsReadConfig()), + ) + + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_glob_too_large', + ) + expect((error as LinuxSandboxProfileError).message).toContain( + `"${pattern}"`, + ) + } finally { + await SandboxManager.reset() + } + }) +}) + +describe.if(isLinux)( + 'an allowed path beneath what a matched link out of the tree leads to', + () => { + // proj/pkg/build -> store/build, and allowRead store/build/public. The + // pattern that starts at proj denies store/build as a whole without + // listing it, so ok.txt beneath the allowed path has no mask of its own: + // the path is not bound back. + let ROOT: string + let proj: string + let build: string + let carveOut: string + const hasBwrap = bwrapCanNamespace() + + beforeAll(() => { + ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-closed-'))) + proj = join(ROOT, 'proj') + build = join(ROOT, 'store', 'build') + carveOut = join(build, 'public') + mkdirSync(carveOut, { recursive: true }) + writeFileSync(join(build, 'secret.out'), 'SECRET') + writeFileSync(join(carveOut, 'ok.txt'), 'PUBLIC') + writeFileSync(join(ROOT, 'store', 'beside.txt'), 'BESIDE') + mkdirSync(join(proj, 'pkg'), { recursive: true }) + symlinkSync(build, join(proj, 'pkg', 'build')) + }) + + afterAll(async () => { + await SandboxManager.reset() + rmSync(ROOT, { recursive: true, force: true }) + }) + + function wrap(command: string, denyRead: string): Promise { + return SandboxManager.wrapWithSandbox(command, undefined, { + filesystem: { + denyRead: [denyRead], + allowRead: [carveOut], + allowWrite: [], + denyWrite: [], + }, + }) + } + + it('is not bound back', async () => { + const wrapped = await wrap('true', join(proj, '**/build/**')) + + expect(countMounts(wrapped, '--tmpfs', build)).toBe(1) + expect(countMounts(wrapped, '--ro-bind', carveOut, carveOut)).toBe(0) + + // Started above both ends of the link, the pattern finds ok.txt: the + // path is bound back, with the file masked beneath it. + const listed = await wrap('true', join(ROOT, '**/build/**')) + expect( + countMounts(listed, '--ro-bind', carveOut, carveOut), + ).toBeGreaterThan(0) + expect( + countMounts(listed, '--ro-bind', '/dev/null', join(carveOut, 'ok.txt')), + ).toBe(1) + }) + + it.skipIf(!hasBwrap)( + 'serves no file beneath it, by either name', + async () => { + // Each command says BOOTED first: a sandbox that refuses to start + // prints nothing, which would read as every file hidden. + const result = spawnSync( + await wrap( + [ + 'echo BOOTED', + `cat ${join(carveOut, 'ok.txt')} || echo HIDDEN`, + `cat ${join(proj, 'pkg', 'build', 'public', 'ok.txt')} || echo HIDDEN`, + `cat ${join(build, 'secret.out')} || echo HIDDEN`, + `cat ${join(ROOT, 'store', 'beside.txt')}`, + ].join('; '), + join(proj, '**/build/**'), + ), + { shell: true, encoding: 'utf8', timeout: 15000 }, + ) + + expect(result.stderr ?? '').not.toContain('bwrap:') + expect(result.stdout.trim().split('\n')).toEqual([ + 'BOOTED', + 'HIDDEN', + 'HIDDEN', + 'HIDDEN', + 'BESIDE', + ]) + }, + ) + }, +) + +describe.if(isLinux)( + 'a file in the tree that the pattern names only by a way out of the tree and back', + () => { + // proj/leave -> ../out/hop, and out/hop/back -> ../../proj/deep: + // `proj/*/*/.env` matches proj/deep/.env as leave/back/.env alone. The + // link out of the tree is not listed through, so the file is not found, + // and what is given up is a file inside the tree. + let ROOT: string + let proj: string + let byItsOwnName: string + let byTheWayRound: string + const hasBwrap = bwrapCanNamespace() + + beforeAll(() => { + ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-return-'))) + proj = join(ROOT, 'proj') + byItsOwnName = join(proj, 'deep', '.env') + byTheWayRound = join(proj, 'leave', 'back', '.env') + mkdirSync(join(proj, 'deep'), { recursive: true }) + mkdirSync(join(ROOT, 'out', 'hop'), { recursive: true }) + writeFileSync(byItsOwnName, 'SECRET\n') + symlinkSync(join('..', 'out', 'hop'), join(proj, 'leave')) + symlinkSync( + join('..', '..', 'proj', 'deep'), + join(ROOT, 'out', 'hop', 'back'), + ) + }) + + afterAll(async () => { + await SandboxManager.reset() + rmSync(ROOT, { recursive: true, force: true }) + }) + + function wrap(command: string, denyRead: string): Promise { + return SandboxManager.wrapWithSandbox(command, undefined, { + filesystem: { denyRead: [denyRead], allowWrite: [], denyWrite: [] }, + }) + } + + it('gets no mount', async () => { + const wrapped = await wrap('true', join(proj, '*/*/.env')) + expect(countMounts(wrapped, '--ro-bind', '/dev/null', byItsOwnName)).toBe( + 0, + ) + + // A pattern that matches it by a name inside the tree masks it. + const named = await wrap('true', join(proj, '*/.env')) + expect(countMounts(named, '--ro-bind', '/dev/null', byItsOwnName)).toBe(1) + }) + + it.skipIf(!hasBwrap)('is served under both names', async () => { + // Each command says BOOTED first: a sandbox that refuses to start + // prints nothing, which would read as every file hidden. + const linesUnder = async (denyRead: string): Promise => { + const result = spawnSync( + await wrap( + [ + 'echo BOOTED', + `cat ${byTheWayRound} || echo HIDDEN`, + `cat ${byItsOwnName} || echo HIDDEN`, + ].join('; '), + denyRead, + ), + { shell: true, encoding: 'utf8', timeout: 15000 }, + ) + expect(result.stderr ?? '').not.toContain('bwrap:') + return result.stdout.trim().split('\n') + } + + expect(await linesUnder(join(proj, '*/*/.env'))).toEqual([ + 'BOOTED', + 'SECRET', + 'SECRET', + ]) + // Matched by a name inside the tree, it is hidden under every name. + expect(await linesUnder(join(proj, '*/.env'))).toEqual([ + 'BOOTED', + 'HIDDEN', + 'HIDDEN', + ]) + }) + }, +) + +describe.if(!isWindows)('the debug line of a read-deny expansion', () => { + let ROOT: string + + beforeAll(() => { + ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-debug-'))) + mkdirSync(join(ROOT, 'proj', 'src'), { recursive: true }) + mkdirSync(join(ROOT, 'outside')) + writeFileSync(join(ROOT, 'proj', '.env'), '') + writeFileSync(join(ROOT, 'proj', 'src', '.env'), '') + writeFileSync(join(ROOT, 'proj', 'src', 'index.js'), '') + symlinkSync(join('..', 'outside'), join(ROOT, 'proj', 'out')) + }) + + afterAll(() => { + rmSync(ROOT, { recursive: true, force: true }) + }) + + /** What the debug logger printed while `fn` ran, at every level. */ + function debugLinesOf(fn: () => void, debug: string | undefined): string[] { + const lines: string[] = [] + const savedDebug = process.env.SRT_DEBUG + if (debug === undefined) delete process.env.SRT_DEBUG + else process.env.SRT_DEBUG = debug + const record = (...parts: unknown[]): void => { + lines.push(parts.map(String).join(' ')) + } + const spies = [ + spyOn(console, 'warn').mockImplementation(record), + spyOn(console, 'error').mockImplementation(record), + ] + try { + fn() + return lines + } finally { + for (const spy of spies) spy.mockRestore() + if (savedDebug === undefined) delete process.env.SRT_DEBUG + else process.env.SRT_DEBUG = savedDebug + } + } + + it('says once what the expansion cost and found', () => { + const pattern = join(ROOT, 'proj', '**/.env') + const lines = debugLinesOf( + () => void expandReadDenyGlobLinux(pattern, []), + '1', + ) + + const summary = lines.filter(line => line.includes('Expanded denyRead')) + expect(summary).toHaveLength(1) + // proj and proj/src listed; .env, src and out in one, .env and index.js + // in the other. + const timed = summary[0]!.replace(/ in \d+ ms: /, ' in ms: ') + expect(timed).not.toBe(summary[0]) + expect(timed).toEndWith( + `Expanded denyRead glob "${pattern}" in ms: 2 matches -> 2 mounts; ` + + `directories listed: 2, entries looked at: 5, ` + + `links out of the tree left unfollowed: 1 ` + + `(first: ${join(ROOT, 'proj', 'out')} -> ${join(ROOT, 'outside')})`, + ) + // The walk says which link it left, in its own line. + expect( + lines.filter( + line => + line.includes('Not following symlink') && + line.includes(join(ROOT, 'proj', 'out')) && + line.includes(`it leads out of ${join(ROOT, 'proj')}`), + ), + ).toHaveLength(1) + }) + + it('names no link when none was left', () => { + const lines = debugLinesOf( + () => void expandReadDenyGlobLinux(join(ROOT, 'proj', 'src', '*'), []), + '1', + ) + + const summary = lines.filter(line => line.includes('Expanded denyRead')) + expect(summary).toHaveLength(1) + expect(summary[0]).toEndWith( + ' ms: 2 matches -> 2 mounts; directories listed: 1, entries looked at: 2, links out of the tree left unfollowed: 0', + ) + }) + + it('says nothing without SRT_DEBUG', () => { + expect( + debugLinesOf( + () => void expandReadDenyGlobLinux(join(ROOT, 'proj', '**/.env'), []), + undefined, + ), + ).toEqual([]) + }) +}) diff --git a/test/sandbox/read-deny-glob.test.ts b/test/sandbox/read-deny-glob.test.ts index 1d36bf4c0..960a56851 100644 --- a/test/sandbox/read-deny-glob.test.ts +++ b/test/sandbox/read-deny-glob.test.ts @@ -244,19 +244,34 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { // pkg/a/build/link -> outside, with allowRead written against the // target: outside/ is denied as a whole, its carve-out and the // entries beneath keep their own mounts, and nothing else beneath it. + // The pattern starts at ROOT, which holds both ends of the link. mkdirSync(join(OUTSIDE, 'pub')) writeFileSync(join(OUTSIDE, 'pub', 'x.txt'), '') try { - const mounts = expandReadDenyGlobLinux( - join(ROOT, 'pkg', 'a', '**/build/**'), - [join(OUTSIDE, 'pub')], - ) + const mounts = expandReadDenyGlobLinux(join(ROOT, '**/build/**'), [ + join(OUTSIDE, 'pub'), + ]) expect(mounts).toContain(join(ROOT, 'pkg', 'a', 'build')) expect(mounts).toContain(OUTSIDE) expect(mounts).toContain(join(OUTSIDE, 'pub')) expect(mounts).toContain(join(OUTSIDE, 'pub', 'x.txt')) expect(mounts).not.toContain(join(OUTSIDE, 'secret.txt')) + + // Started at pkg/a the same link leaves the pattern's tree. It is a + // match, so what it leads to is still denied as a whole, but nothing + // is listed out there: x.txt beneath the carve-out was never found, + // so outside/ is handed back as a directory to bind nothing back + // beneath, like one that could not be listed. + const unlistable = new Set() + expect( + expandReadDenyGlobLinux( + join(ROOT, 'pkg', 'a', '**/build/**'), + [join(OUTSIDE, 'pub')], + unlistable, + ), + ).toEqual([OUTSIDE, join(ROOT, 'pkg', 'a', 'build')]) + expect([...unlistable]).toEqual([OUTSIDE]) } finally { rmSync(join(OUTSIDE, 'pub'), { recursive: true }) } @@ -471,7 +486,8 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { symlinkSync(join(store, 'nm'), join(proj, 'build')) symlinkSync(join('..', 'keep'), join(store, 'nm', 'keep')) - const mounts = expandReadDenyGlobLinux(join(proj, '**/build/**'), [ + // Started above both, so that the links stay inside the tree. + const mounts = expandReadDenyGlobLinux(join(chain, '**/build/**'), [ join(store, 'keep', 'pub'), ]) @@ -481,6 +497,20 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { join(store, 'keep', 'pub', 'y.txt'), join(store, 'nm'), ]) + + // Started at proj, the first link leaves the tree: it is a match, so + // store/nm is denied as a whole, and the second link inside it is + // never seen. What lies behind it was not looked for, so store/nm is + // handed back as a directory to bind nothing back beneath. + const unlistable = new Set() + expect( + expandReadDenyGlobLinux( + join(proj, '**/build/**'), + [join(store, 'keep', 'pub')], + unlistable, + ), + ).toEqual([join(store, 'nm')]) + expect([...unlistable]).toEqual([join(store, 'nm')]) }) it('drops a matched link that does not resolve', () => { @@ -1995,10 +2025,12 @@ describe.if(isLinux)('expandReadDenyGlobLinux (filesystem)', () => { describe.if(isLinux)( 'expandReadDenyGlobLinux (coverage behind a directory link)', () => { - // The walk descends symlinked directories and reports each match where it - // really lives, so a deny glob covers what it matches whichever spelling - // found it, and one mount stands for every spelling. These cases are the - // ones a walk that does not follow links loses outright. + // The walk descends the symlinked directories that stay inside the + // pattern's tree and reports each match where it really lives, so a deny + // glob covers what it matches there whichever spelling found it, and one + // mount stands for every spelling. These cases are the ones a walk that + // does not follow links loses outright. A link that leaves the tree is + // not descended: what lies behind it is for a pattern that names it. let ROOT: string let BASE: string let LNK: string @@ -2090,34 +2122,71 @@ describe.if(isLinux)( }, ) - it('mounts a match behind a link whose target is outside the pattern base, at its target', () => { + it('does not look behind a link whose target is outside the pattern base', () => { + // config/prod -> ../shared/prod leaves config, where the pattern + // starts: the key behind it is not the pattern's to find. + const unfollowedLinks = new Map() const mounts = expandReadDenyGlobLinux( join(ROOT, 'out', 'config', '*', 'key'), [], + undefined, + { unfollowedLinks }, ) - expect(mounts).toEqual([ + expect(mounts).toEqual([join(ROOT, 'out', 'config', 'dev', 'key')]) + expect([...unfollowedLinks]).toEqual([ + [ + join(ROOT, 'out', 'config', 'prod'), + join(ROOT, 'out', 'shared', 'prod'), + ], + ]) + // A pattern that starts above both finds it, where it really is. + expect( + expandReadDenyGlobLinux(join(ROOT, 'out', '*', '*', 'key'), []), + ).toEqual([ join(ROOT, 'out', 'config', 'dev', 'key'), join(ROOT, 'out', 'shared', 'prod', 'key'), ]) }) - it('mounts a match behind an absolute-target link outside the base, at its target', () => { + it('does not look behind an absolute-target link outside the base', () => { + // config/prod is written as an absolute path, and leaves config like + // the relative one: the link is met, and left. + const unfollowedLinks = new Map() const mounts = expandReadDenyGlobLinux( join(ROOT, 'absout', 'config', '*', 'key'), [], + undefined, + { unfollowedLinks }, ) - expect(mounts).toEqual([join(ROOT, 'absout', 'elsewhere', 'prod', 'key')]) + expect(mounts).toEqual([]) + expect([...unfollowedLinks]).toEqual([ + [ + join(ROOT, 'absout', 'config', 'prod'), + join(ROOT, 'absout', 'elsewhere', 'prod'), + ], + ]) + // A pattern that starts above both finds the key, where it really is. + expect( + expandReadDenyGlobLinux(join(ROOT, 'absout', 'con*', '*', 'key'), []), + ).toEqual([join(ROOT, 'absout', 'elsewhere', 'prod', 'key')]) }) - it('mounts a package linked out of node_modules where the package really is', () => { - const mounts = expandReadDenyGlobLinux( - join(ROOT, 'store', 'node_modules', '**', 'index.js'), - [], - ) + it('leaves a package linked out of node_modules to a pattern that names where it is', () => { + // node_modules/pkg -> ../packages/pkg, as a workspace install leaves + // it: the package is not under node_modules, and a pattern that starts + // there does not reach it. + expect( + expandReadDenyGlobLinux( + join(ROOT, 'store', 'node_modules', '**', 'index.js'), + [], + ), + ).toEqual([join(ROOT, 'store', 'node_modules', 'other', 'index.js')]) - expect(mounts).toEqual([ + expect( + expandReadDenyGlobLinux(join(ROOT, 'store', '**', 'index.js'), []), + ).toEqual([ join(ROOT, 'store', 'node_modules', 'other', 'index.js'), join(ROOT, 'store', 'packages', 'pkg', 'index.js'), ]) From c2bb9c885c3e640b1b9bf69395135ac49bdbc32e Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Fri, 25 Sep 2026 20:23:04 +0000 Subject: [PATCH 03/17] README: the tree of a denyRead pattern on Linux, and the budget --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6116ecb15..e33b0c5ab 100644 --- a/README.md +++ b/README.md @@ -437,9 +437,11 @@ bubblewrap binds concrete paths, so glob support is narrower than on macOS: - `allowWrite` / `denyWrite` take literal paths. A trailing `/**` is dropped (`src/**` means `src`); any other glob pattern there is skipped. - `denyRead` / `allowRead` accept the same glob syntax as macOS, expanded to the entries that exist when the command is wrapped, so a file that appears later is not covered. The pattern needs a literal directory to start from (a relative pattern starts at the current directory): one with a wildcard in its first path component, such as `/**/*.pem` or `/opt*/keys/**`, is skipped on Linux. Only directories the pattern can match beneath are listed (`certs/*.pem` lists `certs` alone). -- 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 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. The exception is a directory outside the pattern's tree that a matched link hides and that the pattern would carry on into (`**/build/**` with `build` a link to a directory beside the tree; see below): it is not listed, so nothing is bound back beneath the mount that hides it, `allowRead` and `allowWrite` paths included, as for a directory the expansion cannot list. A directory hidden through a matched link that the pattern ends at (`certs/*.pem` with `vault.pem` a link to a directory), or through one that leads back up the tree, is hidden as a literal deny of the link would hide it: an `allowRead` beneath it is bound back, and what the pattern matches there only through a link that is not descended has no mask. - 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. 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. Only a pattern that does not read as written is matched against real paths alone, with every directory under its starting directory listed: one with a wildcard inside a bracket expression (`[a*]`), or with a second `[` that nothing closes. A link that itself matches it still denies what it leads to. 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, and a link back up the tree denies everything it reaches, as a literal deny of the link would. A link that resolves to nothing is skipped. `allowRead` globs are not expanded through symlinks: they match the link itself. +- A `denyRead` pattern covers its own tree: the literal directory the pattern starts from (what is written before its first wildcard) and everything under it. That is the pattern's directory, not the project's: for `src/**/.env` the tree is `src`. A symlinked directory is descended only when it leads to somewhere inside that tree, judged by where the link resolves and not by how it is written. What this gives up: a file that the pattern matches only by a name that passes through a link out of the tree is not masked. With `src/shared` a link to `../shared`, `src/**/.env` does not mask `shared/.env`, which stays readable under both names; a pattern that starts at the file's directory or above it (`shared/**/.env`, `**/.env`), or an entry naming the file itself, does. Such a file can be inside the tree, when a link out there leads back in: with `src/vendor` a link to `../vendor` and `vendor/secrets` a link to `../src/data`, `src/**/secrets/*.pem` matches `src/data/key.pem` as `src/vendor/secrets/key.pem` alone and does not mask it, under either name; a pattern that matches it by a name inside the tree (`src/**/*.pem`) masks it under every name. A link whose own name matches the pattern still masks what it leads to, wherever that is: a matched link to a file masks the file, and a matched link to a directory hides the whole directory; when that directory is outside the tree it is not listed. +- Inside the tree, 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. Only a pattern that does not read as written is matched against real paths alone, with every directory under its starting directory listed: one with a wildcard inside a bracket expression (`[a*]`), or with a second `[` that nothing closes. A link that itself matches it still denies what it leads to. 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, and a link back up the tree denies everything it reaches, as a literal deny of the link would. A link that resolves to nothing is skipped. `allowRead` globs are not expanded through symlinks: they match the link itself. +- The expansion of a configuration's `denyRead` patterns has a budget, shared by all of them: 2,000,000 directory entries looked at and 10 seconds. The time a tree takes depends on the machine and on what is cached, so the first command after start-up in a very large tree can be refused where a second is not. When either runs out the wrap is refused with a `LinuxSandboxProfileError` whose `.code` is `deny_glob_too_large`, and no command is returned: a deny list cut short would leave readable what the pattern was written to hide. `getFsReadConfig()` throws the same error. The message names the pattern, the directory being listed and what had been spent; narrow the pattern, or remove or move what it walks into. The same are fields of the error's `.cause`, for a caller that words its own message: `pattern` (the one being expanded when the budget ran out, which need not be the large one), `directory`, `exhausted` (`'entries'` or `'time'`), `entries` and `elapsedMs` (what all the patterns had spent between them), `maxEntries` and `timeoutMs`. The time is checked before each directory is listed and at each entry in it, so what is not cut short is a single step: a listing that blocks (a dead network mount inside the tree), the resolution of a link that leads to one (where a link leads is looked up even when it is then not descended), or one name that is slow to match (a pattern with several wildcards in one path component, against a long name). The budget is the walk's: what is done with the matches afterwards is not counted. With debug logging on (`SRT_DEBUG`) each expansion logs one line: the pattern, the milliseconds it took, its matches and mounts, the directories listed, the entries looked at, and how many links out of the tree it left unfollowed, with the first of them. - 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. @@ -745,7 +747,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. -- 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 `denyRead` 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) From 99a93d1120251441c819cffb14d0fb3aaffc6595 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Fri, 25 Sep 2026 20:27:44 +0000 Subject: [PATCH 04/17] linux: the read configuration says which links its patterns left unfollowed A link out of a denyRead pattern's tree is not listed through, and what the pattern would have matched beneath it is not denied. That was recorded on the walk and written to the debug log; a caller had no way to tell its user. getFsReadConfig() now lists each such link in `unfollowedDenyLinks`, with the pattern that came to it, the link and where it resolves. It informs and restricts nothing: no backend reads it. The type is exported as UnfollowedDenyLink. --- README.md | 2 +- src/index.ts | 1 + src/sandbox/sandbox-manager.ts | 27 +++++++++++-- src/sandbox/sandbox-schemas.ts | 21 +++++++++++ test/sandbox/read-deny-glob-bound.test.ts | 46 +++++++++++++++++++++++ 5 files changed, 92 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e33b0c5ab..b2f67a1ce 100644 --- a/README.md +++ b/README.md @@ -441,7 +441,7 @@ bubblewrap binds concrete paths, so glob support is narrower than on macOS: - 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. 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. - A `denyRead` pattern covers its own tree: the literal directory the pattern starts from (what is written before its first wildcard) and everything under it. That is the pattern's directory, not the project's: for `src/**/.env` the tree is `src`. A symlinked directory is descended only when it leads to somewhere inside that tree, judged by where the link resolves and not by how it is written. What this gives up: a file that the pattern matches only by a name that passes through a link out of the tree is not masked. With `src/shared` a link to `../shared`, `src/**/.env` does not mask `shared/.env`, which stays readable under both names; a pattern that starts at the file's directory or above it (`shared/**/.env`, `**/.env`), or an entry naming the file itself, does. Such a file can be inside the tree, when a link out there leads back in: with `src/vendor` a link to `../vendor` and `vendor/secrets` a link to `../src/data`, `src/**/secrets/*.pem` matches `src/data/key.pem` as `src/vendor/secrets/key.pem` alone and does not mask it, under either name; a pattern that matches it by a name inside the tree (`src/**/*.pem`) masks it under every name. A link whose own name matches the pattern still masks what it leads to, wherever that is: a matched link to a file masks the file, and a matched link to a directory hides the whole directory; when that directory is outside the tree it is not listed. - Inside the tree, 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. Only a pattern that does not read as written is matched against real paths alone, with every directory under its starting directory listed: one with a wildcard inside a bracket expression (`[a*]`), or with a second `[` that nothing closes. A link that itself matches it still denies what it leads to. 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, and a link back up the tree denies everything it reaches, as a literal deny of the link would. A link that resolves to nothing is skipped. `allowRead` globs are not expanded through symlinks: they match the link itself. -- The expansion of a configuration's `denyRead` patterns has a budget, shared by all of them: 2,000,000 directory entries looked at and 10 seconds. The time a tree takes depends on the machine and on what is cached, so the first command after start-up in a very large tree can be refused where a second is not. When either runs out the wrap is refused with a `LinuxSandboxProfileError` whose `.code` is `deny_glob_too_large`, and no command is returned: a deny list cut short would leave readable what the pattern was written to hide. `getFsReadConfig()` throws the same error. The message names the pattern, the directory being listed and what had been spent; narrow the pattern, or remove or move what it walks into. The same are fields of the error's `.cause`, for a caller that words its own message: `pattern` (the one being expanded when the budget ran out, which need not be the large one), `directory`, `exhausted` (`'entries'` or `'time'`), `entries` and `elapsedMs` (what all the patterns had spent between them), `maxEntries` and `timeoutMs`. The time is checked before each directory is listed and at each entry in it, so what is not cut short is a single step: a listing that blocks (a dead network mount inside the tree), the resolution of a link that leads to one (where a link leads is looked up even when it is then not descended), or one name that is slow to match (a pattern with several wildcards in one path component, against a long name). The budget is the walk's: what is done with the matches afterwards is not counted. With debug logging on (`SRT_DEBUG`) each expansion logs one line: the pattern, the milliseconds it took, its matches and mounts, the directories listed, the entries looked at, and how many links out of the tree it left unfollowed, with the first of them. +- The expansion of a configuration's `denyRead` patterns has a budget, shared by all of them: 2,000,000 directory entries looked at and 10 seconds. The time a tree takes depends on the machine and on what is cached, so the first command after start-up in a very large tree can be refused where a second is not. When either runs out the wrap is refused with a `LinuxSandboxProfileError` whose `.code` is `deny_glob_too_large`, and no command is returned: a deny list cut short would leave readable what the pattern was written to hide. `getFsReadConfig()` throws the same error. The message names the pattern, the directory being listed and what had been spent; narrow the pattern, or remove or move what it walks into. The same are fields of the error's `.cause`, for a caller that words its own message: `pattern` (the one being expanded when the budget ran out, which need not be the large one), `directory`, `exhausted` (`'entries'` or `'time'`), `entries` and `elapsedMs` (what all the patterns had spent between them), `maxEntries` and `timeoutMs`. The time is checked before each directory is listed and at each entry in it, so what is not cut short is a single step: a listing that blocks (a dead network mount inside the tree), the resolution of a link that leads to one (where a link leads is looked up even when it is then not descended), or one name that is slow to match (a pattern with several wildcards in one path component, against a long name). The budget is the walk's: what is done with the matches afterwards is not counted. `getFsReadConfig()` lists the links the patterns left unfollowed in `unfollowedDenyLinks`, each with its `pattern`, `link` and `target`, for a caller to tell its user what a pattern does not reach. With debug logging on (`SRT_DEBUG`) each expansion logs one line: the pattern, the milliseconds it took, its matches and mounts, the directories listed, the entries looked at, and how many links out of the tree it left unfollowed, with the first of them. - 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. diff --git a/src/index.ts b/src/index.ts index d0a06954e..9bdd7ac49 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,7 @@ export type { CredentialRestrictionConfig, NetworkRestrictionConfig, NetworkHostPattern, + UnfollowedDenyLink, } from './sandbox/sandbox-schemas.js' // Per-request filter diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index d07adda9c..0ffa3a2d8 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -39,6 +39,7 @@ import type { FsReadRestrictionConfig, FsWriteRestrictionConfig, NetworkRestrictionConfig, + UnfollowedDenyLink, } from './sandbox-schemas.js' import { wrapCommandWithSandboxLinux, @@ -1320,16 +1321,22 @@ function expandAllowReadGlob(pattern: string): string[] { function readDenyGlobExpander( reExposedPaths: readonly string[], unlistableDenyDirs: Set, + unfollowedDenyLinks: UnfollowedDenyLink[], ): (pattern: string) => string[] { const budget = newGlobWalkBudget() return pattern => { try { - return expandReadDenyGlobLinux( + const unfollowedLinks = new Map() + const expanded = expandReadDenyGlobLinux( pattern, reExposedPaths, unlistableDenyDirs, - { budget }, + { budget, unfollowedLinks }, ) + for (const [link, target] of unfollowedLinks) { + unfollowedDenyLinks.push({ pattern, link, target }) + } + return expanded } catch (error) { if (!(error instanceof GlobWalkBudgetError)) throw error throw new LinuxSandboxProfileError( @@ -1372,9 +1379,14 @@ function getFsReadConfig(): FsReadRestrictionConfig { ) const reExposedPaths = [...allowPaths, ...getFsWriteConfig().allowOnly] const unlistableDenyDirs = new Set() + const unfollowedDenyLinks: UnfollowedDenyLink[] = [] const denyPaths = resolveReadPathEntries( unionDenyReadPaths(config.filesystem.denyRead, credentialRestrictions), - readDenyGlobExpander(reExposedPaths, unlistableDenyDirs), + readDenyGlobExpander( + reExposedPaths, + unlistableDenyDirs, + unfollowedDenyLinks, + ), credentialRestrictions.degradeToDenyPaths, ) @@ -1382,6 +1394,7 @@ function getFsReadConfig(): FsReadRestrictionConfig { denyOnly: denyPaths, allowWithinDeny: allowPaths, unlistableDenyDirs: [...unlistableDenyDirs], + unfollowedDenyLinks, } } @@ -1757,18 +1770,24 @@ async function wrapWithSandbox( } const reExposedPaths = [...expandedAllowRead, ...writeConfig.allowOnly] const unlistableDenyDirs = new Set() + const unfollowedDenyLinks: UnfollowedDenyLink[] = [] const expandedDenyRead = resolveReadPathEntries( unionDenyReadPaths( customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead ?? [], credentialRestrictions, ), - readDenyGlobExpander(reExposedPaths, unlistableDenyDirs), + readDenyGlobExpander( + reExposedPaths, + unlistableDenyDirs, + unfollowedDenyLinks, + ), credentialRestrictions.degradeToDenyPaths, ) readConfig = { denyOnly: expandedDenyRead, allowWithinDeny: expandedAllowRead, unlistableDenyDirs: [...unlistableDenyDirs], + unfollowedDenyLinks, } } diff --git a/src/sandbox/sandbox-schemas.ts b/src/sandbox/sandbox-schemas.ts index ac2e92ed3..064dd1c21 100644 --- a/src/sandbox/sandbox-schemas.ts +++ b/src/sandbox/sandbox-schemas.ts @@ -17,6 +17,20 @@ * * This is maximally permissive by default - only explicitly denied paths are blocked. */ +/** + * A symbolic link a `denyRead` pattern came to and did not list through, + * because it leads out of the pattern's tree. What the pattern would have + * matched beneath `target` is not denied. + */ +export interface UnfollowedDenyLink { + /** The `denyRead` entry being expanded. */ + pattern: string + /** The link, as the walk spelled it. */ + link: string + /** Where the link resolves. */ + target: string +} + export interface FsReadRestrictionConfig { denyOnly: string[] allowWithinDeny?: string[] @@ -29,6 +43,13 @@ export interface FsReadRestrictionConfig { * unmasked. Linux only: the other backends match globs natively. */ unlistableDenyDirs?: string[] + /** + * The links the `denyRead` patterns left unfollowed, for a caller to tell + * its user what a pattern does not reach. It informs and restricts + * nothing: no backend reads it. Linux only: the other backends match + * patterns against resolved paths and walk nothing. + */ + unfollowedDenyLinks?: UnfollowedDenyLink[] } /** diff --git a/test/sandbox/read-deny-glob-bound.test.ts b/test/sandbox/read-deny-glob-bound.test.ts index bd9995db9..87a97c5f1 100644 --- a/test/sandbox/read-deny-glob-bound.test.ts +++ b/test/sandbox/read-deny-glob-bound.test.ts @@ -1019,6 +1019,52 @@ describe.if(isLinux)('a read-deny glob past its budget, at the manager', () => { expect(wrapped).not.toContain(join(OTHER, 'pkg0', '.env')) }) + it('says in the read configuration which links its patterns left unfollowed', async () => { + const proj = join(ROOT, 'told') + mkdirSync(join(proj, 'src'), { recursive: true }) + writeFileSync(join(proj, '.env'), '') + symlinkSync(OTHER, join(proj, 'bigtree')) + symlinkSync(join(proj, 'src'), join(proj, 'alias')) + const recursive = join(proj, '**/.env') + const shallow = join(proj, '*/.env') + await SandboxManager.reset() + await SandboxManager.initialize({ + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { + // A literal entry and a pattern with nothing to say are in the list + // to show they add nothing. + denyRead: [recursive, join(proj, 'src'), shallow, join(PROJ, '*.key')], + allowWrite: [], + denyWrite: [], + }, + }) + try { + // Once for each pattern that came to the link and would have carried + // on beneath it; the link that stays in the tree is followed and is + // not among them. + expect(SandboxManager.getFsReadConfig().unfollowedDenyLinks).toEqual([ + { pattern: recursive, link: join(proj, 'bigtree'), target: OTHER }, + { pattern: shallow, link: join(proj, 'bigtree'), target: OTHER }, + ]) + } finally { + await SandboxManager.reset() + } + + await SandboxManager.initialize({ + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { + denyRead: [join(PROJ, '**/.env')], + allowWrite: [], + denyWrite: [], + }, + }) + try { + expect(SandboxManager.getFsReadConfig().unfollowedDenyLinks).toEqual([]) + } finally { + await SandboxManager.reset() + } + }) + it('refuses the read configuration the same way', async () => { const pattern = join(PROJ, '**/.env') await SandboxManager.reset() From f9ef1a8f607b8ce36f1e96b80bf9102cac76eed1 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Fri, 25 Sep 2026 20:30:25 +0000 Subject: [PATCH 05/17] Say exactly which links unfollowedDenyLinks holds It lists the links left unfollowed for leading out of a pattern's tree. A link that leads back up the tree, and any link under a pattern that cannot be followed one path component at a time, is not listed through either and is not in the list. The field's comment and the README read as if the list were of every link not followed. --- README.md | 2 +- src/sandbox/sandbox-schemas.ts | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b2f67a1ce..377e2f72b 100644 --- a/README.md +++ b/README.md @@ -441,7 +441,7 @@ bubblewrap binds concrete paths, so glob support is narrower than on macOS: - 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. 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. - A `denyRead` pattern covers its own tree: the literal directory the pattern starts from (what is written before its first wildcard) and everything under it. That is the pattern's directory, not the project's: for `src/**/.env` the tree is `src`. A symlinked directory is descended only when it leads to somewhere inside that tree, judged by where the link resolves and not by how it is written. What this gives up: a file that the pattern matches only by a name that passes through a link out of the tree is not masked. With `src/shared` a link to `../shared`, `src/**/.env` does not mask `shared/.env`, which stays readable under both names; a pattern that starts at the file's directory or above it (`shared/**/.env`, `**/.env`), or an entry naming the file itself, does. Such a file can be inside the tree, when a link out there leads back in: with `src/vendor` a link to `../vendor` and `vendor/secrets` a link to `../src/data`, `src/**/secrets/*.pem` matches `src/data/key.pem` as `src/vendor/secrets/key.pem` alone and does not mask it, under either name; a pattern that matches it by a name inside the tree (`src/**/*.pem`) masks it under every name. A link whose own name matches the pattern still masks what it leads to, wherever that is: a matched link to a file masks the file, and a matched link to a directory hides the whole directory; when that directory is outside the tree it is not listed. - Inside the tree, 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. Only a pattern that does not read as written is matched against real paths alone, with every directory under its starting directory listed: one with a wildcard inside a bracket expression (`[a*]`), or with a second `[` that nothing closes. A link that itself matches it still denies what it leads to. 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, and a link back up the tree denies everything it reaches, as a literal deny of the link would. A link that resolves to nothing is skipped. `allowRead` globs are not expanded through symlinks: they match the link itself. -- The expansion of a configuration's `denyRead` patterns has a budget, shared by all of them: 2,000,000 directory entries looked at and 10 seconds. The time a tree takes depends on the machine and on what is cached, so the first command after start-up in a very large tree can be refused where a second is not. When either runs out the wrap is refused with a `LinuxSandboxProfileError` whose `.code` is `deny_glob_too_large`, and no command is returned: a deny list cut short would leave readable what the pattern was written to hide. `getFsReadConfig()` throws the same error. The message names the pattern, the directory being listed and what had been spent; narrow the pattern, or remove or move what it walks into. The same are fields of the error's `.cause`, for a caller that words its own message: `pattern` (the one being expanded when the budget ran out, which need not be the large one), `directory`, `exhausted` (`'entries'` or `'time'`), `entries` and `elapsedMs` (what all the patterns had spent between them), `maxEntries` and `timeoutMs`. The time is checked before each directory is listed and at each entry in it, so what is not cut short is a single step: a listing that blocks (a dead network mount inside the tree), the resolution of a link that leads to one (where a link leads is looked up even when it is then not descended), or one name that is slow to match (a pattern with several wildcards in one path component, against a long name). The budget is the walk's: what is done with the matches afterwards is not counted. `getFsReadConfig()` lists the links the patterns left unfollowed in `unfollowedDenyLinks`, each with its `pattern`, `link` and `target`, for a caller to tell its user what a pattern does not reach. With debug logging on (`SRT_DEBUG`) each expansion logs one line: the pattern, the milliseconds it took, its matches and mounts, the directories listed, the entries looked at, and how many links out of the tree it left unfollowed, with the first of them. +- The expansion of a configuration's `denyRead` patterns has a budget, shared by all of them: 2,000,000 directory entries looked at and 10 seconds. The time a tree takes depends on the machine and on what is cached, so the first command after start-up in a very large tree can be refused where a second is not. When either runs out the wrap is refused with a `LinuxSandboxProfileError` whose `.code` is `deny_glob_too_large`, and no command is returned: a deny list cut short would leave readable what the pattern was written to hide. `getFsReadConfig()` throws the same error. The message names the pattern, the directory being listed and what had been spent; narrow the pattern, or remove or move what it walks into. The same are fields of the error's `.cause`, for a caller that words its own message: `pattern` (the one being expanded when the budget ran out, which need not be the large one), `directory`, `exhausted` (`'entries'` or `'time'`), `entries` and `elapsedMs` (what all the patterns had spent between them), `maxEntries` and `timeoutMs`. The time is checked before each directory is listed and at each entry in it, so what is not cut short is a single step: a listing that blocks (a dead network mount inside the tree), the resolution of a link that leads to one (where a link leads is looked up even when it is then not descended), or one name that is slow to match (a pattern with several wildcards in one path component, against a long name). The budget is the walk's: what is done with the matches afterwards is not counted. `getFsReadConfig()` lists the links the patterns left unfollowed for leading out of their tree in `unfollowedDenyLinks`, each with its `pattern`, `link` and `target`, for a caller to tell its user what a pattern does not reach. The list is of those links only: a link that leads back up the tree, and any link under a pattern that cannot be followed one path component at a time, is not listed through either and is not in it. With debug logging on (`SRT_DEBUG`) each expansion logs one line: the pattern, the milliseconds it took, its matches and mounts, the directories listed, the entries looked at, and how many links out of the tree it left unfollowed, with the first of them. - 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. diff --git a/src/sandbox/sandbox-schemas.ts b/src/sandbox/sandbox-schemas.ts index 064dd1c21..ed4eccbe9 100644 --- a/src/sandbox/sandbox-schemas.ts +++ b/src/sandbox/sandbox-schemas.ts @@ -44,10 +44,17 @@ export interface FsReadRestrictionConfig { */ unlistableDenyDirs?: string[] /** - * The links the `denyRead` patterns left unfollowed, for a caller to tell - * its user what a pattern does not reach. It informs and restricts - * nothing: no backend reads it. Linux only: the other backends match - * patterns against resolved paths and walk nothing. + * The links the `denyRead` patterns left unfollowed because they lead out + * of the pattern's tree, for a caller to tell its user what a pattern does + * not reach. It informs and restricts nothing: no backend reads it. Linux + * only: the other backends match patterns against resolved paths and walk + * nothing. + * + * Not every link a pattern does not list through is here. A link that + * leads back up the tree (to the pattern's starting directory or above + * it) is not listed through either, and a pattern that cannot be followed + * one path component at a time lists through no link at all; neither is + * reported in this list. Both say so in the debug log. */ unfollowedDenyLinks?: UnfollowedDenyLink[] } From cbc1200ea8137626d3f743ce16577b42217a5d66 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Fri, 25 Sep 2026 23:16:37 +0000 Subject: [PATCH 06/17] linux: a read-deny glob never lists outside its tree Where a link whose own name matches hides a directory outside the pattern's tree, the expansion reported that directory as one it could not list, so that nothing was bound back beneath it. The wrapper then restores no allowed path at or beneath it, the paths that are always writable included: with a link to a directory above the command's temporary directory, every command failed, and an allowed write path beneath such a directory stopped applying. Through the ancestor that hides it, a second link could take the working directory along. The report is taken out again. A directory outside the tree is never listed, whether or not the link that leads there matches and whatever is allowed beneath it; a matched link still hides its target whole; an allowed path beneath it is bound back as written, as beneath a directory denied literally. `unlistableDenyDirs` again names only a directory the walk could not list, and the code that fills it is what it was. What that gives up is what such a link used to add: what the pattern matched beneath the allowed path through the link's name is not masked again. A link out of the tree can only add to what a pattern covers. A file whose real path is in the tree and matches is found by walking the tree itself, so no link a sandboxed command creates takes a mask away. The unfollowed links are handed back sorted by name, so that the list and the debug line do not depend on the order a directory is read in. --- src/sandbox/linux-sandbox-utils.ts | 7 ++-- src/sandbox/read-deny-glob.ts | 65 ++++++++++++++++-------------- src/sandbox/sandbox-schemas.ts | 15 ++++--- 3 files changed, 47 insertions(+), 40 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 5a88eed3a..5fe9f53b6 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -2615,8 +2615,7 @@ async function generateFilesystemArgs( } return hidden } - // Entries a glob expansion produced for a directory it did not list: one - // it could not, or one a matched link leads to out of the pattern's tree. + // Entries a glob expansion produced for a directory it could not list. // What the pattern matches beneath an allowed path in there was never // found, so binding that path back over the tmpfs would show every one of // those matches unmasked. @@ -2677,7 +2676,7 @@ async function generateFilesystemArgs( if (isDirectory) { // A stand-in for an entry this wrap cannot vouch for hides everything // beneath it: nothing under it can be vouched for either. So does a - // directory a glob expansion did not enumerate. + // directory a glob expansion could not enumerate. if (isStandIn) { logForDebugging( `[Sandbox Linux] Read deny path ${normalizedPath} cannot be mounted where it names; hiding ${landing} instead`, @@ -2689,7 +2688,7 @@ async function generateFilesystemArgs( unlistableDenyDirs.has(landing) if (unlistable) { logForDebugging( - `[Sandbox Linux] Read-denied directory was not listed when the glob was expanded; restoring nothing beneath it: ${landing}`, + `[Sandbox Linux] Read-denied directory could not be listed when the glob was expanded; restoring nothing beneath it: ${landing}`, { level: 'warn' }, ) } diff --git a/src/sandbox/read-deny-glob.ts b/src/sandbox/read-deny-glob.ts index 5cd3f3282..2fa2c0273 100644 --- a/src/sandbox/read-deny-glob.ts +++ b/src/sandbox/read-deny-glob.ts @@ -71,31 +71,38 @@ function collapseReadDenyLocations({ * own, the directory written before its first wildcard, and not the project * it may lie in: `/src/**\/.env` covers `/src`, so * `src/shared -> ../shared` leads out of it. A symlinked directory that - * leads out of the tree is not listed through. One that itself matches still - * denies what it leads to, whole; where the pattern would carry on into that - * directory, it is handed back through `unlistableDirs` like one that could - * not be listed: what the pattern matches in there was not looked for. One - * that does not match denies nothing, and a file that only a listing through - * it would have found is not denied. That is not only a file out there: a - * link out there can lead back into the tree, and a file in the tree that - * the pattern matches by no name but the one through both links is not - * found either. + * leads out of the tree is never listed through, whatever is re-exposed + * beneath what it leads to. One that itself matches still denies what it + * leads to, whole; a re-exposed path beneath that directory is bound back as + * written, as beneath a directory denied literally, and what the pattern + * would match under it through the link's name is not masked again. One that + * does not match denies nothing, and a file that only a listing through it + * would have found is not denied. That is not only a file out there: a link + * out there can lead back into the tree, and a file in the tree that the + * pattern matches by no name but the one through both links is not found + * either. + * + * A link out of the tree can only add to what the pattern covers: a file + * whose real path is in the tree and matches is found by walking the tree + * itself, whatever links exist. So no link a sandboxed command makes takes a + * mask away, and what is given up is what a link used to add. That includes + * what is written later beneath a directory that a command has moved out of + * the tree, leaving a link in its place: it lies outside the tree. * * Throws {@link GlobWalkBudgetError} when `budget` runs out before the walk * is done. There is no shorter list to fall back on: a caller that cannot * have the whole expansion must not run the command. * * @param unlistableDirs - receives the returned locations that hide something - * the walk did not enumerate, whether by being that directory or by - * covering it: a directory it could not list, or one out of the tree that a - * matched link denies and the pattern would carry on into. 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. + * the walk could not enumerate, whether by being that directory or by + * 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.budget - what this expansion and the others handed the same * object may spend between them. * @param opts.unfollowedLinks - receives each symlinked directory that was * not listed through because it leads out of the pattern's tree, with the - * directory it leads to. + * directory it leads to, in the order of their names. */ export function expandReadDenyGlobLinux( globPattern: string, @@ -112,7 +119,12 @@ export function expandReadDenyGlobLinux( followSymlinkedDirectories: true, budget: opts.budget, }) - for (const [link, target] of walk.unfollowedLinks) { + // In the order of their names, as the mounts are: the order a directory + // hands its entries back in is the filesystem's. + const unfollowed = [...walk.unfollowedLinks].sort(([a], [b]) => + a < b ? -1 : a > b ? 1 : 0, + ) + for (const [link, target] of unfollowed) { opts.unfollowedLinks?.set(link, target) } // Where a path the walk reported really lives: the denyRead loop mounts an @@ -199,20 +211,11 @@ export function expandReadDenyGlobLinux( reExposedPaths: reExposed, }) - // Which mounts stand for something the walk did not enumerate: a directory - // it could not list, or one a matched link leads to out of the tree, which - // is denied whole and was not listed. The directory itself when it - // survived the collapse, otherwise the kept ancestor that hides it. A link - // out of the tree that is no match denies nothing: where it leads is not - // a location, and nothing is reported for it, even where the mount of - // another match happens to hide it. - const unenumerated = [ - ...walk.unlisted.map(locationOf), - ...[...walk.unfollowedLinks.values()].filter(target => - locations.has(target), - ), - ] - for (const location of unenumerated) { + // Which mounts stand for something the walk could not enumerate: the + // unlistable directory itself when it survived the collapse, otherwise the + // kept ancestor that hides it. + for (const unlisted of walk.unlisted) { + const location = locationOf(unlisted) if (mounts.has(location)) { unlistableDirs?.add(location) continue @@ -227,7 +230,7 @@ export function expandReadDenyGlobLinux( // One line for the whole expansion, with what it cost: a caller that times // its wraps takes the numbers from here. - const [firstUnfollowed] = walk.unfollowedLinks + const [firstUnfollowed] = unfollowed logForDebugging( `[Sandbox Linux] Expanded denyRead glob "${globPattern}" in ${Math.round(performance.now() - startedAt)} ms: ` + `${walk.matches.length} matches -> ${mounts.size} mounts; ` + diff --git a/src/sandbox/sandbox-schemas.ts b/src/sandbox/sandbox-schemas.ts index ed4eccbe9..edc42f431 100644 --- a/src/sandbox/sandbox-schemas.ts +++ b/src/sandbox/sandbox-schemas.ts @@ -20,12 +20,18 @@ /** * A symbolic link a `denyRead` pattern came to and did not list through, * because it leads out of the pattern's tree. What the pattern would have - * matched beneath `target` is not denied. + * matched beneath `target` is not denied. Where the link is itself a match, + * `target` is denied as a whole all the same, and what is not denied is what + * a further link in there leads to, and what lies beneath an allowed path in + * there, which is bound back as it is written. */ export interface UnfollowedDenyLink { /** The `denyRead` entry being expanded. */ pattern: string - /** The link, as the walk spelled it. */ + /** + * The link, as the walk spelled it. One link can be in the list under two + * spellings, where the walk came to it by both. + */ link: string /** Where the link resolves. */ target: string @@ -35,9 +41,8 @@ export interface FsReadRestrictionConfig { denyOnly: string[] allowWithinDeny?: string[] /** - * The `denyOnly` entries that stand for a directory a glob expansion did - * not list: one it could not, or one a matched symlink leads to out of the - * pattern's tree. Nothing is bound back beneath one — neither an + * The `denyOnly` entries that stand for a directory a glob expansion could + * not list. Nothing is bound back beneath one — neither an * `allowWithinDeny` path nor an allowed write path — because what the * pattern matches under such a path was never found and would come back * unmasked. Linux only: the other backends match globs natively. From fc9c36d7060a766afb02a66ac8e302aef95f6ebe Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Fri, 25 Sep 2026 23:16:37 +0000 Subject: [PATCH 07/17] test: nothing outside a read-deny glob's tree is listed, matched or not The cases written for the report of an unlisted directory now state the rule as it is: what a matched link out of the tree leads to is hidden whole and not listed, with nothing allowed beneath it and with an allowed path at it, beneath it or written through the link; nothing is handed back as unlistable; a link inside such a directory is never come to. Two real bubblewrap runs pin what follows from it: beneath such a directory an allowed path is readable and the rest stays hidden, under either name, and a write allowed there succeeds. The unfollowed links come back in the same order whichever way the directory is read. --- test/sandbox/read-deny-glob-bound.test.ts | 916 +++++++++++++++++++--- test/sandbox/read-deny-glob.test.ts | 51 +- 2 files changed, 839 insertions(+), 128 deletions(-) diff --git a/test/sandbox/read-deny-glob-bound.test.ts b/test/sandbox/read-deny-glob-bound.test.ts index 87a97c5f1..fc072e782 100644 --- a/test/sandbox/read-deny-glob-bound.test.ts +++ b/test/sandbox/read-deny-glob-bound.test.ts @@ -4,8 +4,10 @@ import { describe, it, expect, beforeAll, afterAll, spyOn } from 'bun:test' import * as fs from 'fs' import * as sandboxUtils from '../../src/sandbox/sandbox-utils.js' import { + existsSync, mkdirSync, mkdtempSync, + readFileSync, realpathSync, rmSync, symlinkSync, @@ -27,7 +29,7 @@ import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' import { LinuxSandboxProfileError } from '../../src/sandbox/linux-sandbox-utils.js' import { isLinux, isWindows } from '../helpers/platform.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' -import { countMounts } from '../helpers/bwrap-argv.js' +import { countMounts, indexOfMount } from '../helpers/bwrap-argv.js' /** Every directory `fn` had listed, in order, one entry per listing. */ function listedDuring(fn: () => T): { result: T; listed: string[] } { @@ -345,18 +347,60 @@ describe.if(!isWindows)('a read-deny glob and a link out of its tree', () => { ]) }) + it('does not list what a link that only passes through leads to, whatever is allowed beneath it', () => { + // bigtree is no match of `**/.env`: nothing out there is denied, so an + // allowed path out there has no mount to be bound back over, and nothing + // beneath it to mask again. + const proj = project('p-pass') + symlinkSync(OUTSIDE, join(proj, 'bigtree')) + const pattern = join(proj, '**/.env') + + for (const allowed of [ + [OUTSIDE], + [join(OUTSIDE, 'deep')], + // Written through the link. + [join(proj, 'bigtree', 'deep')], + [join(OUTSIDE, 'deep'), OUTSIDE, ROOT], + ]) { + const unlistable = new Set() + const unfollowedLinks = new Map() + const { result: mounts, listed } = listedDuring(() => + expandReadDenyGlobLinux(pattern, allowed, unlistable, { + unfollowedLinks, + }), + ) + + expect(mounts).toEqual([join(proj, '.env'), join(proj, 'src', '.env')]) + expect(listed.sort()).toEqual( + [proj, join(proj, 'src'), join(proj, 'sub')].sort(), + ) + expect([...unfollowedLinks]).toEqual([[join(proj, 'bigtree'), OUTSIDE]]) + expect([...unlistable]).toEqual([]) + } + }) + /** * `/proj` beside `/store`, with `proj/pkg/build -> store/build` * and `proj/certs/vault.pem -> store/vault`. Each target holds `public`, - * the allowed path, with a file in it that the patterns here match. + * the allowed path, and `private`, with files in both that the patterns + * here match. `store/build-cache` lies beside the targets, under a name + * that begins with one of theirs. */ function plantStore(name: string): { top: string; proj: string } { const top = join(ROOT, name) const proj = join(top, 'proj') for (const dir of ['build', 'vault']) { - mkdirSync(join(top, 'store', dir, 'public'), { recursive: true }) + mkdirSync(join(top, 'store', dir, 'public', 'deep'), { recursive: true }) + mkdirSync(join(top, 'store', dir, 'private')) writeFileSync(join(top, 'store', dir, 'public', 'id.pem'), 'KEY') + writeFileSync(join(top, 'store', dir, 'public', 'note.txt'), 'NOTE') + writeFileSync( + join(top, 'store', dir, 'public', 'deep', 'more.pem'), + 'KEY', + ) + writeFileSync(join(top, 'store', dir, 'private', 'id.pem'), 'KEY') } + mkdirSync(join(top, 'store', 'build-cache', 'public'), { recursive: true }) mkdirSync(join(proj, 'pkg'), { recursive: true }) mkdirSync(join(proj, 'certs')) symlinkSync(join(top, 'store', 'build'), join(proj, 'pkg', 'build')) @@ -364,42 +408,252 @@ describe.if(!isWindows)('a read-deny glob and a link out of its tree', () => { return { top, proj } } - it('hands back what a matched link out of the tree leads to as a directory to bind nothing back beneath', () => { - // The target is denied as a whole and was not listed, so id.pem beneath - // the allowed path was never found: bound back, the path would show it. - const { top, proj } = plantStore('p-closed') - const build = join(top, 'store', 'build') - const vault = join(top, 'store', 'vault') + /** A link of plantStore that matches in the directory form, and one that + * matches by its own name: the pattern, the link and where it leads. */ + function matchedLinksOf( + top: string, + proj: string, + ): (readonly [string, string, string])[] { + return [ + [ + join(proj, '**/build/**'), + join(proj, 'pkg', 'build'), + join(top, 'store', 'build'), + ], + [ + join(proj, 'certs', '**/*.pem'), + join(proj, 'certs', 'vault.pem'), + join(top, 'store', 'vault'), + ], + ] + } - // A link that matches in the directory form, and one by its own name. - for (const [pattern, target] of [ - [join(proj, '**/build/**'), build], - [join(proj, 'certs', '**/*.pem'), vault], - ] as const) { - const unlistable = new Set() - const { result: mounts, listed } = listedDuring(() => - expandReadDenyGlobLinux(pattern, [join(target, 'public')], unlistable), - ) + it('does not list what a matched link out of the tree leads to when nothing allowed lies beneath it', () => { + // The target is denied as a whole, and what is in it is not looked at. + // Nothing is handed back as unlistable, which is for a directory whose + // listing failed: an allowed path beneath the target's mount comes back. + const { top, proj } = plantStore('p-whole') + const store = join(top, 'store') - expect(mounts).toEqual([target]) - expect(listed.filter(dir => dir.startsWith(join(top, 'store')))).toEqual( + for (const [pattern, link, target] of matchedLinksOf(top, proj)) { + for (const allowed of [ [], - ) - expect([...unlistable]).toEqual([target]) + // Above the target, beside it, and in the tree. + [store], + [join(store, 'build-cache', 'public'), join(store, 'vault-cache')], + [proj, top], + ]) { + const unlistable = new Set() + const unfollowedLinks = new Map() + const { result: mounts, listed } = listedDuring(() => + expandReadDenyGlobLinux(pattern, allowed, unlistable, { + unfollowedLinks, + }), + ) + + expect(mounts).toEqual([target]) + expect(listed.filter(dir => dir.startsWith(store))).toEqual([]) + expect(unfollowedLinks.get(link)).toBe(target) + expect([...unlistable]).toEqual([]) + } } + }) - // Started above both ends of the link, the target is listed: the allowed - // path is bound back, and what the pattern matches beneath it keeps its - // own mount. - const unlistable = new Set() + it('does not list what a matched link out of the tree leads to when an allowed path lies beneath it', () => { + // The allowed path comes back over the target's mount as it is written, + // as it does beneath a directory denied literally. What the pattern + // matches under it through the link's name was not looked for, and has + // no mount of its own: id.pem beneath `public` is what a pattern gives + // up by starting beside the directory and not above it. + const { top, proj } = plantStore('p-allowed') + const store = join(top, 'store') + + for (const [pattern, link, target] of matchedLinksOf(top, proj)) { + for (const allowed of [ + [join(target, 'public')], + // Written through the link. + [join(link, 'public')], + [join(target, 'public', 'deep'), join(target, 'private')], + // The target itself. + [target], + [link], + ]) { + const unlistable = new Set() + const unfollowedLinks = new Map() + const { result: mounts, listed } = listedDuring(() => + expandReadDenyGlobLinux(pattern, allowed, unlistable, { + unfollowedLinks, + }), + ) + + expect(mounts).toEqual([target]) + expect(listed.filter(dir => dir.startsWith(store))).toEqual([]) + expect(unfollowedLinks.get(link)).toBe(target) + expect([...unlistable]).toEqual([]) + } + } + + // Started above both ends of the link, the target is in the tree and is + // listed: every entry beneath the allowed path is a match and keeps a + // mount of its own, the ones in `deep` under that of `deep`. + const build = join(store, 'build') + const unfollowedLinks = new Map() expect( expandReadDenyGlobLinux( join(top, '**/build/**'), [join(build, 'public')], - unlistable, + undefined, + { unfollowedLinks }, ), - ).toEqual([build, join(build, 'public'), join(build, 'public', 'id.pem')]) - expect([...unlistable]).toEqual([]) + ).toEqual([ + build, + join(build, 'public'), + join(build, 'public', 'deep'), + join(build, 'public', 'id.pem'), + join(build, 'public', 'note.txt'), + ]) + expect([...unfollowedLinks]).toEqual([]) + }) + + it('keeps a link a match wherever it leads, and reports the ones that leave the tree', () => { + const { top, proj } = plantStore('p-forms') + const build = join(top, 'store', 'build') + // Beside the link out of the tree, one that matches and stays in the + // tree, and one that matches and leads back up it. + const real = join(proj, 'pkg', 'real', 'build') + mkdirSync(real, { recursive: true }) + writeFileSync(join(real, '0.out'), '') + mkdirSync(join(proj, 'in')) + mkdirSync(join(proj, 'up')) + symlinkSync(real, join(proj, 'in', 'build')) + symlinkSync(top, join(proj, 'up', 'build')) + + const { result: walk, listed } = listedDuring(() => + walkGlobPattern(join(proj, '**/build/**'), { + withDirectoryForm: true, + followSymlinkedDirectories: true, + }), + ) + + // Each is a match in the directory form, which denies what it leads to. + for (const [link, target] of [ + [join(proj, 'in', 'build'), real], + [join(proj, 'up', 'build'), top], + [join(proj, 'pkg', 'build'), build], + ] as const) { + expect(walk.directoryMatches).toContain(link) + expect(walk.realOf.get(link)).toBe(target) + } + // Nothing is listed but the tree, and nothing found but what is in it. + expect(listed.filter(dir => !dir.startsWith(proj))).toEqual([]) + expect(walk.matches).toEqual([join(real, '0.out')]) + // The links that leave, matched or not: not the one that leads back up. + expect([...walk.unfollowedLinks].sort()).toEqual([ + [join(proj, 'certs', 'vault.pem'), join(top, 'store', 'vault')], + [join(proj, 'pkg', 'build'), build], + ]) + }) + + /** What an expansion returns and reports, with the directories it listed + * at or beneath `outside`. */ + function expandedBeside( + outside: string, + pattern: string, + allowed: string[], + ): { + mounts: string[] + listed: string[] + unlistable: string[] + unfollowedLinks: [string, string][] + } { + const unlistable = new Set() + const unfollowedLinks = new Map() + const { result: mounts, listed } = listedDuring(() => + expandReadDenyGlobLinux(pattern, allowed, unlistable, { + unfollowedLinks, + }), + ) + return { + mounts, + listed: listed.filter(dir => dir.startsWith(outside)).sort(), + unlistable: [...unlistable], + unfollowedLinks: [...unfollowedLinks].sort(), + } + } + + it('never comes to a link inside what a matched link out of the tree leads to', () => { + // keys/d.pem -> out/d is a match of `**/keys/*.pem` by its own name: + // out/d is denied as a whole and not listed, so pub/keys -> stuff in + // there is never come to. Neither is x/keys -> out/d/pub/stuff listed + // through, which leads from the tree into what d.pem denies. x.pem, + // which the pattern matches as pub/keys/x.pem and as x/keys/x.pem alone, + // has no mount, whether or not `pub` is allowed. + const top = join(ROOT, 'p-inner') + const proj = join(top, 'proj') + const d = join(top, 'out', 'd') + mkdirSync(join(d, 'keys'), { recursive: true }) + mkdirSync(join(d, 'pub', 'stuff'), { recursive: true }) + writeFileSync(join(d, 'keys', 'a.pem'), 'KEY') + writeFileSync(join(d, 'pub', 'stuff', 'x.pem'), 'KEY') + symlinkSync('stuff', join(d, 'pub', 'keys')) + mkdirSync(join(proj, 'keys'), { recursive: true }) + mkdirSync(join(proj, 'x')) + symlinkSync(d, join(proj, 'keys', 'd.pem')) + symlinkSync(join(d, 'pub', 'stuff'), join(proj, 'x', 'keys')) + + for (const allowed of [[], [join(d, 'pub')], [d]]) { + expect( + expandedBeside(join(top, 'out'), join(proj, '**/keys/*.pem'), allowed), + ).toEqual({ + mounts: [d], + listed: [], + unlistable: [], + unfollowedLinks: [ + [join(proj, 'keys', 'd.pem'), d], + [join(proj, 'x', 'keys'), join(d, 'pub', 'stuff')], + ], + }) + } + + // Neither to one in there that is a match itself and leads on: build -> + // o1 denies o1, and o2, which o1/pub/build leads to, is not denied. + const chain = join(ROOT, 'p-chain') + const o1 = join(chain, 'o1') + const o2 = join(chain, 'o2') + const o3 = join(chain, 'o3') + for (const [dir, file] of [ + [o1, 'a.txt'], + [o2, 'b.txt'], + [o3, 'c.txt'], + ] as const) { + mkdirSync(join(dir, 'pub'), { recursive: true }) + writeFileSync(join(dir, 'pub', file), '') + } + mkdirSync(join(chain, 'proj')) + symlinkSync(o1, join(chain, 'proj', 'build')) + symlinkSync(o2, join(o1, 'pub', 'build')) + symlinkSync(o3, join(o2, 'pub', 'build')) + + for (const allowed of [ + [], + [join(o1, 'pub')], + [join(o1, 'pub'), join(o2, 'pub')], + ]) { + expect( + expandedBeside(chain, join(chain, 'proj', '**/build/**'), allowed), + ).toEqual({ + mounts: [o1], + listed: [join(chain, 'proj')], + unlistable: [], + unfollowedLinks: [[join(chain, 'proj', 'build'), o1]], + }) + } + // A pattern that starts above them all follows each link in turn. + expect(expandReadDenyGlobLinux(join(chain, '**/build/**'), [])).toEqual([ + o1, + o2, + o3, + ]) }) it('hands back nothing for a link that only passes through, or that the pattern ends at', () => { @@ -426,16 +680,19 @@ describe.if(!isWindows)('a read-deny glob and a link out of its tree', () => { expect([...passedThrough]).toEqual([]) // The link is a match and the pattern has nothing to match beneath it: - // no listing was called for, so nothing is missing from the deny, and - // the allowed path comes back as under a literal deny of the link. + // no listing is called for, whatever is allowed in there, so nothing is + // missing from the deny, and the allowed path comes back as under a + // literal deny of the link. const endedAt = new Set() - expect( + const { result: mounts, listed } = listedDuring(() => expandReadDenyGlobLinux( join(proj, 'certs', '*.pem'), [join(vault, 'public')], endedAt, ), - ).toEqual([vault]) + ) + expect(mounts).toEqual([vault]) + expect(listed).toEqual([join(proj, 'certs')]) expect([...endedAt]).toEqual([]) }) @@ -457,40 +714,59 @@ describe.if(!isWindows)('a read-deny glob and a link out of its tree', () => { const unlistable = new Set() const unfollowedLinks = new Map() - expect( + const { result: mounts, listed } = listedDuring(() => expandReadDenyGlobLinux( join(proj, '*/*.pem'), [join(out, 'sub')], unlistable, { unfollowedLinks }, ), - ).toEqual([out]) + ) + expect(mounts).toEqual([out]) + expect(listed.filter(dir => dir.startsWith(out))).toEqual([]) expect([...unfollowedLinks]).toEqual([[join(proj, 'b'), join(out, 'sub')]]) expect([...unlistable]).toEqual([]) }) - it('hands back the mount that hides what a matched link out of the tree leads to', () => { + it('hands back nothing for the mount that hides what a matched link out of the tree leads to', () => { // up/build -> top leads back up, and denies top as a whole. a/build -> - // top/store leaves the tree beside it: store is denied too and was not - // listed, and the mount that hides it is top's. + // top/store leaves the tree beside it: store is denied too, under top's + // mount, and is not listed. top is not handed back for it, wherever the + // allowed path lies: that would keep a path beneath top from coming + // back. const top = join(ROOT, 'p-hidden') const proj = join(top, 'proj') - mkdirSync(join(top, 'store', 'public'), { recursive: true }) - writeFileSync(join(top, 'store', 'public', 'ok.txt'), 'PUBLIC') + for (const dir of ['store', 'other']) { + mkdirSync(join(top, dir, 'public'), { recursive: true }) + writeFileSync(join(top, dir, 'public', 'ok.txt'), 'PUBLIC') + } mkdirSync(join(proj, 'up'), { recursive: true }) mkdirSync(join(proj, 'a')) symlinkSync(top, join(proj, 'up', 'build')) symlinkSync(join(top, 'store'), join(proj, 'a', 'build')) - const unlistable = new Set() - expect( - expandReadDenyGlobLinux( - join(proj, '**/build/**'), - [join(top, 'store', 'public')], - unlistable, - ), - ).toEqual([top]) - expect([...unlistable]).toEqual([top]) + for (const allowed of [ + join(top, 'other', 'public'), + join(top, 'store', 'public'), + ]) { + const unlistable = new Set() + const unfollowedLinks = new Map() + const { result: mounts, listed } = listedDuring(() => + expandReadDenyGlobLinux( + join(proj, '**/build/**'), + [allowed], + unlistable, + { unfollowedLinks }, + ), + ) + + expect(mounts).toEqual([top]) + expect(listed.filter(dir => !dir.startsWith(proj))).toEqual([]) + expect([...unlistable]).toEqual([]) + expect([...unfollowedLinks]).toEqual([ + [join(proj, 'a', 'build'), join(top, 'store')], + ]) + } }) it('does not find a file in the tree by a name that leaves the tree and comes back', () => { @@ -571,10 +847,48 @@ describe.if(!isWindows)('a read-deny glob and a link out of its tree', () => { expect([...withLink.result.unfollowedLinks]).toEqual([ [join(proj, 'bigtree'), big], ]) + // The expansion lists no more than the walk, whatever is allowed out + // there. + for (const allowed of [[], [join(big, 'd0')], [big]]) { + const expanded = listedDuring(() => + expandReadDenyGlobLinux(pattern, allowed), + ) + expect(expanded.listed.sort()).toEqual(without.listed.sort()) + } // The tree is there to be found by a pattern that names it. expect(expandGlobPattern(join(big, '**/.env'))).toHaveLength(30) }) + it('hands a link back under each spelling the pattern came to it by', () => { + // twice -> p-twice, and the pattern is spelled through it. src is + // listed as twice/src, and again by its real path for d/l -> ../src, + // one component further on in the pattern: src/o, which leaves the + // tree, is met under both names. + const proj = join(ROOT, 'p-twice') + const spelled = join(ROOT, 'twice') + mkdirSync(join(proj, 'src'), { recursive: true }) + mkdirSync(join(proj, 'd')) + symlinkSync(proj, spelled) + symlinkSync(OUTSIDE, join(proj, 'src', 'o')) + symlinkSync(join('..', 'src'), join(proj, 'd', 'l')) + + const unfollowedLinks = new Map() + expandReadDenyGlobLinux(join(spelled, '*/*/*/x'), [], undefined, { + unfollowedLinks, + }) + expect([...unfollowedLinks]).toEqual([ + [join(proj, 'src', 'o'), OUTSIDE], + [join(spelled, 'src', 'o'), OUTSIDE], + ]) + + // Spelled by its real path, the pattern comes to it by one name. + unfollowedLinks.clear() + expandReadDenyGlobLinux(join(proj, '*/*/*/x'), [], undefined, { + unfollowedLinks, + }) + expect([...unfollowedLinks]).toEqual([[join(proj, 'src', 'o'), OUTSIDE]]) + }) + it('still leaves a link alone without the link option', () => { // What the allowRead expansion and the Windows ACL stamp get: the link // as a match of its own, nothing resolved and nothing reported. @@ -804,6 +1118,35 @@ describe.if(!isWindows)('the budget of a glob walk', () => { ) }) + it('is not spent on what a link out of the tree leads to, matched or not', () => { + // linked/build -> tree: the link is the one entry of the pattern's own + // tree, and what it leads to holds sixty. A budget of one entry is + // enough, whatever is allowed out there. + const proj = join(ROOT, 'linked') + mkdirSync(proj) + symlinkSync(TREE, join(proj, 'build')) + + for (const allowed of [[], [join(TREE, 'd0')], [TREE]]) { + // The link is a match: what it leads to is denied as a whole. + const matched = newGlobWalkBudget({ maxEntries: 1 }) + expect( + expandReadDenyGlobLinux(join(proj, '**/build/**'), allowed, undefined, { + budget: matched, + }), + ).toEqual([TREE]) + expect(matched.entries).toBe(1) + + // The link only passes through: nothing out there is denied. + const passedThrough = newGlobWalkBudget({ maxEntries: 1 }) + expect( + expandReadDenyGlobLinux(join(proj, '**/.env'), allowed, undefined, { + budget: passedThrough, + }), + ).toEqual([]) + expect(passedThrough.entries).toBe(1) + } + }) + it('does not take a spent budget for a directory that could not be listed', () => { // An error from the listing itself marks the directory to be denied as // a whole; the budget's error is not one of those, whether the entries @@ -857,20 +1200,26 @@ describe.if(isLinux)('a read-deny glob past its budget, at the manager', () => { return proj } - function filesystemOf(denyRead: string[]) { - return { filesystem: { denyRead, allowWrite: [], denyWrite: [] } } + function filesystemOf( + denyRead: string[], + allowed: { allowRead?: string[]; allowWrite?: string[] } = {}, + ) { + return { + filesystem: { denyRead, allowWrite: [], denyWrite: [], ...allowed }, + } } /** The wrapped command, or what the wrap threw: never both. */ async function wrapOf( denyRead: string[], + allowed: { allowRead?: string[]; allowWrite?: string[] } = {}, ): Promise<{ wrapped?: string; error?: unknown }> { try { return { wrapped: await SandboxManager.wrapWithSandbox( 'echo hello', undefined, - filesystemOf(denyRead), + filesystemOf(denyRead, allowed), ), } } catch (error) { @@ -1009,14 +1358,52 @@ describe.if(isLinux)('a read-deny glob past its budget, at the manager', () => { writeFileSync(join(proj, '.env'), '') symlinkSync(OTHER, join(proj, 'bigtree')) - // Three entries in the tree; OTHER holds forty. - const { wrapped, error } = await withBudgetOf({ maxEntries: 3 }, () => - wrapOf([join(proj, '**/.env')]), - ) + // Three entries in the tree; OTHER holds forty. The link only passes + // through, so that holds whatever is allowed out there. + for (const allowed of [ + {}, + { allowRead: [join(OTHER, 'pkg0')] }, + { allowWrite: [OTHER] }, + ]) { + const { wrapped, error } = await withBudgetOf({ maxEntries: 3 }, () => + wrapOf([join(proj, '**/.env')], allowed), + ) - expect(error).toBeUndefined() - expect(wrapped).toContain(`--ro-bind /dev/null ${join(proj, '.env')}`) - expect(wrapped).not.toContain(join(OTHER, 'pkg0', '.env')) + expect(error).toBeUndefined() + expect(wrapped).toContain(`--ro-bind /dev/null ${join(proj, '.env')}`) + expect(wrapped).not.toContain(join(OTHER, 'pkg0', '.env')) + } + }) + + it('does not spend the budget on what a matched link out of the tree leads to', async () => { + const proj = join(ROOT, 'matched') + mkdirSync(proj) + symlinkSync(OTHER, join(proj, 'build')) + const pattern = join(proj, '**/build/**') + + // One entry in the tree, the link; OTHER holds forty. It is denied as a + // whole for the cost of the link, whatever is allowed beneath it, and an + // allowed path in there comes back with nothing beneath it masked. + for (const allowed of [ + {}, + { allowRead: [join(OTHER, 'pkg0')] }, + { allowWrite: [join(OTHER, 'pkg0')] }, + ]) { + const { wrapped, error } = await withBudgetOf({ maxEntries: 1 }, () => + wrapOf([pattern], allowed), + ) + + expect(error).toBeUndefined() + expect(countMounts(wrapped!, '--tmpfs', OTHER)).toBe(1) + expect( + countMounts( + wrapped!, + '--ro-bind', + '/dev/null', + join(OTHER, 'pkg0', '.env'), + ), + ).toBe(0) + } }) it('says in the read configuration which links its patterns left unfollowed', async () => { @@ -1065,6 +1452,43 @@ describe.if(isLinux)('a read-deny glob past its budget, at the manager', () => { } }) + it('says of a matched link out of the tree that it was left, and not that it could not be listed', async () => { + const proj = join(ROOT, 'through') + mkdirSync(proj) + symlinkSync(OTHER, join(proj, 'build')) + const pattern = join(proj, '**/build/**') + const readConfigWith = async (allowRead: string[]) => { + await SandboxManager.reset() + await SandboxManager.initialize({ + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { + denyRead: [pattern], + allowRead, + allowWrite: [], + denyWrite: [], + }, + }) + try { + return SandboxManager.getFsReadConfig() + } finally { + await SandboxManager.reset() + } + } + + // The same with an allowed path beneath what it leads to and without: + // OTHER is denied as a whole, and nothing in it has a mount of its own. + for (const allowRead of [[], [join(OTHER, 'pkg0')]]) { + expect(await readConfigWith(allowRead)).toEqual({ + denyOnly: [OTHER], + allowWithinDeny: allowRead, + unlistableDenyDirs: [], + unfollowedDenyLinks: [ + { pattern, link: join(proj, 'build'), target: OTHER }, + ], + }) + } + }) + it('refuses the read configuration the same way', async () => { const pattern = join(PROJ, '**/.env') await SandboxManager.reset() @@ -1095,27 +1519,31 @@ describe.if(isLinux)('a read-deny glob past its budget, at the manager', () => { describe.if(isLinux)( 'an allowed path beneath what a matched link out of the tree leads to', () => { - // proj/pkg/build -> store/build, and allowRead store/build/public. The - // pattern that starts at proj denies store/build as a whole without - // listing it, so ok.txt beneath the allowed path has no mask of its own: - // the path is not bound back. + // site/proj/build -> site/outside/build, which holds `pub`, the allowed + // path, and `priv`. A pattern that starts at proj denies outside/build + // as a whole and does not list it, so the allowed path comes back as it + // is written, as beneath a directory denied literally: nothing beneath + // it is masked again. let ROOT: string + let site: string let proj: string let build: string let carveOut: string const hasBwrap = bwrapCanNamespace() beforeAll(() => { - ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-closed-'))) - proj = join(ROOT, 'proj') - build = join(ROOT, 'store', 'build') - carveOut = join(build, 'public') + ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-allowed-'))) + site = join(ROOT, 'site') + proj = join(site, 'proj') + build = join(site, 'outside', 'build') + carveOut = join(build, 'pub') mkdirSync(carveOut, { recursive: true }) - writeFileSync(join(build, 'secret.out'), 'SECRET') - writeFileSync(join(carveOut, 'ok.txt'), 'PUBLIC') - writeFileSync(join(ROOT, 'store', 'beside.txt'), 'BESIDE') - mkdirSync(join(proj, 'pkg'), { recursive: true }) - symlinkSync(build, join(proj, 'pkg', 'build')) + mkdirSync(join(build, 'priv')) + writeFileSync(join(carveOut, 'ok.txt'), 'PUBLIC\n') + writeFileSync(join(build, 'priv', 'secret.txt'), 'SECRET\n') + writeFileSync(join(site, 'outside', 'beside.txt'), 'BESIDE\n') + mkdirSync(proj) + symlinkSync(build, join(proj, 'build')) }) afterAll(async () => { @@ -1123,61 +1551,174 @@ describe.if(isLinux)( rmSync(ROOT, { recursive: true, force: true }) }) - function wrap(command: string, denyRead: string): Promise { + function wrap( + command: string, + filesystem: { + denyRead: string[] + allowRead?: string[] + allowWrite?: string[] + }, + ): Promise { return SandboxManager.wrapWithSandbox(command, undefined, { - filesystem: { - denyRead: [denyRead], - allowRead: [carveOut], - allowWrite: [], - denyWrite: [], - }, + filesystem: { allowWrite: [], denyWrite: [], ...filesystem }, }) } - it('is not bound back', async () => { - const wrapped = await wrap('true', join(proj, '**/build/**')) + /** What the commands print in the sandbox, a line each, after BOOTED: a + * sandbox that refuses to start prints nothing, which would read as + * every file hidden. */ + async function linesOf( + commands: string[], + filesystem: Parameters[1], + ): Promise { + const result = spawnSync( + await wrap(['echo BOOTED', ...commands].join('; '), filesystem), + { shell: true, encoding: 'utf8', timeout: 15000 }, + ) + expect(result.stderr ?? '').not.toContain('bwrap:') + return result.stdout.trim().split('\n') + } - expect(countMounts(wrapped, '--tmpfs', build)).toBe(1) - expect(countMounts(wrapped, '--ro-bind', carveOut, carveOut)).toBe(0) + it('is bound back as it is written, with nothing beneath it masked', async () => { + const wrapped = await wrap('true', { + denyRead: [join(proj, '**/build/**')], + allowRead: [carveOut], + }) + + const hidden = indexOfMount(wrapped, '--tmpfs', build) + expect(hidden).toBeGreaterThan(-1) + expect( + indexOfMount(wrapped, '--ro-bind', carveOut, carveOut), + ).toBeGreaterThan(hidden) + expect( + countMounts( + wrapped, + '--ro-bind', + '/dev/null', + join(carveOut, 'ok.txt'), + ), + ).toBe(0) // Started above both ends of the link, the pattern finds ok.txt: the // path is bound back, with the file masked beneath it. - const listed = await wrap('true', join(ROOT, '**/build/**')) - expect( - countMounts(listed, '--ro-bind', carveOut, carveOut), - ).toBeGreaterThan(0) + const listed = await wrap('true', { + denyRead: [join(site, '**/build/**')], + allowRead: [carveOut], + }) + const boundBack = indexOfMount(listed, '--ro-bind', carveOut, carveOut) + expect(boundBack).toBeGreaterThan(-1) expect( - countMounts(listed, '--ro-bind', '/dev/null', join(carveOut, 'ok.txt')), - ).toBe(1) + indexOfMount( + listed, + '--ro-bind', + '/dev/null', + join(carveOut, 'ok.txt'), + ), + ).toBeGreaterThan(boundBack) + + // With nothing allowed beneath it, the target's mount is all there is. + const whole = await wrap('true', { + denyRead: [join(proj, '**/build/**')], + }) + expect(countMounts(whole, '--tmpfs', build)).toBe(1) + expect(whole).not.toContain(carveOut) }) it.skipIf(!hasBwrap)( - 'serves no file beneath it, by either name', + 'serves what is in it, and nothing else in there, by either name', async () => { - // Each command says BOOTED first: a sandbox that refuses to start - // prints nothing, which would read as every file hidden. - const result = spawnSync( - await wrap( + const reads = [ + `cat ${join(carveOut, 'ok.txt')} 2>/dev/null || echo HIDDEN`, + `cat ${join(proj, 'build', 'pub', 'ok.txt')} 2>/dev/null || echo HIDDEN`, + `cat ${join(build, 'priv', 'secret.txt')} 2>/dev/null || echo HIDDEN`, + `cat ${join(proj, 'build', 'priv', 'secret.txt')} 2>/dev/null || echo HIDDEN`, + `cat ${join(site, 'outside', 'beside.txt')}`, + ] + + expect( + await linesOf(reads, { + denyRead: [join(proj, '**/build/**')], + allowRead: [carveOut], + }), + ).toEqual(['BOOTED', 'PUBLIC', 'PUBLIC', 'HIDDEN', 'HIDDEN', 'BESIDE']) + + // Nothing allowed beneath it: the target is hidden as a whole. + expect( + await linesOf(reads, { denyRead: [join(proj, '**/build/**')] }), + ).toEqual(['BOOTED', 'HIDDEN', 'HIDDEN', 'HIDDEN', 'HIDDEN', 'BESIDE']) + + // Started above both ends of the link, the pattern masks ok.txt + // beneath the allowed path, under either name. + expect( + await linesOf(reads, { + denyRead: [join(site, '**/build/**')], + allowRead: [carveOut], + }), + ).toEqual(['BOOTED', 'HIDDEN', 'HIDDEN', 'HIDDEN', 'HIDDEN', 'BESIDE']) + }, + ) + + it.skipIf(!hasBwrap)( + 'can be written to, where the link hides a directory of temporary files', + async () => { + // job/tmp -> scratch/tmp under `**/tmp/**`, and scratch/tmp/work + // allowed for writing: the directory is hidden, and the command can + // still write where it was told it may, and read what was there. + const job = join(ROOT, 'job') + const tmp = join(ROOT, 'scratch', 'tmp') + const allowed = join(tmp, 'work') + mkdirSync(join(job, 'src'), { recursive: true }) + mkdirSync(allowed, { recursive: true }) + writeFileSync(join(tmp, 'other.txt'), 'OTHER\n') + writeFileSync(join(allowed, 'old.txt'), 'OLD\n') + symlinkSync(tmp, join(job, 'tmp')) + + expect( + await linesOf( [ - 'echo BOOTED', - `cat ${join(carveOut, 'ok.txt')} || echo HIDDEN`, - `cat ${join(proj, 'pkg', 'build', 'public', 'ok.txt')} || echo HIDDEN`, - `cat ${join(build, 'secret.out')} || echo HIDDEN`, - `cat ${join(ROOT, 'store', 'beside.txt')}`, - ].join('; '), - join(proj, '**/build/**'), + `(echo WRITTEN > ${join(allowed, 'out')}) 2>/dev/null && echo WRITE-OK || echo WRITE-FAILED`, + `cat ${join(allowed, 'out')} 2>/dev/null || echo HIDDEN`, + `cat ${join(allowed, 'old.txt')} 2>/dev/null || echo HIDDEN`, + `cat ${join(tmp, 'other.txt')} 2>/dev/null || echo HIDDEN`, + ], + { denyRead: [join(job, '**/tmp/**')], allowWrite: [allowed] }, ), - { shell: true, encoding: 'utf8', timeout: 15000 }, - ) + ).toEqual(['BOOTED', 'WRITE-OK', 'WRITTEN', 'OLD', 'HIDDEN']) + expect(existsSync(join(allowed, 'out'))).toBe(true) + expect(readFileSync(join(allowed, 'out'), 'utf8')).toBe('WRITTEN\n') + }, + ) - expect(result.stderr ?? '').not.toContain('bwrap:') - expect(result.stdout.trim().split('\n')).toEqual([ - 'BOOTED', - 'HIDDEN', - 'HIDDEN', - 'HIDDEN', - 'BESIDE', - ]) + it.skipIf(!hasBwrap)( + 'leaves the working directory in place beneath a mount that hides a target beside it', + async () => { + // up/build -> top leads back up and hides top, the working + // directory with it, which is allowed for writing and bound back. + // a/build -> top/store leaves the tree beside it, and is not listed: + // that must not cost the working directory its way back. + const top = join(ROOT, 'top') + const cwd = join(top, 'proj') + mkdirSync(join(top, 'store', 'public'), { recursive: true }) + writeFileSync(join(top, 'store', 'public', 'ok.txt'), 'PUBLIC\n') + writeFileSync(join(top, 'beside.txt'), 'BESIDE\n') + mkdirSync(join(cwd, 'up'), { recursive: true }) + mkdirSync(join(cwd, 'a')) + writeFileSync(join(cwd, 'main.c'), 'SOURCE\n') + symlinkSync(top, join(cwd, 'up', 'build')) + symlinkSync(join(top, 'store'), join(cwd, 'a', 'build')) + + expect( + await linesOf( + [ + `cat ${join(cwd, 'main.c')} 2>/dev/null || echo HIDDEN`, + `(echo WRITTEN > ${join(cwd, 'out')}) 2>/dev/null && echo WRITE-OK || echo WRITE-FAILED`, + `cat ${join(top, 'store', 'public', 'ok.txt')} 2>/dev/null || echo HIDDEN`, + `cat ${join(top, 'beside.txt')} 2>/dev/null || echo HIDDEN`, + ], + { denyRead: [join(cwd, '**/build/**')], allowWrite: [cwd] }, + ), + ).toEqual(['BOOTED', 'SOURCE', 'WRITE-OK', 'HIDDEN', 'HIDDEN']) + expect(readFileSync(join(cwd, 'out'), 'utf8')).toBe('WRITTEN\n') }, ) }, @@ -1267,6 +1808,108 @@ describe.if(isLinux)( }, ) +describe.if(isLinux)( + 'a file written under the name of a directory that a command moved out of the tree', + () => { + // proj/src/config holds nothing that `proj/src/**/.env` matches, so a + // command that may write the project can move it to proj/moved and leave + // proj/src/config -> ../moved/config in its place. A .env written under + // that name afterwards lies outside the tree. proj/src/held holds a + // masked file, which keeps it where it is. + let ROOT: string + let proj: string + let config: string + let held: string + const hasBwrap = bwrapCanNamespace() + + beforeAll(() => { + ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-moved-'))) + proj = join(ROOT, 'proj') + config = join(proj, 'src', 'config') + held = join(proj, 'src', 'held') + mkdirSync(config, { recursive: true }) + mkdirSync(held) + mkdirSync(join(proj, 'moved')) + writeFileSync(join(config, 'settings.json'), '{}\n') + writeFileSync(join(held, '.env'), 'HELD\n') + }) + + afterAll(async () => { + await SandboxManager.reset() + rmSync(ROOT, { recursive: true, force: true }) + }) + + /** What the commands print in the sandbox, a line each, after BOOTED. */ + async function linesOf( + commands: string[], + denyRead: string, + ): Promise { + const result = spawnSync( + await SandboxManager.wrapWithSandbox( + ['echo BOOTED', ...commands].join('; '), + undefined, + { + filesystem: { + denyRead: [denyRead], + allowWrite: [proj], + denyWrite: [], + }, + }, + ), + { shell: true, encoding: 'utf8', timeout: 15000 }, + ) + expect(result.stderr ?? '').not.toContain('bwrap:') + return result.stdout.trim().split('\n') + } + + it.skipIf(!hasBwrap)( + 'is served, and the link left in its place is handed back', + async () => { + const pattern = join(proj, 'src', '**/.env') + const moved = (dir: string, name: string): string => + `(mv ${dir} ${join(proj, 'moved', name)} && ` + + `ln -s ${join('..', 'moved', name)} ${dir}) 2>/dev/null ` + + `&& echo MOVED || echo REFUSED` + expect( + await linesOf( + [moved(config, 'config'), moved(held, 'held')], + pattern, + ), + ).toEqual(['BOOTED', 'MOVED', 'REFUSED']) + expect(realpathSync(config)).toBe(join(proj, 'moved', 'config')) + expect(realpathSync(held)).toBe(held) + + writeFileSync(join(config, '.env'), 'LATER\n') + const reads = [ + `cat ${join(config, '.env')} 2>/dev/null || echo HIDDEN`, + `cat ${join(held, '.env')} 2>/dev/null || echo HIDDEN`, + ] + expect(await linesOf(reads, pattern)).toEqual([ + 'BOOTED', + 'LATER', + 'HIDDEN', + ]) + const unfollowedLinks = new Map() + expect( + expandReadDenyGlobLinux(pattern, [proj], undefined, { + unfollowedLinks, + }), + ).toEqual([join(held, '.env')]) + expect([...unfollowedLinks]).toEqual([ + [config, join(proj, 'moved', 'config')], + ]) + + // A pattern that starts above both ends of the link masks it. + expect(await linesOf(reads, join(proj, '**/.env'))).toEqual([ + 'BOOTED', + 'HIDDEN', + 'HIDDEN', + ]) + }, + ) + }, +) + describe.if(!isWindows)('the debug line of a read-deny expansion', () => { let ROOT: string @@ -1337,6 +1980,57 @@ describe.if(!isWindows)('the debug line of a read-deny expansion', () => { ).toHaveLength(1) }) + it('hands the links it left back by name, and names the first of them, whatever order they are listed in', () => { + const proj = join(ROOT, 'ordered') + mkdirSync(proj) + for (const name of ['m', 'z', 'a']) { + symlinkSync(join(ROOT, 'outside'), join(proj, name)) + } + const pattern = join(proj, '**/.env') + const byName = ['a', 'm', 'z'].map(name => join(proj, name)) + const readdirSync = fs.readdirSync + + for (const backwards of [false, true]) { + const spy = spyOn(fs, 'readdirSync').mockImplementation((( + ...args: Parameters + ) => { + const entries = readdirSync(...args) + // Listed with their types, as the walk lists: sorted where they are. + const after = backwards ? -1 : 1 + ;(entries as unknown as fs.Dirent[]).sort((x, y) => + x.name < y.name ? -after : after, + ) + return entries + }) as typeof fs.readdirSync) + try { + // The walk meets them in the order the directory lists them. + expect([ + ...walkGlobPattern(pattern, { + followSymlinkedDirectories: true, + }).unfollowedLinks.keys(), + ]).toEqual(backwards ? [...byName].reverse() : byName) + + const unfollowedLinks = new Map() + const lines = debugLinesOf( + () => + void expandReadDenyGlobLinux(pattern, [], undefined, { + unfollowedLinks, + }), + '1', + ) + expect([...unfollowedLinks.keys()]).toEqual(byName) + const summary = lines.filter(line => line.includes('Expanded denyRead')) + expect(summary).toHaveLength(1) + expect(summary[0]).toEndWith( + `links out of the tree left unfollowed: 3 ` + + `(first: ${byName[0]} -> ${join(ROOT, 'outside')})`, + ) + } finally { + spy.mockRestore() + } + } + }) + it('names no link when none was left', () => { const lines = debugLinesOf( () => void expandReadDenyGlobLinux(join(ROOT, 'proj', 'src', '*'), []), diff --git a/test/sandbox/read-deny-glob.test.ts b/test/sandbox/read-deny-glob.test.ts index 960a56851..ef672498f 100644 --- a/test/sandbox/read-deny-glob.test.ts +++ b/test/sandbox/read-deny-glob.test.ts @@ -260,18 +260,23 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { // Started at pkg/a the same link leaves the pattern's tree. It is a // match, so what it leads to is still denied as a whole, but nothing - // is listed out there: x.txt beneath the carve-out was never found, - // so outside/ is handed back as a directory to bind nothing back - // beneath, like one that could not be listed. + // is listed out there: the carve-out is bound back as it is written, + // as beneath a directory denied literally, and x.txt beneath it has + // no mount of its own. Nothing is handed back as unlistable. const unlistable = new Set() + const unfollowedLinks = new Map() expect( expandReadDenyGlobLinux( join(ROOT, 'pkg', 'a', '**/build/**'), [join(OUTSIDE, 'pub')], unlistable, + { unfollowedLinks }, ), ).toEqual([OUTSIDE, join(ROOT, 'pkg', 'a', 'build')]) - expect([...unlistable]).toEqual([OUTSIDE]) + expect([...unlistable]).toEqual([]) + expect([...unfollowedLinks]).toEqual([ + [join(ROOT, 'pkg', 'a', 'build', 'link'), OUTSIDE], + ]) } finally { rmSync(join(OUTSIDE, 'pub'), { recursive: true }) } @@ -498,19 +503,31 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { join(store, 'nm'), ]) - // Started at proj, the first link leaves the tree: it is a match, so - // store/nm is denied as a whole, and the second link inside it is - // never seen. What lies behind it was not looked for, so store/nm is - // handed back as a directory to bind nothing back beneath. - const unlistable = new Set() - expect( - expandReadDenyGlobLinux( - join(proj, '**/build/**'), - [join(store, 'keep', 'pub')], - unlistable, - ), - ).toEqual([join(store, 'nm')]) - expect([...unlistable]).toEqual([join(store, 'nm')]) + // Started at proj, the first link leaves the tree. It is a match, so + // store/nm is denied as a whole, and nothing is listed out there: the + // second link inside it is never seen, and store/keep, which only that + // link leads the pattern to, is not denied. That holds however the + // carve-out is written, and nothing is handed back as unlistable. + for (const carveOut of [ + join(store, 'keep', 'pub'), + // Through the second link, which puts it beneath store/nm by name. + join(store, 'nm', 'keep', 'pub'), + ]) { + const unlistable = new Set() + const unfollowedLinks = new Map() + expect( + expandReadDenyGlobLinux( + join(proj, '**/build/**'), + [carveOut], + unlistable, + { unfollowedLinks }, + ), + ).toEqual([join(store, 'nm')]) + expect([...unlistable]).toEqual([]) + expect([...unfollowedLinks]).toEqual([ + [join(proj, 'build'), join(store, 'nm')], + ]) + } }) it('drops a matched link that does not resolve', () => { From b84e5c5d7133e63b5e551189472df156bf99013d Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Fri, 25 Sep 2026 23:16:37 +0000 Subject: [PATCH 08/17] README: what a denyRead pattern's tree leaves out, in full --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 377e2f72b..0261ba1e8 100644 --- a/README.md +++ b/README.md @@ -437,9 +437,9 @@ bubblewrap binds concrete paths, so glob support is narrower than on macOS: - `allowWrite` / `denyWrite` take literal paths. A trailing `/**` is dropped (`src/**` means `src`); any other glob pattern there is skipped. - `denyRead` / `allowRead` accept the same glob syntax as macOS, expanded to the entries that exist when the command is wrapped, so a file that appears later is not covered. The pattern needs a literal directory to start from (a relative pattern starts at the current directory): one with a wildcard in its first path component, such as `/**/*.pem` or `/opt*/keys/**`, is skipped on Linux. Only directories the pattern can match beneath are listed (`certs/*.pem` lists `certs` alone). -- 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. The exception is a directory outside the pattern's tree that a matched link hides and that the pattern would carry on into (`**/build/**` with `build` a link to a directory beside the tree; see below): it is not listed, so nothing is bound back beneath the mount that hides it, `allowRead` and `allowWrite` paths included, as for a directory the expansion cannot list. A directory hidden through a matched link that the pattern ends at (`certs/*.pem` with `vault.pem` a link to a directory), or through one that leads back up the tree, is hidden as a literal deny of the link would hide it: an `allowRead` beneath it is bound back, and what the pattern matches there only through a link that is not descended has no mask. +- 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 hidden through a matched link that the pattern ends at (`certs/*.pem` with `vault.pem` a link to a directory), or through one that leads back up the tree or out of it (`**/build/**` with `build` a link to a directory beside the tree; see below), is hidden as a literal deny of the link would hide it: an `allowRead` beneath it is bound back as written, and what the pattern matches there only through a link that is not descended has no mask. - 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. 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. -- A `denyRead` pattern covers its own tree: the literal directory the pattern starts from (what is written before its first wildcard) and everything under it. That is the pattern's directory, not the project's: for `src/**/.env` the tree is `src`. A symlinked directory is descended only when it leads to somewhere inside that tree, judged by where the link resolves and not by how it is written. What this gives up: a file that the pattern matches only by a name that passes through a link out of the tree is not masked. With `src/shared` a link to `../shared`, `src/**/.env` does not mask `shared/.env`, which stays readable under both names; a pattern that starts at the file's directory or above it (`shared/**/.env`, `**/.env`), or an entry naming the file itself, does. Such a file can be inside the tree, when a link out there leads back in: with `src/vendor` a link to `../vendor` and `vendor/secrets` a link to `../src/data`, `src/**/secrets/*.pem` matches `src/data/key.pem` as `src/vendor/secrets/key.pem` alone and does not mask it, under either name; a pattern that matches it by a name inside the tree (`src/**/*.pem`) masks it under every name. A link whose own name matches the pattern still masks what it leads to, wherever that is: a matched link to a file masks the file, and a matched link to a directory hides the whole directory; when that directory is outside the tree it is not listed. +- A `denyRead` pattern covers its own tree: the literal directory the pattern starts from (what is written before its first wildcard) and everything under it. That is the pattern's directory, not the project's: for `src/**/.env` the tree is `src`. A directory outside that tree is never listed: a symlinked directory is descended only when it leads to somewhere inside the tree, judged by where the link resolves and not by how it is written, whether or not the link's own name matches and whatever is allowed beneath what it leads to. A link out of the tree can only add to what a pattern covers: a file whose real path is inside the tree and matches is found by walking the tree itself, whatever links exist, so no link a sandboxed command creates takes a mask away. What this gives up is what such a link used to add: a file that the pattern matches only by a name that passes through a link out of the tree is not masked. With `src/shared` a link to `../shared`, `src/**/.env` does not mask `shared/.env`, which stays readable under both names; a pattern that starts at the file's directory or above it (`shared/**/.env`, `**/.env`), or an entry naming the file itself, does. Such a file can be inside the tree, when a link out there leads back in: with `src/vendor` a link to `../vendor` and `vendor/secrets` a link to `../src/data`, `src/**/secrets/*.pem` matches `src/data/key.pem` as `src/vendor/secrets/key.pem` alone and does not mask it, under either name; a pattern that matches it by a name inside the tree (`src/**/*.pem`) masks it under every name. It can be a file written later, too: a command that may write in the tree can move a directory that holds nothing masked out of it and leave a link in its place, and what is written under that name afterwards lies outside the tree and is not masked; `unfollowedDenyLinks` names the link. A link whose own name matches the pattern still masks what it leads to, wherever that is: a matched link to a file masks the file, and a matched link to a directory hides the whole directory. When that directory is outside the tree it is not listed, so an `allowRead` or `allowWrite` path beneath it is bound back as written, as beneath a directory listed in `denyRead` literally, and what the pattern matches under that path through the link's name is not masked again: with `src/build` a link to `../out/build`, `src/**/build/**` and an `allowRead` of `out/build/pub`, everything in `pub` is readable and the rest of `out/build` is hidden, under either name. What a link inside such a directory leads to is not masked either: with `out/build/data` a link to `../../keep`, `keep` stays readable. - Inside the tree, 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. Only a pattern that does not read as written is matched against real paths alone, with every directory under its starting directory listed: one with a wildcard inside a bracket expression (`[a*]`), or with a second `[` that nothing closes. A link that itself matches it still denies what it leads to. 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, and a link back up the tree denies everything it reaches, as a literal deny of the link would. A link that resolves to nothing is skipped. `allowRead` globs are not expanded through symlinks: they match the link itself. - The expansion of a configuration's `denyRead` patterns has a budget, shared by all of them: 2,000,000 directory entries looked at and 10 seconds. The time a tree takes depends on the machine and on what is cached, so the first command after start-up in a very large tree can be refused where a second is not. When either runs out the wrap is refused with a `LinuxSandboxProfileError` whose `.code` is `deny_glob_too_large`, and no command is returned: a deny list cut short would leave readable what the pattern was written to hide. `getFsReadConfig()` throws the same error. The message names the pattern, the directory being listed and what had been spent; narrow the pattern, or remove or move what it walks into. The same are fields of the error's `.cause`, for a caller that words its own message: `pattern` (the one being expanded when the budget ran out, which need not be the large one), `directory`, `exhausted` (`'entries'` or `'time'`), `entries` and `elapsedMs` (what all the patterns had spent between them), `maxEntries` and `timeoutMs`. The time is checked before each directory is listed and at each entry in it, so what is not cut short is a single step: a listing that blocks (a dead network mount inside the tree), the resolution of a link that leads to one (where a link leads is looked up even when it is then not descended), or one name that is slow to match (a pattern with several wildcards in one path component, against a long name). The budget is the walk's: what is done with the matches afterwards is not counted. `getFsReadConfig()` lists the links the patterns left unfollowed for leading out of their tree in `unfollowedDenyLinks`, each with its `pattern`, `link` and `target`, for a caller to tell its user what a pattern does not reach. The list is of those links only: a link that leads back up the tree, and any link under a pattern that cannot be followed one path component at a time, is not listed through either and is not in it. With debug logging on (`SRT_DEBUG`) each expansion logs one line: the pattern, the milliseconds it took, its matches and mounts, the directories listed, the entries looked at, and how many links out of the tree it left unfollowed, with the first of them. - 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. From f3bc5f0a11af3ff1576d10d4413882313284e0d8 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 26 Sep 2026 13:09:41 +0000 Subject: [PATCH 09/17] linux: the read-deny walk follows links as before; only the budget stays The walk stopped at a symbolic link that leaves the pattern's tree. Every release follows such a link and masks what it finds behind it: with `project/out` a link to `../outside` and `project/**/.env` denied, `outside/deep/.env` is unreadable under both names on 0.0.75, 0.0.76 and 0.0.77. Stopping there left it readable, so the rule lowered what a pattern covers and is taken out. The messages of the earlier commits on this branch that describe it as what other releases and platforms do were wrong on that point. Link handling in walkGlobPattern is what it was. `GlobWalk.unfollowedLinks`, the out-parameter of expandReadDenyGlobLinux, and `unfollowedDenyLinks` with its exported type are removed. What stays is what the walk never had: a budget shared by all the denyRead patterns of one configuration, 2,000,000 directory entries and 10 seconds, the clock read before each listing and at each entry. Out of budget the walk throws and hands nothing back, the wrap is refused with LinuxSandboxProfileError `deny_glob_too_large`, and getFsReadConfig() throws the same. What lies behind a link counts against it like anything else. Each expansion logs one line under SRT_DEBUG with what it cost. --- src/index.ts | 1 - src/sandbox/read-deny-glob.ts | 58 +++++----------------------------- src/sandbox/sandbox-manager.ts | 27 +++------------- src/sandbox/sandbox-schemas.ts | 34 -------------------- src/sandbox/sandbox-utils.ts | 32 +++---------------- 5 files changed, 17 insertions(+), 135 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9bdd7ac49..d0a06954e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,7 +32,6 @@ export type { CredentialRestrictionConfig, NetworkRestrictionConfig, NetworkHostPattern, - UnfollowedDenyLink, } from './sandbox/sandbox-schemas.js' // Per-request filter diff --git a/src/sandbox/read-deny-glob.ts b/src/sandbox/read-deny-glob.ts index 2fa2c0273..6719eb931 100644 --- a/src/sandbox/read-deny-glob.ts +++ b/src/sandbox/read-deny-glob.ts @@ -61,34 +61,11 @@ function collapseReadDenyLocations({ * Expand a read-deny glob into the paths bwrap should mount over, collapsed * against `reExposedPaths` (the caller's allowRead and allowWrite entries). * A pattern ending in `/**` also takes its directory form, so - * `**\/build/**` yields one mount per `build/` directory. A directory the - * walk could not list is denied whole. Sorted, so an ancestor precedes its + * `**\/build/**` yields one mount per `build/` directory. A match reached + * through a symlink is listed where it really lives, and a directory the walk + * could not list is denied whole. Sorted, so an ancestor precedes its * descendants. * - * The pattern covers the tree under its literal directory, by every name - * that leads around inside it: a match reached through a symlink that stays - * in the tree is listed where it really lives. The tree is the pattern's - * own, the directory written before its first wildcard, and not the project - * it may lie in: `/src/**\/.env` covers `/src`, so - * `src/shared -> ../shared` leads out of it. A symlinked directory that - * leads out of the tree is never listed through, whatever is re-exposed - * beneath what it leads to. One that itself matches still denies what it - * leads to, whole; a re-exposed path beneath that directory is bound back as - * written, as beneath a directory denied literally, and what the pattern - * would match under it through the link's name is not masked again. One that - * does not match denies nothing, and a file that only a listing through it - * would have found is not denied. That is not only a file out there: a link - * out there can lead back into the tree, and a file in the tree that the - * pattern matches by no name but the one through both links is not found - * either. - * - * A link out of the tree can only add to what the pattern covers: a file - * whose real path is in the tree and matches is found by walking the tree - * itself, whatever links exist. So no link a sandboxed command makes takes a - * mask away, and what is given up is what a link used to add. That includes - * what is written later beneath a directory that a command has moved out of - * the tree, leaving a link in its place: it lies outside the tree. - * * Throws {@link GlobWalkBudgetError} when `budget` runs out before the walk * is done. There is no shorter list to fall back on: a caller that cannot * have the whole expansion must not run the command. @@ -100,18 +77,12 @@ function collapseReadDenyLocations({ * come back unmasked. * @param opts.budget - what this expansion and the others handed the same * object may spend between them. - * @param opts.unfollowedLinks - receives each symlinked directory that was - * not listed through because it leads out of the pattern's tree, with the - * directory it leads to, in the order of their names. */ export function expandReadDenyGlobLinux( globPattern: string, reExposedPaths: readonly string[], unlistableDirs?: Set, - opts: { - budget?: GlobWalkBudget - unfollowedLinks?: Map - } = {}, + opts: { budget?: GlobWalkBudget } = {}, ): string[] { const startedAt = performance.now() const walk = walkGlobPattern(globPattern, { @@ -119,14 +90,6 @@ export function expandReadDenyGlobLinux( followSymlinkedDirectories: true, budget: opts.budget, }) - // In the order of their names, as the mounts are: the order a directory - // hands its entries back in is the filesystem's. - const unfollowed = [...walk.unfollowedLinks].sort(([a], [b]) => - a < b ? -1 : a > b ? 1 : 0, - ) - for (const [link, target] of unfollowed) { - opts.unfollowedLinks?.set(link, target) - } // Where a path the walk reported really lives: the denyRead loop mounts an // entry there, whatever spelling named it. const locationOf = (p: string): string => walk.realOf.get(p) ?? p @@ -142,9 +105,9 @@ export function expandReadDenyGlobLinux( const holdMatches = new Set(walk.matches.map(m => parentOf(locationOf(m)))) for (const dir of walk.directoryMatches) { // A directory-form match that is a symlink counts in its own right: - // one the walk did not list through (a link back up the tree or out - // of it, or a further name for a directory already listed) has no - // match beneath it, yet denies everything it reaches. + // one the walk did not list through (a link back up the tree, or a + // further name for a directory already listed) has no match beneath + // it, yet denies everything it reaches. if (holdMatches.has(locationOf(dir)) || walk.symlinks.has(dir)) { candidates.add(dir) } @@ -230,15 +193,10 @@ export function expandReadDenyGlobLinux( // One line for the whole expansion, with what it cost: a caller that times // its wraps takes the numbers from here. - const [firstUnfollowed] = unfollowed logForDebugging( `[Sandbox Linux] Expanded denyRead glob "${globPattern}" in ${Math.round(performance.now() - startedAt)} ms: ` + `${walk.matches.length} matches -> ${mounts.size} mounts; ` + - `directories listed: ${walk.directoriesListed}, entries looked at: ${walk.entriesExamined}, ` + - `links out of the tree left unfollowed: ${walk.unfollowedLinks.size}` + - (firstUnfollowed - ? ` (first: ${firstUnfollowed[0]} -> ${firstUnfollowed[1]})` - : ''), + `directories listed: ${walk.directoriesListed}, entries looked at: ${walk.entriesExamined}`, ) for (const mount of mounts) { // A matched link decides what is hidden for the whole sandbox: a diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 0ffa3a2d8..d07adda9c 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -39,7 +39,6 @@ import type { FsReadRestrictionConfig, FsWriteRestrictionConfig, NetworkRestrictionConfig, - UnfollowedDenyLink, } from './sandbox-schemas.js' import { wrapCommandWithSandboxLinux, @@ -1321,22 +1320,16 @@ function expandAllowReadGlob(pattern: string): string[] { function readDenyGlobExpander( reExposedPaths: readonly string[], unlistableDenyDirs: Set, - unfollowedDenyLinks: UnfollowedDenyLink[], ): (pattern: string) => string[] { const budget = newGlobWalkBudget() return pattern => { try { - const unfollowedLinks = new Map() - const expanded = expandReadDenyGlobLinux( + return expandReadDenyGlobLinux( pattern, reExposedPaths, unlistableDenyDirs, - { budget, unfollowedLinks }, + { budget }, ) - for (const [link, target] of unfollowedLinks) { - unfollowedDenyLinks.push({ pattern, link, target }) - } - return expanded } catch (error) { if (!(error instanceof GlobWalkBudgetError)) throw error throw new LinuxSandboxProfileError( @@ -1379,14 +1372,9 @@ function getFsReadConfig(): FsReadRestrictionConfig { ) const reExposedPaths = [...allowPaths, ...getFsWriteConfig().allowOnly] const unlistableDenyDirs = new Set() - const unfollowedDenyLinks: UnfollowedDenyLink[] = [] const denyPaths = resolveReadPathEntries( unionDenyReadPaths(config.filesystem.denyRead, credentialRestrictions), - readDenyGlobExpander( - reExposedPaths, - unlistableDenyDirs, - unfollowedDenyLinks, - ), + readDenyGlobExpander(reExposedPaths, unlistableDenyDirs), credentialRestrictions.degradeToDenyPaths, ) @@ -1394,7 +1382,6 @@ function getFsReadConfig(): FsReadRestrictionConfig { denyOnly: denyPaths, allowWithinDeny: allowPaths, unlistableDenyDirs: [...unlistableDenyDirs], - unfollowedDenyLinks, } } @@ -1770,24 +1757,18 @@ async function wrapWithSandbox( } const reExposedPaths = [...expandedAllowRead, ...writeConfig.allowOnly] const unlistableDenyDirs = new Set() - const unfollowedDenyLinks: UnfollowedDenyLink[] = [] const expandedDenyRead = resolveReadPathEntries( unionDenyReadPaths( customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead ?? [], credentialRestrictions, ), - readDenyGlobExpander( - reExposedPaths, - unlistableDenyDirs, - unfollowedDenyLinks, - ), + readDenyGlobExpander(reExposedPaths, unlistableDenyDirs), credentialRestrictions.degradeToDenyPaths, ) readConfig = { denyOnly: expandedDenyRead, allowWithinDeny: expandedAllowRead, unlistableDenyDirs: [...unlistableDenyDirs], - unfollowedDenyLinks, } } diff --git a/src/sandbox/sandbox-schemas.ts b/src/sandbox/sandbox-schemas.ts index edc42f431..ce4243109 100644 --- a/src/sandbox/sandbox-schemas.ts +++ b/src/sandbox/sandbox-schemas.ts @@ -17,26 +17,6 @@ * * This is maximally permissive by default - only explicitly denied paths are blocked. */ -/** - * A symbolic link a `denyRead` pattern came to and did not list through, - * because it leads out of the pattern's tree. What the pattern would have - * matched beneath `target` is not denied. Where the link is itself a match, - * `target` is denied as a whole all the same, and what is not denied is what - * a further link in there leads to, and what lies beneath an allowed path in - * there, which is bound back as it is written. - */ -export interface UnfollowedDenyLink { - /** The `denyRead` entry being expanded. */ - pattern: string - /** - * The link, as the walk spelled it. One link can be in the list under two - * spellings, where the walk came to it by both. - */ - link: string - /** Where the link resolves. */ - target: string -} - export interface FsReadRestrictionConfig { denyOnly: string[] allowWithinDeny?: string[] @@ -48,20 +28,6 @@ export interface FsReadRestrictionConfig { * unmasked. Linux only: the other backends match globs natively. */ unlistableDenyDirs?: string[] - /** - * The links the `denyRead` patterns left unfollowed because they lead out - * of the pattern's tree, for a caller to tell its user what a pattern does - * not reach. It informs and restricts nothing: no backend reads it. Linux - * only: the other backends match patterns against resolved paths and walk - * nothing. - * - * Not every link a pattern does not list through is here. A link that - * leads back up the tree (to the pattern's starting directory or above - * it) is not listed through either, and a pattern that cannot be followed - * one path component at a time lists through no link at all; neither is - * reported in this list. Both say so in the debug log. - */ - unfollowedDenyLinks?: UnfollowedDenyLink[] } /** diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index af9000166..a98f8d1fc 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -1247,11 +1247,6 @@ export interface GlobWalk { * what was found through a symlinked directory, which is reported where * it really lives to begin with. */ realOf: Map - /** With `followSymlinkedDirectories`: the symlinked directories the - * pattern could match beneath that were not listed through because they - * lead out of the tree, each with the directory it leads to. One that is - * itself a match is in `matches` or `directoryMatches` all the same. */ - unfollowedLinks: Map /** How many directories were listed. */ directoriesListed: number /** How many directory entries were looked at: each entry once for every @@ -1572,12 +1567,7 @@ export function toForwardSlashes(s: string): string { * static prefix, filtered by `globPath` and, with `withDirectoryForm`, by * `globPath` without its trailing `/**`, with the symlinks seen recorded. * With `followSymlinkedDirectories` it also lists through a symlinked - * directory that stays inside the tree and reports every match where it - * really lives. The tree is the walk's base, the pattern's static prefix, - * and what lies beneath it, wherever a project around it begins. A link that - * leaves the tree is not listed through: what it leads to is a tree the - * pattern does not name, of any size, and a command allowed to write beneath - * the base can plant one. It is still a match under its own name. + * directory and reports every match where it really lives. * * With a `budget` the walk throws {@link GlobWalkBudgetError} once the budget * is spent, and returns nothing. The clock is read before each listing and @@ -1600,7 +1590,6 @@ export function walkGlobPattern( uninspectableLinks: new Set(), unlisted: [], realOf: new Map(), - unfollowedLinks: new Map(), directoriesListed: 0, entriesExamined: 0, } @@ -1794,10 +1783,10 @@ export function walkGlobPattern( } walk.symlinks.add(fullPath) // Only the read-deny expansion lists through a symlinked directory: it - // has to cover what the pattern reaches inside its tree by every name - // that leads there. The allowRead expansion and the Windows ACL stamp - // take the link itself and stop there, as the allow bind and the ACL - // they feed do — Windows does not follow reparse points at all. + // has to cover what the pattern reaches by every name. The allowRead + // expansion and the Windows ACL stamp take the link itself and stop + // there, as the allow bind and the ACL they feed do — Windows does not + // follow reparse points at all. if (!opts.followSymlinkedDirectories) continue const isDirectoryFormCandidate = opts.withDirectoryForm === true && @@ -1839,17 +1828,6 @@ export function walkGlobPattern( ) continue } - // Neither is a link that leaves the tree, judged by where it resolves - // and not by how it is written. Whatever it matched by its own name is - // recorded above and still denies the target; only the listing of a - // tree the pattern does not name is left out. - if (!isAtOrUnder(target.real, baseReal)) { - walk.unfollowedLinks.set(fullPath, target.real) - logForDebugging( - `[Sandbox] Not following symlink ${fullPath} -> ${target.real} for glob pattern ${globPath}: it leads out of ${baseReal}`, - ) - continue - } pending.push({ dir: target.real, real: target.real, From 60da7028f8208d3d5da45985db7cc2c93c48169d Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 26 Sep 2026 13:09:41 +0000 Subject: [PATCH 10/17] test: a match behind a link out of the tree stays denied, within the budget The two existing glob suites are as they were before this branch. The new file keeps the cases for the budget, for the refusal at the manager and for the debug line, and loses the ones written for the rule. Added: a match behind a link to a directory beside the project is denied where it really is, and a real bubblewrap run shows it unreadable under both names; what such a link leads to is spent from the budget, at the walk and at the manager. --- test/sandbox/glob-expand.test.ts | 78 +- test/sandbox/read-deny-glob-bound.test.ts | 1603 ++------------------- test/sandbox/read-deny-glob.test.ts | 124 +- 3 files changed, 151 insertions(+), 1654 deletions(-) diff --git a/test/sandbox/glob-expand.test.ts b/test/sandbox/glob-expand.test.ts index c656e4550..ec0625c9f 100644 --- a/test/sandbox/glob-expand.test.ts +++ b/test/sandbox/glob-expand.test.ts @@ -234,30 +234,12 @@ describe.if(!isWindows)('walkGlobPattern', () => { expect(plain.matches).not.toContain(join(link, 'outside.out')) expect(plain.realOf.get(link)).toBeUndefined() - // With the option the link is resolved, so that a deny lands on what - // it leads to. It leads out of a/, where the pattern starts, so what - // lies behind it is not listed under either name. const followed = walkGlobPattern(join(RAW_BASE, 'a', '**/build/**'), { followSymlinkedDirectories: true, }) - expect(followed.matches).toContain(link) - expect(followed.realOf.get(link)).toBe(join(BASE, 'elsewhere')) - expect(followed.matches).not.toContain( - join(BASE, 'elsewhere', 'outside.out'), - ) + // What is found through the link is reported where it really is. + expect(followed.matches).toContain(join(BASE, 'elsewhere', 'outside.out')) expect(followed.matches).not.toContain(join(link, 'outside.out')) - expect([...followed.unfollowedLinks]).toEqual([ - [link, join(BASE, 'elsewhere')], - ]) - - // Started where both ends of the link are, what is found through it is - // reported where it really is. - const inside = walkGlobPattern(join(RAW_BASE, '**/build/**'), { - followSymlinkedDirectories: true, - }) - expect(inside.matches).toContain(join(BASE, 'elsewhere', 'outside.out')) - expect(inside.matches).not.toContain(join(link, 'outside.out')) - expect([...inside.unfollowedLinks]).toEqual([]) } finally { rmSync(join(BASE, 'elsewhere', 'outside.out')) } @@ -493,8 +475,6 @@ describe.if(!isWindows)('walkGlobPattern', () => { // crosses every link before it. A walk that carried that spelling would // match each entry against a longer and longer path; what is found // through a link is carried and reported by its real path instead. - // `d0*` names d0 alone and starts the walk at root, which holds the whole - // chain: every other directory is reached through the links only. const root = realPath(mkdtempSync(join(tmpdir(), 'glob-walk-chain-'))) const links = 300 try { @@ -506,7 +486,7 @@ describe.if(!isWindows)('walkGlobPattern', () => { symlinkSync(join('..', `d${i + 1}`), join(root, `d${i}`, 'next')) } - const walk = walkGlobPattern(join(root, 'd0*', '**/*.pem'), { + const walk = walkGlobPattern(join(root, 'd0', '**/*.pem'), { followSymlinkedDirectories: true, }) @@ -520,16 +500,6 @@ describe.if(!isWindows)('walkGlobPattern', () => { for (const seen of walk.symlinks) { expect(seen.length).toBeLessThanOrEqual(longest) } - - // Started at d0, the first link already leaves the tree, and the - // chain is not entered. - const fromFirst = walkGlobPattern(join(root, 'd0', '**/*.pem'), { - followSymlinkedDirectories: true, - }) - expect(fromFirst.matches).toEqual([join(root, 'd0', 'id.pem')]) - expect([...fromFirst.unfollowedLinks]).toEqual([ - [join(root, 'd0', 'next'), join(root, 'd1')], - ]) } finally { rmSync(root, { recursive: true, force: true }) } @@ -541,8 +511,6 @@ describe.if(!isWindows)('walkGlobPattern', () => { // deep is a real directory whose path is a few bytes short of PATH_MAX, // so deep/key.pem cannot be named by its real path at all, only through // base/s. The link is a match, and what it leads to has to be found. - // `bas*` names base alone and starts the walk at root, which holds deep - // as well. const root = realPath(mkdtempSync(join(tmpdir(), 'glob-walk-long-'))) let deep = join(root, 'deep') while (deep.length < 4090) { @@ -557,7 +525,7 @@ describe.if(!isWindows)('walkGlobPattern', () => { symlinkSync(join(root, 'secret.txt'), viaLink) expect(join(deep, 'key.pem').length).toBeGreaterThan(4095) - const walk = walkGlobPattern(join(root, 'bas*', '*/*.pem'), { + const walk = walkGlobPattern(join(root, 'base', '*/*.pem'), { followSymlinkedDirectories: true, }) @@ -565,15 +533,6 @@ describe.if(!isWindows)('walkGlobPattern', () => { expect(walk.matches.map(m => walk.realOf.get(m))).toEqual([ join(root, 'secret.txt'), ]) - - // Started at base, base/s leaves the tree and is not listed through. - const fromBase = walkGlobPattern(join(root, 'base', '*/*.pem'), { - followSymlinkedDirectories: true, - }) - expect(fromBase.matches).toEqual([]) - expect([...fromBase.unfollowedLinks]).toEqual([ - [join(root, 'base', 's'), deep], - ]) } finally { rmSync(viaLink, { force: true }) rmSync(root, { recursive: true, force: true }) @@ -666,9 +625,7 @@ describe.if(!isWindows)('walkGlobPattern', () => { // globToRegex lets `**.pem` and `ce**/x.pem` span directories. Each is // two patterns the walk can follow a name at a time (`*.pem` or // `*` / `**` / `*.pem`), so what they reach through a link is found like - // any other match: here certs leads to a directory beside proj, which - // the patterns name in no other way. `pro*` names proj alone and starts - // the walk at root, which holds both. + // any other match: here certs leads out of the pattern's base. const root = realPath(mkdtempSync(join(tmpdir(), 'glob-walk-glued-'))) try { mkdirSync(join(root, 'proj')) @@ -678,14 +635,13 @@ describe.if(!isWindows)('walkGlobPattern', () => { writeFileSync(join(root, 'proj', 'z.pem'), 'KEY') symlinkSync(join('..', 'outside'), join(root, 'proj', 'certs')) - const foundFrom = (start: string, pattern: string): string[] => { - const walk = walkGlobPattern(join(root, start, pattern), { + const found = (pattern: string): string[] => { + const walk = walkGlobPattern(join(root, 'proj', pattern), { followSymlinkedDirectories: true, }) expect(walk.unlisted).toEqual([]) return walk.matches.map(m => walk.realOf.get(m) ?? m).sort() } - const found = (pattern: string): string[] => foundFrom('pro*', pattern) expect(found('**.pem')).toEqual([ join(root, 'outside', 'deep', 'y.pem'), @@ -701,11 +657,6 @@ describe.if(!isWindows)('walkGlobPattern', () => { join(root, 'outside', 'deep', 'y.pem'), ]) expect(found('***/x.pem')).toEqual([join(root, 'outside', 'x.pem')]) - - // Started at proj, certs leads out of the tree and is not listed - // through, however the pattern spans directories. - expect(foundFrom('proj', '**.pem')).toEqual([join(root, 'proj', 'z.pem')]) - expect(foundFrom('proj', 'ce**/x.pem')).toEqual([]) } finally { rmSync(root, { recursive: true, force: true }) } @@ -713,8 +664,7 @@ describe.if(!isWindows)('walkGlobPattern', () => { it('follows a bracket expression that can match a separator', () => { // `[s/]` is an `s` within a name or a separator between two, and both - // readings are followed, through a link as well. `pro*` names proj alone - // and starts the walk at root, which holds the link's target too. + // readings are followed, through a link as well. const root = realPath(mkdtempSync(join(tmpdir(), 'glob-walk-bracket-'))) try { mkdirSync(join(root, 'proj')) @@ -723,7 +673,7 @@ describe.if(!isWindows)('walkGlobPattern', () => { writeFileSync(join(root, 'outside', 'certsx.pem'), 'KEY') symlinkSync(join('..', 'outside'), join(root, 'proj', 'lnk')) - const walk = walkGlobPattern(join(root, 'pro*', '*/cert[s/]x.pem'), { + const walk = walkGlobPattern(join(root, 'proj', '*/cert[s/]x.pem'), { followSymlinkedDirectories: true, }) @@ -731,16 +681,6 @@ describe.if(!isWindows)('walkGlobPattern', () => { join(root, 'outside', 'cert', 'x.pem'), join(root, 'outside', 'certsx.pem'), ]) - - // Started at proj, the link leaves the tree and neither reading is - // followed through it. - const fromProj = walkGlobPattern(join(root, 'proj', '*/cert[s/]x.pem'), { - followSymlinkedDirectories: true, - }) - expect(fromProj.matches).toEqual([]) - expect([...fromProj.unfollowedLinks.keys()]).toEqual([ - join(root, 'proj', 'lnk'), - ]) } finally { rmSync(root, { recursive: true, force: true }) } diff --git a/test/sandbox/read-deny-glob-bound.test.ts b/test/sandbox/read-deny-glob-bound.test.ts index fc072e782..36c19628e 100644 --- a/test/sandbox/read-deny-glob-bound.test.ts +++ b/test/sandbox/read-deny-glob-bound.test.ts @@ -1,13 +1,10 @@ import { describe, it, expect, beforeAll, afterAll, spyOn } from 'bun:test' -// The namespaces the library binds, so a spy on one is seen by the code under +// The namespace the library binds, so a spy on it is seen by the code under // test. -import * as fs from 'fs' import * as sandboxUtils from '../../src/sandbox/sandbox-utils.js' import { - existsSync, mkdirSync, mkdtempSync, - readFileSync, realpathSync, rmSync, symlinkSync, @@ -21,7 +18,6 @@ import { GLOB_WALK_MAX_ENTRIES, GLOB_WALK_TIMEOUT_MS, GlobWalkBudgetError, - expandGlobPattern, newGlobWalkBudget, walkGlobPattern, } from '../../src/sandbox/sandbox-utils.js' @@ -29,24 +25,7 @@ import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' import { LinuxSandboxProfileError } from '../../src/sandbox/linux-sandbox-utils.js' import { isLinux, isWindows } from '../helpers/platform.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' -import { countMounts, indexOfMount } from '../helpers/bwrap-argv.js' - -/** Every directory `fn` had listed, in order, one entry per listing. */ -function listedDuring(fn: () => T): { result: T; listed: string[] } { - const listed: string[] = [] - const readdirSync = fs.readdirSync - const spy = spyOn(fs, 'readdirSync').mockImplementation((( - ...args: Parameters - ) => { - listed.push(String(args[0])) - return readdirSync(...args) - }) as typeof fs.readdirSync) - try { - return { result: fn(), listed } - } finally { - spy.mockRestore() - } -} +import { countMounts } from '../helpers/bwrap-argv.js' /** * `root/start/next -> ../pool/d1`, `pool/d1/next -> ../d2` and so on, each @@ -76,832 +55,77 @@ function thrownBy(fn: () => unknown): unknown { return undefined } -describe.if(!isWindows)('a read-deny glob and a link out of its tree', () => { - let ROOT: string - let PROJ: string - let OUTSIDE: string - - beforeAll(() => { - ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-bound-'))) - PROJ = join(ROOT, 'proj') - OUTSIDE = join(ROOT, 'outside') - mkdirSync(join(PROJ, 'src'), { recursive: true }) - mkdirSync(join(PROJ, 'sub')) - writeFileSync(join(PROJ, '.env'), 'A=1') - writeFileSync(join(PROJ, 'src', '.env'), 'B=2') - // What a pattern that starts at proj must not go looking for: each of - // these matches `**/.env` by name. - mkdirSync(join(OUTSIDE, 'deep'), { recursive: true }) - writeFileSync(join(OUTSIDE, '.env'), 'C=3') - writeFileSync(join(OUTSIDE, 'deep', '.env'), 'D=4') - }) - - afterAll(() => { - rmSync(ROOT, { recursive: true, force: true }) - }) - - /** A project of its own beside OUTSIDE, for a case that plants links. */ - function project(name: string): string { - const proj = join(ROOT, name) - mkdirSync(join(proj, 'src'), { recursive: true }) - mkdirSync(join(proj, 'sub')) - writeFileSync(join(proj, '.env'), 'A=1') - writeFileSync(join(proj, 'src', '.env'), 'B=2') - return proj - } - - it('does not list what a link out of the tree leads to', () => { - const proj = project('p-out') - symlinkSync(join('..', 'outside'), join(proj, 'rel')) - symlinkSync(OUTSIDE, join(proj, 'abs')) - // A directory beside the tree whose path begins with the tree's own. - const sibling = project('p-out-sibling') - symlinkSync(sibling, join(proj, 'sib')) - - const unfollowedLinks = new Map() - const { result: mounts, listed } = listedDuring(() => - expandReadDenyGlobLinux(join(proj, '**/.env'), [], undefined, { - unfollowedLinks, - }), - ) - - // Only what is in the tree, and neither .env behind the links. - expect(mounts).toEqual([join(proj, '.env'), join(proj, 'src', '.env')]) - // Not read, under any name. - expect(listed.sort()).toEqual( - [proj, join(proj, 'src'), join(proj, 'sub')].sort(), - ) - // Each link that was left is handed back with where it leads. - const left: [string, string][] = [ - [join(proj, 'abs'), OUTSIDE], - [join(proj, 'rel'), OUTSIDE], - [join(proj, 'sib'), sibling], - ] - expect([...unfollowedLinks].sort()).toEqual(left) - expect( - [ - ...walkGlobPattern(join(proj, '**/.env'), { - followSymlinkedDirectories: true, - }).unfollowedLinks, - ].sort(), - ).toEqual(left) - }) - - it('does not report a link back up the tree as one that left it', () => { - // up -> .. leads above the directory the pattern starts from. That is - // the older rule's link: not listed through, and not one of these. - const proj = project('p-up') - symlinkSync('..', join(proj, 'src', 'up')) - symlinkSync(ROOT, join(proj, 'top')) - - const unfollowedLinks = new Map() - const { result: mounts, listed } = listedDuring(() => - expandReadDenyGlobLinux(join(proj, '**/.env'), [], undefined, { - unfollowedLinks, - }), - ) - - expect(mounts).toEqual([join(proj, '.env'), join(proj, 'src', '.env')]) - expect(listed.sort()).toEqual( - [proj, join(proj, 'src'), join(proj, 'sub')].sort(), - ) - expect([...unfollowedLinks]).toEqual([]) - }) - - it('judges a link by where it resolves, not by how it is written', () => { - const proj = project('p-spelled') - // Written from inside the tree, it still leads out of it. - symlinkSync('./sub/../../outside', join(proj, 'dotted')) - // So does one that names a path inside the tree which is itself a link - // out of it. - symlinkSync(join('..', '..', 'outside'), join(proj, 'sub', 'exit')) - symlinkSync(join('sub', 'exit'), join(proj, 'hop')) - // Written out of the tree and back, it leads to a directory inside it. - symlinkSync(join('..', 'p-spelled', 'src'), join(proj, 'back')) - - const unfollowedLinks = new Map() - const { result: mounts, listed } = listedDuring(() => - expandReadDenyGlobLinux(join(proj, '*/.env'), [], undefined, { - unfollowedLinks, - }), - ) - - expect(mounts).toEqual([join(proj, 'src', '.env')]) - expect(listed.filter(dir => dir.startsWith(OUTSIDE))).toEqual([]) - expect([...unfollowedLinks].sort()).toEqual([ - [join(proj, 'dotted'), OUTSIDE], - [join(proj, 'hop'), OUTSIDE], - ]) - // `back` is the only name `b*` matches, so src/.env is found through it. - expect(expandReadDenyGlobLinux(join(proj, 'b*/.env'), [])).toEqual([ - join(proj, 'src', '.env'), - ]) - }) - - it('still finds a match behind a link that stays inside the tree, once, where it really is', () => { - const proj = project('p-in') - symlinkSync('src', join(proj, 'alias')) - - const unfollowedLinks = new Map() - // `a*` matches the link alone: the file is reached through it. - expect( - expandReadDenyGlobLinux(join(proj, 'a*/.env'), [], undefined, { - unfollowedLinks, - }), - ).toEqual([join(proj, 'src', '.env')]) - // `**` reaches it by both names, and it is listed and mounted once. - const { result: mounts, listed } = listedDuring(() => - expandReadDenyGlobLinux(join(proj, '**/.env'), [], undefined, { - unfollowedLinks, - }), - ) - expect(mounts).toEqual([join(proj, '.env'), join(proj, 'src', '.env')]) - expect(listed.filter(dir => dir === join(proj, 'src'))).toHaveLength(1) - expect([...unfollowedLinks]).toEqual([]) - }) - - it('finds a file that only a link inside the tree leads to', () => { - // tlink -> sub, and `t*` matches the link and not sub: the link is the - // only way the pattern has to sub/.env, so a walk that followed no link - // would find nothing. - const proj = join(ROOT, 'p-route') - mkdirSync(join(proj, 'sub'), { recursive: true }) - writeFileSync(join(proj, 'sub', '.env'), 'A=1') - symlinkSync(join(proj, 'sub'), join(proj, 'tlink')) - - const unfollowedLinks = new Map() - expect( - expandReadDenyGlobLinux(join(proj, 't*/**/.env'), [], undefined, { - unfollowedLinks, - }), - ).toEqual([join(proj, 'sub', '.env')]) - expect([...unfollowedLinks]).toEqual([]) - }) - - it('takes the tree to be the directory the pattern starts from, not the one above it', () => { - // src/shared -> ../shared: the pattern that starts at src does not reach - // shared/.env, and the one that starts at the project does. - const proj = join(ROOT, 'p-base') - mkdirSync(join(proj, 'src'), { recursive: true }) - mkdirSync(join(proj, 'shared')) - writeFileSync(join(proj, 'src', '.env'), 'A=1') - writeFileSync(join(proj, 'shared', '.env'), 'B=2') - symlinkSync(join('..', 'shared'), join(proj, 'src', 'shared')) - - const unfollowedLinks = new Map() - expect( - expandReadDenyGlobLinux(join(proj, 'src', '**/.env'), [], undefined, { - unfollowedLinks, - }), - ).toEqual([join(proj, 'src', '.env')]) - expect([...unfollowedLinks]).toEqual([ - [join(proj, 'src', 'shared'), join(proj, 'shared')], - ]) - expect(expandReadDenyGlobLinux(join(proj, '**/.env'), [])).toEqual([ - join(proj, 'shared', '.env'), - join(proj, 'src', '.env'), - ]) - }) - - it('takes the tree to be where the pattern really starts, when it starts from a link', () => { - // entry -> p-aliased: the pattern is spelled through the link, and the - // tree is the directory it leads to. A link inside that directory stays - // inside the tree, and one out of it leaves. - const proj = project('p-aliased') - const entry = join(ROOT, 'entry') - symlinkSync(proj, entry) - symlinkSync('src', join(proj, 'alias')) - symlinkSync(OUTSIDE, join(proj, 'away')) - - const unfollowedLinks = new Map() - expect( - expandReadDenyGlobLinux(join(entry, 'a*/.env'), [], undefined, { - unfollowedLinks, - }), - ).toEqual([join(proj, 'src', '.env')]) - expect([...unfollowedLinks]).toEqual([[join(entry, 'away'), OUTSIDE]]) - }) - - it('still denies what a link leads to when the link itself matches', () => { - const proj = project('p-own') - mkdirSync(join(OUTSIDE, 'vault', 'inner'), { recursive: true }) - writeFileSync(join(OUTSIDE, 'vault', 'inner', 'id.pem'), 'KEY') - writeFileSync(join(OUTSIDE, 'key.pem'), 'KEY') - mkdirSync(join(proj, 'certs')) - symlinkSync(join(OUTSIDE, 'key.pem'), join(proj, 'certs', 'key.pem')) - symlinkSync(join(OUTSIDE, 'vault'), join(proj, 'certs', 'vault.pem')) - - const unfollowedLinks = new Map() - const { result: mounts, listed } = listedDuring(() => - expandReadDenyGlobLinux(join(proj, 'certs', '*.pem'), [], undefined, { - unfollowedLinks, - }), - ) - - // A file and a directory, each denied where the link leads. - expect(mounts).toEqual([join(OUTSIDE, 'key.pem'), join(OUTSIDE, 'vault')]) - expect(listed).toEqual([join(proj, 'certs')]) - // The pattern has nothing to match beneath `*.pem`, so there was nothing - // to follow and nothing is reported as left. - expect([...unfollowedLinks]).toEqual([]) - - // The same with a pattern that carries on beneath the link: the target - // is denied as a whole and not listed. - const beneath = listedDuring(() => - expandReadDenyGlobLinux(join(proj, 'certs', '**/*'), [], undefined, { - unfollowedLinks, - }), - ) - expect(beneath.result).toEqual([ - join(OUTSIDE, 'key.pem'), - join(OUTSIDE, 'vault'), - ]) - expect(beneath.listed).toEqual([join(proj, 'certs')]) - expect([...unfollowedLinks]).toEqual([ - [join(proj, 'certs', 'vault.pem'), join(OUTSIDE, 'vault')], - ]) - }) - - it('denies the target of a directory-form link out of the tree as a whole, without listing it', () => { - const proj = project('p-form') - const outsideBuild = join(OUTSIDE, 'artifacts', 'build') - mkdirSync(join(outsideBuild, 'nested'), { recursive: true }) - writeFileSync(join(outsideBuild, '1.out'), '') - writeFileSync(join(outsideBuild, 'nested', '2.out'), '') - mkdirSync(join(proj, 'pkg', 'build'), { recursive: true }) - writeFileSync(join(proj, 'pkg', 'build', '0.out'), '') - mkdirSync(join(proj, 'linked')) - symlinkSync(outsideBuild, join(proj, 'linked', 'build')) - - const unfollowedLinks = new Map() - const { result: mounts, listed } = listedDuring(() => - expandReadDenyGlobLinux(join(proj, '**/build/**'), [], undefined, { - unfollowedLinks, - }), - ) - - expect(mounts).toEqual([outsideBuild, join(proj, 'pkg', 'build')]) - expect(listed.filter(dir => dir.startsWith(OUTSIDE))).toEqual([]) - expect([...unfollowedLinks]).toEqual([ - [join(proj, 'linked', 'build'), outsideBuild], - ]) - }) - - it('does not list what a link that only passes through leads to, whatever is allowed beneath it', () => { - // bigtree is no match of `**/.env`: nothing out there is denied, so an - // allowed path out there has no mount to be bound back over, and nothing - // beneath it to mask again. - const proj = project('p-pass') - symlinkSync(OUTSIDE, join(proj, 'bigtree')) - const pattern = join(proj, '**/.env') - - for (const allowed of [ - [OUTSIDE], - [join(OUTSIDE, 'deep')], - // Written through the link. - [join(proj, 'bigtree', 'deep')], - [join(OUTSIDE, 'deep'), OUTSIDE, ROOT], - ]) { - const unlistable = new Set() - const unfollowedLinks = new Map() - const { result: mounts, listed } = listedDuring(() => - expandReadDenyGlobLinux(pattern, allowed, unlistable, { - unfollowedLinks, - }), - ) - - expect(mounts).toEqual([join(proj, '.env'), join(proj, 'src', '.env')]) - expect(listed.sort()).toEqual( - [proj, join(proj, 'src'), join(proj, 'sub')].sort(), - ) - expect([...unfollowedLinks]).toEqual([[join(proj, 'bigtree'), OUTSIDE]]) - expect([...unlistable]).toEqual([]) - } - }) - - /** - * `/proj` beside `/store`, with `proj/pkg/build -> store/build` - * and `proj/certs/vault.pem -> store/vault`. Each target holds `public`, - * the allowed path, and `private`, with files in both that the patterns - * here match. `store/build-cache` lies beside the targets, under a name - * that begins with one of theirs. - */ - function plantStore(name: string): { top: string; proj: string } { - const top = join(ROOT, name) - const proj = join(top, 'proj') - for (const dir of ['build', 'vault']) { - mkdirSync(join(top, 'store', dir, 'public', 'deep'), { recursive: true }) - mkdirSync(join(top, 'store', dir, 'private')) - writeFileSync(join(top, 'store', dir, 'public', 'id.pem'), 'KEY') - writeFileSync(join(top, 'store', dir, 'public', 'note.txt'), 'NOTE') - writeFileSync( - join(top, 'store', dir, 'public', 'deep', 'more.pem'), - 'KEY', - ) - writeFileSync(join(top, 'store', dir, 'private', 'id.pem'), 'KEY') - } - mkdirSync(join(top, 'store', 'build-cache', 'public'), { recursive: true }) - mkdirSync(join(proj, 'pkg'), { recursive: true }) - mkdirSync(join(proj, 'certs')) - symlinkSync(join(top, 'store', 'build'), join(proj, 'pkg', 'build')) - symlinkSync(join(top, 'store', 'vault'), join(proj, 'certs', 'vault.pem')) - return { top, proj } - } - - /** A link of plantStore that matches in the directory form, and one that - * matches by its own name: the pattern, the link and where it leads. */ - function matchedLinksOf( - top: string, - proj: string, - ): (readonly [string, string, string])[] { - return [ - [ - join(proj, '**/build/**'), - join(proj, 'pkg', 'build'), - join(top, 'store', 'build'), - ], - [ - join(proj, 'certs', '**/*.pem'), - join(proj, 'certs', 'vault.pem'), - join(top, 'store', 'vault'), - ], - ] - } - - it('does not list what a matched link out of the tree leads to when nothing allowed lies beneath it', () => { - // The target is denied as a whole, and what is in it is not looked at. - // Nothing is handed back as unlistable, which is for a directory whose - // listing failed: an allowed path beneath the target's mount comes back. - const { top, proj } = plantStore('p-whole') - const store = join(top, 'store') - - for (const [pattern, link, target] of matchedLinksOf(top, proj)) { - for (const allowed of [ - [], - // Above the target, beside it, and in the tree. - [store], - [join(store, 'build-cache', 'public'), join(store, 'vault-cache')], - [proj, top], - ]) { - const unlistable = new Set() - const unfollowedLinks = new Map() - const { result: mounts, listed } = listedDuring(() => - expandReadDenyGlobLinux(pattern, allowed, unlistable, { - unfollowedLinks, - }), - ) - - expect(mounts).toEqual([target]) - expect(listed.filter(dir => dir.startsWith(store))).toEqual([]) - expect(unfollowedLinks.get(link)).toBe(target) - expect([...unlistable]).toEqual([]) - } - } - }) - - it('does not list what a matched link out of the tree leads to when an allowed path lies beneath it', () => { - // The allowed path comes back over the target's mount as it is written, - // as it does beneath a directory denied literally. What the pattern - // matches under it through the link's name was not looked for, and has - // no mount of its own: id.pem beneath `public` is what a pattern gives - // up by starting beside the directory and not above it. - const { top, proj } = plantStore('p-allowed') - const store = join(top, 'store') - - for (const [pattern, link, target] of matchedLinksOf(top, proj)) { - for (const allowed of [ - [join(target, 'public')], - // Written through the link. - [join(link, 'public')], - [join(target, 'public', 'deep'), join(target, 'private')], - // The target itself. - [target], - [link], - ]) { - const unlistable = new Set() - const unfollowedLinks = new Map() - const { result: mounts, listed } = listedDuring(() => - expandReadDenyGlobLinux(pattern, allowed, unlistable, { - unfollowedLinks, - }), - ) - - expect(mounts).toEqual([target]) - expect(listed.filter(dir => dir.startsWith(store))).toEqual([]) - expect(unfollowedLinks.get(link)).toBe(target) - expect([...unlistable]).toEqual([]) - } - } - - // Started above both ends of the link, the target is in the tree and is - // listed: every entry beneath the allowed path is a match and keeps a - // mount of its own, the ones in `deep` under that of `deep`. - const build = join(store, 'build') - const unfollowedLinks = new Map() - expect( - expandReadDenyGlobLinux( - join(top, '**/build/**'), - [join(build, 'public')], - undefined, - { unfollowedLinks }, - ), - ).toEqual([ - build, - join(build, 'public'), - join(build, 'public', 'deep'), - join(build, 'public', 'id.pem'), - join(build, 'public', 'note.txt'), - ]) - expect([...unfollowedLinks]).toEqual([]) - }) - - it('keeps a link a match wherever it leads, and reports the ones that leave the tree', () => { - const { top, proj } = plantStore('p-forms') - const build = join(top, 'store', 'build') - // Beside the link out of the tree, one that matches and stays in the - // tree, and one that matches and leads back up it. - const real = join(proj, 'pkg', 'real', 'build') - mkdirSync(real, { recursive: true }) - writeFileSync(join(real, '0.out'), '') - mkdirSync(join(proj, 'in')) - mkdirSync(join(proj, 'up')) - symlinkSync(real, join(proj, 'in', 'build')) - symlinkSync(top, join(proj, 'up', 'build')) - - const { result: walk, listed } = listedDuring(() => - walkGlobPattern(join(proj, '**/build/**'), { - withDirectoryForm: true, - followSymlinkedDirectories: true, - }), - ) - - // Each is a match in the directory form, which denies what it leads to. - for (const [link, target] of [ - [join(proj, 'in', 'build'), real], - [join(proj, 'up', 'build'), top], - [join(proj, 'pkg', 'build'), build], - ] as const) { - expect(walk.directoryMatches).toContain(link) - expect(walk.realOf.get(link)).toBe(target) - } - // Nothing is listed but the tree, and nothing found but what is in it. - expect(listed.filter(dir => !dir.startsWith(proj))).toEqual([]) - expect(walk.matches).toEqual([join(real, '0.out')]) - // The links that leave, matched or not: not the one that leads back up. - expect([...walk.unfollowedLinks].sort()).toEqual([ - [join(proj, 'certs', 'vault.pem'), join(top, 'store', 'vault')], - [join(proj, 'pkg', 'build'), build], - ]) - }) - - /** What an expansion returns and reports, with the directories it listed - * at or beneath `outside`. */ - function expandedBeside( - outside: string, - pattern: string, - allowed: string[], - ): { - mounts: string[] - listed: string[] - unlistable: string[] - unfollowedLinks: [string, string][] - } { - const unlistable = new Set() - const unfollowedLinks = new Map() - const { result: mounts, listed } = listedDuring(() => - expandReadDenyGlobLinux(pattern, allowed, unlistable, { - unfollowedLinks, - }), - ) - return { - mounts, - listed: listed.filter(dir => dir.startsWith(outside)).sort(), - unlistable: [...unlistable], - unfollowedLinks: [...unfollowedLinks].sort(), - } - } - - it('never comes to a link inside what a matched link out of the tree leads to', () => { - // keys/d.pem -> out/d is a match of `**/keys/*.pem` by its own name: - // out/d is denied as a whole and not listed, so pub/keys -> stuff in - // there is never come to. Neither is x/keys -> out/d/pub/stuff listed - // through, which leads from the tree into what d.pem denies. x.pem, - // which the pattern matches as pub/keys/x.pem and as x/keys/x.pem alone, - // has no mount, whether or not `pub` is allowed. - const top = join(ROOT, 'p-inner') - const proj = join(top, 'proj') - const d = join(top, 'out', 'd') - mkdirSync(join(d, 'keys'), { recursive: true }) - mkdirSync(join(d, 'pub', 'stuff'), { recursive: true }) - writeFileSync(join(d, 'keys', 'a.pem'), 'KEY') - writeFileSync(join(d, 'pub', 'stuff', 'x.pem'), 'KEY') - symlinkSync('stuff', join(d, 'pub', 'keys')) - mkdirSync(join(proj, 'keys'), { recursive: true }) - mkdirSync(join(proj, 'x')) - symlinkSync(d, join(proj, 'keys', 'd.pem')) - symlinkSync(join(d, 'pub', 'stuff'), join(proj, 'x', 'keys')) - - for (const allowed of [[], [join(d, 'pub')], [d]]) { - expect( - expandedBeside(join(top, 'out'), join(proj, '**/keys/*.pem'), allowed), - ).toEqual({ - mounts: [d], - listed: [], - unlistable: [], - unfollowedLinks: [ - [join(proj, 'keys', 'd.pem'), d], - [join(proj, 'x', 'keys'), join(d, 'pub', 'stuff')], - ], - }) - } - - // Neither to one in there that is a match itself and leads on: build -> - // o1 denies o1, and o2, which o1/pub/build leads to, is not denied. - const chain = join(ROOT, 'p-chain') - const o1 = join(chain, 'o1') - const o2 = join(chain, 'o2') - const o3 = join(chain, 'o3') - for (const [dir, file] of [ - [o1, 'a.txt'], - [o2, 'b.txt'], - [o3, 'c.txt'], - ] as const) { - mkdirSync(join(dir, 'pub'), { recursive: true }) - writeFileSync(join(dir, 'pub', file), '') - } - mkdirSync(join(chain, 'proj')) - symlinkSync(o1, join(chain, 'proj', 'build')) - symlinkSync(o2, join(o1, 'pub', 'build')) - symlinkSync(o3, join(o2, 'pub', 'build')) - - for (const allowed of [ - [], - [join(o1, 'pub')], - [join(o1, 'pub'), join(o2, 'pub')], - ]) { - expect( - expandedBeside(chain, join(chain, 'proj', '**/build/**'), allowed), - ).toEqual({ - mounts: [o1], - listed: [join(chain, 'proj')], - unlistable: [], - unfollowedLinks: [[join(chain, 'proj', 'build'), o1]], - }) - } - // A pattern that starts above them all follows each link in turn. - expect(expandReadDenyGlobLinux(join(chain, '**/build/**'), [])).toEqual([ - o1, - o2, - o3, - ]) - }) - - it('hands back nothing for a link that only passes through, or that the pattern ends at', () => { - const { top, proj } = plantStore('p-through') - const vault = join(top, 'store', 'vault') - writeFileSync(join(proj, 'id.pem'), 'KEY') - - // Neither link is a match: nothing out there is denied, so there is - // nothing for an allowed path to be bound back over. - const passedThrough = new Set() - const unfollowedLinks = new Map() - expect( - expandReadDenyGlobLinux( - join(proj, '**/id.pem'), - [join(vault, 'public')], - passedThrough, - { unfollowedLinks }, - ), - ).toEqual([join(proj, 'id.pem')]) - expect([...unfollowedLinks.keys()].sort()).toEqual([ - join(proj, 'certs', 'vault.pem'), - join(proj, 'pkg', 'build'), - ]) - expect([...passedThrough]).toEqual([]) - - // The link is a match and the pattern has nothing to match beneath it: - // no listing is called for, whatever is allowed in there, so nothing is - // missing from the deny, and the allowed path comes back as under a - // literal deny of the link. - const endedAt = new Set() - const { result: mounts, listed } = listedDuring(() => - expandReadDenyGlobLinux( - join(proj, 'certs', '*.pem'), - [join(vault, 'public')], - endedAt, - ), - ) - expect(mounts).toEqual([vault]) - expect(listed).toEqual([join(proj, 'certs')]) - expect([...endedAt]).toEqual([]) - }) +describe.if(!isWindows)( + 'a read-deny glob and a link to a directory beside the project', + () => { + // project/out -> ../outside, and outside/deep/.env: `project/**/.env` + // matches the file as project/out/deep/.env, and no other name for it + // lies under the project. + let ROOT: string + let project: string + let secret: string + const hasBwrap = isLinux && bwrapCanNamespace() - it('hands back nothing for a link that passes through to beneath what another link denies', () => { - // a/x.pem -> out is a match the pattern ends at: out is denied as a - // whole, as a literal deny of the link would deny it, with nothing to - // list there. b -> out/sub only passes through. It is no match, so it - // reports nothing, although a mount hides where it leads: the allowed - // path out/sub is bound back, and k.pem beneath it, which only a - // listing through b would have found, gets no mount of its own. - const top = join(ROOT, 'p-beneath') - const proj = join(top, 'proj') - const out = join(top, 'out') - mkdirSync(join(out, 'sub'), { recursive: true }) - writeFileSync(join(out, 'sub', 'k.pem'), 'KEY') - mkdirSync(join(proj, 'a'), { recursive: true }) - symlinkSync(out, join(proj, 'a', 'x.pem')) - symlinkSync(join(out, 'sub'), join(proj, 'b')) - - const unlistable = new Set() - const unfollowedLinks = new Map() - const { result: mounts, listed } = listedDuring(() => - expandReadDenyGlobLinux( - join(proj, '*/*.pem'), - [join(out, 'sub')], - unlistable, - { unfollowedLinks }, - ), - ) - expect(mounts).toEqual([out]) - expect(listed.filter(dir => dir.startsWith(out))).toEqual([]) - expect([...unfollowedLinks]).toEqual([[join(proj, 'b'), join(out, 'sub')]]) - expect([...unlistable]).toEqual([]) - }) + beforeAll(() => { + ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-beside-'))) + project = join(ROOT, 'project') + secret = join(ROOT, 'outside', 'deep', '.env') + mkdirSync(project) + mkdirSync(join(ROOT, 'outside', 'deep'), { recursive: true }) + writeFileSync(join(project, '.env'), 'INSIDE\n') + writeFileSync(secret, 'SECRET\n') + writeFileSync(join(ROOT, 'outside', 'deep', 'notes.txt'), 'NOTES\n') + symlinkSync(join('..', 'outside'), join(project, 'out')) + }) - it('hands back nothing for the mount that hides what a matched link out of the tree leads to', () => { - // up/build -> top leads back up, and denies top as a whole. a/build -> - // top/store leaves the tree beside it: store is denied too, under top's - // mount, and is not listed. top is not handed back for it, wherever the - // allowed path lies: that would keep a path beneath top from coming - // back. - const top = join(ROOT, 'p-hidden') - const proj = join(top, 'proj') - for (const dir of ['store', 'other']) { - mkdirSync(join(top, dir, 'public'), { recursive: true }) - writeFileSync(join(top, dir, 'public', 'ok.txt'), 'PUBLIC') - } - mkdirSync(join(proj, 'up'), { recursive: true }) - mkdirSync(join(proj, 'a')) - symlinkSync(top, join(proj, 'up', 'build')) - symlinkSync(join(top, 'store'), join(proj, 'a', 'build')) - - for (const allowed of [ - join(top, 'other', 'public'), - join(top, 'store', 'public'), - ]) { - const unlistable = new Set() - const unfollowedLinks = new Map() - const { result: mounts, listed } = listedDuring(() => - expandReadDenyGlobLinux( - join(proj, '**/build/**'), - [allowed], - unlistable, - { unfollowedLinks }, - ), - ) + afterAll(async () => { + await SandboxManager.reset() + rmSync(ROOT, { recursive: true, force: true }) + }) - expect(mounts).toEqual([top]) - expect(listed.filter(dir => !dir.startsWith(proj))).toEqual([]) - expect([...unlistable]).toEqual([]) - expect([...unfollowedLinks]).toEqual([ - [join(proj, 'a', 'build'), join(top, 'store')], + it('denies a match behind the link, where it really is', () => { + expect(expandReadDenyGlobLinux(join(project, '**/.env'), [])).toEqual([ + secret, + join(project, '.env'), ]) - } - }) - - it('does not find a file in the tree by a name that leaves the tree and comes back', () => { - // leave -> ../out/hop is not listed through, so back -> ../../proj/deep - // out there is never met. `*/*/.env` matches deep/.env as - // leave/back/.env and by no other name: the file is inside the tree, - // and is not denied. - const top = join(ROOT, 'p-return') - const proj = join(top, 'proj') - const hop = join(top, 'out', 'hop') - mkdirSync(join(proj, 'deep'), { recursive: true }) - mkdirSync(hop, { recursive: true }) - writeFileSync(join(proj, 'deep', '.env'), 'A=1') - symlinkSync(join('..', 'out', 'hop'), join(proj, 'leave')) - symlinkSync(join('..', '..', 'proj', 'deep'), join(hop, 'back')) - - const unfollowedLinks = new Map() - const { result: mounts, listed } = listedDuring(() => - expandReadDenyGlobLinux(join(proj, '*/*/.env'), [], undefined, { - unfollowedLinks, - }), - ) - - expect(mounts).toEqual([]) - expect(listed.filter(dir => dir.startsWith(join(top, 'out')))).toEqual([]) - expect([...unfollowedLinks]).toEqual([[join(proj, 'leave'), hop]]) - // A pattern that matches the file by a name inside the tree denies it. - expect(expandReadDenyGlobLinux(join(proj, '*/.env'), [])).toEqual([ - join(proj, 'deep', '.env'), - ]) - - // The same where the link out there has the name the pattern asks for: - // src/vendor -> ../vendor and vendor/secrets -> ../src/data, so - // `**/secrets/*.pem` matches data/key.pem as vendor/secrets/key.pem. - const src = join(ROOT, 'p-named', 'src') - const vendor = join(ROOT, 'p-named', 'vendor') - mkdirSync(join(src, 'data'), { recursive: true }) - mkdirSync(vendor) - writeFileSync(join(src, 'data', 'key.pem'), 'KEY') - symlinkSync(join('..', 'vendor'), join(src, 'vendor')) - symlinkSync(join('..', 'src', 'data'), join(vendor, 'secrets')) - - expect(expandReadDenyGlobLinux(join(src, '**/secrets/*.pem'), [])).toEqual( - [], - ) - expect(expandReadDenyGlobLinux(join(src, '**/*.pem'), [])).toEqual([ - join(src, 'data', 'key.pem'), - ]) - }) - - it('costs no listing of a large tree a link leads out to', () => { - const proj = project('p-cost') - const big = join(ROOT, 'big') - for (let d = 0; d < 30; d++) { - mkdirSync(join(big, `d${d}`), { recursive: true }) - for (let f = 0; f < 100; f++) { - writeFileSync(join(big, `d${d}`, f === 0 ? '.env' : `f${f}`), '') - } - } - const pattern = join(proj, '**/.env') - const without = listedDuring(() => - walkGlobPattern(pattern, { followSymlinkedDirectories: true }), - ) - - symlinkSync(big, join(proj, 'bigtree')) - const withLink = listedDuring(() => - walkGlobPattern(pattern, { followSymlinkedDirectories: true }), - ) - - // The same listings, and one entry more to look at: the link itself. - expect(withLink.listed.sort()).toEqual(without.listed.sort()) - expect(withLink.listed).toHaveLength(3) - expect(withLink.result.directoriesListed).toBe(3) - expect(withLink.result.entriesExamined).toBe( - without.result.entriesExamined + 1, - ) - expect(withLink.result.matches).toEqual(without.result.matches) - expect([...withLink.result.unfollowedLinks]).toEqual([ - [join(proj, 'bigtree'), big], - ]) - // The expansion lists no more than the walk, whatever is allowed out - // there. - for (const allowed of [[], [join(big, 'd0')], [big]]) { - const expanded = listedDuring(() => - expandReadDenyGlobLinux(pattern, allowed), - ) - expect(expanded.listed.sort()).toEqual(without.listed.sort()) - } - // The tree is there to be found by a pattern that names it. - expect(expandGlobPattern(join(big, '**/.env'))).toHaveLength(30) - }) - - it('hands a link back under each spelling the pattern came to it by', () => { - // twice -> p-twice, and the pattern is spelled through it. src is - // listed as twice/src, and again by its real path for d/l -> ../src, - // one component further on in the pattern: src/o, which leaves the - // tree, is met under both names. - const proj = join(ROOT, 'p-twice') - const spelled = join(ROOT, 'twice') - mkdirSync(join(proj, 'src'), { recursive: true }) - mkdirSync(join(proj, 'd')) - symlinkSync(proj, spelled) - symlinkSync(OUTSIDE, join(proj, 'src', 'o')) - symlinkSync(join('..', 'src'), join(proj, 'd', 'l')) - - const unfollowedLinks = new Map() - expandReadDenyGlobLinux(join(spelled, '*/*/*/x'), [], undefined, { - unfollowedLinks, - }) - expect([...unfollowedLinks]).toEqual([ - [join(proj, 'src', 'o'), OUTSIDE], - [join(spelled, 'src', 'o'), OUTSIDE], - ]) - - // Spelled by its real path, the pattern comes to it by one name. - unfollowedLinks.clear() - expandReadDenyGlobLinux(join(proj, '*/*/*/x'), [], undefined, { - unfollowedLinks, }) - expect([...unfollowedLinks]).toEqual([[join(proj, 'src', 'o'), OUTSIDE]]) - }) - it('still leaves a link alone without the link option', () => { - // What the allowRead expansion and the Windows ACL stamp get: the link - // as a match of its own, nothing resolved and nothing reported. - const proj = project('p-plain') - symlinkSync(OUTSIDE, join(proj, 'out')) - - const walk = walkGlobPattern(join(proj, '*')) + it.skipIf(!hasBwrap)( + 'hides the match from the sandbox under both names', + async () => { + // BOOTED first: a sandbox that refuses to start prints nothing, which + // would read as every file hidden. + const result = spawnSync( + await SandboxManager.wrapWithSandbox( + [ + 'echo BOOTED', + `cat ${join(project, 'out', 'deep', '.env')} 2>/dev/null || echo HIDDEN`, + `cat ${secret} 2>/dev/null || echo HIDDEN`, + `cat ${join(project, 'out', 'deep', 'notes.txt')}`, + ].join('; '), + undefined, + { + filesystem: { + denyRead: [join(project, '**/.env')], + allowWrite: [], + denyWrite: [], + }, + }, + ), + { shell: true, encoding: 'utf8', timeout: 15000 }, + ) - expect(walk.matches).toContain(join(proj, 'out')) - expect(walk.realOf.get(join(proj, 'out'))).toBeUndefined() - expect([...walk.unfollowedLinks]).toEqual([]) - }) -}) + expect(result.stderr ?? '').not.toContain('bwrap:') + expect(result.stdout.trim().split('\n')).toEqual([ + 'BOOTED', + 'HIDDEN', + 'HIDDEN', + 'NOTES', + ]) + }, + ) + }, +) describe.if(!isWindows)('the budget of a glob walk', () => { let ROOT: string @@ -1088,10 +312,9 @@ describe.if(!isWindows)('the budget of a glob walk', () => { }) it('throws on a tree that only links inside it lead through', () => { - // start/next -> ../pool/d1, d1/next -> ../d2, …, all beneath the - // directory the pattern starts from, so every link is followed: a tree a - // command allowed to write there can make as long as it likes. `start*` - // names start alone, so nothing but the links leads into pool. + // start/next -> ../pool/d1, d1/next -> ../d2, …: a tree a command + // allowed to write there can make as long as it likes. `start*` names + // start alone, so nothing but the links leads into pool. const chain = join(ROOT, 'chain') const links = 40 plantChain(chain, links) @@ -1100,7 +323,6 @@ describe.if(!isWindows)('the budget of a glob walk', () => { followSymlinkedDirectories: true, }) expect(whole.matches).toHaveLength(links) - expect([...whole.unfollowedLinks]).toEqual([]) // chain holds start and pool; every other entry was met through a link. expect(whole.entriesExamined).toBe(2 + 2 * links) @@ -1118,33 +340,33 @@ describe.if(!isWindows)('the budget of a glob walk', () => { ) }) - it('is not spent on what a link out of the tree leads to, matched or not', () => { - // linked/build -> tree: the link is the one entry of the pattern's own - // tree, and what it leads to holds sixty. A budget of one entry is - // enough, whatever is allowed out there. + it('is spent on what a link to a directory beside the project leads to', () => { + // linked/bigtree -> tree: the link is the one entry under the directory + // the pattern starts from, and what it leads to holds sixty. const proj = join(ROOT, 'linked') mkdirSync(proj) - symlinkSync(TREE, join(proj, 'build')) + symlinkSync(join('..', 'tree'), join(proj, 'bigtree')) + const pattern = join(proj, '**/.env') + const whole = newGlobWalkBudget({ maxEntries: 1 + TREE_ENTRIES }) + expect( + expandReadDenyGlobLinux(pattern, [], undefined, { budget: whole }), + ).toHaveLength(12) + expect(whole.entries).toBe(1 + TREE_ENTRIES) - for (const allowed of [[], [join(TREE, 'd0')], [TREE]]) { - // The link is a match: what it leads to is denied as a whole. - const matched = newGlobWalkBudget({ maxEntries: 1 }) - expect( - expandReadDenyGlobLinux(join(proj, '**/build/**'), allowed, undefined, { - budget: matched, - }), - ).toEqual([TREE]) - expect(matched.entries).toBe(1) - - // The link only passes through: nothing out there is denied. - const passedThrough = newGlobWalkBudget({ maxEntries: 1 }) - expect( - expandReadDenyGlobLinux(join(proj, '**/.env'), allowed, undefined, { - budget: passedThrough, - }), - ).toEqual([]) - expect(passedThrough.entries).toBe(1) - } + let mounts: string[] | undefined + const error = thrownBy(() => { + mounts = expandReadDenyGlobLinux(pattern, [], undefined, { + budget: newGlobWalkBudget({ maxEntries: TREE_ENTRIES }), + }) + }) + + expect(mounts).toBeUndefined() + expect(error).toBeInstanceOf(GlobWalkBudgetError) + const budgetError = error as GlobWalkBudgetError + expect(budgetError.exhausted).toBe('entries') + expect(budgetError.pattern).toBe(pattern) + // Named where it really is, as what is found in it would be. + expect(budgetError.directory.startsWith(TREE + '/')).toBe(true) }) it('does not take a spent budget for a directory that could not be listed', () => { @@ -1200,26 +422,20 @@ describe.if(isLinux)('a read-deny glob past its budget, at the manager', () => { return proj } - function filesystemOf( - denyRead: string[], - allowed: { allowRead?: string[]; allowWrite?: string[] } = {}, - ) { - return { - filesystem: { denyRead, allowWrite: [], denyWrite: [], ...allowed }, - } + function filesystemOf(denyRead: string[]) { + return { filesystem: { denyRead, allowWrite: [], denyWrite: [] } } } /** The wrapped command, or what the wrap threw: never both. */ async function wrapOf( denyRead: string[], - allowed: { allowRead?: string[]; allowWrite?: string[] } = {}, ): Promise<{ wrapped?: string; error?: unknown }> { try { return { wrapped: await SandboxManager.wrapWithSandbox( 'echo hello', undefined, - filesystemOf(denyRead, allowed), + filesystemOf(denyRead), ), } } catch (error) { @@ -1352,141 +568,37 @@ describe.if(isLinux)('a read-deny glob past its budget, at the manager', () => { ).toStartWith(join(chain, 'pool') + '/') }) - it('does not spend the budget on a tree a link leads out to', async () => { + it('refuses the wrap over a tree that a link leads to from beside it', async () => { const proj = join(ROOT, 'linked') mkdirSync(join(proj, 'src'), { recursive: true }) writeFileSync(join(proj, '.env'), '') - symlinkSync(OTHER, join(proj, 'bigtree')) - - // Three entries in the tree; OTHER holds forty. The link only passes - // through, so that holds whatever is allowed out there. - for (const allowed of [ - {}, - { allowRead: [join(OTHER, 'pkg0')] }, - { allowWrite: [OTHER] }, - ]) { - const { wrapped, error } = await withBudgetOf({ maxEntries: 3 }, () => - wrapOf([join(proj, '**/.env')], allowed), - ) - - expect(error).toBeUndefined() - expect(wrapped).toContain(`--ro-bind /dev/null ${join(proj, '.env')}`) - expect(wrapped).not.toContain(join(OTHER, 'pkg0', '.env')) - } - }) - - it('does not spend the budget on what a matched link out of the tree leads to', async () => { - const proj = join(ROOT, 'matched') - mkdirSync(proj) - symlinkSync(OTHER, join(proj, 'build')) - const pattern = join(proj, '**/build/**') - - // One entry in the tree, the link; OTHER holds forty. It is denied as a - // whole for the cost of the link, whatever is allowed beneath it, and an - // allowed path in there comes back with nothing beneath it masked. - for (const allowed of [ - {}, - { allowRead: [join(OTHER, 'pkg0')] }, - { allowWrite: [join(OTHER, 'pkg0')] }, - ]) { - const { wrapped, error } = await withBudgetOf({ maxEntries: 1 }, () => - wrapOf([pattern], allowed), - ) - - expect(error).toBeUndefined() - expect(countMounts(wrapped!, '--tmpfs', OTHER)).toBe(1) + symlinkSync(join('..', 'other'), join(proj, 'bigtree')) + const pattern = join(proj, '**/.env') + // With room for it, what the link leads to is walked and masked. + const roomy = await wrapOf([pattern]) + expect(roomy.error).toBeUndefined() + for (const masked of [join(proj, '.env'), join(OTHER, 'pkg0', '.env')]) { expect( - countMounts( - wrapped!, - '--ro-bind', - '/dev/null', - join(OTHER, 'pkg0', '.env'), - ), - ).toBe(0) + countMounts(roomy.wrapped!, '--ro-bind', '/dev/null', masked), + ).toBe(1) } - }) - it('says in the read configuration which links its patterns left unfollowed', async () => { - const proj = join(ROOT, 'told') - mkdirSync(join(proj, 'src'), { recursive: true }) - writeFileSync(join(proj, '.env'), '') - symlinkSync(OTHER, join(proj, 'bigtree')) - symlinkSync(join(proj, 'src'), join(proj, 'alias')) - const recursive = join(proj, '**/.env') - const shallow = join(proj, '*/.env') - await SandboxManager.reset() - await SandboxManager.initialize({ - network: { allowedDomains: [], deniedDomains: [] }, - filesystem: { - // A literal entry and a pattern with nothing to say are in the list - // to show they add nothing. - denyRead: [recursive, join(proj, 'src'), shallow, join(PROJ, '*.key')], - allowWrite: [], - denyWrite: [], - }, - }) - try { - // Once for each pattern that came to the link and would have carried - // on beneath it; the link that stays in the tree is followed and is - // not among them. - expect(SandboxManager.getFsReadConfig().unfollowedDenyLinks).toEqual([ - { pattern: recursive, link: join(proj, 'bigtree'), target: OTHER }, - { pattern: shallow, link: join(proj, 'bigtree'), target: OTHER }, - ]) - } finally { - await SandboxManager.reset() - } - - await SandboxManager.initialize({ - network: { allowedDomains: [], deniedDomains: [] }, - filesystem: { - denyRead: [join(PROJ, '**/.env')], - allowWrite: [], - denyWrite: [], - }, - }) - try { - expect(SandboxManager.getFsReadConfig().unfollowedDenyLinks).toEqual([]) - } finally { - await SandboxManager.reset() - } - }) - - it('says of a matched link out of the tree that it was left, and not that it could not be listed', async () => { - const proj = join(ROOT, 'through') - mkdirSync(proj) - symlinkSync(OTHER, join(proj, 'build')) - const pattern = join(proj, '**/build/**') - const readConfigWith = async (allowRead: string[]) => { - await SandboxManager.reset() - await SandboxManager.initialize({ - network: { allowedDomains: [], deniedDomains: [] }, - filesystem: { - denyRead: [pattern], - allowRead, - allowWrite: [], - denyWrite: [], - }, - }) - try { - return SandboxManager.getFsReadConfig() - } finally { - await SandboxManager.reset() - } - } + // proj itself holds three entries, and OTHER forty. + const { wrapped, error } = await withBudgetOf({ maxEntries: 30 }, () => + wrapOf([pattern]), + ) - // The same with an allowed path beneath what it leads to and without: - // OTHER is denied as a whole, and nothing in it has a mount of its own. - for (const allowRead of [[], [join(OTHER, 'pkg0')]]) { - expect(await readConfigWith(allowRead)).toEqual({ - denyOnly: [OTHER], - allowWithinDeny: allowRead, - unlistableDenyDirs: [], - unfollowedDenyLinks: [ - { pattern, link: join(proj, 'build'), target: OTHER }, - ], - }) - } + expect(wrapped).toBeUndefined() + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe('deny_glob_too_large') + expect((error as LinuxSandboxProfileError).message).toContain( + `"${pattern}"`, + ) + const cause = (error as LinuxSandboxProfileError).cause + expect(cause).toBeInstanceOf(GlobWalkBudgetError) + expect((cause as GlobWalkBudgetError).directory).toMatch( + new RegExp(`^${OTHER}(/|$)`), + ) }) it('refuses the read configuration the same way', async () => { @@ -1516,400 +628,6 @@ describe.if(isLinux)('a read-deny glob past its budget, at the manager', () => { }) }) -describe.if(isLinux)( - 'an allowed path beneath what a matched link out of the tree leads to', - () => { - // site/proj/build -> site/outside/build, which holds `pub`, the allowed - // path, and `priv`. A pattern that starts at proj denies outside/build - // as a whole and does not list it, so the allowed path comes back as it - // is written, as beneath a directory denied literally: nothing beneath - // it is masked again. - let ROOT: string - let site: string - let proj: string - let build: string - let carveOut: string - const hasBwrap = bwrapCanNamespace() - - beforeAll(() => { - ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-allowed-'))) - site = join(ROOT, 'site') - proj = join(site, 'proj') - build = join(site, 'outside', 'build') - carveOut = join(build, 'pub') - mkdirSync(carveOut, { recursive: true }) - mkdirSync(join(build, 'priv')) - writeFileSync(join(carveOut, 'ok.txt'), 'PUBLIC\n') - writeFileSync(join(build, 'priv', 'secret.txt'), 'SECRET\n') - writeFileSync(join(site, 'outside', 'beside.txt'), 'BESIDE\n') - mkdirSync(proj) - symlinkSync(build, join(proj, 'build')) - }) - - afterAll(async () => { - await SandboxManager.reset() - rmSync(ROOT, { recursive: true, force: true }) - }) - - function wrap( - command: string, - filesystem: { - denyRead: string[] - allowRead?: string[] - allowWrite?: string[] - }, - ): Promise { - return SandboxManager.wrapWithSandbox(command, undefined, { - filesystem: { allowWrite: [], denyWrite: [], ...filesystem }, - }) - } - - /** What the commands print in the sandbox, a line each, after BOOTED: a - * sandbox that refuses to start prints nothing, which would read as - * every file hidden. */ - async function linesOf( - commands: string[], - filesystem: Parameters[1], - ): Promise { - const result = spawnSync( - await wrap(['echo BOOTED', ...commands].join('; '), filesystem), - { shell: true, encoding: 'utf8', timeout: 15000 }, - ) - expect(result.stderr ?? '').not.toContain('bwrap:') - return result.stdout.trim().split('\n') - } - - it('is bound back as it is written, with nothing beneath it masked', async () => { - const wrapped = await wrap('true', { - denyRead: [join(proj, '**/build/**')], - allowRead: [carveOut], - }) - - const hidden = indexOfMount(wrapped, '--tmpfs', build) - expect(hidden).toBeGreaterThan(-1) - expect( - indexOfMount(wrapped, '--ro-bind', carveOut, carveOut), - ).toBeGreaterThan(hidden) - expect( - countMounts( - wrapped, - '--ro-bind', - '/dev/null', - join(carveOut, 'ok.txt'), - ), - ).toBe(0) - - // Started above both ends of the link, the pattern finds ok.txt: the - // path is bound back, with the file masked beneath it. - const listed = await wrap('true', { - denyRead: [join(site, '**/build/**')], - allowRead: [carveOut], - }) - const boundBack = indexOfMount(listed, '--ro-bind', carveOut, carveOut) - expect(boundBack).toBeGreaterThan(-1) - expect( - indexOfMount( - listed, - '--ro-bind', - '/dev/null', - join(carveOut, 'ok.txt'), - ), - ).toBeGreaterThan(boundBack) - - // With nothing allowed beneath it, the target's mount is all there is. - const whole = await wrap('true', { - denyRead: [join(proj, '**/build/**')], - }) - expect(countMounts(whole, '--tmpfs', build)).toBe(1) - expect(whole).not.toContain(carveOut) - }) - - it.skipIf(!hasBwrap)( - 'serves what is in it, and nothing else in there, by either name', - async () => { - const reads = [ - `cat ${join(carveOut, 'ok.txt')} 2>/dev/null || echo HIDDEN`, - `cat ${join(proj, 'build', 'pub', 'ok.txt')} 2>/dev/null || echo HIDDEN`, - `cat ${join(build, 'priv', 'secret.txt')} 2>/dev/null || echo HIDDEN`, - `cat ${join(proj, 'build', 'priv', 'secret.txt')} 2>/dev/null || echo HIDDEN`, - `cat ${join(site, 'outside', 'beside.txt')}`, - ] - - expect( - await linesOf(reads, { - denyRead: [join(proj, '**/build/**')], - allowRead: [carveOut], - }), - ).toEqual(['BOOTED', 'PUBLIC', 'PUBLIC', 'HIDDEN', 'HIDDEN', 'BESIDE']) - - // Nothing allowed beneath it: the target is hidden as a whole. - expect( - await linesOf(reads, { denyRead: [join(proj, '**/build/**')] }), - ).toEqual(['BOOTED', 'HIDDEN', 'HIDDEN', 'HIDDEN', 'HIDDEN', 'BESIDE']) - - // Started above both ends of the link, the pattern masks ok.txt - // beneath the allowed path, under either name. - expect( - await linesOf(reads, { - denyRead: [join(site, '**/build/**')], - allowRead: [carveOut], - }), - ).toEqual(['BOOTED', 'HIDDEN', 'HIDDEN', 'HIDDEN', 'HIDDEN', 'BESIDE']) - }, - ) - - it.skipIf(!hasBwrap)( - 'can be written to, where the link hides a directory of temporary files', - async () => { - // job/tmp -> scratch/tmp under `**/tmp/**`, and scratch/tmp/work - // allowed for writing: the directory is hidden, and the command can - // still write where it was told it may, and read what was there. - const job = join(ROOT, 'job') - const tmp = join(ROOT, 'scratch', 'tmp') - const allowed = join(tmp, 'work') - mkdirSync(join(job, 'src'), { recursive: true }) - mkdirSync(allowed, { recursive: true }) - writeFileSync(join(tmp, 'other.txt'), 'OTHER\n') - writeFileSync(join(allowed, 'old.txt'), 'OLD\n') - symlinkSync(tmp, join(job, 'tmp')) - - expect( - await linesOf( - [ - `(echo WRITTEN > ${join(allowed, 'out')}) 2>/dev/null && echo WRITE-OK || echo WRITE-FAILED`, - `cat ${join(allowed, 'out')} 2>/dev/null || echo HIDDEN`, - `cat ${join(allowed, 'old.txt')} 2>/dev/null || echo HIDDEN`, - `cat ${join(tmp, 'other.txt')} 2>/dev/null || echo HIDDEN`, - ], - { denyRead: [join(job, '**/tmp/**')], allowWrite: [allowed] }, - ), - ).toEqual(['BOOTED', 'WRITE-OK', 'WRITTEN', 'OLD', 'HIDDEN']) - expect(existsSync(join(allowed, 'out'))).toBe(true) - expect(readFileSync(join(allowed, 'out'), 'utf8')).toBe('WRITTEN\n') - }, - ) - - it.skipIf(!hasBwrap)( - 'leaves the working directory in place beneath a mount that hides a target beside it', - async () => { - // up/build -> top leads back up and hides top, the working - // directory with it, which is allowed for writing and bound back. - // a/build -> top/store leaves the tree beside it, and is not listed: - // that must not cost the working directory its way back. - const top = join(ROOT, 'top') - const cwd = join(top, 'proj') - mkdirSync(join(top, 'store', 'public'), { recursive: true }) - writeFileSync(join(top, 'store', 'public', 'ok.txt'), 'PUBLIC\n') - writeFileSync(join(top, 'beside.txt'), 'BESIDE\n') - mkdirSync(join(cwd, 'up'), { recursive: true }) - mkdirSync(join(cwd, 'a')) - writeFileSync(join(cwd, 'main.c'), 'SOURCE\n') - symlinkSync(top, join(cwd, 'up', 'build')) - symlinkSync(join(top, 'store'), join(cwd, 'a', 'build')) - - expect( - await linesOf( - [ - `cat ${join(cwd, 'main.c')} 2>/dev/null || echo HIDDEN`, - `(echo WRITTEN > ${join(cwd, 'out')}) 2>/dev/null && echo WRITE-OK || echo WRITE-FAILED`, - `cat ${join(top, 'store', 'public', 'ok.txt')} 2>/dev/null || echo HIDDEN`, - `cat ${join(top, 'beside.txt')} 2>/dev/null || echo HIDDEN`, - ], - { denyRead: [join(cwd, '**/build/**')], allowWrite: [cwd] }, - ), - ).toEqual(['BOOTED', 'SOURCE', 'WRITE-OK', 'HIDDEN', 'HIDDEN']) - expect(readFileSync(join(cwd, 'out'), 'utf8')).toBe('WRITTEN\n') - }, - ) - }, -) - -describe.if(isLinux)( - 'a file in the tree that the pattern names only by a way out of the tree and back', - () => { - // proj/leave -> ../out/hop, and out/hop/back -> ../../proj/deep: - // `proj/*/*/.env` matches proj/deep/.env as leave/back/.env alone. The - // link out of the tree is not listed through, so the file is not found, - // and what is given up is a file inside the tree. - let ROOT: string - let proj: string - let byItsOwnName: string - let byTheWayRound: string - const hasBwrap = bwrapCanNamespace() - - beforeAll(() => { - ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-return-'))) - proj = join(ROOT, 'proj') - byItsOwnName = join(proj, 'deep', '.env') - byTheWayRound = join(proj, 'leave', 'back', '.env') - mkdirSync(join(proj, 'deep'), { recursive: true }) - mkdirSync(join(ROOT, 'out', 'hop'), { recursive: true }) - writeFileSync(byItsOwnName, 'SECRET\n') - symlinkSync(join('..', 'out', 'hop'), join(proj, 'leave')) - symlinkSync( - join('..', '..', 'proj', 'deep'), - join(ROOT, 'out', 'hop', 'back'), - ) - }) - - afterAll(async () => { - await SandboxManager.reset() - rmSync(ROOT, { recursive: true, force: true }) - }) - - function wrap(command: string, denyRead: string): Promise { - return SandboxManager.wrapWithSandbox(command, undefined, { - filesystem: { denyRead: [denyRead], allowWrite: [], denyWrite: [] }, - }) - } - - it('gets no mount', async () => { - const wrapped = await wrap('true', join(proj, '*/*/.env')) - expect(countMounts(wrapped, '--ro-bind', '/dev/null', byItsOwnName)).toBe( - 0, - ) - - // A pattern that matches it by a name inside the tree masks it. - const named = await wrap('true', join(proj, '*/.env')) - expect(countMounts(named, '--ro-bind', '/dev/null', byItsOwnName)).toBe(1) - }) - - it.skipIf(!hasBwrap)('is served under both names', async () => { - // Each command says BOOTED first: a sandbox that refuses to start - // prints nothing, which would read as every file hidden. - const linesUnder = async (denyRead: string): Promise => { - const result = spawnSync( - await wrap( - [ - 'echo BOOTED', - `cat ${byTheWayRound} || echo HIDDEN`, - `cat ${byItsOwnName} || echo HIDDEN`, - ].join('; '), - denyRead, - ), - { shell: true, encoding: 'utf8', timeout: 15000 }, - ) - expect(result.stderr ?? '').not.toContain('bwrap:') - return result.stdout.trim().split('\n') - } - - expect(await linesUnder(join(proj, '*/*/.env'))).toEqual([ - 'BOOTED', - 'SECRET', - 'SECRET', - ]) - // Matched by a name inside the tree, it is hidden under every name. - expect(await linesUnder(join(proj, '*/.env'))).toEqual([ - 'BOOTED', - 'HIDDEN', - 'HIDDEN', - ]) - }) - }, -) - -describe.if(isLinux)( - 'a file written under the name of a directory that a command moved out of the tree', - () => { - // proj/src/config holds nothing that `proj/src/**/.env` matches, so a - // command that may write the project can move it to proj/moved and leave - // proj/src/config -> ../moved/config in its place. A .env written under - // that name afterwards lies outside the tree. proj/src/held holds a - // masked file, which keeps it where it is. - let ROOT: string - let proj: string - let config: string - let held: string - const hasBwrap = bwrapCanNamespace() - - beforeAll(() => { - ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-moved-'))) - proj = join(ROOT, 'proj') - config = join(proj, 'src', 'config') - held = join(proj, 'src', 'held') - mkdirSync(config, { recursive: true }) - mkdirSync(held) - mkdirSync(join(proj, 'moved')) - writeFileSync(join(config, 'settings.json'), '{}\n') - writeFileSync(join(held, '.env'), 'HELD\n') - }) - - afterAll(async () => { - await SandboxManager.reset() - rmSync(ROOT, { recursive: true, force: true }) - }) - - /** What the commands print in the sandbox, a line each, after BOOTED. */ - async function linesOf( - commands: string[], - denyRead: string, - ): Promise { - const result = spawnSync( - await SandboxManager.wrapWithSandbox( - ['echo BOOTED', ...commands].join('; '), - undefined, - { - filesystem: { - denyRead: [denyRead], - allowWrite: [proj], - denyWrite: [], - }, - }, - ), - { shell: true, encoding: 'utf8', timeout: 15000 }, - ) - expect(result.stderr ?? '').not.toContain('bwrap:') - return result.stdout.trim().split('\n') - } - - it.skipIf(!hasBwrap)( - 'is served, and the link left in its place is handed back', - async () => { - const pattern = join(proj, 'src', '**/.env') - const moved = (dir: string, name: string): string => - `(mv ${dir} ${join(proj, 'moved', name)} && ` + - `ln -s ${join('..', 'moved', name)} ${dir}) 2>/dev/null ` + - `&& echo MOVED || echo REFUSED` - expect( - await linesOf( - [moved(config, 'config'), moved(held, 'held')], - pattern, - ), - ).toEqual(['BOOTED', 'MOVED', 'REFUSED']) - expect(realpathSync(config)).toBe(join(proj, 'moved', 'config')) - expect(realpathSync(held)).toBe(held) - - writeFileSync(join(config, '.env'), 'LATER\n') - const reads = [ - `cat ${join(config, '.env')} 2>/dev/null || echo HIDDEN`, - `cat ${join(held, '.env')} 2>/dev/null || echo HIDDEN`, - ] - expect(await linesOf(reads, pattern)).toEqual([ - 'BOOTED', - 'LATER', - 'HIDDEN', - ]) - const unfollowedLinks = new Map() - expect( - expandReadDenyGlobLinux(pattern, [proj], undefined, { - unfollowedLinks, - }), - ).toEqual([join(held, '.env')]) - expect([...unfollowedLinks]).toEqual([ - [config, join(proj, 'moved', 'config')], - ]) - - // A pattern that starts above both ends of the link masks it. - expect(await linesOf(reads, join(proj, '**/.env'))).toEqual([ - 'BOOTED', - 'HIDDEN', - 'HIDDEN', - ]) - }, - ) - }, -) - describe.if(!isWindows)('the debug line of a read-deny expansion', () => { let ROOT: string @@ -1959,88 +677,13 @@ describe.if(!isWindows)('the debug line of a read-deny expansion', () => { const summary = lines.filter(line => line.includes('Expanded denyRead')) expect(summary).toHaveLength(1) - // proj and proj/src listed; .env, src and out in one, .env and index.js - // in the other. + // proj, proj/src and what proj/out leads to listed; .env, src and out in + // the first, .env and index.js in the second, nothing in the third. const timed = summary[0]!.replace(/ in \d+ ms: /, ' in ms: ') expect(timed).not.toBe(summary[0]) expect(timed).toEndWith( `Expanded denyRead glob "${pattern}" in ms: 2 matches -> 2 mounts; ` + - `directories listed: 2, entries looked at: 5, ` + - `links out of the tree left unfollowed: 1 ` + - `(first: ${join(ROOT, 'proj', 'out')} -> ${join(ROOT, 'outside')})`, - ) - // The walk says which link it left, in its own line. - expect( - lines.filter( - line => - line.includes('Not following symlink') && - line.includes(join(ROOT, 'proj', 'out')) && - line.includes(`it leads out of ${join(ROOT, 'proj')}`), - ), - ).toHaveLength(1) - }) - - it('hands the links it left back by name, and names the first of them, whatever order they are listed in', () => { - const proj = join(ROOT, 'ordered') - mkdirSync(proj) - for (const name of ['m', 'z', 'a']) { - symlinkSync(join(ROOT, 'outside'), join(proj, name)) - } - const pattern = join(proj, '**/.env') - const byName = ['a', 'm', 'z'].map(name => join(proj, name)) - const readdirSync = fs.readdirSync - - for (const backwards of [false, true]) { - const spy = spyOn(fs, 'readdirSync').mockImplementation((( - ...args: Parameters - ) => { - const entries = readdirSync(...args) - // Listed with their types, as the walk lists: sorted where they are. - const after = backwards ? -1 : 1 - ;(entries as unknown as fs.Dirent[]).sort((x, y) => - x.name < y.name ? -after : after, - ) - return entries - }) as typeof fs.readdirSync) - try { - // The walk meets them in the order the directory lists them. - expect([ - ...walkGlobPattern(pattern, { - followSymlinkedDirectories: true, - }).unfollowedLinks.keys(), - ]).toEqual(backwards ? [...byName].reverse() : byName) - - const unfollowedLinks = new Map() - const lines = debugLinesOf( - () => - void expandReadDenyGlobLinux(pattern, [], undefined, { - unfollowedLinks, - }), - '1', - ) - expect([...unfollowedLinks.keys()]).toEqual(byName) - const summary = lines.filter(line => line.includes('Expanded denyRead')) - expect(summary).toHaveLength(1) - expect(summary[0]).toEndWith( - `links out of the tree left unfollowed: 3 ` + - `(first: ${byName[0]} -> ${join(ROOT, 'outside')})`, - ) - } finally { - spy.mockRestore() - } - } - }) - - it('names no link when none was left', () => { - const lines = debugLinesOf( - () => void expandReadDenyGlobLinux(join(ROOT, 'proj', 'src', '*'), []), - '1', - ) - - const summary = lines.filter(line => line.includes('Expanded denyRead')) - expect(summary).toHaveLength(1) - expect(summary[0]).toEndWith( - ' ms: 2 matches -> 2 mounts; directories listed: 1, entries looked at: 2, links out of the tree left unfollowed: 0', + `directories listed: 3, entries looked at: 5`, ) }) diff --git a/test/sandbox/read-deny-glob.test.ts b/test/sandbox/read-deny-glob.test.ts index ef672498f..1d36bf4c0 100644 --- a/test/sandbox/read-deny-glob.test.ts +++ b/test/sandbox/read-deny-glob.test.ts @@ -244,39 +244,19 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { // pkg/a/build/link -> outside, with allowRead written against the // target: outside/ is denied as a whole, its carve-out and the // entries beneath keep their own mounts, and nothing else beneath it. - // The pattern starts at ROOT, which holds both ends of the link. mkdirSync(join(OUTSIDE, 'pub')) writeFileSync(join(OUTSIDE, 'pub', 'x.txt'), '') try { - const mounts = expandReadDenyGlobLinux(join(ROOT, '**/build/**'), [ - join(OUTSIDE, 'pub'), - ]) + const mounts = expandReadDenyGlobLinux( + join(ROOT, 'pkg', 'a', '**/build/**'), + [join(OUTSIDE, 'pub')], + ) expect(mounts).toContain(join(ROOT, 'pkg', 'a', 'build')) expect(mounts).toContain(OUTSIDE) expect(mounts).toContain(join(OUTSIDE, 'pub')) expect(mounts).toContain(join(OUTSIDE, 'pub', 'x.txt')) expect(mounts).not.toContain(join(OUTSIDE, 'secret.txt')) - - // Started at pkg/a the same link leaves the pattern's tree. It is a - // match, so what it leads to is still denied as a whole, but nothing - // is listed out there: the carve-out is bound back as it is written, - // as beneath a directory denied literally, and x.txt beneath it has - // no mount of its own. Nothing is handed back as unlistable. - const unlistable = new Set() - const unfollowedLinks = new Map() - expect( - expandReadDenyGlobLinux( - join(ROOT, 'pkg', 'a', '**/build/**'), - [join(OUTSIDE, 'pub')], - unlistable, - { unfollowedLinks }, - ), - ).toEqual([OUTSIDE, join(ROOT, 'pkg', 'a', 'build')]) - expect([...unlistable]).toEqual([]) - expect([...unfollowedLinks]).toEqual([ - [join(ROOT, 'pkg', 'a', 'build', 'link'), OUTSIDE], - ]) } finally { rmSync(join(OUTSIDE, 'pub'), { recursive: true }) } @@ -491,8 +471,7 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { symlinkSync(join(store, 'nm'), join(proj, 'build')) symlinkSync(join('..', 'keep'), join(store, 'nm', 'keep')) - // Started above both, so that the links stay inside the tree. - const mounts = expandReadDenyGlobLinux(join(chain, '**/build/**'), [ + const mounts = expandReadDenyGlobLinux(join(proj, '**/build/**'), [ join(store, 'keep', 'pub'), ]) @@ -502,32 +481,6 @@ describe.if(!isWindows)('expandReadDenyGlobLinux (symlinks)', () => { join(store, 'keep', 'pub', 'y.txt'), join(store, 'nm'), ]) - - // Started at proj, the first link leaves the tree. It is a match, so - // store/nm is denied as a whole, and nothing is listed out there: the - // second link inside it is never seen, and store/keep, which only that - // link leads the pattern to, is not denied. That holds however the - // carve-out is written, and nothing is handed back as unlistable. - for (const carveOut of [ - join(store, 'keep', 'pub'), - // Through the second link, which puts it beneath store/nm by name. - join(store, 'nm', 'keep', 'pub'), - ]) { - const unlistable = new Set() - const unfollowedLinks = new Map() - expect( - expandReadDenyGlobLinux( - join(proj, '**/build/**'), - [carveOut], - unlistable, - { unfollowedLinks }, - ), - ).toEqual([join(store, 'nm')]) - expect([...unlistable]).toEqual([]) - expect([...unfollowedLinks]).toEqual([ - [join(proj, 'build'), join(store, 'nm')], - ]) - } }) it('drops a matched link that does not resolve', () => { @@ -2042,12 +1995,10 @@ describe.if(isLinux)('expandReadDenyGlobLinux (filesystem)', () => { describe.if(isLinux)( 'expandReadDenyGlobLinux (coverage behind a directory link)', () => { - // The walk descends the symlinked directories that stay inside the - // pattern's tree and reports each match where it really lives, so a deny - // glob covers what it matches there whichever spelling found it, and one - // mount stands for every spelling. These cases are the ones a walk that - // does not follow links loses outright. A link that leaves the tree is - // not descended: what lies behind it is for a pattern that names it. + // The walk descends symlinked directories and reports each match where it + // really lives, so a deny glob covers what it matches whichever spelling + // found it, and one mount stands for every spelling. These cases are the + // ones a walk that does not follow links loses outright. let ROOT: string let BASE: string let LNK: string @@ -2139,71 +2090,34 @@ describe.if(isLinux)( }, ) - it('does not look behind a link whose target is outside the pattern base', () => { - // config/prod -> ../shared/prod leaves config, where the pattern - // starts: the key behind it is not the pattern's to find. - const unfollowedLinks = new Map() + it('mounts a match behind a link whose target is outside the pattern base, at its target', () => { const mounts = expandReadDenyGlobLinux( join(ROOT, 'out', 'config', '*', 'key'), [], - undefined, - { unfollowedLinks }, ) - expect(mounts).toEqual([join(ROOT, 'out', 'config', 'dev', 'key')]) - expect([...unfollowedLinks]).toEqual([ - [ - join(ROOT, 'out', 'config', 'prod'), - join(ROOT, 'out', 'shared', 'prod'), - ], - ]) - // A pattern that starts above both finds it, where it really is. - expect( - expandReadDenyGlobLinux(join(ROOT, 'out', '*', '*', 'key'), []), - ).toEqual([ + expect(mounts).toEqual([ join(ROOT, 'out', 'config', 'dev', 'key'), join(ROOT, 'out', 'shared', 'prod', 'key'), ]) }) - it('does not look behind an absolute-target link outside the base', () => { - // config/prod is written as an absolute path, and leaves config like - // the relative one: the link is met, and left. - const unfollowedLinks = new Map() + it('mounts a match behind an absolute-target link outside the base, at its target', () => { const mounts = expandReadDenyGlobLinux( join(ROOT, 'absout', 'config', '*', 'key'), [], - undefined, - { unfollowedLinks }, ) - expect(mounts).toEqual([]) - expect([...unfollowedLinks]).toEqual([ - [ - join(ROOT, 'absout', 'config', 'prod'), - join(ROOT, 'absout', 'elsewhere', 'prod'), - ], - ]) - // A pattern that starts above both finds the key, where it really is. - expect( - expandReadDenyGlobLinux(join(ROOT, 'absout', 'con*', '*', 'key'), []), - ).toEqual([join(ROOT, 'absout', 'elsewhere', 'prod', 'key')]) + expect(mounts).toEqual([join(ROOT, 'absout', 'elsewhere', 'prod', 'key')]) }) - it('leaves a package linked out of node_modules to a pattern that names where it is', () => { - // node_modules/pkg -> ../packages/pkg, as a workspace install leaves - // it: the package is not under node_modules, and a pattern that starts - // there does not reach it. - expect( - expandReadDenyGlobLinux( - join(ROOT, 'store', 'node_modules', '**', 'index.js'), - [], - ), - ).toEqual([join(ROOT, 'store', 'node_modules', 'other', 'index.js')]) + it('mounts a package linked out of node_modules where the package really is', () => { + const mounts = expandReadDenyGlobLinux( + join(ROOT, 'store', 'node_modules', '**', 'index.js'), + [], + ) - expect( - expandReadDenyGlobLinux(join(ROOT, 'store', '**', 'index.js'), []), - ).toEqual([ + expect(mounts).toEqual([ join(ROOT, 'store', 'node_modules', 'other', 'index.js'), join(ROOT, 'store', 'packages', 'pkg', 'index.js'), ]) From 48151af01aa96bf74707574b46e0c75d943504e3 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sat, 26 Sep 2026 13:09:41 +0000 Subject: [PATCH 11/17] README: the budget of the denyRead expansion; links as before --- README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0261ba1e8..845081253 100644 --- a/README.md +++ b/README.md @@ -437,11 +437,10 @@ bubblewrap binds concrete paths, so glob support is narrower than on macOS: - `allowWrite` / `denyWrite` take literal paths. A trailing `/**` is dropped (`src/**` means `src`); any other glob pattern there is skipped. - `denyRead` / `allowRead` accept the same glob syntax as macOS, expanded to the entries that exist when the command is wrapped, so a file that appears later is not covered. The pattern needs a literal directory to start from (a relative pattern starts at the current directory): one with a wildcard in its first path component, such as `/**/*.pem` or `/opt*/keys/**`, is skipped on Linux. Only directories the pattern can match beneath are listed (`certs/*.pem` lists `certs` alone). -- 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 hidden through a matched link that the pattern ends at (`certs/*.pem` with `vault.pem` a link to a directory), or through one that leads back up the tree or out of it (`**/build/**` with `build` a link to a directory beside the tree; see below), is hidden as a literal deny of the link would hide it: an `allowRead` beneath it is bound back as written, and what the pattern matches there only through a link that is not descended has no mask. +- 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. 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. -- A `denyRead` pattern covers its own tree: the literal directory the pattern starts from (what is written before its first wildcard) and everything under it. That is the pattern's directory, not the project's: for `src/**/.env` the tree is `src`. A directory outside that tree is never listed: a symlinked directory is descended only when it leads to somewhere inside the tree, judged by where the link resolves and not by how it is written, whether or not the link's own name matches and whatever is allowed beneath what it leads to. A link out of the tree can only add to what a pattern covers: a file whose real path is inside the tree and matches is found by walking the tree itself, whatever links exist, so no link a sandboxed command creates takes a mask away. What this gives up is what such a link used to add: a file that the pattern matches only by a name that passes through a link out of the tree is not masked. With `src/shared` a link to `../shared`, `src/**/.env` does not mask `shared/.env`, which stays readable under both names; a pattern that starts at the file's directory or above it (`shared/**/.env`, `**/.env`), or an entry naming the file itself, does. Such a file can be inside the tree, when a link out there leads back in: with `src/vendor` a link to `../vendor` and `vendor/secrets` a link to `../src/data`, `src/**/secrets/*.pem` matches `src/data/key.pem` as `src/vendor/secrets/key.pem` alone and does not mask it, under either name; a pattern that matches it by a name inside the tree (`src/**/*.pem`) masks it under every name. It can be a file written later, too: a command that may write in the tree can move a directory that holds nothing masked out of it and leave a link in its place, and what is written under that name afterwards lies outside the tree and is not masked; `unfollowedDenyLinks` names the link. A link whose own name matches the pattern still masks what it leads to, wherever that is: a matched link to a file masks the file, and a matched link to a directory hides the whole directory. When that directory is outside the tree it is not listed, so an `allowRead` or `allowWrite` path beneath it is bound back as written, as beneath a directory listed in `denyRead` literally, and what the pattern matches under that path through the link's name is not masked again: with `src/build` a link to `../out/build`, `src/**/build/**` and an `allowRead` of `out/build/pub`, everything in `pub` is readable and the rest of `out/build` is hidden, under either name. What a link inside such a directory leads to is not masked either: with `out/build/data` a link to `../../keep`, `keep` stays readable. -- Inside the tree, 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. Only a pattern that does not read as written is matched against real paths alone, with every directory under its starting directory listed: one with a wildcard inside a bracket expression (`[a*]`), or with a second `[` that nothing closes. A link that itself matches it still denies what it leads to. 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, and a link back up the tree denies everything it reaches, as a literal deny of the link would. A link that resolves to nothing is skipped. `allowRead` globs are not expanded through symlinks: they match the link itself. -- The expansion of a configuration's `denyRead` patterns has a budget, shared by all of them: 2,000,000 directory entries looked at and 10 seconds. The time a tree takes depends on the machine and on what is cached, so the first command after start-up in a very large tree can be refused where a second is not. When either runs out the wrap is refused with a `LinuxSandboxProfileError` whose `.code` is `deny_glob_too_large`, and no command is returned: a deny list cut short would leave readable what the pattern was written to hide. `getFsReadConfig()` throws the same error. The message names the pattern, the directory being listed and what had been spent; narrow the pattern, or remove or move what it walks into. The same are fields of the error's `.cause`, for a caller that words its own message: `pattern` (the one being expanded when the budget ran out, which need not be the large one), `directory`, `exhausted` (`'entries'` or `'time'`), `entries` and `elapsedMs` (what all the patterns had spent between them), `maxEntries` and `timeoutMs`. The time is checked before each directory is listed and at each entry in it, so what is not cut short is a single step: a listing that blocks (a dead network mount inside the tree), the resolution of a link that leads to one (where a link leads is looked up even when it is then not descended), or one name that is slow to match (a pattern with several wildcards in one path component, against a long name). The budget is the walk's: what is done with the matches afterwards is not counted. `getFsReadConfig()` lists the links the patterns left unfollowed for leading out of their tree in `unfollowedDenyLinks`, each with its `pattern`, `link` and `target`, for a caller to tell its user what a pattern does not reach. The list is of those links only: a link that leads back up the tree, and any link under a pattern that cannot be followed one path component at a time, is not listed through either and is not in it. With debug logging on (`SRT_DEBUG`) each expansion logs one line: the pattern, the milliseconds it took, its matches and mounts, the directories listed, the entries looked at, and how many links out of the tree it left unfollowed, with the first of them. +- 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. Only a pattern that does not read as written is matched against real paths alone, with every directory under its starting directory listed: one with a wildcard inside a bracket expression (`[a*]`), or with a second `[` that nothing closes. A link that itself matches it still denies what it leads to. 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, and a link back up the tree denies everything it reaches, as a literal deny of the link would. A link that resolves to nothing is skipped. `allowRead` globs are not expanded through symlinks: they match the link itself. +- The expansion of a configuration's `denyRead` patterns has a budget, shared by all of them: 2,000,000 directory entries looked at and 10 seconds. The time a tree takes depends on the machine and on what is cached, so the first command after start-up in a very large tree can be refused where a second is not. When either runs out the wrap is refused with a `LinuxSandboxProfileError` whose `.code` is `deny_glob_too_large`, and no command is returned: a deny list cut short would leave readable what the pattern was written to hide. `getFsReadConfig()` throws the same error. The message names the pattern, the directory being listed and what had been spent; narrow the pattern, or remove or move what it walks into. The same are fields of the error's `.cause`, for a caller that words its own message: `pattern` (the one being expanded when the budget ran out, which need not be the large one), `directory`, `exhausted` (`'entries'` or `'time'`), `entries` and `elapsedMs` (what all the patterns had spent between them), `maxEntries` and `timeoutMs`. The time is checked before each directory is listed and at each entry in it, so what is not cut short is a single step: a listing that blocks (a dead network mount), the resolution of a link that leads to one, or one name that is slow to match (a pattern with several wildcards in one path component, against a long name). The budget is the walk's: what is done with the matches afterwards is not counted. With debug logging on (`SRT_DEBUG`) each expansion logs one line: the pattern, the milliseconds it took, its matches and mounts, the directories listed and the entries looked at. - 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. From 0b19ff9fea26f47bd3be9fdf30107ac56d70059f Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 28 Sep 2026 13:48:02 +0000 Subject: [PATCH 12/17] Shorten the comments this change adds Comments only: the JavaScript emitted with comments removed is byte-identical for every file, and the test names are unchanged. Kept: every invariant and what could be done without it, the documentation of exported names, and the reasons for what is not done the obvious way. Cut: how the code came to be as it is, narration of the lines that follow, and explanations repeated at several sites. --- src/sandbox/linux-sandbox-utils.ts | 7 ++- src/sandbox/read-deny-glob.ts | 11 ++--- src/sandbox/sandbox-manager.ts | 13 ++--- src/sandbox/sandbox-utils.ts | 31 +++++------- test/sandbox/read-deny-glob-bound.test.ts | 58 ++++++++--------------- 5 files changed, 42 insertions(+), 78 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 5fe9f53b6..36dd492d6 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -751,10 +751,9 @@ export type LinuxSandboxProfileErrorCode = /** The line does not fit one shell argument even with the mounts in a file. */ | 'command_too_long' /** - * The `denyRead` patterns could not be expanded within their budget of - * directory entries and time, so which paths to hide is not known. The - * error on `.cause` names the pattern, the directory being listed and what - * had been spent. + * The `denyRead` patterns could not be expanded within their budget, so + * which paths to hide is not known. The error on `.cause` names the + * pattern, the directory being listed and what had been spent. */ | 'deny_glob_too_large' diff --git a/src/sandbox/read-deny-glob.ts b/src/sandbox/read-deny-glob.ts index 6719eb931..ca8d9b66e 100644 --- a/src/sandbox/read-deny-glob.ts +++ b/src/sandbox/read-deny-glob.ts @@ -66,17 +66,15 @@ function collapseReadDenyLocations({ * could not list is denied whole. Sorted, so an ancestor precedes its * descendants. * - * Throws {@link GlobWalkBudgetError} when `budget` runs out before the walk - * is done. There is no shorter list to fall back on: a caller that cannot - * have the whole expansion must not run the command. + * Throws {@link GlobWalkBudgetError} when `budget` runs out. There is no + * shorter list to fall back on: the caller must not run the command. * * @param unlistableDirs - receives the returned locations that hide something * the walk could not enumerate, whether by being that directory or by * 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.budget - what this expansion and the others handed the same - * object may spend between them. + * @param opts.budget - shared with every expansion handed the same object. */ export function expandReadDenyGlobLinux( globPattern: string, @@ -191,8 +189,7 @@ export function expandReadDenyGlobLinux( } } - // One line for the whole expansion, with what it cost: a caller that times - // its wraps takes the numbers from here. + // One line per expansion: a caller that times its wraps reads the cost here. logForDebugging( `[Sandbox Linux] Expanded denyRead glob "${globPattern}" in ${Math.round(performance.now() - startedAt)} ms: ` + `${walk.matches.length} matches -> ${mounts.size} mounts; ` + diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index d07adda9c..b5d5ab075 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -1309,13 +1309,9 @@ function expandAllowReadGlob(pattern: string): string[] { } /** - * The expansion of every denyRead glob of one read configuration, on Linux. - * The patterns share one budget of directory entries and time, so a - * configuration costs at most that however many patterns it holds. When the - * budget runs out the returned function throws - * {@link LinuxSandboxProfileError} `deny_glob_too_large`: there is no deny - * list to go on with, since one cut short leaves readable what the pattern - * was written to hide. + * The expansion of every denyRead glob of one read configuration, on Linux, + * all on one budget. When it runs out the returned function throws + * {@link LinuxSandboxProfileError} `deny_glob_too_large`. */ function readDenyGlobExpander( reExposedPaths: readonly string[], @@ -1350,8 +1346,7 @@ function readDenyGlobExpander( * this config's allowRead and {@link getFsWriteConfig}'s allowOnly, so * `denyOnly` is only sound alongside that write config and must not be handed * to wrapCommandWithSandboxLinux with a different one. Throws - * {@link LinuxSandboxProfileError} `deny_glob_too_large` when the globs - * cannot be expanded within their budget, as the wrap does. + * {@link LinuxSandboxProfileError} `deny_glob_too_large` as the wrap does. */ function getFsReadConfig(): FsReadRestrictionConfig { if (!config || config.filesystem.disabled) { diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index a98f8d1fc..d726b5d81 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -1137,19 +1137,16 @@ export interface ExpandGlobOptions { caseInsensitive?: boolean } -/** - * The most directory entries the read-deny expansions of one read - * configuration look at, all its patterns together. - */ +/** The most directory entries the read-deny expansions of one read + * configuration look at, all its patterns together. */ export const GLOB_WALK_MAX_ENTRIES = 2_000_000 /** The longest they take, all together, in milliseconds. */ export const GLOB_WALK_TIMEOUT_MS = 10_000 /** - * What the walks handed it may spend between them. One budget is meant to be - * shared: every walk draws on the same count and the same deadline, so the - * patterns of one configuration cannot each spend a full one. + * What the walks handed it may spend between them: one count and one + * deadline, so the patterns of one configuration cannot each spend a full one. */ export interface GlobWalkBudget { /** The directory entries the walks may look at, together. */ @@ -1176,9 +1173,8 @@ export function newGlobWalkBudget( } /** - * Thrown by {@link walkGlobPattern} when its budget runs out. What the walk - * had found by then is not handed back: for a deny expansion, a list cut - * short is a path left readable. + * 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 { /** The pattern being walked, as the caller wrote it. */ @@ -1187,8 +1183,7 @@ export class GlobWalkBudgetError extends Error { readonly directory: string /** Which limit was reached. */ readonly exhausted: 'entries' | 'time' - /** The directory entries looked at under the budget, by this walk and the - * ones before it. */ + /** The entries looked at under the budget, by this walk and those before. */ readonly entries: number /** Milliseconds since the budget was made. */ readonly elapsedMs: number @@ -1569,10 +1564,8 @@ export function toForwardSlashes(s: string): string { * With `followSymlinkedDirectories` it also lists through a symlinked * directory and reports every match where it really lives. * - * With a `budget` the walk throws {@link GlobWalkBudgetError} once the budget - * is spent, and returns nothing. The clock is read before each listing and - * at each entry, so what is not cut short is one step: a filesystem call - * that blocks, or a name that is slow to match. + * 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, @@ -1649,10 +1642,8 @@ export function walkGlobPattern( const listings = new Map() const pending: Frame[] = [] /** Counts one more entry of `dir` looked at, or none before a listing, and - * throws once the budget is spent. The clock is read every time, before - * each listing and at each entry: what an entry costs is not bounded (a - * name can be slow to match, a link slow to resolve), so a stride of - * entries between readings would be no bound on the time either. Called + * throws once the budget is spent. The clock is read every time: what one + * entry costs is not bounded, so a stride would not bound the time. Called * outside every `try` here, so a spent budget is never taken for a * directory that could not be listed. */ const spend = (dir: string, entries: 0 | 1): void => { diff --git a/test/sandbox/read-deny-glob-bound.test.ts b/test/sandbox/read-deny-glob-bound.test.ts index 36c19628e..7ada746ee 100644 --- a/test/sandbox/read-deny-glob-bound.test.ts +++ b/test/sandbox/read-deny-glob-bound.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, beforeAll, afterAll, spyOn } from 'bun:test' -// The namespace the library binds, so a spy on it is seen by the code under -// test. +// The namespace the library binds, so the code under test sees a spy on it. import * as sandboxUtils from '../../src/sandbox/sandbox-utils.js' import { mkdirSync, @@ -29,8 +28,7 @@ import { countMounts } from '../helpers/bwrap-argv.js' /** * `root/start/next -> ../pool/d1`, `pool/d1/next -> ../d2` and so on, each - * `pool/d` holding a `.env`: `links` directories that nothing but the link - * before them leads to from `start`. + * `pool/d` holding a `.env`: `links` directories only the links lead to. */ function plantChain(root: string, links: number): void { mkdirSync(join(root, 'start'), { recursive: true }) @@ -58,9 +56,8 @@ function thrownBy(fn: () => unknown): unknown { describe.if(!isWindows)( 'a read-deny glob and a link to a directory beside the project', () => { - // project/out -> ../outside, and outside/deep/.env: `project/**/.env` - // matches the file as project/out/deep/.env, and no other name for it - // lies under the project. + // project/out -> ../outside, which holds deep/.env: `project/**/.env` + // matches that file as project/out/deep/.env and under no other name. let ROOT: string let project: string let secret: string @@ -159,8 +156,7 @@ describe.if(!isWindows)('the budget of a glob walk', () => { expect(GLOB_WALK_TIMEOUT_MS).toBe(10_000) const budget = newGlobWalkBudget() expect(budget.maxEntries).toBe(2_000_000) - // Rounded: the deadline is a clock reading plus ten seconds, and a - // floating-point sum less the reading need not be ten seconds exactly. + // Rounded: a floating-point sum less its first term need not be exact. expect(Math.round(budget.deadline - budget.startedAt)).toBe(10_000) expect(budget.entries).toBe(0) }) @@ -194,15 +190,12 @@ describe.if(!isWindows)('the budget of a glob walk', () => { const budgetError = error as GlobWalkBudgetError expect(budgetError.exhausted).toBe('entries') expect(budgetError.pattern).toBe(pattern) - // The directory it was in when the count ran out, which is one of the - // tree's own. expect(budgetError.directory.startsWith(TREE + '/')).toBe(true) expect(budgetError.entries).toBe(TREE_ENTRIES) expect(budgetError.maxEntries).toBe(TREE_ENTRIES - 1) expect(budgetError.elapsedMs).toBeGreaterThanOrEqual(0) expect(budgetError.message).toContain(pattern) expect(budgetError.message).toContain(budgetError.directory) - // The walk itself throws the same, whoever calls it. expect(() => walkGlobPattern(pattern, { budget: newGlobWalkBudget({ maxEntries: 5 }), @@ -230,11 +223,9 @@ describe.if(!isWindows)('the budget of a glob walk', () => { }) it('throws when the deadline passes while it walks', () => { - // A clock that moves a second each time it is read, and the walk reads - // it before each listing and at each entry. The budget is made at 1 s; - // the tree and its twelve entries take the readings up to 14 s, and each - // of the first two directories beneath it five more, up to 24 s. The - // next reading, before the third is listed, is past the deadline. + // A clock that moves a second each time it is read. The budget is made at + // 1 s, the tree and the first two directories beneath it take the readings + // to 24 s, and the next, before the third is listed, is past the deadline. let now = 0 const clock = spyOn(performance, 'now').mockImplementation( () => (now += 1000), @@ -255,11 +246,9 @@ describe.if(!isWindows)('the budget of a glob walk', () => { }) it('throws inside a directory of few entries, at the entry the deadline passes on', () => { - // The clock is read at every entry, not every so many: a name can be - // slow to match, and a directory of forty such names must not be walked - // to its end long after the time is up. One reading a second again: the - // budget is made at 1 s, the listing is at 2 s, and the fourth entry, at - // 6 s, is past the deadline. + // The clock is read at every entry, not every so many, so a directory of + // slow names is not walked to its end. One reading a second again: the + // fourth entry, at 6 s, is past the deadline. const few = join(ROOT, 'few') mkdirSync(few) for (let f = 0; f < 40; f++) writeFileSync(join(few, `f${f}`), '') @@ -294,8 +283,7 @@ describe.if(!isWindows)('the budget of a glob walk', () => { ).toHaveLength(12) expect(budget.entries).toBe(TREE_ENTRIES) - // The second tree fits a budget of its own, and does not fit what the - // first expansion left of this one. + // The second tree fits a fresh budget, and not what is left of this one. expect( expandReadDenyGlobLinux(join(second, '**/.env'), [], undefined, { budget: newGlobWalkBudget({ maxEntries: TREE_ENTRIES + 10 }), @@ -312,9 +300,8 @@ describe.if(!isWindows)('the budget of a glob walk', () => { }) it('throws on a tree that only links inside it lead through', () => { - // start/next -> ../pool/d1, d1/next -> ../d2, …: a tree a command - // allowed to write there can make as long as it likes. `start*` names - // start alone, so nothing but the links leads into pool. + // A tree a command allowed to write there can make as long as it likes. + // `start*` names start alone, so nothing but the links leads into pool. const chain = join(ROOT, 'chain') const links = 40 plantChain(chain, links) @@ -341,8 +328,7 @@ describe.if(!isWindows)('the budget of a glob walk', () => { }) it('is spent on what a link to a directory beside the project leads to', () => { - // linked/bigtree -> tree: the link is the one entry under the directory - // the pattern starts from, and what it leads to holds sixty. + // linked/bigtree -> tree: one entry under the base, sixty behind the link. const proj = join(ROOT, 'linked') mkdirSync(proj) symlinkSync(join('..', 'tree'), join(proj, 'bigtree')) @@ -370,11 +356,9 @@ describe.if(!isWindows)('the budget of a glob walk', () => { }) it('does not take a spent budget for a directory that could not be listed', () => { - // An error from the listing itself marks the directory to be denied as - // a whole; the budget's error is not one of those, whether the entries - // ran out or the time did. The time is looked at right before each - // listing: taken for a listing that failed, it would deny the tree as a - // whole and hand that back. + // A listing that fails marks its directory to be denied whole. A spent + // budget, found right before a listing or at an entry, is not one of + // those: taken for one, it would deny the tree whole and hand that back. for (const limits of [{ maxEntries: 20 }, { timeoutMs: 0 }]) { const unlistable = new Set() let mounts: string[] | undefined @@ -482,8 +466,7 @@ describe.if(isLinux)('a read-deny glob past its budget, at the manager', () => { const cause = profileError.cause as GlobWalkBudgetError expect(cause.exhausted).toBe('entries') expect(profileError.message).toContain(cause.directory) - // What a caller builds its own message from, read off `.cause` as plain - // fields: it needs neither the class nor the text of the message. + // Plain fields on `.cause`: a caller needs neither the class nor the text. const fields: Record = { ...cause } expect(fields).toEqual({ name: 'GlobWalkBudgetError', @@ -553,8 +536,7 @@ describe.if(isLinux)('a read-deny glob past its budget, at the manager', () => { `--ro-bind /dev/null ${join(chain, 'pool', 'd30', '.env')}`, ) - // chain itself holds two entries: the rest of the twenty go on what the - // links lead to. + // chain itself holds two entries: the rest are spent behind the links. const { wrapped, error } = await withBudgetOf({ maxEntries: 20 }, () => wrapOf([pattern]), ) From faed62f581078025f70c752e68bb923784c7cd1b Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 28 Sep 2026 17:09:04 +0000 Subject: [PATCH 13/17] linux: a read-deny glob budget that real trees fit in, and an option to change it The default of 2,000,000 entries and 10 seconds refused every command in a large monorepo: one `**/.env` pattern over 2.5 million entries walks in about 7 seconds. Such a user waited before; refused, they could not run anything, and nothing could raise the limit. The defaults are now 20,000,000 entries and 60 seconds, and `filesystem.denyReadGlobBudget` (`maxEntries`, `timeoutMs`) sets others, from the initialized configuration, updateConfig() or one wrap's customConfig. The refusal names the option. README: wrapWithSandboxArgv() rejects the same way; the patterns share one deadline; an over-budget configuration is accepted at initialize() and every wrap of it spends the budget again. --- README.md | 3 +- src/sandbox/sandbox-config.ts | 11 ++ src/sandbox/sandbox-manager.ts | 25 ++++- src/sandbox/sandbox-utils.ts | 9 +- test/sandbox/read-deny-glob-bound.test.ts | 130 +++++++++++++++++++++- 5 files changed, 161 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 845081253..17d1078ac 100644 --- a/README.md +++ b/README.md @@ -406,6 +406,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; positive integers, either or both). What expanding the `denyRead` globs of one configuration may spend, in directory entries looked at and in milliseconds, before the wrap is refused; see "Path Syntax (Linux)". **Write restrictions** (allow-only pattern) - all writes denied by default: @@ -440,7 +441,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. 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. Only a pattern that does not read as written is matched against real paths alone, with every directory under its starting directory listed: one with a wildcard inside a bracket expression (`[a*]`), or with a second `[` that nothing closes. A link that itself matches it still denies what it leads to. 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, and a link back up the tree denies everything it reaches, as a literal deny of the link would. A link that resolves to nothing is skipped. `allowRead` globs are not expanded through symlinks: they match the link itself. -- The expansion of a configuration's `denyRead` patterns has a budget, shared by all of them: 2,000,000 directory entries looked at and 10 seconds. The time a tree takes depends on the machine and on what is cached, so the first command after start-up in a very large tree can be refused where a second is not. When either runs out the wrap is refused with a `LinuxSandboxProfileError` whose `.code` is `deny_glob_too_large`, and no command is returned: a deny list cut short would leave readable what the pattern was written to hide. `getFsReadConfig()` throws the same error. The message names the pattern, the directory being listed and what had been spent; narrow the pattern, or remove or move what it walks into. The same are fields of the error's `.cause`, for a caller that words its own message: `pattern` (the one being expanded when the budget ran out, which need not be the large one), `directory`, `exhausted` (`'entries'` or `'time'`), `entries` and `elapsedMs` (what all the patterns had spent between them), `maxEntries` and `timeoutMs`. The time is checked before each directory is listed and at each entry in it, so what is not cut short is a single step: a listing that blocks (a dead network mount), the resolution of a link that leads to one, or one name that is slow to match (a pattern with several wildcards in one path component, against a long name). The budget is the walk's: what is done with the matches afterwards is not counted. With debug logging on (`SRT_DEBUG`) each expansion logs one line: the pattern, the milliseconds it took, its matches and mounts, the directories listed and the entries looked at. +- The expansion of a configuration's `denyRead` patterns has a budget, shared by all of them: 20,000,000 directory entries looked at and 60 seconds, unless `filesystem.denyReadGlobBudget` (`maxEntries`, `timeoutMs`) sets another, in the initialized configuration, through `updateConfig()`, or in the `customConfig` of one wrap. The time a tree takes depends on the machine and on what is cached, so the first command after start-up in a very large tree can be refused where a second is not. When either runs out the wrap is refused with a `LinuxSandboxProfileError` whose `.code` is `deny_glob_too_large`, and no command is returned: a deny list cut short would leave readable what the pattern was written to hide. `wrapWithSandboxArgv()` rejects the same way, and `getFsReadConfig()` throws the same error. `initialize()` and `updateConfig()` expand nothing, so they accept a configuration that is past the budget, and nothing is kept between wraps: every wrap of it is refused, each after spending the budget again. The message names the pattern, the directory being listed and what had been spent; narrow the pattern, remove or move what it walks into, or raise the budget. The same are fields of the error's `.cause`, for a caller that words its own message: `pattern` (the one being expanded when the budget ran out, which need not be the large one), `directory`, `exhausted` (`'entries'` or `'time'`), `entries` and `elapsedMs` (what all the patterns had spent between them), `maxEntries` and `timeoutMs`. The time is checked before each directory is listed and at each entry in it, so what is not cut short is a single step: a listing that blocks (a dead network mount), the resolution of a link that leads to one, or one name that is slow to match (a pattern with several wildcards in one path component, against a long name). The patterns share one deadline, so what is done with one pattern's matches after its walk is time taken from the patterns still to come; only what follows the last walk is not counted. With debug logging on (`SRT_DEBUG`) each expansion logs one line: the pattern, the milliseconds it took, its matches and mounts, the directories listed and the entries looked at. - 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. diff --git a/src/sandbox/sandbox-config.ts b/src/sandbox/sandbox-config.ts index 6a2b32767..9a053e75d 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -909,6 +909,17 @@ 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(), + }) + .optional() + .describe( + 'Linux: what expanding the denyRead globs of one configuration may spend, all of them together, ' + + 'before the wrap is refused: directory entries looked at (default 20,000,000) and ' + + 'milliseconds (default 60,000).', + ), allowWrite: z .array(filesystemPathSchema) .describe('Paths allowed for writing'), diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index b5d5ab075..36287c0c3 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -1310,14 +1310,16 @@ function expandAllowReadGlob(pattern: string): string[] { /** * The expansion of every denyRead glob of one read configuration, on Linux, - * all on one budget. When it runs out the returned function throws - * {@link LinuxSandboxProfileError} `deny_glob_too_large`. + * all on one budget of `limits` (`filesystem.denyReadGlobBudget`). When it + * runs out the returned function throws {@link LinuxSandboxProfileError} + * `deny_glob_too_large`. */ function readDenyGlobExpander( reExposedPaths: readonly string[], unlistableDenyDirs: Set, + limits: SandboxRuntimeConfig['filesystem']['denyReadGlobBudget'], ): (pattern: string) => string[] { - const budget = newGlobWalkBudget() + const budget = newGlobWalkBudget(limits) return pattern => { try { return expandReadDenyGlobLinux( @@ -1333,7 +1335,7 @@ function readDenyGlobExpander( `denyRead pattern "${pattern}" could not be expanded: the denyRead patterns of one configuration share ` + `${error.maxEntries} directory entries and ${error.timeoutMs} ms, and the ${error.exhausted} ran out ` + `with ${error.entries} entries looked at after ${error.elapsedMs} ms, while listing ${error.directory}; ` + - `narrow the pattern, or remove or move what it walks into`, + `narrow the pattern, remove or move what it walks into, or raise filesystem.denyReadGlobBudget`, error, ) } @@ -1369,7 +1371,11 @@ function getFsReadConfig(): FsReadRestrictionConfig { const unlistableDenyDirs = new Set() const denyPaths = resolveReadPathEntries( unionDenyReadPaths(config.filesystem.denyRead, credentialRestrictions), - readDenyGlobExpander(reExposedPaths, unlistableDenyDirs), + readDenyGlobExpander( + reExposedPaths, + unlistableDenyDirs, + config.filesystem.denyReadGlobBudget, + ), credentialRestrictions.degradeToDenyPaths, ) @@ -1757,7 +1763,12 @@ async function wrapWithSandbox( customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead ?? [], credentialRestrictions, ), - readDenyGlobExpander(reExposedPaths, unlistableDenyDirs), + readDenyGlobExpander( + reExposedPaths, + unlistableDenyDirs, + customConfig?.filesystem?.denyReadGlobBudget ?? + config?.filesystem.denyReadGlobBudget, + ), credentialRestrictions.degradeToDenyPaths, ) readConfig = { @@ -2465,6 +2476,8 @@ export interface ISandboxManager { command: string args?: string[] }): 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 d726b5d81..ac3a47520 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -1138,11 +1138,12 @@ export interface ExpandGlobOptions { } /** The most directory entries the read-deny expansions of one read - * configuration look at, all its patterns together. */ -export const GLOB_WALK_MAX_ENTRIES = 2_000_000 + * configuration look at, all its patterns together, unless + * `filesystem.denyReadGlobBudget` sets another. */ +export const GLOB_WALK_MAX_ENTRIES = 20_000_000 -/** The longest they take, all together, in milliseconds. */ -export const GLOB_WALK_TIMEOUT_MS = 10_000 +/** The longest they take, all together, in milliseconds; likewise. */ +export const GLOB_WALK_TIMEOUT_MS = 60_000 /** * What the walks handed it may spend between them: one count and one diff --git a/test/sandbox/read-deny-glob-bound.test.ts b/test/sandbox/read-deny-glob-bound.test.ts index 7ada746ee..8b4115455 100644 --- a/test/sandbox/read-deny-glob-bound.test.ts +++ b/test/sandbox/read-deny-glob-bound.test.ts @@ -20,6 +20,7 @@ import { newGlobWalkBudget, walkGlobPattern, } from '../../src/sandbox/sandbox-utils.js' +import { FilesystemConfigSchema } from '../../src/sandbox/sandbox-config.js' import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' import { LinuxSandboxProfileError } from '../../src/sandbox/linux-sandbox-utils.js' import { isLinux, isWindows } from '../helpers/platform.js' @@ -151,13 +152,13 @@ describe.if(!isWindows)('the budget of a glob walk', () => { rmSync(ROOT, { recursive: true, force: true }) }) - it('defaults to two million entries and ten seconds', () => { - expect(GLOB_WALK_MAX_ENTRIES).toBe(2_000_000) - expect(GLOB_WALK_TIMEOUT_MS).toBe(10_000) + it('defaults to twenty million entries and sixty seconds', () => { + expect(GLOB_WALK_MAX_ENTRIES).toBe(20_000_000) + expect(GLOB_WALK_TIMEOUT_MS).toBe(60_000) const budget = newGlobWalkBudget() - expect(budget.maxEntries).toBe(2_000_000) + expect(budget.maxEntries).toBe(20_000_000) // Rounded: a floating-point sum less its first term need not be exact. - expect(Math.round(budget.deadline - budget.startedAt)).toBe(10_000) + expect(Math.round(budget.deadline - budget.startedAt)).toBe(60_000) expect(budget.entries).toBe(0) }) @@ -476,7 +477,7 @@ describe.if(isLinux)('a read-deny glob past its budget, at the manager', () => { entries: 26, elapsedMs: expect.any(Number), maxEntries: 25, - timeoutMs: 10_000, + timeoutMs: 60_000, }) // A rejected promise, which is how every caller of the wrap meets it. await withBudgetOf({ maxEntries: 25 }, async () => { @@ -608,6 +609,123 @@ describe.if(isLinux)('a read-deny glob past its budget, at the manager', () => { await SandboxManager.reset() } }) + + it('takes its limits from filesystem.denyReadGlobBudget: initialized, updated, or of one wrap', async () => { + const pattern = join(PROJ, '**/.env') + const masked = `--ro-bind /dev/null ${join(PROJ, 'pkg9', '.env')}` + const configOf = (denyReadGlobBudget: { + maxEntries?: number + timeoutMs?: number + }) => ({ + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { ...filesystemOf([pattern]).filesystem, denyReadGlobBudget }, + }) + /** The limits named by the refusal `run` ends in. */ + async function refusedWith(run: () => unknown): Promise { + let error: unknown + try { + await run() + } catch (thrown) { + error = thrown + } + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + const profileError = error as LinuxSandboxProfileError + expect(profileError.code).toBe('deny_glob_too_large') + expect(profileError.message).toContain( + 'or raise filesystem.denyReadGlobBudget', + ) + const { maxEntries, timeoutMs } = + profileError.cause as GlobWalkBudgetError + return { maxEntries, timeoutMs } + } + + await SandboxManager.reset() + // PROJ is 40 entries. + await SandboxManager.initialize( + configOf({ maxEntries: 39, timeoutMs: 1234 }), + ) + try { + for (const run of [ + () => SandboxManager.getFsReadConfig(), + () => SandboxManager.wrapWithSandbox('echo hello'), + () => SandboxManager.wrapWithSandboxArgv('echo hello'), + // A filesystem of one wrap that names no budget keeps the initialized. + () => + SandboxManager.wrapWithSandbox( + 'echo hello', + undefined, + filesystemOf([pattern]), + ), + ]) { + expect(await refusedWith(run)).toEqual({ + maxEntries: 39, + timeoutMs: 1234, + }) + } + // One that names a budget is held to that. + expect( + await SandboxManager.wrapWithSandbox( + 'echo hello', + undefined, + configOf({ maxEntries: 40 }), + ), + ).toContain(masked) + + SandboxManager.updateConfig(configOf({ maxEntries: 40 })) + expect(SandboxManager.getFsReadConfig().denyOnly).toHaveLength(10) + expect(await SandboxManager.wrapWithSandbox('echo hello')).toContain( + masked, + ) + expect( + await refusedWith(() => + SandboxManager.wrapWithSandbox( + 'echo hello', + undefined, + configOf({ maxEntries: 39 }), + ), + ), + ).toEqual({ maxEntries: 39, timeoutMs: 60_000 }) + } finally { + await SandboxManager.reset() + } + }) +}) + +describe('the filesystem.denyReadGlobBudget option', () => { + const parse = (denyReadGlobBudget: unknown) => + FilesystemConfigSchema.safeParse({ + denyRead: [], + allowWrite: [], + denyWrite: [], + denyReadGlobBudget, + }) + + it('takes positive integers, either or both, and nothing else', () => { + for (const budget of [ + undefined, + {}, + { maxEntries: 1 }, + { timeoutMs: 120_000 }, + { maxEntries: 50_000_000, timeoutMs: 1 }, + ]) { + const parsed = parse(budget) + expect(parsed.success).toBe(true) + expect(parsed.data?.denyReadGlobBudget).toEqual(budget) + } + for (const budget of [ + { maxEntries: 0 }, + { maxEntries: -1 }, + { maxEntries: 1.5 }, + { maxEntries: '1000' }, + { maxEntries: Infinity }, + { timeoutMs: 0 }, + { timeoutMs: 0.5 }, + { timeoutMs: null }, + 1000, + ]) { + expect(parse(budget).success).toBe(false) + } + }) }) describe.if(!isWindows)('the debug line of a read-deny expansion', () => { From 5707925a06e623222450ca2141018e13104c5233 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 28 Sep 2026 17:12:25 +0000 Subject: [PATCH 14/17] denyReadGlobBudget: refuse a key it does not know A misspelt `maxEntires` was accepted and ignored, and the default applied in silence. --- src/sandbox/sandbox-config.ts | 1 + test/sandbox/read-deny-glob-bound.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/sandbox/sandbox-config.ts b/src/sandbox/sandbox-config.ts index 9a053e75d..d427ce77d 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -914,6 +914,7 @@ export const FilesystemConfigSchema = z.object({ maxEntries: z.number().int().positive().optional(), timeoutMs: z.number().int().positive().optional(), }) + .strict() .optional() .describe( 'Linux: what expanding the denyRead globs of one configuration may spend, all of them together, ' + diff --git a/test/sandbox/read-deny-glob-bound.test.ts b/test/sandbox/read-deny-glob-bound.test.ts index 8b4115455..39ccf6cfb 100644 --- a/test/sandbox/read-deny-glob-bound.test.ts +++ b/test/sandbox/read-deny-glob-bound.test.ts @@ -718,6 +718,7 @@ describe('the filesystem.denyReadGlobBudget option', () => { { maxEntries: 1.5 }, { maxEntries: '1000' }, { maxEntries: Infinity }, + { maxEntires: 1 }, { timeoutMs: 0 }, { timeoutMs: 0.5 }, { timeoutMs: null }, From b34cef559fde638a6ffb906eda4a09dbb9afe707 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:42:17 +0000 Subject: [PATCH 15/17] linux: what a review of the whole glob budget found - An entry counts against `maxEntries` when it is read from disk. One that another pattern has read, and that comes from the shared listings, costs time alone. Charged like the rest, it made the tree that fits 20,000,000 divided by the number of `**` patterns: a 2.5 million entry repository under nine patterns was refused after about 12 of its 60 seconds, and it runs on main. This turns round a choice the join suite asserted. - allowRead patterns draw on the same budget. They ran the same walk with none. - A wrap's `denyReadGlobBudget` replaces the limit it names, not the whole object: naming one used to reset the other to its default. - The limits are checked where the budget is made, by the schema. Only the CLI ran it, so through initialize(), updateConfig() or customConfig a NaN turned a limit off, a misspelt key left the default, '1000' was concatenated. - On a file system that lists names without types, the clock is read before each lstat. 200,000 such entries at 1 ms held the thread for 200 s. - The refusal no longer says "denyRead pattern" of a credentials.files or allowRead one. The type of its `.cause` is exported. - The budget keeps `timeoutMs` as given, instead of a deadline it is rebuilt from by subtraction and rounding. The options of the expansion are declared once. The directories are counted as they are listed. - Tests: an anchored walk, a credentials.files pattern and an allowRead one are each held to the budget; each new case fails with the line it guards taken out. "A signal wins over a spent budget" goes: it aborted in the allowRead walk, before the budget was drawn on, and nothing but a test can abort in the middle of a step. No temp path goes into a RegExp, and the join suite has the platform gate its sibling has. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv Co-authored-by: ronleizrowice --- README.md | 6 +- src/index.ts | 1 + src/sandbox/linux-sandbox-utils.ts | 6 +- src/sandbox/read-deny-glob.ts | 24 +- src/sandbox/sandbox-config.ts | 4 +- src/sandbox/sandbox-manager.ts | 118 +++-- src/sandbox/sandbox-utils.ts | 64 ++- test/sandbox/read-deny-glob-bound.test.ts | 89 +++- test/sandbox/read-deny-walk-joins.test.ts | 554 +++++++++++----------- 9 files changed, 491 insertions(+), 375 deletions(-) diff --git a/README.md b/README.md index 128996d58..a3c18ed66 100644 --- a/README.md +++ b/README.md @@ -530,7 +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; positive integers, either or both). What expanding the `denyRead` globs of one configuration may spend, in directory entries looked at and in milliseconds, before the wrap is refused; see "Path Syntax (Linux)". +- `filesystem.denyReadGlobBudget` - Optional, Linux only: `{ "maxEntries": 20000000, "timeoutMs": 60000 }` (the defaults; positive integers, either or both). What expanding the globs of one read configuration may spend, in directory entries read and in milliseconds, before the wrap is refused; see "Path Syntax (Linux)". **Write restrictions** (allow-only pattern) - all writes denied by default: @@ -565,7 +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. -- The expansion of a configuration's `denyRead` patterns has a budget, shared by all of them: 20,000,000 directory entries looked at and 60 seconds, unless `filesystem.denyReadGlobBudget` (`maxEntries`, `timeoutMs`) sets another, in the initialized configuration, through `updateConfig()`, or in the `customConfig` of one wrap. The time a tree takes depends on the machine and on what is cached, so the first command after start-up in a very large tree can be refused where a second is not. When either runs out the wrap is refused with a `LinuxSandboxProfileError` whose `.code` is `deny_glob_too_large`, and no command is returned: a deny list cut short would leave readable what the pattern was written to hide. `wrapWithSandboxArgv()` rejects the same way, and `getFsReadConfig()` throws the same error. `initialize()` and `updateConfig()` expand nothing, so they accept a configuration that is past the budget, and nothing is kept between wraps: every wrap of it is refused, each after spending the budget again. The message names the pattern, the directory being listed and what had been spent; narrow the pattern, remove or move what it walks into, or raise the budget. The same are fields of the error's `.cause`, for a caller that words its own message: `pattern` (the one being expanded when the budget ran out, which need not be the large one), `directory`, `exhausted` (`'entries'` or `'time'`), `entries` and `elapsedMs` (what all the patterns had spent between them), `maxEntries` and `timeoutMs`. The time is checked before each directory is listed and at each entry in it, so what is not cut short is a single step: a listing that blocks (a dead network mount), the resolution of a link that leads to one, or one name that is slow to match (a pattern with several wildcards in one path component, against a long name). The patterns share one deadline, so what is done with one pattern's matches after its walk is time taken from the patterns still to come; only what follows the last walk is not counted. With debug logging on (`SRT_DEBUG`) each expansion logs one line: the pattern, the milliseconds it took, its matches and mounts, the directories listed and the entries looked at. +- The expansion of a read configuration's patterns (`allowRead`, `denyRead`, and the globs among `credentials.files`) has a budget, shared by all of them: 20,000,000 directory entries read from disk and 60 seconds, unless `filesystem.denyReadGlobBudget` (`maxEntries`, `timeoutMs`) sets another, in the initialized configuration, through `updateConfig()`, or in the `customConfig` of one wrap, which replaces only the limit it names. A value that is not a positive integer, or a key that is neither of the two, fails the wrap. A directory that several `denyRead` patterns walk is read once and counted once; going through it again costs time alone. What was read is kept until the last of them is expanded, about 100 bytes an entry. The time is that of the clock on the wall: it depends on the machine and on what is cached, so the first command after start-up in a very large tree can be refused where a second is not, and it includes what the host itself does in the turns the walk leaves it. When either runs out the wrap is refused with a `LinuxSandboxProfileError` whose `.code` is `deny_glob_too_large`, and no command is returned: a deny list cut short would leave readable what the pattern was written to hide. `wrapWithSandboxArgv()` rejects the same way, and `getFsReadConfig()` throws the same error. `initialize()` and `updateConfig()` expand nothing, so they accept a configuration that is past the budget, and nothing is kept between wraps: every wrap of it is refused, each after spending the budget again. The message names the pattern, the directory being listed and what had been spent; narrow the pattern, remove or move what it walks into, or raise the budget. The same are fields of the error's `.cause`, a `GlobWalkBudgetError` (the type is exported), for a caller that words its own message or retries when only the time ran out: `pattern` (the one being expanded when the budget ran out, which need not be the large one), `directory`, `exhausted` (`'entries'` or `'time'`), `entries` and `elapsedMs` (what all the patterns had spent between them), `maxEntries` and `timeoutMs`. The time is checked before each directory is listed and at each entry in it, so what is not cut short is a single step: a listing that blocks (a dead network mount), the resolution of a link that leads to one, or one name that is slow to match (a pattern with several wildcards in one path component, against a long name). What is done with one pattern's matches after its walk is time taken from the patterns still to come; only what follows the last walk is not counted. With debug logging on (`SRT_DEBUG`) each `denyRead` expansion logs one line: the pattern, the milliseconds it took, its matches and mounts, the directories gone through and the entries looked at. - 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. @@ -1139,7 +1139,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, and `deny_glob_too_large`, which says the `denyRead` 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. +- 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 c763cdd6a..9559499ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,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 1e68d3bc0..e14a1a179 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -973,9 +973,9 @@ export type LinuxSandboxProfileErrorCode = /** The line does not fit one shell argument even with the mounts in a file. */ | 'command_too_long' /** - * The `denyRead` patterns could not be expanded within their budget, so - * which paths to hide is not known. The error on `.cause` names the - * pattern, the directory being listed and what had been spent. + * 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' diff --git a/src/sandbox/read-deny-glob.ts b/src/sandbox/read-deny-glob.ts index d69d34392..728da9887 100644 --- a/src/sandbox/read-deny-glob.ts +++ b/src/sandbox/read-deny-glob.ts @@ -1,7 +1,6 @@ import { logForDebugging } from '../utils/debug.js' import { - type GlobWalkBudget, - type GlobWalkListings, + type GlobWalkOptions, isAtOrUnder, normalizePathForSandbox, pathSpellings, @@ -60,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). @@ -86,11 +90,7 @@ export function expandReadDenyGlobLinux( globPattern: string, reExposedPaths: readonly string[], unlistableDirs?: Set, - opts: { - anchor?: string - budget?: GlobWalkBudget - listings?: GlobWalkListings - } = {}, + opts: ReadDenyGlobOptions = {}, ): string[] { return finish( expandReadDenyGlobLinuxSteps( @@ -107,19 +107,13 @@ export function* expandReadDenyGlobLinuxSteps( globPattern: string, reExposedPaths: readonly string[], unlistableDirs?: Set, - opts: { - anchor?: string - budget?: GlobWalkBudget - listings?: GlobWalkListings - } = {}, + opts: ReadDenyGlobOptions = {}, ): Steps { const startedAt = performance.now() const walk = yield* walkGlobPatternSteps(globPattern, { withDirectoryForm: true, followSymlinkedDirectories: true, - anchor: opts.anchor, - budget: opts.budget, - 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 6c39b9f42..0c65c5848 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -984,8 +984,8 @@ export const FilesystemConfigSchema = z.object({ .strict() .optional() .describe( - 'Linux: what expanding the denyRead globs of one configuration may spend, all of them together, ' + - 'before the wrap is refused: directory entries looked at (default 20,000,000) and ' + + '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 diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index a198446b7..b754fb343 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -41,11 +41,12 @@ import type { RipgrepConfig } from '../utils/ripgrep.js' import { getPlatform, getWslVersion } from '../utils/platform.js' import * as fs from 'fs' import { randomBytes } from 'node:crypto' -import type { - CredentialsConfig, - FilesystemPathEntry, - SandboxRuntimeConfig, - SeccompConfig, +import { + FilesystemConfigSchema, + type CredentialsConfig, + type FilesystemPathEntry, + type SandboxRuntimeConfig, + type SeccompConfig, } from './sandbox-config.js' import type { SandboxAskCallback, @@ -114,6 +115,7 @@ import { GlobWalkBudgetError, walkGlobPatternSteps, workingDirectory, + type GlobWalkBudget, type GlobWalkListings, newGlobWalkBudget, type Steps, @@ -1957,53 +1959,77 @@ function stripWriteGlobs( }) } -function* expandAllowReadGlob( - pattern: string, - anchor?: string, -): Steps { - const expanded = (yield* walkGlobPatternSteps(pattern, { anchor })).matches - logForDebugging( - `[Sandbox] Expanded allowRead glob pattern "${pattern}" to ${expanded.length} paths on Linux`, +/** + * 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 { + return newGlobWalkBudget( + FilesystemConfigSchema.shape.denyReadGlobBudget.parse( + Object.assign({}, ...limits), + { path: ['filesystem', 'denyReadGlobBudget'] }, + ), ) - return expanded +} + +/** `walk`, which is of `pattern`, with a budget that runs out in it told as + * {@link LinuxSandboxProfileError} `deny_glob_too_large`. */ +function* withinBudget(pattern: string, walk: Steps): Steps { + try { + return yield* walk + } catch (error) { + if (!(error instanceof GlobWalkBudgetError)) throw error + throw new LinuxSandboxProfileError( + 'deny_glob_too_large', + `Pattern "${pattern}" could not be expanded: the allowRead, denyRead and credentials.files patterns of one ` + + `configuration share ${error.maxEntries} directory entries and ${error.timeoutMs} ms, and the ${error.exhausted} ran out ` + + `with ${error.entries} entries read after ${error.elapsedMs} ms, while listing ${error.directory}; ` + + `narrow the pattern, remove or move what it walks into, or raise filesystem.denyReadGlobBudget`, + error, + ) + } +} + +function allowReadGlobExpander( + budget: GlobWalkBudget, +): (pattern: string, anchor?: string) => Steps { + return function* (pattern, anchor) { + const expanded = (yield* withinBudget( + pattern, + walkGlobPatternSteps(pattern, { anchor, budget }), + )).matches + logForDebugging( + `[Sandbox] Expanded allowRead glob pattern "${pattern}" to ${expanded.length} paths on Linux`, + ) + return expanded + } } /** * The expansion of every denyRead glob of one read configuration, on Linux, - * all on one budget of `limits` (`filesystem.denyReadGlobBudget`) and listing - * each directory once between them, in `listings`, which a caller that goes - * on to other work hands in to empty it afterwards. When the budget runs out - * the returned function throws {@link LinuxSandboxProfileError} - * `deny_glob_too_large`. + * listing each directory once between them, in `listings`, which a caller that + * goes on to other work hands in to empty it afterwards. */ function readDenyGlobExpander( reExposedPaths: readonly string[], unlistableDenyDirs: Set, - limits: SandboxRuntimeConfig['filesystem']['denyReadGlobBudget'], + budget: GlobWalkBudget, listings: GlobWalkListings = new Map(), ): (pattern: string, anchor?: string) => Steps { - const budget = newGlobWalkBudget(limits) - return function* (pattern, anchor) { - try { - // Both: a walk beneath an anchor draws on the budget like any other. - return yield* expandReadDenyGlobLinuxSteps( + return (pattern, anchor) => + withinBudget( + pattern, + expandReadDenyGlobLinuxSteps( pattern, reExposedPaths, unlistableDenyDirs, { anchor, budget, listings }, - ) - } catch (error) { - if (!(error instanceof GlobWalkBudgetError)) throw error - throw new LinuxSandboxProfileError( - 'deny_glob_too_large', - `denyRead pattern "${pattern}" could not be expanded: the denyRead patterns of one configuration share ` + - `${error.maxEntries} directory entries and ${error.timeoutMs} ms, and the ${error.exhausted} ran out ` + - `with ${error.entries} entries looked at after ${error.elapsedMs} ms, while listing ${error.directory}; ` + - `narrow the pattern, remove or move what it walks into, or raise filesystem.denyReadGlobBudget`, - error, - ) - } - } + ), + ) } /** @@ -2028,11 +2054,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, + allowReadGlobExpander(budget), ), ) const reExposedPaths = reExposedBy( @@ -2045,11 +2072,7 @@ function getFsReadConfig(): FsReadRestrictionConfig { resolveReadDenies( config.filesystem.denyRead, credentialRestrictions, - readDenyGlobExpander( - reExposedPaths, - unlistableDenyDirs, - config.filesystem.denyReadGlobBudget, - ), + readDenyGlobExpander(reExposedPaths, unlistableDenyDirs, budget), ), ) @@ -2602,13 +2625,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, + allowReadGlobExpander(budget), ), ) if (expandedAllowRead === REPLACED) return startOver() @@ -2638,8 +2665,7 @@ async function wrapWithSandboxOnce( readDenyGlobExpander( reExposedPaths, unlistableDenyDirs, - customConfig?.filesystem?.denyReadGlobBudget ?? - config?.filesystem.denyReadGlobBudget, + budget, listings, ), ), diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index 517ffd3e4..3e8068807 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -1252,8 +1252,8 @@ export interface ExpandGlobOptions { anchor?: string } -/** The most directory entries the read-deny expansions of one read - * configuration look at, all its patterns together, unless +/** The most directory entries the glob walks of one read configuration read + * from disk, all its patterns together, unless * `filesystem.denyReadGlobBudget` sets another. */ export const GLOB_WALK_MAX_ENTRIES = 20_000_000 @@ -1261,17 +1261,17 @@ export const GLOB_WALK_MAX_ENTRIES = 20_000_000 export const GLOB_WALK_TIMEOUT_MS = 60_000 /** - * What the walks handed it may spend between them: one count and one - * deadline, so the patterns of one configuration cannot each spend a full one. + * 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 look at, together. */ + /** 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 - /** When the walks must be done, the same way. */ - readonly deadline: number - /** The directory entries looked at so far. */ + /** The directory entries read so far. */ entries: number } @@ -1279,11 +1279,10 @@ export interface GlobWalkBudget { export function newGlobWalkBudget( limits: { maxEntries?: number; timeoutMs?: number } = {}, ): GlobWalkBudget { - const startedAt = performance.now() return { maxEntries: limits.maxEntries ?? GLOB_WALK_MAX_ENTRIES, - startedAt, - deadline: startedAt + (limits.timeoutMs ?? GLOB_WALK_TIMEOUT_MS), + timeoutMs: limits.timeoutMs ?? GLOB_WALK_TIMEOUT_MS, + startedAt: performance.now(), entries: 0, } } @@ -1299,7 +1298,7 @@ export class GlobWalkBudgetError extends Error { readonly directory: string /** Which limit was reached. */ readonly exhausted: 'entries' | 'time' - /** The entries looked at under the budget, by this walk and those before. */ + /** The entries read under the budget, by this walk and those before. */ readonly entries: number /** Milliseconds since the budget was made. */ readonly elapsedMs: number @@ -1314,9 +1313,8 @@ export class GlobWalkBudgetError extends Error { budget: GlobWalkBudget, ) { const elapsedMs = Math.round(performance.now() - budget.startedAt) - const timeoutMs = Math.round(budget.deadline - budget.startedAt) super( - `Glob pattern ${pattern} was not walked to the end: ${budget.entries} directory entries looked at in ${elapsedMs} ms (the budget is ${budget.maxEntries} entries and ${timeoutMs} ms), stopped while listing ${directory}`, + `Glob pattern ${pattern} was not walked to the end: ${budget.entries} directory entries read in ${elapsedMs} ms (the budget is ${budget.maxEntries} entries and ${budget.timeoutMs} ms), stopped while listing ${directory}`, ) this.name = 'GlobWalkBudgetError' this.pattern = pattern @@ -1325,7 +1323,7 @@ export class GlobWalkBudgetError extends Error { this.entries = budget.entries this.elapsedMs = elapsedMs this.maxEntries = budget.maxEntries - this.timeoutMs = timeoutMs + this.timeoutMs = budget.timeoutMs } } @@ -1412,10 +1410,10 @@ export interface GlobWalk { * what was found through a symlinked directory, which is reported where * it really lives to begin with. */ realOf: Map - /** How many directories were listed. */ + /** How many directories were gone through, whoever read them from disk. */ directoriesListed: number - /** How many directory entries were looked at: each entry once for every - * listing of its directory the pattern called for. */ + /** How many of their entries were looked at: each once for every time the + * pattern called for its directory. */ entriesExamined: number } @@ -1807,20 +1805,19 @@ export function* walkGlobPatternSteps( const records = new Map() const listings: GlobWalkListings = opts.listings ?? new Map() const pending: Frame[] = [] - /** Counts one more entry of `dir` looked at, or none before a listing, and - * throws once the budget is spent. The clock is read every time: what one - * entry costs is not bounded, so a stride would not bound the time. Called - * outside every `try` here, so a spent budget is never taken for a - * directory that could not be listed. */ - const spend = (dir: string, entries: 0 | 1): void => { - walk.entriesExamined += entries + /** 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 += entries + budget.entries += read if (budget.entries > budget.maxEntries) { throw new GlobWalkBudgetError(globPath, dir, 'entries', budget) } - if (performance.now() >= budget.deadline) { + if (performance.now() - budget.startedAt >= budget.timeoutMs) { throw new GlobWalkBudgetError(globPath, dir, 'time', budget) } } @@ -1924,6 +1921,7 @@ export function* walkGlobPatternSteps( ) { return entry } + spend(real, 0) const { name } = entry const spelled = path.join(listedAs, name) let type: 'directory' | 'link' | 'other' = 'other' @@ -2002,8 +2000,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 ?? @@ -2019,6 +2017,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}`, @@ -2040,10 +2040,12 @@ export function* walkGlobPatternSteps( } continue } + if (record.listedFor.size === 0) walk.directoriesListed++ for (const p of fresh) record.listedFor.add(p) for (const entry of entries) { // Before the skip below: an entry it passes over was looked at too. - spend(dir, 1) + walk.entriesExamined++ + 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. @@ -2162,9 +2164,5 @@ export function* walkGlobPatternSteps( } } - // Not `records.size`: that counts a directory that could not be listed too. - for (const record of records.values()) { - if (record.listedFor.size > 0) walk.directoriesListed++ - } return walk } diff --git a/test/sandbox/read-deny-glob-bound.test.ts b/test/sandbox/read-deny-glob-bound.test.ts index 39ccf6cfb..fd84f61a5 100644 --- a/test/sandbox/read-deny-glob-bound.test.ts +++ b/test/sandbox/read-deny-glob-bound.test.ts @@ -157,8 +157,7 @@ describe.if(!isWindows)('the budget of a glob walk', () => { expect(GLOB_WALK_TIMEOUT_MS).toBe(60_000) const budget = newGlobWalkBudget() expect(budget.maxEntries).toBe(20_000_000) - // Rounded: a floating-point sum less its first term need not be exact. - expect(Math.round(budget.deadline - budget.startedAt)).toBe(60_000) + expect(budget.timeoutMs).toBe(60_000) expect(budget.entries).toBe(0) }) @@ -472,7 +471,7 @@ describe.if(isLinux)('a read-deny glob past its budget, at the manager', () => { expect(fields).toEqual({ name: 'GlobWalkBudgetError', pattern, - directory: expect.stringMatching(new RegExp(`^${PROJ}(/|$)`)), + directory: expect.stringContaining(PROJ), exhausted: 'entries', entries: 26, elapsedMs: expect.any(Number), @@ -529,6 +528,44 @@ describe.if(isLinux)('a read-deny glob past its budget, at the manager', () => { }) }) + // PROJ is 40 entries, and so is each tree planted here. + it.each([ + ['an allowRead pattern', (p: string) => ({ allowRead: [p] }), 'proj'], + [ + 'a credentials.files pattern', + (p: string) => ({ credentials: { files: [{ path: p, mode: 'deny' }] } }), + 'proj', + ], + // Walked beneath an anchor: the folder, brackets and all, is a name. + ['a pattern in a folder named with brackets', () => ({}), 'pk[g]s'], + ] as const)('holds %s to the budget too', async (_what, listing, name) => { + const tree = plantProject(name) + const pattern = join(tree, '**/.env') + const { allowRead, credentials } = { + allowRead: undefined, + credentials: undefined, + ...listing(pattern), + } + const wrap = (maxEntries: number): Promise => + SandboxManager.wrapWithSandbox('echo hello', undefined, { + filesystem: { + denyRead: allowRead || credentials ? [] : [pattern], + allowRead, + allowWrite: [], + denyWrite: [], + denyReadGlobBudget: { maxEntries }, + }, + credentials, + }).catch((error: unknown) => error) + + expect(await wrap(1000)).toBeString() + const refusal = await wrap(39) + expect(refusal).toBeInstanceOf(LinuxSandboxProfileError) + const { cause } = refusal as LinuxSandboxProfileError + expect((cause as GlobWalkBudgetError).pattern).toBe(pattern) + expect((cause as GlobWalkBudgetError).directory).toStartWith(tree) + }) + it('refuses the wrap over a tree that only links inside it lead through', async () => { const chain = join(ROOT, 'chain') plantChain(chain, 30) @@ -579,9 +616,7 @@ describe.if(isLinux)('a read-deny glob past its budget, at the manager', () => { ) const cause = (error as LinuxSandboxProfileError).cause expect(cause).toBeInstanceOf(GlobWalkBudgetError) - expect((cause as GlobWalkBudgetError).directory).toMatch( - new RegExp(`^${OTHER}(/|$)`), - ) + expect((cause as GlobWalkBudgetError).directory).toStartWith(OTHER) }) it('refuses the read configuration the same way', async () => { @@ -662,7 +697,16 @@ describe.if(isLinux)('a read-deny glob past its budget, at the manager', () => { timeoutMs: 1234, }) } - // One that names a budget is held to that. + // One that names a limit is held to that, and to the other as it was. + expect( + await refusedWith(() => + SandboxManager.wrapWithSandbox( + 'echo hello', + undefined, + configOf({ maxEntries: 38 }), + ), + ), + ).toEqual({ maxEntries: 38, timeoutMs: 1234 }) expect( await SandboxManager.wrapWithSandbox( 'echo hello', @@ -691,6 +735,37 @@ describe.if(isLinux)('a read-deny glob past its budget, at the manager', () => { }) }) +describe.if(isLinux)('limits that no schema has seen', () => { + const configOf = (denyReadGlobBudget: object) => ({ + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { + denyRead: [], + allowWrite: [], + denyWrite: [], + denyReadGlobBudget, + }, + }) + + afterAll(() => SandboxManager.reset()) + + // NaN would never be reached, and a misspelt key would leave the default. + it.each([{ maxEntries: NaN }, { timeoutMs: '1000' }, { maxEntires: 1 }])( + 'fail the wrap and the read configuration: %o', + async limits => { + await SandboxManager.reset() + await SandboxManager.initialize(configOf({})) + const named = /filesystem[^]*denyReadGlobBudget/ + // eslint-disable-next-line @typescript-eslint/await-thenable -- bun:test types .rejects.toThrow() as void; the await is required at runtime + await expect( + SandboxManager.wrapWithSandbox('true', undefined, configOf(limits)), + ).rejects.toThrow(named) + + SandboxManager.updateConfig(configOf(limits)) + expect(() => SandboxManager.getFsReadConfig()).toThrow(named) + }, + ) +}) + describe('the filesystem.denyReadGlobBudget option', () => { const parse = (denyReadGlobBudget: unknown) => FilesystemConfigSchema.safeParse({ diff --git a/test/sandbox/read-deny-walk-joins.test.ts b/test/sandbox/read-deny-walk-joins.test.ts index 5db6b173a..f9c3ae3a3 100644 --- a/test/sandbox/read-deny-walk-joins.test.ts +++ b/test/sandbox/read-deny-walk-joins.test.ts @@ -20,316 +20,338 @@ import { walkGlobPattern, walkGlobPatternSteps, } from '../../src/sandbox/sandbox-utils.js' -import { isLinux } from '../helpers/platform.js' +import { isLinux, isWindows } from '../helpers/platform.js' /** * Where the walk's budget, its shared listings and its steps meet. Each has * its own suite; these are the choices that only exist between them. */ -describe('the read-deny walk: budget, shared listings and steps together', () => { - const DIRECTORIES = 6 - const PLAIN_FILES = 10 - let ROOT: string - /** Every entry below ROOT: the directories, and the files in each. */ - const ENTRIES = DIRECTORIES * (1 + PLAIN_FILES + 1) +describe.if(!isWindows)( + 'the read-deny walk: budget, shared listings and steps together', + () => { + const DIRECTORIES = 6 + const PLAIN_FILES = 10 + let ROOT: string + /** Every entry below ROOT: the directories, and the files in each. */ + const ENTRIES = DIRECTORIES * (1 + PLAIN_FILES + 1) - beforeAll(() => { - ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'walk-joins-'))) - for (let d = 0; d < DIRECTORIES; d++) { - mkdirSync(join(ROOT, `d${d}`)) - writeFileSync(join(ROOT, `d${d}`, 'key.pem'), '') - for (let f = 0; f < PLAIN_FILES; f++) { - writeFileSync(join(ROOT, `d${d}`, `plain${f}.txt`), '') + beforeAll(() => { + ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'walk-joins-'))) + for (let d = 0; d < DIRECTORIES; d++) { + mkdirSync(join(ROOT, `d${d}`)) + writeFileSync(join(ROOT, `d${d}`, 'key.pem'), '') + for (let f = 0; f < PLAIN_FILES; f++) { + writeFileSync(join(ROOT, `d${d}`, `plain${f}.txt`), '') + } } - } - }) - - afterAll(async () => { - await SandboxManager.reset() - rmSync(ROOT, { recursive: true, force: true }) - }) + }) - /** Holds the thread for longer than a turn lasts. */ - const outlastATurn = (): void => { - const until = performance.now() + 15 - while (performance.now() < until); - } + afterAll(async () => { + await SandboxManager.reset() + rmSync(ROOT, { recursive: true, force: true }) + }) - it('charges the budget for an entry the pattern cannot match', () => { - const budget = newGlobWalkBudget() + /** Holds the thread for longer than a turn lasts. */ + const outlastATurn = (): void => { + const until = performance.now() + 15 + while (performance.now() < until); + } - walkGlobPattern(join(ROOT, '**/*.pem'), { budget }) + it('charges the budget for an entry the pattern cannot match', () => { + const budget = newGlobWalkBudget() - expect(budget.entries).toBe(ENTRIES) - expect(() => - walkGlobPattern(join(ROOT, '**/*.pem'), { - // More than the entries that match or are directories, and fewer - // than there are. - budget: newGlobWalkBudget({ maxEntries: DIRECTORIES * 2 + 1 }), - }), - ).toThrow(GlobWalkBudgetError) - }) + walkGlobPattern(join(ROOT, '**/*.pem'), { budget }) - it('charges the budget for an entry read from a listing another pattern made', () => { - const budget = newGlobWalkBudget() - const listings: GlobWalkListings = new Map() - const readdir = spyOn(fs, 'readdirSync') - try { - walkGlobPattern(join(ROOT, '**/*.pem'), { budget, listings }) - const listed = readdir.mock.calls.length - walkGlobPattern(join(ROOT, '**/*.txt'), { budget, listings }) + expect(budget.entries).toBe(ENTRIES) + expect(() => + walkGlobPattern(join(ROOT, '**/*.pem'), { + // More than the entries that match or are directories, and fewer + // than there are. + budget: newGlobWalkBudget({ maxEntries: DIRECTORIES * 2 + 1 }), + }), + ).toThrow(GlobWalkBudgetError) + }) - expect(listed).toBe(DIRECTORIES + 1) - expect(readdir.mock.calls.length).toBe(listed) - expect(budget.entries).toBe(ENTRIES * 2) - } finally { - readdir.mockRestore() - } - }) + it('charges the budget once for an entry that two patterns go through', () => { + const budget = newGlobWalkBudget() + const listings: GlobWalkListings = new Map() + const readdir = spyOn(fs, 'readdirSync') + try { + walkGlobPattern(join(ROOT, '**/*.pem'), { budget, listings }) + const listed = readdir.mock.calls.length + const second = walkGlobPattern(join(ROOT, '**/*.txt'), { + budget, + listings, + }) - it('reads the clock after a step and before the listing that follows it', () => { - const steps = walkGlobPatternSteps(join(ROOT, '**/*.pem'), { - budget: newGlobWalkBudget({ timeoutMs: 5 }), + expect(listed).toBe(DIRECTORIES + 1) + expect(readdir.mock.calls.length).toBe(listed) + expect(second.entriesExamined).toBe(ENTRIES) + expect(budget.entries).toBe(ENTRIES) + } finally { + readdir.mockRestore() + } }) - expect(steps.next().done).toBe(false) - // What a turn given to other work can take. - outlastATurn() - const readdir = spyOn(fs, 'readdirSync') - try { + + it('reads the clock at an entry another pattern has read', () => { + const listings: GlobWalkListings = new Map() + walkGlobPattern(join(ROOT, '**/*.pem'), { listings }) + const steps = walkGlobPatternSteps(join(ROOT, '**/*.txt'), { + budget: newGlobWalkBudget({ timeoutMs: 5 }), + listings, + }) + expect(steps.next().done).toBe(false) + outlastATurn() expect(() => steps.next()).toThrow(GlobWalkBudgetError) - expect(readdir.mock.calls.length).toBe(0) - } finally { - readdir.mockRestore() - } - }) + }) - // A walk by itself tries a directory under each name that leads to it - // (glob-expand: "does not let one name that fails to list answer for the - // others"). Between the patterns of one configuration, what the first - // pattern got is kept, and which of two things that is depends on the name - // the walk came by: - // - its own: there is no shorter name to ask, so the failure is kept, and - // has the directory denied whole by every pattern that follows; - // - a shorter one, through a link: the real path fails, the shorter name - // lists. The pattern that saw the failure has the directory denied whole - // all the same, and what was listed is kept, so a later pattern reads - // entries and adds only what it matches among them. - // Nothing is lost either way: a configuration denies what any of its - // patterns gives. The walk takes the entry listed last first. - it.each([ - ['its own name, so that the failure is kept', 'lnk', 2], - ['a shorter name, so that what that lists is kept', 'pkg', 1], - ])( - 'has a directory that failed to list denied whole for the configuration, reached by %s', - (_how, listedFirst, patternsThatGiveIt) => { - const root = realpathSync(mkdtempSync(join(tmpdir(), 'walk-joins-name-'))) - const certs = join(root, 'pkg', 'certs') - mkdirSync(certs, { recursive: true }) - writeFileSync(join(certs, 'id.pem'), '') - symlinkSync(join('pkg', 'certs'), join(root, 'lnk')) - let attempts = 0 - const readdirSync = fs.readdirSync - const spy = spyOn(fs, 'readdirSync').mockImplementation((( + it('reads the clock before each entry whose type has to be asked for', () => { + // Listed the way some NFS and FUSE mounts list: names, and no types. + const no = (): boolean => false + const { readdirSync, lstatSync } = fs + const listing = spyOn(fs, 'readdirSync').mockImplementation((( ...args: Parameters + ) => + (readdirSync(...args) as unknown as fs.Dirent[]).map(({ name }) => ({ + name, + isFile: no, + isDirectory: no, + isSymbolicLink: no, + isFIFO: no, + isSocket: no, + isCharacterDevice: no, + isBlockDevice: no, + }))) as unknown as typeof fs.readdirSync) + const asked = spyOn(fs, 'lstatSync').mockImplementation((( + ...args: Parameters ) => { - if (String(args[0]) === certs && ++attempts === 1) { - throw Object.assign(new Error('EMFILE: too many open files'), { - code: 'EMFILE', - }) - } - const entries = readdirSync(...args) as unknown as fs.Dirent[] - return String(args[0]) === root - ? entries.sort((x, y) => - x.name === listedFirst ? -1 : y.name === listedFirst ? 1 : 0, - ) - : entries - }) as unknown as typeof fs.readdirSync) + outlastATurn() + return lstatSync(...args) + }) as typeof fs.lstatSync) try { - const listings: GlobWalkListings = new Map() - const found = ['*.pem', '*.key'].map(name => - expandReadDenyGlobLinux(join(root, '**', name), [], undefined, { - listings, + expect(() => + walkGlobPattern(join(ROOT, '**/*.pem'), { + budget: newGlobWalkBudget({ timeoutMs: 20 }), }), - ) - // The pattern that saw the failure, whichever name it came by. - expect(found[0]).toEqual([certs]) - expect(found[1]).toEqual(patternsThatGiveIt === 2 ? [certs] : []) - expect([...new Set(found.flat())]).toEqual([certs]) - expect(listings.get(certs) instanceof Error).toBe( - patternsThatGiveIt === 2, - ) - expect(attempts).toBe(1) + ).toThrow(GlobWalkBudgetError) + expect(asked.mock.calls.length).toBeLessThan(DIRECTORIES) } finally { - spy.mockRestore() - rmSync(root, { recursive: true, force: true }) + listing.mockRestore() + asked.mockRestore() } - }, - ) - - describe.if(isLinux)('through the wrap', () => { - const network = { allowedDomains: [], deniedDomains: [] } - const filesystemWith = ( - denyReadGlobBudget: { maxEntries: number } | undefined, - ): { - denyRead: string[] - allowRead: string[] - allowWrite: string[] - denyWrite: string[] - denyReadGlobBudget?: { maxEntries: number } - } => ({ - denyRead: [join(ROOT, '**/*.pem')], - // Walked first, and in turns, so that there is a turn to act in. - allowRead: [join(ROOT, '**/*.none')], - allowWrite: [], - denyWrite: [], - ...(denyReadGlobBudget === undefined ? {} : { denyReadGlobBudget }), }) - /** Runs `act` in the listing below ROOT that `at` counts to, the second - * unless told otherwise, each outlasting a turn. */ - const duringTheWalk = ( - act: () => void, - at = 2, - ): { restore: () => void } => { - let listed = 0 - const readdirSync = fs.readdirSync - const spy = spyOn(fs, 'readdirSync').mockImplementation((( - ...args: Parameters - ) => { - if (String(args[0]).startsWith(ROOT)) { - if (++listed === at) act() - outlastATurn() - } - return readdirSync(...args) - }) as typeof fs.readdirSync) - return { restore: () => spy.mockRestore() } - } - const outcomeOf = (wrap: Promise): Promise => - wrap.catch((error: unknown) => error) - it('lists a directory once for all the denyRead patterns of a configuration, and charges each pattern for it', async () => { - await SandboxManager.reset() - await SandboxManager.initialize({ - network, - filesystem: { - denyRead: [join(ROOT, '**/*.pem'), join(ROOT, '**/*.txt')], - allowWrite: [], - denyWrite: [], - // Enough for one pattern and not for two. - denyReadGlobBudget: { maxEntries: ENTRIES + 1 }, - }, + it('reads the clock after a step and before the listing that follows it', () => { + const steps = walkGlobPatternSteps(join(ROOT, '**/*.pem'), { + budget: newGlobWalkBudget({ timeoutMs: 5 }), }) + expect(steps.next().done).toBe(false) + // What a turn given to other work can take. + outlastATurn() const readdir = spyOn(fs, 'readdirSync') - const listedBelowRoot = (): number => - readdir.mock.calls.filter(([dir]) => String(dir).startsWith(ROOT)) - .length try { - expect(() => SandboxManager.getFsReadConfig()).toThrow( - LinuxSandboxProfileError, + expect(() => steps.next()).toThrow(GlobWalkBudgetError) + expect(readdir.mock.calls.length).toBe(0) + } finally { + readdir.mockRestore() + } + }) + + // A walk by itself tries a directory under each name that leads to it + // (glob-expand: "does not let one name that fails to list answer for the + // others"). Between the patterns of one configuration, what the first + // pattern got is kept, and which of two things that is depends on the name + // the walk came by: + // - its own: there is no shorter name to ask, so the failure is kept, and + // has the directory denied whole by every pattern that follows; + // - a shorter one, through a link: the real path fails, the shorter name + // lists. The pattern that saw the failure has the directory denied whole + // all the same, and what was listed is kept, so a later pattern reads + // entries and adds only what it matches among them. + // Nothing is lost either way: a configuration denies what any of its + // patterns gives. The walk takes the entry listed last first. + it.each([ + ['its own name, so that the failure is kept', 'lnk', 2], + ['a shorter name, so that what that lists is kept', 'pkg', 1], + ])( + 'has a directory that failed to list denied whole for the configuration, reached by %s', + (_how, listedFirst, patternsThatGiveIt) => { + const root = realpathSync( + mkdtempSync(join(tmpdir(), 'walk-joins-name-')), ) - expect(listedBelowRoot()).toBe(DIRECTORIES + 1) + const certs = join(root, 'pkg', 'certs') + mkdirSync(certs, { recursive: true }) + writeFileSync(join(certs, 'id.pem'), '') + symlinkSync(join('pkg', 'certs'), join(root, 'lnk')) + let attempts = 0 + const readdirSync = fs.readdirSync + const spy = spyOn(fs, 'readdirSync').mockImplementation((( + ...args: Parameters + ) => { + if (String(args[0]) === certs && ++attempts === 1) { + throw Object.assign(new Error('EMFILE: too many open files'), { + code: 'EMFILE', + }) + } + const entries = readdirSync(...args) as unknown as fs.Dirent[] + return String(args[0]) === root + ? entries.sort((x, y) => + x.name === listedFirst ? -1 : y.name === listedFirst ? 1 : 0, + ) + : entries + }) as unknown as typeof fs.readdirSync) + try { + const listings: GlobWalkListings = new Map() + const found = ['*.pem', '*.key'].map(name => + expandReadDenyGlobLinux(join(root, '**', name), [], undefined, { + listings, + }), + ) + // The pattern that saw the failure, whichever name it came by. + expect(found[0]).toEqual([certs]) + expect(found[1]).toEqual(patternsThatGiveIt === 2 ? [certs] : []) + expect([...new Set(found.flat())]).toEqual([certs]) + expect(listings.get(certs) instanceof Error).toBe( + patternsThatGiveIt === 2, + ) + expect(attempts).toBe(1) + } finally { + spy.mockRestore() + rmSync(root, { recursive: true, force: true }) + } + }, + ) + + describe.if(isLinux)('through the wrap', () => { + const network = { allowedDomains: [], deniedDomains: [] } + const filesystemWith = ( + denyReadGlobBudget: { maxEntries: number } | undefined, + ): { + denyRead: string[] + allowRead: string[] + allowWrite: string[] + denyWrite: string[] + denyReadGlobBudget?: { maxEntries: number } + } => ({ + denyRead: [join(ROOT, '**/*.pem')], + // Walked first, and in turns, so that there is a turn to act in. + allowRead: [join(ROOT, '**/*.none')], + allowWrite: [], + denyWrite: [], + ...(denyReadGlobBudget === undefined ? {} : { denyReadGlobBudget }), + }) + /** Runs `act` in the listing below ROOT that `at` counts to, the second + * unless told otherwise, each outlasting a turn. */ + const duringTheWalk = ( + act: () => void, + at = 2, + ): { restore: () => void } => { + let listed = 0 + const readdirSync = fs.readdirSync + const spy = spyOn(fs, 'readdirSync').mockImplementation((( + ...args: Parameters + ) => { + if (String(args[0]).startsWith(ROOT)) { + if (++listed === at) act() + outlastATurn() + } + return readdirSync(...args) + }) as typeof fs.readdirSync) + return { restore: () => spy.mockRestore() } + } + const outcomeOf = (wrap: Promise): Promise => + wrap.catch((error: unknown) => error) - SandboxManager.updateConfig({ + it('lists a directory once for all the denyRead patterns of a configuration, and charges it once', async () => { + await SandboxManager.reset() + await SandboxManager.initialize({ network, filesystem: { denyRead: [join(ROOT, '**/*.pem'), join(ROOT, '**/*.txt')], allowWrite: [], denyWrite: [], + // Enough for the tree, however many patterns go through it. + denyReadGlobBudget: { maxEntries: ENTRIES }, }, }) - readdir.mockClear() - await SandboxManager.wrapWithSandbox('true') - expect(listedBelowRoot()).toBe(DIRECTORIES + 1) - } finally { - readdir.mockRestore() - } - }) + const readdir = spyOn(fs, 'readdirSync') + const listedBelowRoot = (): number => + readdir.mock.calls.filter(([dir]) => String(dir).startsWith(ROOT)) + .length + try { + SandboxManager.getFsReadConfig() + expect(listedBelowRoot()).toBe(DIRECTORIES + 1) - it('gives up for the signal, not for the budget, when both have run out', async () => { - await SandboxManager.reset() - await SandboxManager.initialize({ - network, - filesystem: filesystemWith({ maxEntries: 1 }), + readdir.mockClear() + await SandboxManager.wrapWithSandbox('true') + expect(listedBelowRoot()).toBe(DIRECTORIES + 1) + } finally { + readdir.mockRestore() + } }) - const stopped = new AbortController() - const reason = new Error('stopped by the user') - const walk = duringTheWalk(() => stopped.abort(reason)) - try { - expect( - await outcomeOf( - SandboxManager.wrapWithSandbox( - 'true', - undefined, - undefined, - stopped.signal, - ), - ), - ).toBe(reason) - } finally { - walk.restore() - } - }) - it('takes the budget of the configuration that replaced the one it started with', async () => { - await SandboxManager.reset() - await SandboxManager.initialize({ - network, - filesystem: filesystemWith(undefined), - }) - const walk = duringTheWalk(() => - SandboxManager.updateConfig({ + it('takes the budget of the configuration that replaced the one it started with', async () => { + await SandboxManager.reset() + await SandboxManager.initialize({ network, - filesystem: filesystemWith({ maxEntries: 1 }), - }), - ) - try { - const disturbed = await outcomeOf( - SandboxManager.wrapWithSandbox('true'), - ) - walk.restore() - - expect(disturbed).toBeInstanceOf(LinuxSandboxProfileError) - expect((disturbed as LinuxSandboxProfileError).code).toBe( - 'deny_glob_too_large', - ) - const undisturbed = await outcomeOf( - SandboxManager.wrapWithSandbox('true'), - ) - expect((undisturbed as LinuxSandboxProfileError).code).toBe( - 'deny_glob_too_large', - ) - } finally { - walk.restore() - } - }) - - it('takes the budget of a configuration that arrives during the denyRead walk', async () => { - await SandboxManager.reset() - await SandboxManager.initialize({ - network, - filesystem: filesystemWith(undefined), - }) - // In the denyRead walk, which comes after the allowRead one has listed - // every directory, and which began under a budget that has room. The - // case above never gets there: its first attempt reads the new budget. - const walk = duringTheWalk( - () => + filesystem: filesystemWith(undefined), + }) + const walk = duringTheWalk(() => SandboxManager.updateConfig({ network, - filesystem: filesystemWith({ maxEntries: ENTRIES - 1 }), + filesystem: filesystemWith({ maxEntries: 1 }), }), - DIRECTORIES + 1 + 2, - ) - try { - const disturbed = await outcomeOf( - SandboxManager.wrapWithSandbox('true'), ) - expect(disturbed).toBeInstanceOf(LinuxSandboxProfileError) - expect((disturbed as LinuxSandboxProfileError).code).toBe( - 'deny_glob_too_large', + try { + const disturbed = await outcomeOf( + SandboxManager.wrapWithSandbox('true'), + ) + walk.restore() + + expect(disturbed).toBeInstanceOf(LinuxSandboxProfileError) + expect((disturbed as LinuxSandboxProfileError).code).toBe( + 'deny_glob_too_large', + ) + const undisturbed = await outcomeOf( + SandboxManager.wrapWithSandbox('true'), + ) + expect((undisturbed as LinuxSandboxProfileError).code).toBe( + 'deny_glob_too_large', + ) + } finally { + walk.restore() + } + }) + + it('takes the budget of a configuration that arrives during the denyRead walk', async () => { + await SandboxManager.reset() + await SandboxManager.initialize({ + network, + filesystem: filesystemWith(undefined), + }) + // In the denyRead walk, which comes after the allowRead one has listed + // every directory, and which began under a budget that has room. The + // case above never gets there: its first attempt reads the new budget. + const walk = duringTheWalk( + () => + SandboxManager.updateConfig({ + network, + filesystem: filesystemWith({ maxEntries: ENTRIES - 1 }), + }), + DIRECTORIES + 1 + 2, ) - } finally { - walk.restore() - } + try { + const disturbed = await outcomeOf( + SandboxManager.wrapWithSandbox('true'), + ) + expect(disturbed).toBeInstanceOf(LinuxSandboxProfileError) + expect((disturbed as LinuxSandboxProfileError).code).toBe( + 'deny_glob_too_large', + ) + } finally { + walk.restore() + } + }) }) - }) -}) + }, +) From 1951705cbb200e6e49abd4aa20da255cafdf9b55 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:58:10 +0000 Subject: [PATCH 16/17] linux: the glob budget in a third of the lines A simplification pass over the whole diff against main. No behaviour of the budget changes. - A spent budget is turned into the profile error in one place, where the read entries are resolved, and the two expander factories are gone. - GlobWalkBudgetError works out which limit was reached and has one message. - The walk no longer counts directories listed and entries looked at, which only the debug line read; that line is main's again. - The two suites are one, over one planted tree and one clock that moves a millisecond a reading: 1,231 lines of tests are 392. - The README says it in one paragraph. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv Co-authored-by: ronleizrowice --- README.md | 4 +- src/sandbox/linux-sandbox-utils.ts | 3 +- src/sandbox/read-deny-glob.ts | 14 +- src/sandbox/sandbox-manager.ts | 101 +- src/sandbox/sandbox-utils.ts | 50 +- test/sandbox/read-deny-glob-bound.test.ts | 1116 ++++++--------------- test/sandbox/read-deny-walk-joins.test.ts | 357 ------- 7 files changed, 375 insertions(+), 1270 deletions(-) delete mode 100644 test/sandbox/read-deny-walk-joins.test.ts diff --git a/README.md b/README.md index a3c18ed66..0093bbb0d 100644 --- a/README.md +++ b/README.md @@ -530,7 +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; positive integers, either or both). What expanding the globs of one read configuration may spend, in directory entries read and in milliseconds, before the wrap is refused; see "Path Syntax (Linux)". +- `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: @@ -565,7 +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. -- The expansion of a read configuration's patterns (`allowRead`, `denyRead`, and the globs among `credentials.files`) has a budget, shared by all of them: 20,000,000 directory entries read from disk and 60 seconds, unless `filesystem.denyReadGlobBudget` (`maxEntries`, `timeoutMs`) sets another, in the initialized configuration, through `updateConfig()`, or in the `customConfig` of one wrap, which replaces only the limit it names. A value that is not a positive integer, or a key that is neither of the two, fails the wrap. A directory that several `denyRead` patterns walk is read once and counted once; going through it again costs time alone. What was read is kept until the last of them is expanded, about 100 bytes an entry. The time is that of the clock on the wall: it depends on the machine and on what is cached, so the first command after start-up in a very large tree can be refused where a second is not, and it includes what the host itself does in the turns the walk leaves it. When either runs out the wrap is refused with a `LinuxSandboxProfileError` whose `.code` is `deny_glob_too_large`, and no command is returned: a deny list cut short would leave readable what the pattern was written to hide. `wrapWithSandboxArgv()` rejects the same way, and `getFsReadConfig()` throws the same error. `initialize()` and `updateConfig()` expand nothing, so they accept a configuration that is past the budget, and nothing is kept between wraps: every wrap of it is refused, each after spending the budget again. The message names the pattern, the directory being listed and what had been spent; narrow the pattern, remove or move what it walks into, or raise the budget. The same are fields of the error's `.cause`, a `GlobWalkBudgetError` (the type is exported), for a caller that words its own message or retries when only the time ran out: `pattern` (the one being expanded when the budget ran out, which need not be the large one), `directory`, `exhausted` (`'entries'` or `'time'`), `entries` and `elapsedMs` (what all the patterns had spent between them), `maxEntries` and `timeoutMs`. The time is checked before each directory is listed and at each entry in it, so what is not cut short is a single step: a listing that blocks (a dead network mount), the resolution of a link that leads to one, or one name that is slow to match (a pattern with several wildcards in one path component, against a long name). What is done with one pattern's matches after its walk is time taken from the patterns still to come; only what follows the last walk is not counted. With debug logging on (`SRT_DEBUG`) each `denyRead` expansion logs one line: the pattern, the milliseconds it took, its matches and mounts, the directories gone through and the entries looked at. +- 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 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. diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index e14a1a179..04b86aa3d 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -983,8 +983,7 @@ export type LinuxSandboxProfileErrorCode = * Thrown when a Linux bubblewrap profile cannot be run on this host: what the * configuration expands to, with the mandatory denies of the working * directory, is past a limit, or, for `command_too_long` and `nul_in_path`, - * what the caller passed in is; for `deny_glob_too_large` the profile could - * not be worked out at all. The command was not run and no + * what the caller passed in is. The command was not run and no * profile file stays open, so do not run the per-command cleanup * (`cleanupAfterCommand()`, `cleanupBwrapMountPoints()`) for a wrap that * threw: it would release a second time, and a sandbox still running would diff --git a/src/sandbox/read-deny-glob.ts b/src/sandbox/read-deny-glob.ts index 728da9887..58e81ac0b 100644 --- a/src/sandbox/read-deny-glob.ts +++ b/src/sandbox/read-deny-glob.ts @@ -73,18 +73,14 @@ type ReadDenyGlobOptions = Pick< * could not list is denied whole. Sorted, so an ancestor precedes its * descendants. * - * Throws {@link GlobWalkBudgetError} when `budget` runs out. There is no - * shorter list to fall back on: the caller must not run the command. - * * @param unlistableDirs - receives the returned locations that hide something * the walk could not enumerate, whether by being that directory or by * 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`. - * @param opts.budget - shared with every expansion handed the same object. - * @param opts.listings - likewise: one map for all the patterns of a - * configuration. + * @param opts.budget - as in `GlobWalkOptions.budget`. + * @param opts.listings - one map for all the patterns of a configuration. */ export function expandReadDenyGlobLinux( globPattern: string, @@ -109,7 +105,6 @@ export function* expandReadDenyGlobLinuxSteps( unlistableDirs?: Set, opts: ReadDenyGlobOptions = {}, ): Steps { - const startedAt = performance.now() const walk = yield* walkGlobPatternSteps(globPattern, { withDirectoryForm: true, followSymlinkedDirectories: true, @@ -223,11 +218,8 @@ export function* expandReadDenyGlobLinuxSteps( } } - // One line per expansion: a caller that times its wraps reads the cost here. logForDebugging( - `[Sandbox Linux] Expanded denyRead glob "${globPattern}" in ${Math.round(performance.now() - startedAt)} ms: ` + - `${walk.matches.length} matches -> ${mounts.size} mounts; ` + - `directories listed: ${walk.directoriesListed}, entries looked at: ${walk.entriesExamined}`, + `[Sandbox Linux] Expanded denyRead glob "${globPattern}": ${walk.matches.length} matches -> ${mounts.size} mounts`, ) for (const mount of mounts) { // A matched link decides what is hidden for the whole sandbox: a diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index b754fb343..656086fea 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -41,13 +41,13 @@ import type { RipgrepConfig } from '../utils/ripgrep.js' import { getPlatform, getWslVersion } from '../utils/platform.js' import * as fs from 'fs' import { randomBytes } from 'node:crypto' -import { - FilesystemConfigSchema, - type CredentialsConfig, - type FilesystemPathEntry, - type SandboxRuntimeConfig, - type SeccompConfig, +import type { + CredentialsConfig, + FilesystemPathEntry, + SandboxRuntimeConfig, + SeccompConfig, } from './sandbox-config.js' +import { FilesystemConfigSchema } from './sandbox-config.js' import type { SandboxAskCallback, CredentialRestrictionConfig, @@ -1918,11 +1918,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) } @@ -1976,60 +1973,33 @@ function readGlobBudget( ) } -/** `walk`, which is of `pattern`, with a budget that runs out in it told as +/** `walk`, with a budget that runs out in it told as * {@link LinuxSandboxProfileError} `deny_glob_too_large`. */ -function* withinBudget(pattern: string, walk: Steps): Steps { +function* withinBudget(walk: Steps): Steps { try { return yield* walk } catch (error) { if (!(error instanceof GlobWalkBudgetError)) throw error throw new LinuxSandboxProfileError( 'deny_glob_too_large', - `Pattern "${pattern}" could not be expanded: the allowRead, denyRead and credentials.files patterns of one ` + - `configuration share ${error.maxEntries} directory entries and ${error.timeoutMs} ms, and the ${error.exhausted} ran out ` + - `with ${error.entries} entries read after ${error.elapsedMs} ms, while listing ${error.directory}; ` + + `${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 allowReadGlobExpander( +function* expandAllowReadGlob( budget: GlobWalkBudget, -): (pattern: string, anchor?: string) => Steps { - return function* (pattern, anchor) { - const expanded = (yield* withinBudget( - pattern, - walkGlobPatternSteps(pattern, { anchor, budget }), - )).matches - logForDebugging( - `[Sandbox] Expanded allowRead glob pattern "${pattern}" to ${expanded.length} paths on Linux`, - ) - return expanded - } -} - -/** - * The expansion of every denyRead glob of one read configuration, on Linux, - * listing each directory once between them, in `listings`, which a caller that - * goes on to other work hands in to empty it afterwards. - */ -function readDenyGlobExpander( - reExposedPaths: readonly string[], - unlistableDenyDirs: Set, - budget: GlobWalkBudget, - listings: GlobWalkListings = new Map(), -): (pattern: string, anchor?: string) => Steps { - return (pattern, anchor) => - withinBudget( - pattern, - expandReadDenyGlobLinuxSteps( - pattern, - reExposedPaths, - unlistableDenyDirs, - { anchor, budget, listings }, - ), - ) + pattern: string, + anchor?: string, +): Steps { + const expanded = (yield* walkGlobPatternSteps(pattern, { anchor, budget })) + .matches + logForDebugging( + `[Sandbox] Expanded allowRead glob pattern "${pattern}" to ${expanded.length} paths on Linux`, + ) + return expanded } /** @@ -2038,8 +2008,7 @@ function readDenyGlobExpander( * this config's allowRead and {@link getFsWriteConfig}'s write roots, so * `denyOnly` is only sound alongside that write config and must not be handed * to wrapCommandWithSandboxLinux with a different one. Marked entries are - * in the `literal…` lists, as spelled: pass the object on whole. Throws - * {@link LinuxSandboxProfileError} `deny_glob_too_large` as the wrap does. + * in the `literal…` lists, as spelled: pass the object on whole. */ function getFsReadConfig(): FsReadRestrictionConfig { if (!config || config.filesystem.disabled) { @@ -2059,7 +2028,7 @@ function getFsReadConfig(): FsReadRestrictionConfig { resolveReadPathEntries( 'allow', config.filesystem.allowRead ?? [], - allowReadGlobExpander(budget), + (pattern, anchor) => expandAllowReadGlob(budget, pattern, anchor), ), ) const reExposedPaths = reExposedBy( @@ -2068,11 +2037,18 @@ function getFsReadConfig(): FsReadRestrictionConfig { getFsWriteConfig(), ) const unlistableDenyDirs = new Set() + const listings: GlobWalkListings = new Map() const denyPaths = finish( resolveReadDenies( config.filesystem.denyRead, credentialRestrictions, - readDenyGlobExpander(reExposedPaths, unlistableDenyDirs, budget), + (pattern, anchor) => + expandReadDenyGlobLinuxSteps( + pattern, + reExposedPaths, + unlistableDenyDirs, + { anchor, budget, listings }, + ), ), ) @@ -2635,7 +2611,7 @@ async function wrapWithSandboxOnce( customConfig?.filesystem?.allowRead ?? config?.filesystem.allowRead ?? [], - allowReadGlobExpander(budget), + (pattern, anchor) => expandAllowReadGlob(budget, pattern, anchor), ), ) if (expandedAllowRead === REPLACED) return startOver() @@ -2662,12 +2638,13 @@ async function wrapWithSandboxOnce( resolveReadDenies( customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead, credentialRestrictions, - readDenyGlobExpander( - reExposedPaths, - unlistableDenyDirs, - budget, - listings, - ), + (pattern, anchor) => + expandReadDenyGlobLinuxSteps( + pattern, + reExposedPaths, + unlistableDenyDirs, + { anchor, budget, listings }, + ), ), ) // Emptied by hand: a runtime may keep what this function has named for as diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index 3e8068807..2698ea053 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -1252,14 +1252,6 @@ export interface ExpandGlobOptions { anchor?: string } -/** The most directory entries the glob walks of one read configuration read - * from disk, all its patterns together, unless - * `filesystem.denyReadGlobBudget` sets another. */ -export const GLOB_WALK_MAX_ENTRIES = 20_000_000 - -/** The longest they take, all together, in milliseconds; likewise. */ -export const GLOB_WALK_TIMEOUT_MS = 60_000 - /** * 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. @@ -1280,8 +1272,8 @@ export function newGlobWalkBudget( limits: { maxEntries?: number; timeoutMs?: number } = {}, ): GlobWalkBudget { return { - maxEntries: limits.maxEntries ?? GLOB_WALK_MAX_ENTRIES, - timeoutMs: limits.timeoutMs ?? GLOB_WALK_TIMEOUT_MS, + maxEntries: limits.maxEntries ?? 20_000_000, + timeoutMs: limits.timeoutMs ?? 60_000, startedAt: performance.now(), entries: 0, } @@ -1292,33 +1284,27 @@ export function newGlobWalkBudget( * found is not handed back: a deny list cut short is a path left readable. */ export class GlobWalkBudgetError extends Error { - /** The pattern being walked, as the caller wrote it. */ - readonly pattern: string - /** The directory the walk was listing. */ - readonly directory: string /** 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 - /** The entries the budget allowed. */ readonly maxEntries: number - /** The milliseconds it allowed. */ readonly timeoutMs: number constructor( - pattern: string, - directory: string, - exhausted: 'entries' | 'time', + /** 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( - `Glob pattern ${pattern} was not walked to the end: ${budget.entries} directory entries read in ${elapsedMs} ms (the budget is ${budget.maxEntries} entries and ${budget.timeoutMs} ms), stopped while listing ${directory}`, + `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.pattern = pattern - this.directory = directory this.exhausted = exhausted this.entries = budget.entries this.elapsedMs = elapsedMs @@ -1410,11 +1396,6 @@ export interface GlobWalk { * what was found through a symlinked directory, which is reported where * it really lives to begin with. */ realOf: Map - /** How many directories were gone through, whoever read them from disk. */ - directoriesListed: number - /** How many of their entries were looked at: each once for every time the - * pattern called for its directory. */ - entriesExamined: number } /** @@ -1715,8 +1696,6 @@ export function* walkGlobPatternSteps( uninspectableLinks: new Set(), unlisted: [], realOf: new Map(), - directoriesListed: 0, - entriesExamined: 0, } // Beneath an anchor the pattern is the tail alone, so no character of the @@ -1814,11 +1793,11 @@ export function* walkGlobPatternSteps( const budget = opts.budget if (budget === undefined) return budget.entries += read - if (budget.entries > budget.maxEntries) { - throw new GlobWalkBudgetError(globPath, dir, 'entries', budget) - } - if (performance.now() - budget.startedAt >= budget.timeoutMs) { - throw new GlobWalkBudgetError(globPath, dir, 'time', budget) + if ( + budget.entries > budget.maxEntries || + performance.now() - budget.startedAt >= budget.timeoutMs + ) { + throw new GlobWalkBudgetError(globPath, dir, budget) } } const codeOf = (err: unknown): string | undefined => @@ -2040,11 +2019,8 @@ export function* walkGlobPatternSteps( } continue } - if (record.listedFor.size === 0) walk.directoriesListed++ for (const p of fresh) record.listedFor.add(p) for (const entry of entries) { - // Before the skip below: an entry it passes over was looked at too. - walk.entriesExamined++ 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 diff --git a/test/sandbox/read-deny-glob-bound.test.ts b/test/sandbox/read-deny-glob-bound.test.ts index fd84f61a5..9eeb5def4 100644 --- a/test/sandbox/read-deny-glob-bound.test.ts +++ b/test/sandbox/read-deny-glob-bound.test.ts @@ -1,874 +1,392 @@ -import { describe, it, expect, beforeAll, afterAll, spyOn } from 'bun:test' -// The namespace the library binds, so the code under test sees a spy on it. -import * as sandboxUtils from '../../src/sandbox/sandbox-utils.js' import { - mkdirSync, - mkdtempSync, - realpathSync, - rmSync, - symlinkSync, - writeFileSync, -} from 'node:fs' -import { spawnSync } from 'node:child_process' + 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 { - GLOB_WALK_MAX_ENTRIES, - GLOB_WALK_TIMEOUT_MS, - GlobWalkBudgetError, + 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 { FilesystemConfigSchema } from '../../src/sandbox/sandbox-config.js' -import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' -import { LinuxSandboxProfileError } from '../../src/sandbox/linux-sandbox-utils.js' import { isLinux, isWindows } from '../helpers/platform.js' -import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' -import { countMounts } from '../helpers/bwrap-argv.js' - -/** - * `root/start/next -> ../pool/d1`, `pool/d1/next -> ../d2` and so on, each - * `pool/d` holding a `.env`: `links` directories only the links lead to. - */ -function plantChain(root: string, links: number): void { - mkdirSync(join(root, 'start'), { recursive: true }) - mkdirSync(join(root, 'pool')) - symlinkSync(join('..', 'pool', 'd1'), join(root, 'start', 'next')) - for (let i = 1; i <= links; i++) { - mkdirSync(join(root, 'pool', `d${i}`)) - writeFileSync(join(root, 'pool', `d${i}`, '.env'), '') - if (i < links) { - symlinkSync(join('..', `d${i + 1}`), join(root, 'pool', `d${i}`, 'next')) + +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) } -/** What `fn` throws, or undefined when it returns. */ -function thrownBy(fn: () => unknown): unknown { +/** 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 { - fn() + await run() + return undefined } catch (error) { return error } - return undefined } -describe.if(!isWindows)( - 'a read-deny glob and a link to a directory beside the project', - () => { - // project/out -> ../outside, which holds deep/.env: `project/**/.env` - // matches that file as project/out/deep/.env and under no other name. - let ROOT: string - let project: string - let secret: string - const hasBwrap = isLinux && bwrapCanNamespace() - - beforeAll(() => { - ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-beside-'))) - project = join(ROOT, 'project') - secret = join(ROOT, 'outside', 'deep', '.env') - mkdirSync(project) - mkdirSync(join(ROOT, 'outside', 'deep'), { recursive: true }) - writeFileSync(join(project, '.env'), 'INSIDE\n') - writeFileSync(secret, 'SECRET\n') - writeFileSync(join(ROOT, 'outside', 'deep', 'notes.txt'), 'NOTES\n') - symlinkSync(join('..', 'outside'), join(project, 'out')) - }) - - afterAll(async () => { - await SandboxManager.reset() - rmSync(ROOT, { recursive: true, force: true }) - }) +beforeAll(() => { + ROOT = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), 'deny-glob-budget-'))) + TREE = plantTree('tree') + PATTERN = join(TREE, '**/.env') +}) - it('denies a match behind the link, where it really is', () => { - expect(expandReadDenyGlobLinux(join(project, '**/.env'), [])).toEqual([ - secret, - join(project, '.env'), - ]) - }) +afterEach(() => { + mock.restore() +}) - it.skipIf(!hasBwrap)( - 'hides the match from the sandbox under both names', - async () => { - // BOOTED first: a sandbox that refuses to start prints nothing, which - // would read as every file hidden. - const result = spawnSync( - await SandboxManager.wrapWithSandbox( - [ - 'echo BOOTED', - `cat ${join(project, 'out', 'deep', '.env')} 2>/dev/null || echo HIDDEN`, - `cat ${secret} 2>/dev/null || echo HIDDEN`, - `cat ${join(project, 'out', 'deep', 'notes.txt')}`, - ].join('; '), - undefined, - { - filesystem: { - denyRead: [join(project, '**/.env')], - allowWrite: [], - denyWrite: [], - }, - }, - ), - { shell: true, encoding: 'utf8', timeout: 15000 }, - ) - - expect(result.stderr ?? '').not.toContain('bwrap:') - expect(result.stdout.trim().split('\n')).toEqual([ - 'BOOTED', - 'HIDDEN', - 'HIDDEN', - 'NOTES', - ]) - }, - ) - }, -) +afterAll(() => { + fs.rmSync(ROOT, { recursive: true, force: true }) +}) describe.if(!isWindows)('the budget of a glob walk', () => { - let ROOT: string - /** Twelve directories of four files, one of them a match: 60 entries. */ - let TREE: string - let TREE_ENTRIES: number - - function plantTree(name: string): string { - const tree = join(ROOT, name) - for (let d = 0; d < 12; d++) { - mkdirSync(join(tree, `d${d}`), { recursive: true }) - for (const f of ['.env', 'a', 'b', 'c']) { - writeFileSync(join(tree, `d${d}`, f), '') - } - } - return tree - } - - beforeAll(() => { - ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-budget-'))) - TREE = plantTree('tree') - TREE_ENTRIES = walkGlobPattern(join(TREE, '**/.env')).entriesExamined - }) - - afterAll(() => { - rmSync(ROOT, { recursive: true, force: true }) - }) + const expand = (pattern: string, maxEntries: number): string[] => + expandReadDenyGlobLinux(pattern, [], undefined, { + budget: newGlobWalkBudget({ maxEntries }), + }) it('defaults to twenty million entries and sixty seconds', () => { - expect(GLOB_WALK_MAX_ENTRIES).toBe(20_000_000) - expect(GLOB_WALK_TIMEOUT_MS).toBe(60_000) - const budget = newGlobWalkBudget() - expect(budget.maxEntries).toBe(20_000_000) - expect(budget.timeoutMs).toBe(60_000) - expect(budget.entries).toBe(0) + expect(newGlobWalkBudget()).toMatchObject({ + maxEntries: 20_000_000, + timeoutMs: 60_000, + entries: 0, + }) }) - it('counts every entry it looks at, with or without a budget', () => { - expect(TREE_ENTRIES).toBe(12 + 12 * 4) + it('is charged for every entry read from disk, matched or not, and not for one another walk has read', () => { const budget = newGlobWalkBudget() - const walk = walkGlobPattern(join(TREE, '**/.env'), { budget }) - expect(walk.entriesExamined).toBe(TREE_ENTRIES) - expect(walk.directoriesListed).toBe(13) - expect(budget.entries).toBe(TREE_ENTRIES) + 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 when the tree holds more entries than the budget, and hands nothing back', () => { - const pattern = join(TREE, '**/.env') + it('throws at the entry past maxEntries, and says where it was', async () => { // Exactly enough is enough. - expect( - expandReadDenyGlobLinux(pattern, [], undefined, { - budget: newGlobWalkBudget({ maxEntries: TREE_ENTRIES }), - }), - ).toHaveLength(12) - - const budget = newGlobWalkBudget({ maxEntries: TREE_ENTRIES - 1 }) - let mounts: string[] | undefined - const error = thrownBy(() => { - mounts = expandReadDenyGlobLinux(pattern, [], undefined, { budget }) - }) + expect(expand(PATTERN, ENTRIES)).toHaveLength(DIRECTORIES) - expect(mounts).toBeUndefined() - expect(error).toBeInstanceOf(GlobWalkBudgetError) - const budgetError = error as GlobWalkBudgetError - expect(budgetError.exhausted).toBe('entries') - expect(budgetError.pattern).toBe(pattern) - expect(budgetError.directory.startsWith(TREE + '/')).toBe(true) - expect(budgetError.entries).toBe(TREE_ENTRIES) - expect(budgetError.maxEntries).toBe(TREE_ENTRIES - 1) - expect(budgetError.elapsedMs).toBeGreaterThanOrEqual(0) - expect(budgetError.message).toContain(pattern) - expect(budgetError.message).toContain(budgetError.directory) - expect(() => - walkGlobPattern(pattern, { - budget: newGlobWalkBudget({ maxEntries: 5 }), - }), - ).toThrow(GlobWalkBudgetError) - }) - - it('throws when the deadline has passed before it starts', () => { - const pattern = join(TREE, '**/.env') - let mounts: string[] | undefined - const error = thrownBy(() => { - mounts = expandReadDenyGlobLinux(pattern, [], undefined, { - budget: newGlobWalkBudget({ timeoutMs: 0 }), - }) + 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, }) - - expect(mounts).toBeUndefined() - expect(error).toBeInstanceOf(GlobWalkBudgetError) - const budgetError = error as GlobWalkBudgetError - expect(budgetError.exhausted).toBe('time') - expect(budgetError.pattern).toBe(pattern) - expect(budgetError.directory).toBe(TREE) - expect(budgetError.entries).toBe(0) - expect(budgetError.timeoutMs).toBe(0) - }) - - it('throws when the deadline passes while it walks', () => { - // A clock that moves a second each time it is read. The budget is made at - // 1 s, the tree and the first two directories beneath it take the readings - // to 24 s, and the next, before the third is listed, is past the deadline. - let now = 0 - const clock = spyOn(performance, 'now').mockImplementation( - () => (now += 1000), - ) - try { - const pattern = join(TREE, '**/.env') - const budget = newGlobWalkBudget({ timeoutMs: 23_500 }) - const error = thrownBy(() => walkGlobPattern(pattern, { budget })) - - expect(error).toBeInstanceOf(GlobWalkBudgetError) - const budgetError = error as GlobWalkBudgetError - expect(budgetError.exhausted).toBe('time') - expect(budgetError.directory.startsWith(TREE + '/')).toBe(true) - expect(budgetError.entries).toBe(12 + 2 * 4) - } finally { - clock.mockRestore() - } - }) - - it('throws inside a directory of few entries, at the entry the deadline passes on', () => { - // The clock is read at every entry, not every so many, so a directory of - // slow names is not walked to its end. One reading a second again: the - // fourth entry, at 6 s, is past the deadline. - const few = join(ROOT, 'few') - mkdirSync(few) - for (let f = 0; f < 40; f++) writeFileSync(join(few, `f${f}`), '') - let now = 0 - const clock = spyOn(performance, 'now').mockImplementation( - () => (now += 1000), - ) - try { - const budget = newGlobWalkBudget({ timeoutMs: 4500 }) - const error = thrownBy(() => - walkGlobPattern(join(few, '*.env'), { budget }), - ) - - expect(error).toBeInstanceOf(GlobWalkBudgetError) - const budgetError = error as GlobWalkBudgetError - expect(budgetError.exhausted).toBe('time') - expect(budgetError.directory).toBe(few) - expect(budgetError.entries).toBe(4) - } finally { - clock.mockRestore() - } }) - it('is spent by every expansion it is handed to', () => { - const second = plantTree('second') - const budget = newGlobWalkBudget({ maxEntries: TREE_ENTRIES + 10 }) - - expect( - expandReadDenyGlobLinux(join(TREE, '**/.env'), [], undefined, { - budget, - }), - ).toHaveLength(12) - expect(budget.entries).toBe(TREE_ENTRIES) - - // The second tree fits a fresh budget, and not what is left of this one. - expect( - expandReadDenyGlobLinux(join(second, '**/.env'), [], undefined, { - budget: newGlobWalkBudget({ maxEntries: TREE_ENTRIES + 10 }), - }), - ).toHaveLength(12) - const error = thrownBy(() => - expandReadDenyGlobLinux(join(second, '**/.env'), [], undefined, { - budget, - }), - ) - expect(error).toBeInstanceOf(GlobWalkBudgetError) - expect((error as GlobWalkBudgetError).pattern).toBe(join(second, '**/.env')) - expect((error as GlobWalkBudgetError).entries).toBe(TREE_ENTRIES + 11) - }) - - it('throws on a tree that only links inside it lead through', () => { - // A tree a command allowed to write there can make as long as it likes. - // `start*` names start alone, so nothing but the links leads into pool. - const chain = join(ROOT, 'chain') - const links = 40 - plantChain(chain, links) - const pattern = join(chain, 'start*', '**/.env') - const whole = walkGlobPattern(pattern, { - followSymlinkedDirectories: true, - }) - expect(whole.matches).toHaveLength(links) - // chain holds start and pool; every other entry was met through a link. - expect(whole.entriesExamined).toBe(2 + 2 * links) - - let mounts: string[] | undefined - const error = thrownBy(() => { - mounts = expandReadDenyGlobLinux(pattern, [], undefined, { - budget: newGlobWalkBudget({ maxEntries: whole.entriesExamined - 1 }), - }) + 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 + '/'), }) - - expect(mounts).toBeUndefined() - expect(error).toBeInstanceOf(GlobWalkBudgetError) - expect((error as GlobWalkBudgetError).directory).toBe( - join(chain, 'pool', `d${links}`), - ) }) - it('is spent on what a link to a directory beside the project leads to', () => { - // linked/bigtree -> tree: one entry under the base, sixty behind the link. - const proj = join(ROOT, 'linked') - mkdirSync(proj) - symlinkSync(join('..', 'tree'), join(proj, 'bigtree')) - const pattern = join(proj, '**/.env') - const whole = newGlobWalkBudget({ maxEntries: 1 + TREE_ENTRIES }) - expect( - expandReadDenyGlobLinux(pattern, [], undefined, { budget: whole }), - ).toHaveLength(12) - expect(whole.entries).toBe(1 + TREE_ENTRIES) - - let mounts: string[] | undefined - const error = thrownBy(() => { - mounts = expandReadDenyGlobLinux(pattern, [], undefined, { - budget: newGlobWalkBudget({ maxEntries: TREE_ENTRIES }), - }) - }) - - expect(mounts).toBeUndefined() - expect(error).toBeInstanceOf(GlobWalkBudgetError) - const budgetError = error as GlobWalkBudgetError - expect(budgetError.exhausted).toBe('entries') - expect(budgetError.pattern).toBe(pattern) - // Named where it really is, as what is found in it would be. - expect(budgetError.directory.startsWith(TREE + '/')).toBe(true) - }) + // 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 }) - it('does not take a spent budget for a directory that could not be listed', () => { - // A listing that fails marks its directory to be denied whole. A spent - // budget, found right before a listing or at an entry, is not one of - // those: taken for one, it would deny the tree whole and hand that back. - for (const limits of [{ maxEntries: 20 }, { timeoutMs: 0 }]) { - const unlistable = new Set() - let mounts: string[] | undefined - const error = thrownBy(() => { - mounts = expandReadDenyGlobLinux( - join(TREE, '**/.env'), - [], - unlistable, - { budget: newGlobWalkBudget(limits) }, - ) + expect( + await refusalOf(() => walkGlobPattern(PATTERN, { budget, listings })), + ).toMatchObject({ + exhausted: 'time', + directory: expect.stringMatching(directory), + entries, }) + }, + ) - expect(error).toBeInstanceOf(GlobWalkBudgetError) - expect(mounts).toBeUndefined() - expect([...unlistable]).toEqual([]) + 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), } + listingsThrough((_dir, entries) => 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) }) }) -describe.if(isLinux)('a read-deny glob past its budget, at the manager', () => { - let ROOT: string - let PROJ: string - let OTHER: string - const newBudget = sandboxUtils.newGlobWalkBudget - - /** Has the manager make its budgets with these limits instead. */ - function withBudgetOf( - limits: { maxEntries?: number; timeoutMs?: number }, - fn: (made: () => number) => Promise, - ): Promise { - const spy = spyOn(sandboxUtils, 'newGlobWalkBudget').mockImplementation( - () => newBudget(limits), - ) - return fn(() => spy.mock.calls.length).finally(() => spy.mockRestore()) - } - - function plantProject(name: string): string { - const proj = join(ROOT, name) - for (let d = 0; d < 10; d++) { - mkdirSync(join(proj, `pkg${d}`), { recursive: true }) - for (const f of ['.env', 'index.js', 'readme.md']) { - writeFileSync(join(proj, `pkg${d}`, f), '') - } - } - return proj - } - - function filesystemOf(denyRead: string[]) { - return { filesystem: { denyRead, allowWrite: [], denyWrite: [] } } - } - - /** The wrapped command, or what the wrap threw: never both. */ - async function wrapOf( - denyRead: string[], - ): Promise<{ wrapped?: string; error?: unknown }> { - try { - return { - wrapped: await SandboxManager.wrapWithSandbox( - 'echo hello', - undefined, - filesystemOf(denyRead), - ), - } - } catch (error) { - return { error } +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, } } - beforeAll(() => { - ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-manager-'))) - PROJ = plantProject('proj') - OTHER = plantProject('other') - }) - - afterAll(async () => { - await SandboxManager.reset() - rmSync(ROOT, { recursive: true, force: true }) - }) - - it('wraps an ordinary project with the default budget', async () => { - const { wrapped, error } = await wrapOf([join(PROJ, '**/.env')]) + afterAll(() => SandboxManager.reset()) - expect(error).toBeUndefined() - for (let d = 0; d < 10; d++) { - expect(wrapped).toContain( - `--ro-bind /dev/null ${join(PROJ, `pkg${d}`, '.env')}`, - ) + 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('refuses the wrap, and returns no command, when the tree is past the budget', async () => { - const pattern = join(PROJ, '**/.env') - const { wrapped, error } = await withBudgetOf({ maxEntries: 25 }, () => - wrapOf([pattern]), - ) - - expect(wrapped).toBeUndefined() - expect(error).toBeInstanceOf(LinuxSandboxProfileError) - const profileError = error as LinuxSandboxProfileError - expect(profileError.code).toBe('deny_glob_too_large') - expect(profileError.message).toContain(`"${pattern}"`) - expect(profileError.message).toContain('25 directory entries') - expect(profileError.message).toContain('narrow the pattern') - expect(profileError.cause).toBeInstanceOf(GlobWalkBudgetError) - const cause = profileError.cause as GlobWalkBudgetError - expect(cause.exhausted).toBe('entries') - expect(profileError.message).toContain(cause.directory) - // Plain fields on `.cause`: a caller needs neither the class nor the text. - const fields: Record = { ...cause } - expect(fields).toEqual({ - name: 'GlobWalkBudgetError', - pattern, - directory: expect.stringContaining(PROJ), - exhausted: 'entries', - entries: 26, - elapsedMs: expect.any(Number), - maxEntries: 25, - timeoutMs: 60_000, - }) - // A rejected promise, which is how every caller of the wrap meets it. - await withBudgetOf({ maxEntries: 25 }, async () => { - // eslint-disable-next-line @typescript-eslint/await-thenable -- bun:test types .rejects.toThrow() as void; the await is required at runtime - await expect( - SandboxManager.wrapWithSandbox( - 'echo hello', - undefined, - filesystemOf([pattern]), - ), - ).rejects.toThrow(LinuxSandboxProfileError) - }) - }) - - it('refuses the wrap when the time is up', async () => { - const { wrapped, error } = await withBudgetOf({ timeoutMs: 0 }, () => - wrapOf([join(PROJ, '**/.env')]), - ) - - expect(wrapped).toBeUndefined() - expect(error).toBeInstanceOf(LinuxSandboxProfileError) - expect((error as LinuxSandboxProfileError).code).toBe('deny_glob_too_large') - expect((error as LinuxSandboxProfileError).message).toContain( - 'the time ran out', - ) - }) - - it('gives the patterns of one wrap one budget between them', async () => { - const first = join(PROJ, '**/.env') - const second = join(OTHER, '**/.env') - // Each tree is 40 entries: either fits 60, and the two do not. - await withBudgetOf({ maxEntries: 60 }, async made => { - expect((await wrapOf([first])).error).toBeUndefined() - expect((await wrapOf([second])).error).toBeUndefined() - expect(made()).toBe(2) - - const { wrapped, error } = await wrapOf([first, second]) - - expect(made()).toBe(3) - expect(wrapped).toBeUndefined() - expect(error).toBeInstanceOf(LinuxSandboxProfileError) - expect((error as LinuxSandboxProfileError).code).toBe( - 'deny_glob_too_large', - ) - // The pattern that was being expanded when the budget ran out. - expect((error as LinuxSandboxProfileError).message).toContain( - `"${second}"`, - ) - }) - }) - // PROJ is 40 entries, and so is each tree planted here. - it.each([ - ['an allowRead pattern', (p: string) => ({ allowRead: [p] }), 'proj'], - [ - 'a credentials.files pattern', - (p: string) => ({ credentials: { files: [{ path: p, mode: 'deny' }] } }), - 'proj', - ], - // Walked beneath an anchor: the folder, brackets and all, is a name. - ['a pattern in a folder named with brackets', () => ({}), 'pk[g]s'], - ] as const)('holds %s to the budget too', async (_what, listing, name) => { - const tree = plantProject(name) - const pattern = join(tree, '**/.env') - const { allowRead, credentials } = { - allowRead: undefined, - credentials: undefined, - ...listing(pattern), - } - const wrap = (maxEntries: number): Promise => - SandboxManager.wrapWithSandbox('echo hello', undefined, { - filesystem: { - denyRead: allowRead || credentials ? [] : [pattern], - allowRead, - allowWrite: [], - denyWrite: [], - denyReadGlobBudget: { maxEntries }, + 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, }, - credentials, - }).catch((error: unknown) => error) - - expect(await wrap(1000)).toBeString() - const refusal = await wrap(39) - expect(refusal).toBeInstanceOf(LinuxSandboxProfileError) - const { cause } = refusal as LinuxSandboxProfileError - expect((cause as GlobWalkBudgetError).pattern).toBe(pattern) - expect((cause as GlobWalkBudgetError).directory).toStartWith(tree) - }) - - it('refuses the wrap over a tree that only links inside it lead through', async () => { - const chain = join(ROOT, 'chain') - plantChain(chain, 30) - const pattern = join(chain, 'start*', '**/.env') - expect((await wrapOf([pattern])).wrapped).toContain( - `--ro-bind /dev/null ${join(chain, 'pool', 'd30', '.env')}`, - ) + }) + 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) + } + }) - // chain itself holds two entries: the rest are spent behind the links. - const { wrapped, error } = await withBudgetOf({ maxEntries: 20 }, () => - wrapOf([pattern]), + 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 } }) + }, ) - expect(wrapped).toBeUndefined() - expect(error).toBeInstanceOf(LinuxSandboxProfileError) - expect((error as LinuxSandboxProfileError).code).toBe('deny_glob_too_large') - expect( - ((error as LinuxSandboxProfileError).cause as GlobWalkBudgetError) - .directory, - ).toStartWith(join(chain, 'pool') + '/') - }) + 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') - it('refuses the wrap over a tree that a link leads to from beside it', async () => { - const proj = join(ROOT, 'linked') - mkdirSync(join(proj, 'src'), { recursive: true }) - writeFileSync(join(proj, '.env'), '') - symlinkSync(join('..', 'other'), join(proj, 'bigtree')) - const pattern = join(proj, '**/.env') - // With room for it, what the link leads to is walked and masked. - const roomy = await wrapOf([pattern]) - expect(roomy.error).toBeUndefined() - for (const masked of [join(proj, '.env'), join(OTHER, 'pkg0', '.env')]) { expect( - countMounts(roomy.wrapped!, '--ro-bind', '/dev/null', masked), - ).toBe(1) - } - - // proj itself holds three entries, and OTHER forty. - const { wrapped, error } = await withBudgetOf({ maxEntries: 30 }, () => - wrapOf([pattern]), - ) + 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 }, + }) + } + }) - expect(wrapped).toBeUndefined() - expect(error).toBeInstanceOf(LinuxSandboxProfileError) - expect((error as LinuxSandboxProfileError).code).toBe('deny_glob_too_large') - expect((error as LinuxSandboxProfileError).message).toContain( - `"${pattern}"`, + // 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/), + }) + }, ) - const cause = (error as LinuxSandboxProfileError).cause - expect(cause).toBeInstanceOf(GlobWalkBudgetError) - expect((cause as GlobWalkBudgetError).directory).toStartWith(OTHER) }) - it('refuses the read configuration the same way', async () => { - const pattern = join(PROJ, '**/.env') - await SandboxManager.reset() - await SandboxManager.initialize({ - network: { allowedDomains: [], deniedDomains: [] }, - filesystem: { denyRead: [pattern], allowWrite: [], denyWrite: [] }, - }) - try { - expect(SandboxManager.getFsReadConfig().denyOnly).toHaveLength(10) - - const error = await withBudgetOf({ maxEntries: 25 }, async () => - thrownBy(() => SandboxManager.getFsReadConfig()), - ) - - expect(error).toBeInstanceOf(LinuxSandboxProfileError) - expect((error as LinuxSandboxProfileError).code).toBe( - 'deny_glob_too_large', - ) - expect((error as LinuxSandboxProfileError).message).toContain( - `"${pattern}"`, + 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), ) - } finally { - await SandboxManager.reset() } - }) - - it('takes its limits from filesystem.denyReadGlobBudget: initialized, updated, or of one wrap', async () => { - const pattern = join(PROJ, '**/.env') - const masked = `--ro-bind /dev/null ${join(PROJ, 'pkg9', '.env')}` - const configOf = (denyReadGlobBudget: { - maxEntries?: number - timeoutMs?: number - }) => ({ - network: { allowedDomains: [], deniedDomains: [] }, - filesystem: { ...filesystemOf([pattern]).filesystem, denyReadGlobBudget }, - }) - /** The limits named by the refusal `run` ends in. */ - async function refusedWith(run: () => unknown): Promise { - let error: unknown - try { - await run() - } catch (thrown) { - error = thrown - } - expect(error).toBeInstanceOf(LinuxSandboxProfileError) - const profileError = error as LinuxSandboxProfileError - expect(profileError.code).toBe('deny_glob_too_large') - expect(profileError.message).toContain( - 'or raise filesystem.denyReadGlobBudget', - ) - const { maxEntries, timeoutMs } = - profileError.cause as GlobWalkBudgetError - return { maxEntries, timeoutMs } - } - await SandboxManager.reset() - // PROJ is 40 entries. await SandboxManager.initialize( - configOf({ maxEntries: 39, timeoutMs: 1234 }), + configOf({ maxEntries: ENTRIES - 1, timeoutMs: 1234 }, lists), ) - try { - for (const run of [ - () => SandboxManager.getFsReadConfig(), - () => SandboxManager.wrapWithSandbox('echo hello'), - () => SandboxManager.wrapWithSandboxArgv('echo hello'), - // A filesystem of one wrap that names no budget keeps the initialized. - () => - SandboxManager.wrapWithSandbox( - 'echo hello', - undefined, - filesystemOf([pattern]), - ), - ]) { - expect(await refusedWith(run)).toEqual({ - maxEntries: 39, - timeoutMs: 1234, - }) - } - // One that names a limit is held to that, and to the other as it was. - expect( - await refusedWith(() => - SandboxManager.wrapWithSandbox( - 'echo hello', - undefined, - configOf({ maxEntries: 38 }), - ), - ), - ).toEqual({ maxEntries: 38, timeoutMs: 1234 }) - expect( - await SandboxManager.wrapWithSandbox( - 'echo hello', - undefined, - configOf({ maxEntries: 40 }), - ), - ).toContain(masked) - - SandboxManager.updateConfig(configOf({ maxEntries: 40 })) - expect(SandboxManager.getFsReadConfig().denyOnly).toHaveLength(10) - expect(await SandboxManager.wrapWithSandbox('echo hello')).toContain( - masked, - ) - expect( - await refusedWith(() => - SandboxManager.wrapWithSandbox( - 'echo hello', - undefined, - configOf({ maxEntries: 39 }), - ), - ), - ).toEqual({ maxEntries: 39, timeoutMs: 60_000 }) - } finally { - await SandboxManager.reset() - } - }) -}) -describe.if(isLinux)('limits that no schema has seen', () => { - const configOf = (denyReadGlobBudget: object) => ({ - network: { allowedDomains: [], deniedDomains: [] }, - filesystem: { - denyRead: [], - allowWrite: [], - denyWrite: [], - denyReadGlobBudget, - }, + expect(await refusalWith()).toMatchObject({ + cause: { maxEntries: ENTRIES - 1, timeoutMs: 1234 }, + }) + expect(await refusalWith({ maxEntries: ENTRIES - 2 })).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 }, + }) }) - afterAll(() => SandboxManager.reset()) + // 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', + async (_walk, arrivesAt) => { + const lists = { allowRead: [join(TREE, '**/a')], denyRead: [PATTERN] } + const tighter = configOf({ maxEntries: ENTRIES - 1 }, lists) + SandboxManager.updateConfig(configOf(undefined, lists)) + let listed = 0 + listingsThrough((dir, entries) => { + if (dir.startsWith(TREE) && ++listed === arrivesAt) { + SandboxManager.updateConfig(tighter) + } + return entries + }) - // NaN would never be reached, and a misspelt key would leave the default. - it.each([{ maxEntries: NaN }, { timeoutMs: '1000' }, { maxEntires: 1 }])( - 'fail the wrap and the read configuration: %o', - async limits => { - await SandboxManager.reset() - await SandboxManager.initialize(configOf({})) - const named = /filesystem[^]*denyReadGlobBudget/ - // eslint-disable-next-line @typescript-eslint/await-thenable -- bun:test types .rejects.toThrow() as void; the await is required at runtime - await expect( - SandboxManager.wrapWithSandbox('true', undefined, configOf(limits)), - ).rejects.toThrow(named) - - SandboxManager.updateConfig(configOf(limits)) - expect(() => SandboxManager.getFsReadConfig()).toThrow(named) + expect( + await refusalOf(() => SandboxManager.wrapWithSandbox('true')), + ).toMatchObject({ cause: { maxEntries: ENTRIES - 1 } }) }, ) }) -describe('the filesystem.denyReadGlobBudget option', () => { - const parse = (denyReadGlobBudget: unknown) => - FilesystemConfigSchema.safeParse({ - denyRead: [], - allowWrite: [], - denyWrite: [], - denyReadGlobBudget, - }) - - it('takes positive integers, either or both, and nothing else', () => { - for (const budget of [ - undefined, - {}, - { maxEntries: 1 }, - { timeoutMs: 120_000 }, - { maxEntries: 50_000_000, timeoutMs: 1 }, - ]) { - const parsed = parse(budget) - expect(parsed.success).toBe(true) - expect(parsed.data?.denyReadGlobBudget).toEqual(budget) - } - for (const budget of [ - { maxEntries: 0 }, - { maxEntries: -1 }, - { maxEntries: 1.5 }, - { maxEntries: '1000' }, - { maxEntries: Infinity }, - { maxEntires: 1 }, - { timeoutMs: 0 }, - { timeoutMs: 0.5 }, - { timeoutMs: null }, - 1000, - ]) { - expect(parse(budget).success).toBe(false) - } - }) -}) - -describe.if(!isWindows)('the debug line of a read-deny expansion', () => { - let ROOT: string - - beforeAll(() => { - ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'deny-glob-debug-'))) - mkdirSync(join(ROOT, 'proj', 'src'), { recursive: true }) - mkdirSync(join(ROOT, 'outside')) - writeFileSync(join(ROOT, 'proj', '.env'), '') - writeFileSync(join(ROOT, 'proj', 'src', '.env'), '') - writeFileSync(join(ROOT, 'proj', 'src', 'index.js'), '') - symlinkSync(join('..', 'outside'), join(ROOT, 'proj', 'out')) - }) - - afterAll(() => { - rmSync(ROOT, { recursive: true, force: true }) - }) - - /** What the debug logger printed while `fn` ran, at every level. */ - function debugLinesOf(fn: () => void, debug: string | undefined): string[] { - const lines: string[] = [] - const savedDebug = process.env.SRT_DEBUG - if (debug === undefined) delete process.env.SRT_DEBUG - else process.env.SRT_DEBUG = debug - const record = (...parts: unknown[]): void => { - lines.push(parts.map(String).join(' ')) - } - const spies = [ - spyOn(console, 'warn').mockImplementation(record), - spyOn(console, 'error').mockImplementation(record), - ] - try { - fn() - return lines - } finally { - for (const spy of spies) spy.mockRestore() - if (savedDebug === undefined) delete process.env.SRT_DEBUG - else process.env.SRT_DEBUG = savedDebug - } - } - - it('says once what the expansion cost and found', () => { - const pattern = join(ROOT, 'proj', '**/.env') - const lines = debugLinesOf( - () => void expandReadDenyGlobLinux(pattern, []), - '1', - ) - - const summary = lines.filter(line => line.includes('Expanded denyRead')) - expect(summary).toHaveLength(1) - // proj, proj/src and what proj/out leads to listed; .env, src and out in - // the first, .env and index.js in the second, nothing in the third. - const timed = summary[0]!.replace(/ in \d+ ms: /, ' in ms: ') - expect(timed).not.toBe(summary[0]) - expect(timed).toEndWith( - `Expanded denyRead glob "${pattern}" in ms: 2 matches -> 2 mounts; ` + - `directories listed: 3, entries looked at: 5`, - ) - }) - - it('says nothing without SRT_DEBUG', () => { - expect( - debugLinesOf( - () => void expandReadDenyGlobLinux(join(ROOT, 'proj', '**/.env'), []), - undefined, - ), - ).toEqual([]) - }) +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) + }, + ) }) diff --git a/test/sandbox/read-deny-walk-joins.test.ts b/test/sandbox/read-deny-walk-joins.test.ts deleted file mode 100644 index f9c3ae3a3..000000000 --- a/test/sandbox/read-deny-walk-joins.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it, spyOn } from 'bun:test' -import * as fs from 'fs' -import { - mkdirSync, - mkdtempSync, - realpathSync, - rmSync, - symlinkSync, - writeFileSync, -} from 'fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { LinuxSandboxProfileError } from '../../src/sandbox/linux-sandbox-utils.js' -import { expandReadDenyGlobLinux } from '../../src/sandbox/read-deny-glob.js' -import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' -import { - GlobWalkBudgetError, - type GlobWalkListings, - newGlobWalkBudget, - walkGlobPattern, - walkGlobPatternSteps, -} from '../../src/sandbox/sandbox-utils.js' -import { isLinux, isWindows } from '../helpers/platform.js' - -/** - * Where the walk's budget, its shared listings and its steps meet. Each has - * its own suite; these are the choices that only exist between them. - */ -describe.if(!isWindows)( - 'the read-deny walk: budget, shared listings and steps together', - () => { - const DIRECTORIES = 6 - const PLAIN_FILES = 10 - let ROOT: string - /** Every entry below ROOT: the directories, and the files in each. */ - const ENTRIES = DIRECTORIES * (1 + PLAIN_FILES + 1) - - beforeAll(() => { - ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'walk-joins-'))) - for (let d = 0; d < DIRECTORIES; d++) { - mkdirSync(join(ROOT, `d${d}`)) - writeFileSync(join(ROOT, `d${d}`, 'key.pem'), '') - for (let f = 0; f < PLAIN_FILES; f++) { - writeFileSync(join(ROOT, `d${d}`, `plain${f}.txt`), '') - } - } - }) - - afterAll(async () => { - await SandboxManager.reset() - rmSync(ROOT, { recursive: true, force: true }) - }) - - /** Holds the thread for longer than a turn lasts. */ - const outlastATurn = (): void => { - const until = performance.now() + 15 - while (performance.now() < until); - } - - it('charges the budget for an entry the pattern cannot match', () => { - const budget = newGlobWalkBudget() - - walkGlobPattern(join(ROOT, '**/*.pem'), { budget }) - - expect(budget.entries).toBe(ENTRIES) - expect(() => - walkGlobPattern(join(ROOT, '**/*.pem'), { - // More than the entries that match or are directories, and fewer - // than there are. - budget: newGlobWalkBudget({ maxEntries: DIRECTORIES * 2 + 1 }), - }), - ).toThrow(GlobWalkBudgetError) - }) - - it('charges the budget once for an entry that two patterns go through', () => { - const budget = newGlobWalkBudget() - const listings: GlobWalkListings = new Map() - const readdir = spyOn(fs, 'readdirSync') - try { - walkGlobPattern(join(ROOT, '**/*.pem'), { budget, listings }) - const listed = readdir.mock.calls.length - const second = walkGlobPattern(join(ROOT, '**/*.txt'), { - budget, - listings, - }) - - expect(listed).toBe(DIRECTORIES + 1) - expect(readdir.mock.calls.length).toBe(listed) - expect(second.entriesExamined).toBe(ENTRIES) - expect(budget.entries).toBe(ENTRIES) - } finally { - readdir.mockRestore() - } - }) - - it('reads the clock at an entry another pattern has read', () => { - const listings: GlobWalkListings = new Map() - walkGlobPattern(join(ROOT, '**/*.pem'), { listings }) - const steps = walkGlobPatternSteps(join(ROOT, '**/*.txt'), { - budget: newGlobWalkBudget({ timeoutMs: 5 }), - listings, - }) - expect(steps.next().done).toBe(false) - outlastATurn() - expect(() => steps.next()).toThrow(GlobWalkBudgetError) - }) - - it('reads the clock before each entry whose type has to be asked for', () => { - // Listed the way some NFS and FUSE mounts list: names, and no types. - const no = (): boolean => false - const { readdirSync, lstatSync } = fs - const listing = spyOn(fs, 'readdirSync').mockImplementation((( - ...args: Parameters - ) => - (readdirSync(...args) as unknown as fs.Dirent[]).map(({ name }) => ({ - name, - isFile: no, - isDirectory: no, - isSymbolicLink: no, - isFIFO: no, - isSocket: no, - isCharacterDevice: no, - isBlockDevice: no, - }))) as unknown as typeof fs.readdirSync) - const asked = spyOn(fs, 'lstatSync').mockImplementation((( - ...args: Parameters - ) => { - outlastATurn() - return lstatSync(...args) - }) as typeof fs.lstatSync) - try { - expect(() => - walkGlobPattern(join(ROOT, '**/*.pem'), { - budget: newGlobWalkBudget({ timeoutMs: 20 }), - }), - ).toThrow(GlobWalkBudgetError) - expect(asked.mock.calls.length).toBeLessThan(DIRECTORIES) - } finally { - listing.mockRestore() - asked.mockRestore() - } - }) - - it('reads the clock after a step and before the listing that follows it', () => { - const steps = walkGlobPatternSteps(join(ROOT, '**/*.pem'), { - budget: newGlobWalkBudget({ timeoutMs: 5 }), - }) - expect(steps.next().done).toBe(false) - // What a turn given to other work can take. - outlastATurn() - const readdir = spyOn(fs, 'readdirSync') - try { - expect(() => steps.next()).toThrow(GlobWalkBudgetError) - expect(readdir.mock.calls.length).toBe(0) - } finally { - readdir.mockRestore() - } - }) - - // A walk by itself tries a directory under each name that leads to it - // (glob-expand: "does not let one name that fails to list answer for the - // others"). Between the patterns of one configuration, what the first - // pattern got is kept, and which of two things that is depends on the name - // the walk came by: - // - its own: there is no shorter name to ask, so the failure is kept, and - // has the directory denied whole by every pattern that follows; - // - a shorter one, through a link: the real path fails, the shorter name - // lists. The pattern that saw the failure has the directory denied whole - // all the same, and what was listed is kept, so a later pattern reads - // entries and adds only what it matches among them. - // Nothing is lost either way: a configuration denies what any of its - // patterns gives. The walk takes the entry listed last first. - it.each([ - ['its own name, so that the failure is kept', 'lnk', 2], - ['a shorter name, so that what that lists is kept', 'pkg', 1], - ])( - 'has a directory that failed to list denied whole for the configuration, reached by %s', - (_how, listedFirst, patternsThatGiveIt) => { - const root = realpathSync( - mkdtempSync(join(tmpdir(), 'walk-joins-name-')), - ) - const certs = join(root, 'pkg', 'certs') - mkdirSync(certs, { recursive: true }) - writeFileSync(join(certs, 'id.pem'), '') - symlinkSync(join('pkg', 'certs'), join(root, 'lnk')) - let attempts = 0 - const readdirSync = fs.readdirSync - const spy = spyOn(fs, 'readdirSync').mockImplementation((( - ...args: Parameters - ) => { - if (String(args[0]) === certs && ++attempts === 1) { - throw Object.assign(new Error('EMFILE: too many open files'), { - code: 'EMFILE', - }) - } - const entries = readdirSync(...args) as unknown as fs.Dirent[] - return String(args[0]) === root - ? entries.sort((x, y) => - x.name === listedFirst ? -1 : y.name === listedFirst ? 1 : 0, - ) - : entries - }) as unknown as typeof fs.readdirSync) - try { - const listings: GlobWalkListings = new Map() - const found = ['*.pem', '*.key'].map(name => - expandReadDenyGlobLinux(join(root, '**', name), [], undefined, { - listings, - }), - ) - // The pattern that saw the failure, whichever name it came by. - expect(found[0]).toEqual([certs]) - expect(found[1]).toEqual(patternsThatGiveIt === 2 ? [certs] : []) - expect([...new Set(found.flat())]).toEqual([certs]) - expect(listings.get(certs) instanceof Error).toBe( - patternsThatGiveIt === 2, - ) - expect(attempts).toBe(1) - } finally { - spy.mockRestore() - rmSync(root, { recursive: true, force: true }) - } - }, - ) - - describe.if(isLinux)('through the wrap', () => { - const network = { allowedDomains: [], deniedDomains: [] } - const filesystemWith = ( - denyReadGlobBudget: { maxEntries: number } | undefined, - ): { - denyRead: string[] - allowRead: string[] - allowWrite: string[] - denyWrite: string[] - denyReadGlobBudget?: { maxEntries: number } - } => ({ - denyRead: [join(ROOT, '**/*.pem')], - // Walked first, and in turns, so that there is a turn to act in. - allowRead: [join(ROOT, '**/*.none')], - allowWrite: [], - denyWrite: [], - ...(denyReadGlobBudget === undefined ? {} : { denyReadGlobBudget }), - }) - /** Runs `act` in the listing below ROOT that `at` counts to, the second - * unless told otherwise, each outlasting a turn. */ - const duringTheWalk = ( - act: () => void, - at = 2, - ): { restore: () => void } => { - let listed = 0 - const readdirSync = fs.readdirSync - const spy = spyOn(fs, 'readdirSync').mockImplementation((( - ...args: Parameters - ) => { - if (String(args[0]).startsWith(ROOT)) { - if (++listed === at) act() - outlastATurn() - } - return readdirSync(...args) - }) as typeof fs.readdirSync) - return { restore: () => spy.mockRestore() } - } - const outcomeOf = (wrap: Promise): Promise => - wrap.catch((error: unknown) => error) - - it('lists a directory once for all the denyRead patterns of a configuration, and charges it once', async () => { - await SandboxManager.reset() - await SandboxManager.initialize({ - network, - filesystem: { - denyRead: [join(ROOT, '**/*.pem'), join(ROOT, '**/*.txt')], - allowWrite: [], - denyWrite: [], - // Enough for the tree, however many patterns go through it. - denyReadGlobBudget: { maxEntries: ENTRIES }, - }, - }) - const readdir = spyOn(fs, 'readdirSync') - const listedBelowRoot = (): number => - readdir.mock.calls.filter(([dir]) => String(dir).startsWith(ROOT)) - .length - try { - SandboxManager.getFsReadConfig() - expect(listedBelowRoot()).toBe(DIRECTORIES + 1) - - readdir.mockClear() - await SandboxManager.wrapWithSandbox('true') - expect(listedBelowRoot()).toBe(DIRECTORIES + 1) - } finally { - readdir.mockRestore() - } - }) - - it('takes the budget of the configuration that replaced the one it started with', async () => { - await SandboxManager.reset() - await SandboxManager.initialize({ - network, - filesystem: filesystemWith(undefined), - }) - const walk = duringTheWalk(() => - SandboxManager.updateConfig({ - network, - filesystem: filesystemWith({ maxEntries: 1 }), - }), - ) - try { - const disturbed = await outcomeOf( - SandboxManager.wrapWithSandbox('true'), - ) - walk.restore() - - expect(disturbed).toBeInstanceOf(LinuxSandboxProfileError) - expect((disturbed as LinuxSandboxProfileError).code).toBe( - 'deny_glob_too_large', - ) - const undisturbed = await outcomeOf( - SandboxManager.wrapWithSandbox('true'), - ) - expect((undisturbed as LinuxSandboxProfileError).code).toBe( - 'deny_glob_too_large', - ) - } finally { - walk.restore() - } - }) - - it('takes the budget of a configuration that arrives during the denyRead walk', async () => { - await SandboxManager.reset() - await SandboxManager.initialize({ - network, - filesystem: filesystemWith(undefined), - }) - // In the denyRead walk, which comes after the allowRead one has listed - // every directory, and which began under a budget that has room. The - // case above never gets there: its first attempt reads the new budget. - const walk = duringTheWalk( - () => - SandboxManager.updateConfig({ - network, - filesystem: filesystemWith({ maxEntries: ENTRIES - 1 }), - }), - DIRECTORIES + 1 + 2, - ) - try { - const disturbed = await outcomeOf( - SandboxManager.wrapWithSandbox('true'), - ) - expect(disturbed).toBeInstanceOf(LinuxSandboxProfileError) - expect((disturbed as LinuxSandboxProfileError).code).toBe( - 'deny_glob_too_large', - ) - } finally { - walk.restore() - } - }) - }) - }, -) From 2c015874ed577e55b1a4901c3533c03cd73eecd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:23:56 +0000 Subject: [PATCH 17/17] linux: what a second review of the glob budget found - A budget that runs out while the types of a listing are asked for was taken for a real path that could not be read where a shorter name leads to the directory: it was listed once more, and had that name answered with nothing by then the directory was covered whole and the wrap went on. - A limit a wrap's own configuration holds as undefined no longer resets the session's, and a budget that is no object is refused like any other value. - A wrap that starts over is shown to start with nothing spent. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv Co-authored-by: ronleizrowice --- README.md | 2 +- src/sandbox/sandbox-manager.ts | 13 +++-- src/sandbox/sandbox-utils.ts | 2 +- test/sandbox/read-deny-glob-bound.test.ts | 65 ++++++++++++++--------- 4 files changed, 50 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 0093bbb0d..f0f1494c7 100644 --- a/README.md +++ b/README.md @@ -565,7 +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 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. +- 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. diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 656086fea..09d86e68c 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -1965,12 +1965,15 @@ function stripWriteGlobs( function readGlobBudget( ...limits: Array ): GlobWalkBudget { - return newGlobWalkBudget( - FilesystemConfigSchema.shape.denyReadGlobBudget.parse( - Object.assign({}, ...limits), - { path: ['filesystem', 'denyReadGlobBudget'] }, - ), + 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 diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index 2698ea053..48506ed2d 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -1827,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) diff --git a/test/sandbox/read-deny-glob-bound.test.ts b/test/sandbox/read-deny-glob-bound.test.ts index 9eeb5def4..b92ab10d3 100644 --- a/test/sandbox/read-deny-glob-bound.test.ts +++ b/test/sandbox/read-deny-glob-bound.test.ts @@ -94,14 +94,6 @@ describe.if(!isWindows)('the budget of a glob walk', () => { budget: newGlobWalkBudget({ maxEntries }), }) - it('defaults to twenty million entries and sixty seconds', () => { - expect(newGlobWalkBudget()).toMatchObject({ - maxEntries: 20_000_000, - timeoutMs: 60_000, - entries: 0, - }) - }) - 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() @@ -178,7 +170,11 @@ describe.if(!isWindows)('the budget of a glob walk', () => { const untyped: ProxyHandler = { get: ({ name }, key) => (key === 'name' ? name : () => false), } - listingsThrough((_dir, entries) => entries.map(e => new Proxy(e, untyped))) + 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 }) @@ -187,6 +183,20 @@ describe.if(!isWindows)('the budget of a glob walk', () => { 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]) }) }) @@ -325,9 +335,9 @@ describe.if(isLinux)('the budget of a read configuration', () => { expect(await refusalWith()).toMatchObject({ cause: { maxEntries: ENTRIES - 1, timeoutMs: 1234 }, }) - expect(await refusalWith({ maxEntries: ENTRIES - 2 })).toMatchObject({ - cause: { maxEntries: ENTRIES - 2, 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)) @@ -342,22 +352,27 @@ describe.if(isLinux)('the budget of a read configuration', () => { ['allowRead', 2], ['denyRead', DIRECTORIES + 1 + 2], ])( - 'starts over under the budget of a configuration that arrives during the %s walk', + '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 tighter = configOf({ maxEntries: ENTRIES - 1 }, lists) - SandboxManager.updateConfig(configOf(undefined, lists)) - let listed = 0 - listingsThrough((dir, entries) => { - if (dir.startsWith(TREE) && ++listed === arrivesAt) { - SandboxManager.updateConfig(tighter) - } - return entries - }) + 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 refusalOf(() => SandboxManager.wrapWithSandbox('true')), - ).toMatchObject({ cause: { maxEntries: ENTRIES - 1 } }) + 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() }, ) })