From 632eb2071a03fa8bdd9e0e96a8a8304f401f0aaa Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sun, 27 Sep 2026 16:05:56 +0000 Subject: [PATCH 1/5] linux: the manifest directory is worked out from the user id, not the environment Two processes of one user keep each other's mount points only when each reads what the other writes. Where the manifests went followed XDG_RUNTIME_DIR and TMPDIR, which differ between a login shell and what a service, an IDE or cron starts: such processes did not see each other, the second took the first's mount point for the user's own file, and the first's clean-up removed it from under the second sandbox. The candidates are now /run/user//srt-mount-points, where /run/user/ is a real directory of the user's alone, then /tmp/srt-mount-points-, and only then the two names the environment gives. A process writes to the first it can use. It believes the manifests under the first two, and under the environment's names only when it can use neither of the first two, which is when it writes there, and in the directory it has settled on. What the environment names is kept out of a sandbox's reach only by processes with that environment, so a manifest found there is not taken on trust by a process that has somewhere else to look. Every candidate is still made when missing, bound read-only and pinned by every wrap that restricts writes, whether or not the process writes there: a command that may write /tmp would otherwise make the directory itself and be believed. A clean-up makes one pass per directory under that directory's lock, and a live manifest in any of them keeps a path. --- src/sandbox/bwrap-mount-manifests.ts | 496 +++++++++++++++++++-------- src/sandbox/linux-sandbox-utils.ts | 12 +- 2 files changed, 355 insertions(+), 153 deletions(-) diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index 12117aaf2..36f94d2ca 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -25,19 +25,32 @@ * The lock belongs to the sandbox's init process, not to the command: bwrap * opens the manifest with O_CLOEXEC, so the descriptor does not survive the * exec into the command, and a POSIX lock is only dropped by the process that - * took it. The directory is bound read-only into every sandbox that - * restricts writes at all, whether or not its own invocation names a manifest - * in it, and so is the other name a process of the same user would keep its - * manifests under (see {@link mountPointManifestDirectories}), so a sandboxed - * command can neither delete nor rewrite one, its own or another sandbox's; - * and the directories between each of them and the write root it lies in are - * pinned there, so that the command cannot rename one aside and make the - * directory again under the old name, with manifests of its own in it. + * took it. + * + * Two processes of one user keep each other's mount points only when each + * reads what the other writes, so where the manifests go is worked out from + * the user id and the file system before the environment, which differs + * between a login shell and what a service, an IDE or cron starts: see + * {@link manifestDirectoryCandidates}. A process writes to the first of those + * directories it can use, and believes what it finds in the ones that come + * from the user id, or, where it can use none of those, in the ones its + * environment names (see {@link believedManifestDirectories}). + * + * Every one of them is bound read-only into every sandbox that restricts + * writes at all, whether or not its own invocation names a manifest, and + * whichever of them the wrapping process writes to (see + * {@link mountPointManifestDirectories}), so a sandboxed command can neither + * delete nor rewrite a manifest, its own or another sandbox's; one that is + * not there is made first, so that the command cannot make it itself, with + * manifests of its own in it; and the directories between each of them and + * the write root it lies in are pinned there, so that the command cannot + * rename one aside and make the directory again under the old name. * What that leaves out is a sandbox this library did not start, or an older - * release of it did, with the directory writable, a process that looks - * for its runtime or temp directory somewhere this one does not, and a - * runtime or temp directory whose name is a link inside a write root, or not - * there yet below one: neither can be pinned. + * release of it did, with a directory writable, the sandbox of a process with + * another environment where a process has to keep its manifests under a name + * its own environment gives, and a directory whose name, or a name above it, + * is a link inside a write root, or whose parent is not there yet below one: + * neither can be pinned. * * Both of the kernel's answers are relative to a PID namespace: /proc/locks * lists a lock only when its holder has a pid in the namespace of the /proc @@ -289,7 +302,7 @@ function deviceOf(dev: bigint): string { * device of their own - and there only the inode number can be compared. */ const FILESYSTEMS_WITH_ONE_DEVICE = new Set([ - 0x01021994, // tmpfs, which is what $XDG_RUNTIME_DIR is + 0x01021994, // tmpfs, which is what /run/user/UID is 0xef53, // ext2, ext3, ext4 0x58465342, // xfs ]) @@ -308,8 +321,8 @@ type Locks = { inodes: Set /** The same locks as `major:minor:inode`, in decimal. */ files: Set - /** Whether a manifest's device can be held against `files`. */ - comparesDevices: boolean + /** The directories whose manifests' device can be held against `files`. */ + comparesDevices: Set } /** @@ -328,22 +341,22 @@ type Locks = { */ function holdsLock( locks: Locks, - file: { inode: string; device: string }, + file: { file: string; inode: string; device: string }, ): boolean { - return locks.comparesDevices + return locks.comparesDevices.has(path.dirname(file.file)) ? locks.files.has(`${file.device}:${file.inode}`) : locks.inodes.has(file.inode) } /** - * What /proc/locks lists, for the manifests in `dir`. Only the locks whose + * What /proc/locks lists, for the manifests in `dirs`. Only the locks whose * holder has a pid in the PID namespace of the /proc being read are listed, * which is why a manifest from another namespace is never judged by this. * * `undefined` when the list could not be read, which every caller reads as * "every manifest is locked". */ -function readLocks(dir: string): Locks | undefined { +function readLocks(dirs: readonly string[]): Locks | undefined { let text: string try { text = fs.readFileSync('/proc/locks', 'utf8') @@ -371,7 +384,11 @@ function readLocks(dir: string): Locks | undefined { } } } - return { inodes, files, comparesDevices: reportsTheLockedDevice(dir) } + return { + inodes, + files, + comparesDevices: new Set(dirs.filter(reportsTheLockedDevice)), + } } /** Whether `dir` is a directory of ours that nobody else can write, and we can. */ @@ -445,30 +462,141 @@ function isBuriedBySandboxMounts(dir: string): boolean { ) } +/** + * The places the manifest directories are looked for in. The first two are + * the same for every process of a user, whatever started it; the last two are + * what this process's environment says. + */ +export type ManifestDirectoryPlaces = { + /** The user's runtime directory where the system keeps one: /run/user/UID. */ + runtimeDir: string + /** The temp dir every process can name: /tmp. */ + tempDir: string + /** $XDG_RUNTIME_DIR, where it is set. */ + environmentRuntimeDir: string | undefined + /** The system temp dir as this process sees it, which is $TMPDIR's. */ + environmentTempDir: string +} + +// What a test has put in the place of some of those, and nothing else has. +let placesForTesting: Partial | undefined + +function manifestDirectoryPlaces(): ManifestDirectoryPlaces { + return { + runtimeDir: `/run/user/${process.getuid?.() ?? 0}`, + tempDir: '/tmp', + environmentRuntimeDir: process.env['XDG_RUNTIME_DIR'], + environmentTempDir: tmpdir(), + ...placesForTesting, + } +} + +/** + * Whether `dir` is a runtime directory of this user's as the system makes + * one: a real directory, not a link to one, the user's, and nobody else's to + * look into. + */ +function isTheUsersRuntimeDirectory(dir: string): boolean { + try { + const stat = fs.lstatSync(dir) + return ( + stat.isDirectory() && + stat.uid === process.getuid?.() && + (stat.mode & 0o077) === 0 + ) + } catch { + return false + } +} + +/** + * Where the directory `name` names really is, as far as that can be told + * before it is made: under its parent as the links above it resolve, by its + * own last component. + */ +function whereItReallyIs(name: string): string { + try { + return path.join(fs.realpathSync(path.dirname(name)), path.basename(name)) + } catch { + return path.resolve(name) + } +} + /** * The names under which the processes of this user keep their manifests where - * several can find them, in the order they are tried: under $XDG_RUNTIME_DIR - * when there is one, else under the system temp dir. One that a sandbox's own - * mounts would bury is left out. + * several can find them, in the order they are tried for writing: + * - `/run/user/UID/srt-mount-points`, where `/run/user/UID` is a runtime + * directory of the user's (see {@link isTheUsersRuntimeDirectory}); + * - `/tmp/srt-mount-points-UID`; + * - `$XDG_RUNTIME_DIR/srt-mount-points`, where that is set; + * - `srt-mount-points-UID` under the system temp dir, which is `$TMPDIR`'s. + * + * The user id and the file system come first because they are the same for + * every process of the user. The environment is not: a login shell and what a + * service, an IDE, cron or `docker exec` starts differ in `XDG_RUNTIME_DIR`, + * two shells can differ in `TMPDIR`, and two processes that each wrote where + * their own environment said never read each other's manifests. The second + * wrap then took the first's mount point for the user's own file, and the + * first's clean-up removed it from under the second sandbox. The names from + * the environment stay, last, for a process that can use neither of the first + * two. + * + * Two names for one directory are one, the earlier, by where they really are. + * One that a sandbox's own mounts would bury is left out. */ +function manifestDirectoryCandidates(): { + name: string + fromTheEnvironment: boolean +}[] { + const places = manifestDirectoryPlaces() + const perUser = `srt-mount-points-${process.getuid?.() ?? 0}` + const names: { name: string; fromTheEnvironment: boolean }[] = [] + if (isTheUsersRuntimeDirectory(places.runtimeDir)) { + names.push({ + name: path.join(places.runtimeDir, 'srt-mount-points'), + fromTheEnvironment: false, + }) + } + names.push({ + name: path.join(places.tempDir, perUser), + fromTheEnvironment: false, + }) + if ( + places.environmentRuntimeDir !== undefined && + path.isAbsolute(places.environmentRuntimeDir) + ) { + names.push({ + name: path.join(places.environmentRuntimeDir, 'srt-mount-points'), + fromTheEnvironment: true, + }) + } + names.push({ + name: path.join(places.environmentTempDir, perUser), + fromTheEnvironment: true, + }) + const found = new Set() + return names.filter(candidate => { + const where = whereItReallyIs(candidate.name) + if (isBuriedBySandboxMounts(candidate.name) || found.has(where)) { + return false + } + found.add(where) + return true + }) +} + +/** The names of {@link manifestDirectoryCandidates}, in their order. */ function sharedManifestDirectoryNames(): string[] { - const runtimeDir = process.env['XDG_RUNTIME_DIR'] - const shared = - runtimeDir !== undefined && path.isAbsolute(runtimeDir) - ? [path.join(runtimeDir, 'srt-mount-points')] - : [] - shared.push( - path.join(tmpdir(), `srt-mount-points-${process.getuid?.() ?? 0}`), - ) - return shared.filter(candidate => !isBuriedBySandboxMounts(candidate)) + return manifestDirectoryCandidates().map(candidate => candidate.name) } /** - * The directory manifests live in: under $XDG_RUNTIME_DIR when there is one, - * else a per-user directory under the system temp dir, else - when neither can - * be made ours alone and written, as in a sandbox that binds them read-only - - * a private directory only this process knows, which keeps the guarantee - * within this process and loses only what other processes would have read. + * The directory this process writes its manifests to: the first of the shared + * names (see {@link sharedManifestDirectoryNames}) that is, or can be made, a + * directory of ours alone that we can write, else - when none can, as in a + * sandbox that binds them read-only - a private directory only this process + * knows, which keeps the guarantee within this process and loses only what + * other processes would have read. * * Revalidated on every use: the temp dir is somewhere sandboxed commands * commonly write, and a directory swapped for a symlink between two wraps @@ -489,11 +617,14 @@ function ensureManifestDirectory(): string | undefined { } } manifestDirectory = undefined - if (manifestDirectoryUnavailable || isBuriedBySandboxMounts(tmpdir())) { + const systemTempDir = manifestDirectoryPlaces().environmentTempDir + if (manifestDirectoryUnavailable || isBuriedBySandboxMounts(systemTempDir)) { return undefined } try { - const private_ = fs.mkdtempSync(path.join(tmpdir(), 'srt-mount-points-')) + const private_ = fs.mkdtempSync( + path.join(systemTempDir, 'srt-mount-points-'), + ) if (!makeOurPrivateDirectory(private_)) { // A temp dir that keeps no modes or ownership: nothing made in it can be // told from somebody else's, now or on the next call. @@ -534,25 +665,72 @@ function isOurDirectory(dir: string): boolean { } } +/** + * Every directory whose manifests this process believes, for a wrap, a + * clean-up and {@link liveMountPoints} to read: + * - each of the names that come from the user id that is there and is a + * directory of ours alone that we can write, whichever of them this process + * writes to; + * - the names the environment gives, held to the same, only where none of + * the first kind will do, which is where this process writes to one of + * them; + * - the one this process has settled on, whatever it is. + * + * What the environment names is kept out of a sandboxed command's reach by + * the processes whose environment names it, and by no other: a process with + * another temp dir does not know of it, and its command may write there. A + * manifest planted that way would keep a path, or have one removed, on the + * word of a process that never relied on the directory, so it is believed + * only by a process that has nowhere else to keep its own. Nothing is lost by + * that: every process that can use a name from the user id meets every other + * one there. + * + * One that is a link, somebody else's, open to others or not to be written is + * passed over: what is in it is not believed, and nothing fails for it. + * Nothing is made. + */ +function believedManifestDirectories(): string[] { + const usable = manifestDirectoryCandidates().filter(candidate => + isOurPrivateDirectory(candidate.name), + ) + const fromTheUserId = usable.filter( + candidate => !candidate.fromTheEnvironment, + ) + const dirs = (fromTheUserId.length > 0 ? fromTheUserId : usable).map( + candidate => candidate.name, + ) + if ( + manifestDirectory !== undefined && + !dirs.includes(manifestDirectory) && + isOurPrivateDirectory(manifestDirectory) + ) { + dirs.push(manifestDirectory) + } + return dirs +} + /** * Every directory a sandbox must not be able to write, because some process * of this user keeps manifests in it and a collect on the host believes what - * it finds there: both shared names, as this process sees them, and the one - * this process has settled on where that is neither. For a wrap to bind + * it finds there: every shared name, as this process sees them, and the one + * this process has settled on where that is none of them. For a wrap to bind * read-only, whether or not it has a manifest of its own. * - * Both names, and not only the one this process uses: a process started with - * $XDG_RUNTIME_DIR uses the first and one started without it (from cron, over - * a plain ssh, as a service) the second, and a sandbox of the first kind with - * the temp dir writable could otherwise delete and forge the manifests of the - * second. Each is made if it can be, so that a sandboxed command cannot make - * it first and fill it before the process that will believe it comes along; - * one that is there and the user's is listed even when it cannot be written - * from here. The last resort is not made for this: there is nothing to keep - * out of reach in a directory nobody has made. A wrap that is about to record - * says so with `recording`, and the directory its manifest will go to is then - * settled first, the last resort included: the wrap has to know every one of - * these before it publishes, to pin what lies above them. + * Every name, and not only the one this process writes to: every process of + * the user believes what is under the ones that come from the user id, and + * one with this environment that can use none of those what is under the + * ones the environment gives, so a sandbox with the temp dir writable could + * otherwise delete and forge manifests in the one under it while its own + * process writes under the runtime directory. Each is made if it can be, so + * that a sandboxed command cannot make it first and fill it before a process + * that will believe it comes along: the command runs as the user, so what it + * makes passes every check of owner and mode, which keep other users out and + * not the command. One that is there and the user's is listed even when it + * cannot be written from here. The last resort is not made for this: there is + * nothing to keep out of reach in a directory nobody has made. A wrap that is + * about to record says so with `recording`, and the directory its manifest + * will go to is then settled first, the last resort included: the wrap has to + * know every one of these before it publishes, to pin what lies above them. */ export function mountPointManifestDirectories(recording = false): string[] { if (!onLinux()) { @@ -1094,34 +1272,44 @@ export function discardMountPointManifest(file: string): void { } /** - * The mount points the manifests name, on one reading of the directory: - * `live`, by a manifest whose sandbox may still be running, and `named`, by - * any manifest at all. What only a finished manifest names is what a sandbox - * that has ended left behind and no pass has collected yet, and the next pass - * in any process removes it on that manifest's word. + * The path of every file in `dirs`, for a reading on which nothing is + * removed: a directory that cannot be listed adds nothing. + */ +function filesIn(dirs: readonly string[]): string[] { + return dirs.flatMap(dir => { + try { + return fs.readdirSync(dir).map(name => path.join(dir, name)) + } catch { + return [] + } + }) +} + +/** + * The mount points the manifests name, on one reading of every directory this + * process believes (see {@link believedManifestDirectories}): `live`, by a + * manifest whose sandbox may still be running, and `named`, by any manifest at + * all. What only a finished manifest names is what a sandbox that has ended + * left behind and no pass has collected yet, and the next pass in any process + * removes it on that manifest's word. * - * `live` is empty when /proc/locks cannot be read, and both are when the - * directory cannot be. A wrap takes an existing path for a mount point on a - * manifest's word only, never by the look of it, so it then takes every - * existing path for the caller's own. + * `live` is empty when /proc/locks cannot be read, and a directory that + * cannot be listed adds to neither. A wrap takes an existing path for a mount + * point on a manifest's word only, never by the look of it, so with none to + * be read it takes every existing path for the caller's own. */ export function namedMountPoints(): { live: Set; named: Set } { const live = new Set() const named = new Set() - const dir = onLinux() ? ensureManifestDirectory() : undefined - if (dir === undefined) { - return { live, named } - } - const locks = readLocks(dir) - let names: string[] - try { - names = fs.readdirSync(dir) - } catch { + if (!onLinux()) { return { live, named } } - for (const name of names) { - if (!name.endsWith(MANIFEST_SUFFIX)) continue - const manifest = readManifest(path.join(dir, name)) + ensureManifestDirectory() + const dirs = believedManifestDirectories() + const locks = dirs.length > 0 ? readLocks(dirs) : undefined + for (const file of filesIn(dirs)) { + if (!file.endsWith(MANIFEST_SUFFIX)) continue + const manifest = readManifest(file) if (manifest === undefined) continue const isLiveOne = locks !== undefined && isLive(manifest, locks) for (const mountPoint of manifest.paths) { @@ -1134,19 +1322,6 @@ export function namedMountPoints(): { live: Set; named: Set } { return { live, named } } -/** - * The directory this process keeps its manifests in, if it is there already: - * the one it has settled on, else the first of the shared names that is a - * directory of the user's alone. Nothing is made, and nothing is settled: for - * a look that has to leave the host as it found it. - */ -function existingManifestDirectory(): string | undefined { - return manifestDirectory !== undefined && - isOurPrivateDirectory(manifestDirectory) - ? manifestDirectory - : sharedManifestDirectoryNames().find(isOurPrivateDirectory) -} - /** * Whether what is at `p` now is still something a clean-up would take away on * a manifest's word: the empty file bubblewrap made (see @@ -1174,7 +1349,7 @@ function isEmptyMountPoint(p: string): boolean { * its own accord after a command, to leave out the ones in this set. Removing * a mount point from under a running sandbox lifts the deny there. * - * It is a snapshot, taken without a lock, from one listing of the manifest + * It is a snapshot, taken without a lock, from one listing of each manifest * directory and one reading of /proc/locks, and it may be out of date by the * time the caller acts on it: a sandbox can start on a path the moment after. * Its only safe use is to SKIP a removal. That a path is not in it never @@ -1196,39 +1371,33 @@ function isEmptyMountPoint(p: string): boolean { * directories above the last component resolved and the last component as it * stands. A caller compares its own paths in that form. * - * The manifests are believed as found. Their directory can be written by the - * user's own processes outside any sandbox; every sandbox that restricts - * writes has it bound read-only, with the directories between it and the - * write root it lies in pinned, which does not reach a runtime or temp - * directory whose name is a link inside a write root, nor a runtime directory - * that is not there yet below one. It answers for one manifest directory, - * the one this process keeps its manifests in or, before it has kept any, the - * first of the shared names that is there, so a sandbox of a process that - * keeps its manifests elsewhere, or of something that writes none, is not - * seen. + * The manifests are believed as found. Their directories can be written by + * the user's own processes outside any sandbox; every sandbox that restricts + * writes has each of them bound read-only, with the directories between it + * and the write root it lies in pinned, which does not reach a directory + * whose name, or a name above it, is a link inside a write root, nor one + * whose parent is not there yet below one. It answers for every manifest + * directory this process believes (see {@link believedManifestDirectories}), + * whichever of them it writes to, so a sandbox of a process that keeps its + * manifests under a name its environment gives, or in a directory of its own, + * for want of a name from the user id that it can use, or of something that + * writes none, is not seen. * - * Makes nothing on the host, the manifest directory included. Empty where + * Makes nothing on the host, a manifest directory included. Empty where * there is no manifest directory, and anywhere but on Linux. */ export function liveMountPoints(): ReadonlySet { const spared = new Set() - const dir = onLinux() ? existingManifestDirectory() : undefined - if (dir === undefined) { - return spared - } - const locks = readLocks(dir) - let names: string[] - try { - names = fs.readdirSync(dir) - } catch { + const dirs = onLinux() ? believedManifestDirectories() : [] + if (dirs.length === 0) { return spared } + const locks = readLocks(dirs) const live = new Set() const named = new Set() let cannotTell = locks === undefined - for (const name of names) { - if (!name.endsWith(MANIFEST_SUFFIX)) continue - const file = path.join(dir, name) + for (const file of filesIn(dirs)) { + if (!file.endsWith(MANIFEST_SUFFIX)) continue const manifest = readManifest(file) if (manifest === undefined) { // Gone since the listing, or not to be read: held to what the clean-up @@ -1238,6 +1407,7 @@ export function liveMountPoints(): ReadonlySet { cannotTell ||= locks === undefined || holdsLock(locks, { + file, inode: String(stat.ino), device: deviceOf(stat.dev), }) || @@ -1268,6 +1438,11 @@ export function liveMountPoints(): ReadonlySet { * over, and remove every mount point no live manifest names, from any process, * at any time, any number of times. Returns the mount points removed. Does * nothing anywhere but on Linux. + * + * One pass for each directory this process believes, under that directory's + * lock: a pass takes off the disk what the finished manifests of its own + * directory name, and those manifests, and goes by the manifests of every + * directory for what is still relied on. */ export function collectMountPoints( release: OwnManifestRelease = 'all', @@ -1287,24 +1462,32 @@ export function collectMountPoints( own.over = true } } - const dir = ensureManifestDirectory() - if (dir === undefined) { - return [] - } - const collected = withDirectoryLock(dir, () => collectUnderLock(dir)) - if (!collected.ran) { + ensureManifestDirectory() + const dirs = believedManifestDirectories() + const removed: string[] = [] + for (const dir of dirs) { + const collected = withDirectoryLock(dir, () => collectUnderLock(dir, dirs)) + if (collected.ran) { + removed.push(...collected.value) + continue + } logForDebugging( collected.held ? `[Sandbox Linux] The lock on the mount point directory could not be had, leaving this pass to whoever has it: ${dir}` : `[Sandbox Linux] No lock can be taken on the mount point directory from here, so nothing is collected from it until that is put right: ${dir}`, ) - return [] } - return collected.value + return removed } /** - * One pass, which the caller makes under the directory lock where it can. + * One pass over `dir`, which the caller makes under that directory's lock + * where it can. `dirs` is every directory this process believes, `dir` among + * them. The pass reads the manifests of all of them, and a mount point stays + * while a live manifest in any of them names it; what it takes off the disk + * is what the finished manifests of `dir` name, and those manifests. What the + * finished manifests of another directory name goes at the pass over that + * one, under its lock. * * Nothing is taken off the disk until the end. A manifest is all that names * its mount points, so it goes last, after them, and only when every one of @@ -1317,7 +1500,7 @@ export function collectMountPoints( * The lock does not exclude (see {@link withDirectoryLock}), so the pass does * not rely on the directory staying as first listed. Immediately before it * removes anything, and again whenever its last listing is more than - * {@link LISTING_GOOD_FOR_MS} old, it lists the directory again, where a + * {@link LISTING_GOOD_FOR_MS} old, it lists every directory again, where a * manifest that was not there at first keeps everything it names whatever * else is true of it: that is how a sandbox about to start on the same path * shows. And before the first removal, and again every @@ -1332,10 +1515,17 @@ export function collectMountPoints( * longer than that between looking and removing, with a sandbox starting on * the same path in that time, still takes that sandbox's mount point away. */ -function collectUnderLock(dir: string): string[] { - let names: string[] +function collectUnderLock(dir: string, dirs: readonly string[]): string[] { + // Every file in every directory, or nothing: a directory that is believed + // and cannot be listed may hold the manifest of a running sandbox. + const listEveryDirectory = (): string[] => + dirs.flatMap(each => + fs.readdirSync(each).map(name => path.join(each, name)), + ) + const isHere = (file: string): boolean => path.dirname(file) === dir + let files: string[] try { - names = fs.readdirSync(dir) + files = listEveryDirectory() } catch (e) { logForDebugging( `[Sandbox Linux] The mount point manifests could not be listed (${String(e)}) - nothing removed`, @@ -1345,9 +1535,8 @@ function collectUnderLock(dir: string): string[] { } const manifests: Manifest[] = [] const unreadable: string[] = [] - for (const name of names) { - if (!name.endsWith(MANIFEST_SUFFIX)) continue - const file = path.join(dir, name) + for (const file of files) { + if (!file.endsWith(MANIFEST_SUFFIX)) continue const manifest = readManifest(file) if (manifest === undefined) { unreadable.push(file) @@ -1355,17 +1544,17 @@ function collectUnderLock(dir: string): string[] { manifests.push(manifest) } } - const locks = readLocks(dir) + const locks = readLocks(dirs) if (locks === undefined) { return [] } // What the pass knows of: the first listing, so that everything found // later is a newcomer. - const known = new Set(names) + const known = new Set(files) // A manifest of this process that something else has taken off the disk is // nothing to remember. for (const file of ownManifests.keys()) { - if (path.dirname(file) === dir && !known.has(path.basename(file))) { + if (isHere(file) && !known.has(file)) { ownManifests.delete(file) } } @@ -1387,13 +1576,14 @@ function collectUnderLock(dir: string): string[] { const age = Date.now() - Number(stat.mtimeMs) if ( holdsLock(locks, { + file, inode: String(stat.ino), device: deviceOf(stat.dev), }) || age < MANIFEST_GRACE_MS ) { unreadableIsLive = true - } else if (age > UNREADABLE_MANIFEST_MAX_AGE_MS) { + } else if (isHere(file) && age > UNREADABLE_MANIFEST_MAX_AGE_MS) { try { fs.unlinkSync(file) } catch { @@ -1411,9 +1601,8 @@ function collectUnderLock(dir: string): string[] { // What a process killed between writing a file and moving it into place // left. Nothing reads these, and nothing else removes them. - for (const name of names) { - if (!name.endsWith(TEMPORARY_SUFFIX)) continue - const file = path.join(dir, name) + for (const file of files) { + if (!isHere(file) || !file.endsWith(TEMPORARY_SUFFIX)) continue try { if ( Date.now() - fs.lstatSync(file).mtimeMs > @@ -1450,8 +1639,11 @@ function collectUnderLock(dir: string): string[] { } } // Each path once, and as a mount point where a manifest names it as one. + // Only what the finished manifests of this directory name: the others are + // kept in `finished` to be asked after again, not to be acted on. const candidates = new Map() for (const manifest of finished) { + if (!isHere(manifest.file)) continue for (const mountPoint of manifest.paths) { if (!claimed.has(mountPoint)) { candidates.set(mountPoint, false) @@ -1479,7 +1671,7 @@ function collectUnderLock(dir: string): string[] { const listingIsFresh = (): boolean => listedAt !== undefined && performance.now() - listedAt < LISTING_GOOD_FOR_MS const readTheLocksAgain = (): boolean => { - const locksNow = readLocks(dir) + const locksNow = readLocks(dirs) if (locksNow === undefined) { return false } @@ -1494,16 +1686,15 @@ function collectUnderLock(dir: string): string[] { return true } const listTheDirectoryAgain = (): boolean => { - let namesNow: string[] + let filesNow: string[] try { - namesNow = fs.readdirSync(dir) + filesNow = listEveryDirectory() } catch { return false } - for (const name of namesNow) { - if (!name.endsWith(MANIFEST_SUFFIX) || known.has(name)) continue - known.add(name) - const file = path.join(dir, name) + for (const file of filesNow) { + if (!file.endsWith(MANIFEST_SUFFIX) || known.has(file)) continue + known.add(file) const arrived = readManifest(file) if (arrived !== undefined) { // Published since the pass began: a sandbox is about to start under @@ -1551,6 +1742,7 @@ function collectUnderLock(dir: string): string[] { // The manifests themselves, last. Only a lock taken since can speak for one // of these; a manifest somebody else has published in the meantime cannot. for (const manifest of finished) { + if (!isHere(manifest.file)) continue if (!locksAreFresh() && !readTheLocksAgain()) { return removed } @@ -1579,13 +1771,23 @@ function collectUnderLock(dir: string): string[] { } /** - * Forget which directory the manifests are kept in, so that the next use - * works it out again from the environment. Test seam: a test gives itself a - * runtime directory of its own, so that what it lists, attacks and collects - * is never another process's, and this module may have settled on the real - * one before the test was loaded. + * Put directories of a test's own in the place of those the manifest + * directories are looked for in (see {@link ManifestDirectoryPlaces}), or, + * with nothing, put the real ones back. Test seam, and nothing else may call + * it: the real places are every other process's of the user, and a test that + * lists, attacks or collects there does so to the sandboxes that user is + * running. A place that is left out stays what it really is, so a test can + * stand in for `/run/user/UID` and `/tmp` and leave the environment to say the + * rest. + * + * Which directory the manifests are written to is forgotten with it, so that + * the next use works it out again: this module may have settled on one before + * the test that wants another was loaded. */ -export function forgetMountPointManifestDirectory(): void { +export function setMountPointManifestPlacesForTesting( + places?: Partial, +): void { + placesForTesting = places manifestDirectory = undefined manifestDirectoryIsPrivate = false manifestDirectoryUnavailable = false diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 7af3e3ea8..72c274b8a 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -2963,10 +2963,10 @@ async function generateFilesystemArgs( // a manifest could name any path at all for a collect on the host to // remove. That holds for a sandbox whose own wrap names no manifest as much // as for one that does, and for the directory some other process of this - // user keeps its manifests in as much as for this one's: the directory sits - // under the system temp dir wherever there is no $XDG_RUNTIME_DIR, which a - // caller's allowWrite commonly covers, and what is in it then belongs to - // OTHER sandboxes. So every wrap that restricts writes binds every one of + // user keeps its manifests in as much as for this one's: one of them sits + // under /tmp whether or not this process writes there, where a caller's + // allowWrite commonly reaches, and what is in it then belongs to OTHER + // sandboxes. So every wrap that restricts writes binds every one of // them read-only (see mountPointManifestDirectories), with what lies above // them pinned (they are seeds of the ancestor pins), and a wrap with mount // points of its own adds the lock. Emitted after every allow and deny bind, @@ -2976,8 +2976,8 @@ async function generateFilesystemArgs( // the mounts, so a profile with a tmpfs over the temp dir would otherwise // leave nothing there to open. // - // Each is bound where it really is, like a pin, and not at the name the - // environment gave for it: bubblewrap makes a bind's destination by name + // Each is bound where it really is, like a pin, and not at the name it + // was worked out as: bubblewrap makes a bind's destination by name // inside the new root before it mounts, and a link on the way whose target // is absolute leads nowhere from there ("Can't mkdir"), so with the temp dir // reached through such a link no command that restricts writes started. From 4c904f4737a55fa1588feb6248474d139af41518 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sun, 27 Sep 2026 16:05:56 +0000 Subject: [PATCH 2/5] test: processes with different environments see each other; no suite touches the user's own manifest directories Real bubblewrap runs with two real processes, in a mount namespace where a private directory stands at /tmp and at /run/user: the deny holds in a second sandbox after the first has ended, with different XDG_RUNTIME_DIR and with different TMPDIR; a command that may write /tmp can neither make, write nor move aside the directory under it; a manifest forged under one process's TMPDIR by the sandbox of another gets nothing removed. bunfig.toml preloads stand-ins for the four places before any test file is loaded, and children are handed a module or a program that does the same, so no suite reads, writes or collects where the user's own sandboxes keep their manifests. Several did. --- bunfig.toml | 5 + test/cli.test.ts | 9 +- test/control-fd.test.ts | 7 +- test/helpers/private-manifest-directory.ts | 315 ++++++++- test/preload.ts | 12 + test/sandbox/default-write-paths.test.ts | 7 +- test/sandbox/linux-bwrap-args-file.test.ts | 28 +- test/sandbox/mount-point-liveness.test.ts | 683 ++++++++++++++++--- test/sandbox/mount-point-manifests.test.ts | 751 ++++++++++++++++++++- test/sandbox/path-canonicalization.test.ts | 13 +- test/sandbox/stale-mount-point.test.ts | 10 +- 11 files changed, 1671 insertions(+), 169 deletions(-) create mode 100644 bunfig.toml create mode 100644 test/preload.ts diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 000000000..60638959a --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,5 @@ +[test] +# Directories of the run's own stand in for the ones the mount point manifests +# are kept in, before any test file is loaded: see +# test/helpers/private-manifest-directory.ts. +preload = ["./test/preload.ts"] diff --git a/test/cli.test.ts b/test/cli.test.ts index 654490796..c5be11f20 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -9,12 +9,17 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { isolatedProgram } from './helpers/private-manifest-directory.js' + +let cliPath: string | undefined /** - * Get the path to the CLI entry point + * Get the path to the CLI entry point: the tool itself, started with + * directories of the run's own in place of the ones it keeps its mount point + * manifests in, which are every other srt process's of the user. */ function getCliPath(): string { - return join(process.cwd(), 'src', 'cli.ts') + return (cliPath ??= isolatedProgram(join(process.cwd(), 'src', 'cli.ts'))) } /** diff --git a/test/control-fd.test.ts b/test/control-fd.test.ts index 770d6c2e0..2e7f0fd73 100644 --- a/test/control-fd.test.ts +++ b/test/control-fd.test.ts @@ -10,9 +10,12 @@ import * as os from 'os' import * as path from 'path' import { type Writable } from 'stream' import { isLinux, isWindows } from './helpers/platform.js' +import { isolatedProgram } from './helpers/private-manifest-directory.js' -// Get the path to the built CLI -const CLI_PATH = path.join(process.cwd(), 'dist', 'cli.js') +// Get the path to the built CLI: the tool itself, started with directories of +// the run's own in place of the ones it keeps its mount point manifests in, +// which are every other srt process's of the user. +const CLI_PATH = isolatedProgram(path.join(process.cwd(), 'dist', 'cli.js')) // srt is expected to exit on its own shortly after the wrapped command // (which runs for well under a second) finishes; a hang is a failure, not diff --git a/test/helpers/private-manifest-directory.ts b/test/helpers/private-manifest-directory.ts index 897287435..2180c3373 100644 --- a/test/helpers/private-manifest-directory.ts +++ b/test/helpers/private-manifest-directory.ts @@ -1,21 +1,258 @@ import { afterAll, beforeAll } from 'bun:test' -import { chmodSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { forgetMountPointManifestDirectory } from '../../src/sandbox/bwrap-mount-manifests.js' +import { dirname, extname, join } from 'node:path' +import { + type ManifestDirectoryPlaces, + setMountPointManifestPlacesForTesting, +} from '../../src/sandbox/bwrap-mount-manifests.js' import { cleanupBwrapMountPoints } from '../../src/sandbox/linux-sandbox-utils.js' +const REPOSITORY = join(import.meta.dir, '../..') +const SEAM = join(REPOSITORY, 'src/sandbox/bwrap-mount-manifests.ts') + /** - * Gives the enclosing `describe` a runtime directory of its own for the mount - * point manifests, for as long as its tests run. + * The manifests of every srt process of a user live in a few directories that + * are worked out from the user id: `/run/user/UID/srt-mount-points` and + * `/tmp/srt-mount-points-UID`, and after those what the environment names. + * Every wrap makes and binds them, and every clean-up reads, judges and + * removes what is in them. A test that did so in the real ones would be doing + * it to whatever else that user is running on the machine, and be failed by + * it in turn, and nothing in the environment moves a process away from them. + * So no test looks there: * - * The manifests of every srt process of a user live in one directory, and a - * collect reads, judges and removes all of them. A test that lists that - * directory, attacks it from inside a sandbox or asserts on what is left in it - * would otherwise be doing so to whatever else that user is running on the - * machine, and be failed by it in turn. `$XDG_RUNTIME_DIR` is where the library - * looks first, and child processes inherit it, so pointing it at a fresh - * directory moves this process and everything it spawns at once. + * - in the test process, directories of the test's own stand in for all four + * places, through the seam the module has for it + * ({@link isolateManifestDirectoriesForTheRun} for every test file of the + * run, {@link usePrivateManifestDirectory} for a `describe` that wants them + * to itself); + * - a child process that loads the library from a script is given a module to + * load that puts the same stand-ins in place first ({@link isolatedModule}), + * and one that runs the command line tool a program that does + * ({@link isolatedProgram}); + * - a child process that is to work the directories out for itself, from the + * real names, runs in a mount namespace of its own in which the stand-ins + * are bound over `/tmp` and `/run/user` ({@link inPrivateNamespace}). + * + * All of it is bubblewrap's, which is Linux's: on any other platform the + * library keeps no manifest anywhere, nothing is put in the place of + * anything, and a child process is given the module or the program itself. + */ +type Places = ManifestDirectoryPlaces + +const onLinux = process.platform === 'linux' + +type PrivatePlaces = { + /** Holds the stand-ins, and what is made for child processes to load. */ + base: string + places: Places +} + +// Every base that was made and not removed yet, for the end of the run: a +// `describe` that is skipped is read all the same, and never torn down. +const bases = new Set() + +/** + * Directories of the caller's own to stand in for the four places. The + * runtime directory is named after the user id under its parent, as the real + * one is, so that the parent can be bound over `/run/user`. Nothing stands in + * for `$XDG_RUNTIME_DIR`, which is as if it were not set, and the temp dir + * the environment names is the one that stands in for `/tmp`. + */ +function makePlaces(): PrivatePlaces { + const base = realpathSync(mkdtempSync(join(tmpdir(), 'srt-test-places-'))) + bases.add(base) + const runtimeDir = join(base, 'run', String(process.getuid?.() ?? 0)) + const tempDir = join(base, 'tmp') + mkdirSync(runtimeDir, { recursive: true }) + chmodSync(runtimeDir, 0o700) + mkdirSync(tempDir) + chmodSync(tempDir, 0o1777) + return { + base, + places: { + runtimeDir, + tempDir, + environmentRuntimeDir: undefined, + environmentTempDir: tempDir, + }, + } +} + +function removePlaces(made: PrivatePlaces): void { + rmSync(made.base, { recursive: true, force: true }) + bases.delete(made.base) +} + +/** `places` as source text, with what is not set spelled out. */ +function sourceOf(places: Partial): string { + const fields = Object.entries(places).map( + ([name, value]) => + `${name}: ${value === undefined ? 'undefined' : JSON.stringify(value)}`, + ) + return `{ ${fields.join(', ')} }` +} + +let filesMade = 0 + +/** + * A file for a child process to load in place of `target`, which puts + * `places` in place and then is `target`: a module of the sources, which it + * gives again, or the command line tool, from the sources or as it was built, + * which it runs. The tool's seam is the one beside it. + * + * The stand-ins are in place before the child does anything with what it has + * loaded. A module's imports are evaluated before its own body, and the body + * is the call to the seam; a program starts work as it is loaded, so it is + * loaded after the call. + */ +function isolatedFile( + made: PrivatePlaces, + target: string, + places: Partial, + kind: 'module' | 'program', +): string { + const built = extname(target) !== '.ts' + const seam = + kind === 'module' + ? SEAM + : join( + dirname(target), + 'sandbox', + `bwrap-mount-manifests${extname(target)}`, + ) + const file = join( + made.base, + `isolated-${filesMade++}${built ? '.mjs' : '.ts'}`, + ) + writeFileSync( + file, + [ + `import { setMountPointManifestPlacesForTesting } from ${JSON.stringify(seam)}`, + `setMountPointManifestPlacesForTesting(${sourceOf(places)})`, + kind === 'module' + ? `export * from ${JSON.stringify(target)}` + : `await import(${JSON.stringify(target)})`, + '', + ].join('\n'), + ) + return file +} + +// What stands in for the real places in every test file of the run, which a +// `describe` with stand-ins of its own puts back when it is done. +let placesOfTheRun: PrivatePlaces | undefined + +/** + * Puts stand-ins in place for the whole run: most suites wrap a command + * somewhere, and none of them is to make, bind or collect in the user's real + * directories for it. The test runner's preload does it before any test file + * is loaded, and takes the stand-ins away after the last one + * ({@link removeManifestDirectoriesOfTheRun}). + */ +export function isolateManifestDirectoriesForTheRun(): void { + if (onLinux && placesOfTheRun === undefined) { + placesOfTheRun = makePlaces() + setMountPointManifestPlacesForTesting(placesOfTheRun.places) + } +} + +/** + * Removes every stand-in that is still there. They stay in place in the + * module, so that nothing that still runs finds the real ones. + */ +export function removeManifestDirectoriesOfTheRun(): void { + for (const left of bases) { + rmSync(left, { recursive: true, force: true }) + } + bases.clear() +} + +/** The stand-ins of the run, which are put in place if they were not. */ +function ofTheRun(): PrivatePlaces { + isolateManifestDirectoriesForTheRun() + return placesOfTheRun! +} + +/** + * The path of a module for a child process to load in place of `module`, with + * the stand-ins of the run in place, or with `places` where the child is to + * look somewhere else. A place that is left out of `places` is the real one + * for that child: what its environment says, for the last two. + */ +export function isolatedModule( + module: string, + places?: Partial, +): string { + if (!onLinux) { + return module + } + const run = ofTheRun() + return isolatedFile(run, module, places ?? run.places, 'module') +} + +/** + * The path of a program for a child process to run in place of `program`, the + * command line tool as it is in the sources or as it was built, with the + * stand-ins of the run in place. + */ +export function isolatedProgram(program: string): string { + if (!onLinux) { + return program + } + const run = ofTheRun() + return isolatedFile(run, program, run.places, 'program') +} + +/** + * The start of a command line that runs what follows it in a mount namespace + * of its own, in which `places.tempDir` is bound over `/tmp` and the parent of + * `places.runtimeDir` over `/run/user`: for a child process that is to work + * the directories out for itself, from the real names, without reaching the + * real ones. Whatever is in `keep` is bound back where it is, for what the + * child needs that lies under the real `/tmp`, and so are this repository and + * the runtime where they lie there. No PID namespace, so `/proc/locks` and + * the pids in it are the test's own. + * + * Needs a bubblewrap that can make a namespace (see `bwrapCanNamespace`). + */ +export function inPrivateNamespace( + places: Pick, + keep: readonly string[] = [], +): string[] { + const needed = [REPOSITORY, dirname(process.execPath)].filter(dir => + dir.startsWith('/tmp/'), + ) + return [ + 'bwrap', + '--dev-bind', + '/', + '/', + '--bind', + places.tempDir, + '/tmp', + // Where there is no /run/user there is no runtime directory to reach. + ...(existsSync('/run/user') + ? ['--bind', dirname(places.runtimeDir), '/run/user'] + : []), + ...[...needed, ...keep].flatMap(kept => ['--bind', kept, kept]), + '--', + ] +} + +/** + * Gives the enclosing `describe` directories of its own for the mount point + * manifests, for as long as its tests run: a test that lists the manifests, + * attacks them from inside a sandbox or asserts on what is left of them needs + * to be the only one that writes there. * * It also starts the `describe` with no wrap of this process outstanding, and * leaves it so. The library counts the wraps it has handed out and releases @@ -24,37 +261,51 @@ import { cleanupBwrapMountPoints } from '../../src/sandbox/linux-sandbox-utils.j * without cleaning up. A test that expects one clean-up to take its mount point * away would otherwise pass or fail by which file ran before it. * - * Call it inside the `describe` callback. Returns where the manifests go. + * Call it inside the `describe` callback. Returns: + * - `manifestDir()`, where this process's manifests go: under the runtime + * directory, the first place looked in; + * - `places()`, the stand-ins themselves; + * - `isolated(module, places)`, which is {@link isolatedModule} with the + * stand-ins of this `describe`. */ -export function usePrivateManifestDirectory(): { manifestDir(): string } { - const saved = process.env.XDG_RUNTIME_DIR - let runtimeDir: string | undefined +export function usePrivateManifestDirectory(): { + manifestDir(): string + places(): Places + isolated(module: string, places?: Partial): string +} { + let made: PrivatePlaces | undefined + // Made on first use, which may be while the `describe` is being read, for + // the module a child is to load. + const own = (): PrivatePlaces => (made ??= makePlaces()) beforeAll(() => { - // In the directory the earlier suites used, before it is left behind. + if (!onLinux) { + return + } + // In the directories the earlier suites used, before they are left + // behind. cleanupBwrapMountPoints({ force: true }) - runtimeDir = realpathSync(mkdtempSync(join(tmpdir(), 'srt-test-runtime-'))) - chmodSync(runtimeDir, 0o700) - process.env.XDG_RUNTIME_DIR = runtimeDir - forgetMountPointManifestDirectory() + setMountPointManifestPlacesForTesting(own().places) }) afterAll(() => { + if (!onLinux) { + return + } cleanupBwrapMountPoints({ force: true }) - if (saved === undefined) delete process.env.XDG_RUNTIME_DIR - else process.env.XDG_RUNTIME_DIR = saved - forgetMountPointManifestDirectory() - if (runtimeDir !== undefined) { - rmSync(runtimeDir, { recursive: true, force: true }) + setMountPointManifestPlacesForTesting(placesOfTheRun?.places) + if (made !== undefined) { + removePlaces(made) + made = undefined } }) return { - manifestDir: () => { - if (runtimeDir === undefined) { - throw new Error('asked for before the tests began') - } - return join(runtimeDir, 'srt-mount-points') - }, + manifestDir: () => join(own().places.runtimeDir, 'srt-mount-points'), + places: () => own().places, + isolated: (module, places) => + onLinux + ? isolatedFile(own(), module, places ?? own().places, 'module') + : module, } } diff --git a/test/preload.ts b/test/preload.ts new file mode 100644 index 000000000..a6c4ef0be --- /dev/null +++ b/test/preload.ts @@ -0,0 +1,12 @@ +import { afterAll } from 'bun:test' +import { + isolateManifestDirectoriesForTheRun, + removeManifestDirectoriesOfTheRun, +} from './helpers/private-manifest-directory.js' + +// Before any test file is loaded, so that no wrap and no clean-up of the run +// reaches the directories the user's own sandboxes keep their manifests in. A +// hook here belongs to the run and not to a file: the stand-ins go after the +// last test file. +isolateManifestDirectoriesForTheRun() +afterAll(removeManifestDirectoriesOfTheRun) diff --git a/test/sandbox/default-write-paths.test.ts b/test/sandbox/default-write-paths.test.ts index c6fd91384..2b4c181d6 100644 --- a/test/sandbox/default-write-paths.test.ts +++ b/test/sandbox/default-write-paths.test.ts @@ -9,6 +9,7 @@ import { isAtOrUnder, } from '../../src/sandbox/sandbox-utils.js' import { isLinux, isMacOS, isWindows } from '../helpers/platform.js' +import { isolatedModule } from '../helpers/private-manifest-directory.js' const REPO_ROOT = join(import.meta.dir, '../..') const NPM_LOGS = '.npm/_logs' @@ -193,7 +194,11 @@ describe.if(isLinux || isMacOS)( mkdirSync(join(fakeHome, NPM_LOGS), { recursive: true }) const { allowOnly, wrapped, reopened } = runWithHome( fakeHome, - `const { SandboxManager } = await import('./src/sandbox/sandbox-manager.ts') + // The manager as a child is to load it: it wraps, and the directories + // the manifests are kept in are not this test's to reach. + `const { SandboxManager } = await import(${JSON.stringify( + isolatedModule(join(REPO_ROOT, 'src/sandbox/sandbox-manager.ts')), + )}) const filesystem = { denyRead: ['~/.npm'], allowWrite: [], denyWrite: [] } await SandboxManager.initialize({ network: { allowedDomains: [], deniedDomains: [] }, diff --git a/test/sandbox/linux-bwrap-args-file.test.ts b/test/sandbox/linux-bwrap-args-file.test.ts index 98fa867d0..851f519b0 100644 --- a/test/sandbox/linux-bwrap-args-file.test.ts +++ b/test/sandbox/linux-bwrap-args-file.test.ts @@ -52,7 +52,7 @@ describe('the bwrap profile error at the package root', () => { * command line. */ describe.if(isLinux)('bwrap --args for over-long profiles', () => { - usePrivateManifestDirectory() + const runtime = usePrivateManifestDirectory() const MAX_ARG_STRLEN = 32 * Number(spawnSync('getconf', ['PAGESIZE'], { encoding: 'utf8' }).stdout) // The largest rendering kept on the command line: the kernel's limit less @@ -170,8 +170,23 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { launcher: string[] = [], ): unknown { const files = overLongProfile() + // A tmpdir of its own, so what a scenario leaves there goes with BASE, + // and no runtime directory: the mount point manifests are then kept under + // that tmpdir, which is the arrangement several scenarios are about. The + // child's own tmpdir is what stands in for /tmp, and nothing for + // /run/user/UID. + const childEnv: NodeJS.ProcessEnv = { + ...process.env, + TMPDIR: join(BASE, 'tmp'), + ...env, + } + delete childEnv.XDG_RUNTIME_DIR + const library = runtime.isolated(MODULE, { + runtimeDir: join(BASE, 'no-runtime-directory'), + tempDir: childEnv.TMPDIR, + }) const script = ` - import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints, LinuxSandboxProfileError } from ${JSON.stringify(MODULE)} + import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints, LinuxSandboxProfileError } from ${JSON.stringify(library)} import * as fs from 'node:fs' const overLong = ${JSON.stringify(files)} const small = overLong.slice(0, 1) @@ -197,16 +212,7 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { // fit one argument itself. const scriptFile = join(BASE, 'isolated.ts') writeFileSync(scriptFile, script) - // A tmpdir of its own, so what a scenario leaves there goes with BASE, - // and no runtime directory: the mount point manifests are then kept under - // that tmpdir, which is the arrangement several scenarios are about. mkdirSync(join(BASE, 'tmp'), { recursive: true }) - const childEnv: NodeJS.ProcessEnv = { - ...process.env, - TMPDIR: join(BASE, 'tmp'), - ...env, - } - delete childEnv.XDG_RUNTIME_DIR const argv = [...launcher, process.execPath, 'run', scriptFile] const run = spawnSync(argv[0]!, argv.slice(1), { cwd: BASE, diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index 878f71936..2ce6447ac 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -16,8 +16,12 @@ import { writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { liveMountPoints } from '../../src/index.js' +import { + type ManifestDirectoryPlaces, + setMountPointManifestPlacesForTesting, +} from '../../src/sandbox/bwrap-mount-manifests.js' import { cleanupBwrapMountPoints, wrapCommandWithSandboxLinux, @@ -26,7 +30,10 @@ import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' import { countMounts, indexOfMount } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { isLinux } from '../helpers/platform.js' -import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' +import { + inPrivateNamespace, + usePrivateManifestDirectory, +} from '../helpers/private-manifest-directory.js' /** * A denyWrite path that does not exist is blocked by binding /dev/null over @@ -47,9 +54,12 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { // assert on what is left: in a directory of their own. const runtime = usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() - const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + const LIBRARY_PATH = join( + import.meta.dir, + '../../src/sandbox/linux-sandbox-utils.ts', ) + // For a child process: the library with this describe's stand-ins in place. + const LIBRARY = JSON.stringify(runtime.isolated(LIBRARY_PATH)) let BASE: string let AREA: string // the allowed write area let LOCK: string // the denyWrite path, absent to begin with @@ -86,16 +96,20 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { return `echo up > ${SIGNAL}; while [ ! -e ${GO} ]; do sleep 0.05; done; echo pwned > ${LOCK}; echo rc=$?` } - /** A child process that wraps `command`, runs it and writes what it said. */ + /** + * A child process that wraps `command`, runs it and writes what it said, + * with the library as `library` names it. + */ function wrapperScript( command: string, out: string, after: string[] = [], + library = LIBRARY, ): string { return [ `import { spawnSync } from 'node:child_process'`, `import { writeFileSync } from 'node:fs'`, - `import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints } from ${LIBRARY}`, + `import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints } from ${library}`, `const out = ${JSON.stringify(out)}`, `const wrapped = await wrapCommandWithSandboxLinux({`, ` command: ${JSON.stringify(command)},`, @@ -146,8 +160,11 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { * `writer`: a process that is gone, which makes it a finished one, unless * told otherwise. */ - function manifestNaming(paths: string[], writer?: number): string { - const dir = runtime.manifestDir() + function manifestNaming( + paths: string[], + writer?: number, + dir = runtime.manifestDir(), + ): string { mkdirSync(dir, { recursive: true, mode: 0o700 }) chmodSync(dir, 0o700) let pid = 4194000 @@ -361,6 +378,401 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { 90000, ) + for (const differing of ['XDG_RUNTIME_DIR', 'TMPDIR'] as const) { + it.if(BWRAP_CAN_NAMESPACE)( + `stays while the sandbox of a process with another ${differing} runs, after the one it was made for has ended and been cleaned up after`, + async () => { + // Two processes as they really are, each working the directories out + // for itself from the real names, in a mount namespace where this + // test's stand-ins are what is at those names. They differ in what + // the environment says, as a login shell and what a service or an IDE + // starts do, and each kept its manifests where its own environment + // said: the second wrap took the first's mount point for the user's + // own file, and it went from under the second sandbox when the first + // process cleaned up. The two that differ in their temp dir have no + // runtime directory, and meet under /tmp. + const noRuntimeDirectory = join(BASE, 'run-user') + mkdirSync(noRuntimeDirectory) + const launcher = inPrivateNamespace( + differing === 'TMPDIR' + ? { + runtimeDir: join(noRuntimeDirectory, 'nobody'), + tempDir: runtime.places().tempDir, + } + : runtime.places(), + [BASE], + ) + const environment = (n: number): Record => { + const own = join(BASE, `${differing.toLowerCase()}-${n}`) + mkdirSync(own, { recursive: true }) + chmodSync(own, 0o700) + const env: Record = { + ...process.env, + XDG_RUNTIME_DIR: undefined, + TMPDIR: join(BASE, 'tmp'), + [differing]: own, + } + mkdirSync(env.TMPDIR!, { recursive: true }) + for (const [name, value] of Object.entries(env)) { + if (value === undefined) delete env[name] + } + return env as Record + } + const asItIs = ( + name: string, + command: string, + out: string, + after: string[], + ): string => + writeScript( + name, + wrapperScript(command, out, after, JSON.stringify(LIBRARY_PATH)), + ) + const started = (script: string, n: number): ChildProcess => + spawn( + launcher[0]!, + [...launcher.slice(1), process.execPath, script], + { + env: environment(n), + stdio: 'ignore', + }, + ) + + const upSecond = join(AREA, 'up-second') + const goSecond = join(AREA, 'go-second') + const outSecond = join(BASE, 'out-second.txt') + const first = started( + asItIs( + 'first.ts', + `echo up > ${SIGNAL}; while [ ! -e ${GO} ]; do sleep 0.05; done`, + OUT, + ['cleanupBwrapMountPoints()'], + ), + 1, + ) + const firstExited = new Promise(resolve => first.on('exit', resolve)) + let second: ChildProcess | undefined + try { + await waitFor(SIGNAL) + expect(lstatSync(LOCK).size).toBe(0) + + second = started( + asItIs( + 'second.ts', + `echo up > ${upSecond}; n=0; while [ ! -e ${goSecond} ]; do if (echo pwned > ${LOCK}) 2>/dev/null; then n=$((n+1)); fi; sleep 0.05; done; echo writes=$n`, + outSecond, + [], + ), + 2, + ) + await waitFor(upSecond) + + writeFileSync(GO, '') + await firstExited // and its own clean-up has run + // Past the grace, and with a third process cleaning up as well. + await sleep(700) + const third = spawnSync( + launcher[0]!, + [ + ...launcher.slice(1), + process.execPath, + asItIs('third.ts', 'true', join(BASE, 'out-third.txt'), [ + 'cleanupBwrapMountPoints()', + ]), + ], + { env: environment(3), encoding: 'utf8', timeout: 60000 }, + ) + expect(third.status).toBe(0) + expect(lstatSync(LOCK).size).toBe(0) + // For the command to have tried for a while longer. + await sleep(300) + } finally { + writeFileSync(GO, '') + writeFileSync(goSecond, '') + await firstExited + if (second !== undefined) { + const running = second + await new Promise(resolve => running.on('exit', resolve)) + } + } + + expect(readFileSync(OUT, 'utf8')).not.toContain('bwrap:') + expect(readFileSync(outSecond, 'utf8')).toBe('writes=0\n') + expect(contentAt(LOCK)).toBe('') + // Both wrote where the user id says, which is where this process + // looks too, and neither where its own environment says. + for (const n of [1, 2]) { + const own = join(BASE, `${differing.toLowerCase()}-${n}`) + for (const name of readdirSync(own)) { + expect(readdirSync(join(own, name))).toEqual([]) + } + } + + // And goes once nothing runs under it. + await sleep(600) + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + }, + 90000, + ) + } + + it('is taken for one on the word of a manifest in a directory this process does not write to', async () => { + // What a killed process that had no runtime directory left, and the + // manifest it left under /tmp. This process writes under the runtime + // directory, and reads both. + const theirs = join( + runtime.places().tempDir, + `srt-mount-points-${process.getuid!()}`, + ) + writeFileSync(LOCK, '') + chmodSync(LOCK, 0o444) + const left = manifestNaming([LOCK], undefined, theirs) + + const command = await wrap('true') + expect(command).toContain(`--ro-bind /dev/null ${LOCK}`) + expect(command).not.toContain(`--ro-bind ${LOCK} ${LOCK}`) + expect(dirname(manifestOf(command))).toBe(runtime.manifestDir()) + expect(readFileSync(manifestOf(command), 'utf8')).toContain(LOCK) + + // It goes when this wrap is cleaned up after, and the manifest that named + // it first with it. + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + expect(existsSync(left)).toBe(false) + }) + + it('is not taken for one on the word of a manifest where the environment says, by a process that can use a directory that comes from the user id', async () => { + // What the environment names is out of reach of the sandboxes of the + // processes with that environment, and of no others. While this process + // has a directory every process of the user looks in, what is found + // there is nobody's word: the file is the user's own, empty and + // read-only as it is. + const xdg = join(BASE, 'xdg') + mkdirSync(xdg) + chmodSync(xdg, 0o700) + const theirs = join(xdg, 'srt-mount-points') + writeFileSync(LOCK, '') + chmodSync(LOCK, 0o444) + const planted = manifestNaming([LOCK], undefined, theirs) + setMountPointManifestPlacesForTesting({ + ...runtime.places(), + environmentRuntimeDir: xdg, + }) + try { + const command = await wrap('true') + expect(command).toContain(`--ro-bind ${LOCK} ${LOCK}`) + expect(command).not.toContain(`--ro-bind /dev/null ${LOCK}`) + expect(command).not.toContain('--lock-file') + // Out of the command's reach all the same: a process with this + // environment that can use nothing else writes there. + expect(command).toContain(`--ro-bind-try ${theirs} ${theirs} `) + + cleanupBwrapMountPoints() + expect(lstatSync(LOCK).size).toBe(0) + expect(existsSync(planted)).toBe(true) + } finally { + cleanupBwrapMountPoints({ force: true }) + setMountPointManifestPlacesForTesting(runtime.places()) + } + }) + + it.if(BWRAP_CAN_NAMESPACE)( + 'is not removed on the word of a manifest that the sandbox of a process with another temp dir wrote under this one', + () => { + // Two processes as they really are, in a mount namespace where this + // test's stand-ins are at the real names. The first has a temp dir of + // its own, inside what the second lets its command write. The second + // does not know of that temp dir, so its sandbox has nothing bound + // there, and its command writes a manifest under it that names + // somebody's empty read-only file. The first has a directory under + // /tmp to keep its manifests in, and believes nothing under its temp + // dir. + const uid = process.getuid!() + const HOME = join(BASE, 'home') + const theirTmp = join(HOME, 'tmp') + const theirs = join(theirTmp, `srt-mount-points-${uid}`) + const BYSTANDER = join(BASE, 'outside', 'bystander.lock') + mkdirSync(theirTmp, { recursive: true }) + mkdirSync(dirname(BYSTANDER)) + writeFileSync(BYSTANDER, '') + chmodSync(BYSTANDER, 0o444) + const launcher = inPrivateNamespace(runtime.places(), [BASE]) + const inTheNamespace = ( + name: string, + lines: string[], + env: Record, + ): { status: number | null; stdout: string } => { + const merged: Record = { + ...process.env, + XDG_RUNTIME_DIR: undefined, + TMPDIR: undefined, + ...env, + } + for (const [key, value] of Object.entries(merged)) { + if (value === undefined) delete merged[key] + } + return spawnSync( + launcher[0]!, + [ + ...launcher.slice(1), + process.execPath, + writeScript(name, lines.join('\n')), + ], + { + env: merged as Record, + encoding: 'utf8', + timeout: 60000, + }, + ) + } + const wrapping = ( + command: string, + allowOnly: string[], + deny: string[], + ): string[] => [ + `import { spawnSync } from 'node:child_process'`, + `import { wrapCommandWithSandboxLinux } from ${JSON.stringify(LIBRARY_PATH)}`, + `const wrapped = await wrapCommandWithSandboxLinux({`, + ` command: ${JSON.stringify(command)},`, + ` needsNetworkRestriction: false,`, + ` readConfig: { denyOnly: [] },`, + ` writeConfig: { allowOnly: ${JSON.stringify(allowOnly)}, denyWithinAllow: ${JSON.stringify(deny)} },`, + `})`, + `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, + `console.log(JSON.stringify({ wrapped, said: String(r.stdout ?? '') + String(r.stderr ?? '') }))`, + ] + type Ran = { wrapped: string; said: string } + + // The first process wraps a command: it makes the directory under its + // temp dir, and writes under /tmp or the runtime directory. + const first = inTheNamespace( + 'first.ts', + wrapping('true', [AREA], [LOCK]), + { TMPDIR: theirTmp }, + ) + expect(first.status).toBe(0) + const firstRan = JSON.parse(first.stdout) as Ran + expect(firstRan.said).not.toContain('bwrap:') + expect(firstRan.wrapped).toContain(`--ro-bind-try ${theirs} ${theirs} `) + expect(firstRan.wrapped).not.toContain(`--lock-file ${theirs}/`) + expect(lstatSync(theirs).isDirectory()).toBe(true) + + // The command of the second, which may write the first's temp dir. + const forged = JSON.stringify({ + version: 1, + pid: 4194000, + start: '0', + created: 0, + paths: [BYSTANDER], + sources: [], + }) + const second = inTheNamespace( + 'second.ts', + wrapping( + `mkdir -p -m 700 ${theirs} 2>/dev/null; echo '${forged}' > ${theirs}/forged.json; echo plant=$?`, + [HOME], + [], + ), + {}, + ) + expect(second.status).toBe(0) + const secondRan = JSON.parse(second.stdout) as Ran + // Nothing of the second's sandbox is bound there. + expect(secondRan.wrapped).not.toContain(`-bind ${theirs} `) + expect(secondRan.wrapped).not.toContain(`-bind-try ${theirs} `) + expect(secondRan.said).toContain('plant=0') + expect(readFileSync(join(theirs, 'forged.json'), 'utf8')).toContain( + BYSTANDER, + ) + + // The next clean-up of a process with the first's environment. + const cleaner = inTheNamespace( + 'cleaner.ts', + [ + `import { cleanupBwrapMountPoints } from ${JSON.stringify(LIBRARY_PATH)}`, + `cleanupBwrapMountPoints()`, + ], + { TMPDIR: theirTmp }, + ) + expect(cleaner.status).toBe(0) + expect(lstatSync(BYSTANDER).size).toBe(0) + expect(existsSync(join(theirs, 'forged.json'))).toBe(true) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE && existsSync('/run/user'))( + 'cannot be made, written or moved aside under /tmp by a command that may write /tmp, while its process writes under the runtime directory', + () => { + // The directory under /tmp is where a process without a runtime + // directory writes, and every process reads it. A command that may write + // /tmp runs as the same user: a directory it made there itself would + // pass every check of owner and mode, and what it put in it would be + // believed. So the wrap makes it first, and binds it read-only. + const uid = process.getuid!() + const theirs = `/tmp/srt-mount-points-${uid}` + const onTheHost = join(runtime.places().tempDir, basename(theirs)) + // Not there, as on a host where every process so far had a runtime + // directory to write to. + rmSync(onTheHost, { recursive: true, force: true }) + const attack = [ + `mkdir -m 700 ${theirs} 2>/dev/null; echo mkdir=$?`, + `echo forged > ${theirs}/planted.json; echo plant=$?`, + `mv ${theirs} /tmp/aside 2>/dev/null; echo mv=$?`, + `rmdir ${theirs} 2>/dev/null; echo rmdir=$?`, + ].join('; ') + const script = writeScript( + 'attacker.ts', + [ + `import { spawnSync } from 'node:child_process'`, + `import { wrapCommandWithSandboxLinux } from ${JSON.stringify(LIBRARY_PATH)}`, + `const wrapped = await wrapCommandWithSandboxLinux({`, + ` command: ${JSON.stringify(attack)},`, + ` needsNetworkRestriction: false,`, + ` readConfig: { denyOnly: [] },`, + ` writeConfig: { allowOnly: ['/tmp', ${JSON.stringify(AREA)}], denyWithinAllow: [${JSON.stringify(LOCK)}] },`, + `})`, + `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, + `console.log(JSON.stringify({ wrapped, said: String(r.stdout ?? '') + String(r.stderr ?? '') }))`, + ].join('\n'), + ) + const env: Record = { ...process.env } + delete env.XDG_RUNTIME_DIR + delete env.TMPDIR + const launcher = inPrivateNamespace(runtime.places(), [BASE]) + const child = spawnSync( + launcher[0]!, + [...launcher.slice(1), process.execPath, script], + { + env: env as Record, + encoding: 'utf8', + timeout: 60000, + }, + ) + expect(child.status).toBe(0) + const { wrapped, said } = JSON.parse(child.stdout) as { + wrapped: string + said: string + } + expect(wrapped).toContain( + `--lock-file /run/user/${uid}/srt-mount-points/`, + ) + expect(wrapped).toContain(`--ro-bind-try ${theirs} ${theirs} `) + expect(said).not.toContain('bwrap:') + expect(said).toMatch(/mkdir=[1-9]/) + expect(said).toMatch(/plant=[1-9]/) + expect(said).toMatch(/mv=[1-9]/) + expect(said).toMatch(/rmdir=[1-9]/) + // Made by the wrap, the user's alone, and with nothing in it. + expect(lstatSync(onTheHost).isDirectory()).toBe(true) + expect(lstatSync(onTheHost).mode & 0o777).toBe(0o700) + expect(readdirSync(onTheHost)).toEqual([]) + expect(existsSync(join(runtime.places().tempDir, 'aside'))).toBe(false) + }, + 90000, + ) + it.if(BWRAP_CAN_NAMESPACE)( 'is collected, manifest and all, when the process that made it never cleans up', async () => { @@ -480,27 +892,36 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { ) it.if(BWRAP_CAN_NAMESPACE)( - 'are read-only, too, where a process that was started without a runtime directory keeps them', + 'are read-only, too, in the directory under the temp dir, for a process that writes under the runtime directory', async () => { - // A process started with $XDG_RUNTIME_DIR keeps its manifests under it, - // and one started without (from cron, over a plain ssh) under the temp - // dir. A sandbox of the first kind that may write the temp dir could - // delete and forge the manifests of the second, which a collect on the - // host then believed. So every wrap binds both names read-only, and - // makes the one that is not there so that the command cannot make it - // first. - const RUN = join(BASE, 'run') + // A process with a runtime directory keeps its manifests under it, and + // one without (in a container, under another user's `su`) under the + // temp dir, and every process reads both. A sandbox of the first kind + // that may write the temp dir could delete and forge the manifests of + // the second, which a collect on the host then believed. So every wrap + // binds every name read-only, and makes the one that is not there so + // that the command cannot make it first. const TMP = join(BASE, 'tmp') - mkdirSync(RUN, { mode: 0o700 }) - chmodSync(RUN, 0o700) mkdirSync(TMP) const theirs = join(TMP, `srt-mount-points-${process.getuid!()}`) + // The runtime directory this process has too, and TMP for /tmp. + const library = JSON.stringify( + runtime.isolated(LIBRARY_PATH, { + runtimeDir: runtime.places().runtimeDir, + tempDir: TMP, + }), + ) + const env: Record = { + ...process.env, + TMPDIR: TMP, + } + delete env.XDG_RUNTIME_DIR const attacker = (script: string, attack: string): string => writeScript( script, [ `import { spawnSync } from 'node:child_process'`, - `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + `import { wrapCommandWithSandboxLinux } from ${library}`, `const wrapped = await wrapCommandWithSandboxLinux({`, ` command: ${JSON.stringify(attack)},`, ` needsNetworkRestriction: false,`, @@ -513,7 +934,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { ) const withARuntimeDirectory = (script: string) => spawnSync(process.execPath, [script], { - env: { ...process.env, XDG_RUNTIME_DIR: RUN, TMPDIR: TMP }, + env: env as Record, encoding: 'utf8', timeout: 60000, }) @@ -836,12 +1257,19 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { it.if(BWRAP_CAN_NAMESPACE)( "is recorded in a directory of the process's own only when there is one to record, and that directory goes with the process", async () => { - // Neither shared name will do: no runtime directory, and the name under - // the temp dir is somebody's file. + // None of the shared names will do: no runtime directory, and the name + // under the temp dir, which is what stands in for /tmp too, is + // somebody's file. const TMP = join(BASE, 'tmp') mkdirSync(TMP) const squatted = join(TMP, `srt-mount-points-${process.getuid!()}`) writeFileSync(squatted, '') + const library = JSON.stringify( + runtime.isolated(LIBRARY_PATH, { + runtimeDir: join(BASE, 'no-runtime-directory'), + tempDir: TMP, + }), + ) const env: Record = { ...process.env, TMPDIR: TMP, @@ -856,7 +1284,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { [ `import { spawnSync } from 'node:child_process'`, `import { readdirSync } from 'node:fs'`, - `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + `import { wrapCommandWithSandboxLinux } from ${library}`, `const wrapped = await wrapCommandWithSandboxLinux({`, ` command: 'true',`, ` needsNetworkRestriction: false,`, @@ -971,11 +1399,15 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { describe.if(isLinux)( 'A mount point whose sandbox outlives the process that wrapped it', () => { - usePrivateManifestDirectory() + const runtime = usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() - const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + const LIBRARY_PATH = join( + import.meta.dir, + '../../src/sandbox/linux-sandbox-utils.ts', ) + // For a child process: the library with this describe's stand-ins in + // place. + const LIBRARY = JSON.stringify(runtime.isolated(LIBRARY_PATH)) // Comfortably past the grace a new manifest is given (500 ms): after this // only the writer being alive, or the lock, can make the manifest live. const PAST_GRACE_MS = 1200 @@ -1374,9 +1806,13 @@ describe.if(isLinux)( () => { const runtime = usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() - const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + const LIBRARY_PATH = join( + import.meta.dir, + '../../src/sandbox/linux-sandbox-utils.ts', ) + // For a child process: the library with this describe's stand-ins in + // place. + const LIBRARY = JSON.stringify(runtime.isolated(LIBRARY_PATH)) let BASE: string let AREA: string let DIRECTORY: string // the first missing component of LEAF @@ -1756,16 +2192,32 @@ describe.if(isLinux)( describe.if(isLinux)( 'The directories above the ones this library keeps on the host', () => { - usePrivateManifestDirectory() + const runtime = usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() - const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + const LIBRARY_PATH = join( + import.meta.dir, + '../../src/sandbox/linux-sandbox-utils.ts', ) const UID = isLinux ? process.getuid!() : 0 let BASE: string let HOME: string // the allowed write root - let TMP: string // the temp dir, strictly inside it + let TMP: string // the temp dir, strictly inside it, and what is at /tmp let GUARD: string // a denyWrite path, absent to begin with + let LIBRARY: string // the library, for a child process + + /** + * The library for a child process that has no runtime directory and TMP + * at `/tmp`, or `places` instead of either. + */ + function libraryWith(places: Partial): string { + return JSON.stringify( + runtime.isolated(LIBRARY_PATH, { + runtimeDir: join(BASE, 'no-runtime-directory'), + tempDir: TMP, + ...places, + }), + ) + } beforeEach(() => { BASE = realpathSync(mkdtempSync(join(tmpdir(), 'mount-point-pins-'))) @@ -1773,6 +2225,7 @@ describe.if(isLinux)( TMP = join(HOME, 'tmp') mkdirSync(TMP, { recursive: true }) GUARD = join(HOME, 'guard.lock') + LIBRARY = libraryWith({}) }) afterEach(() => { @@ -1781,8 +2234,9 @@ describe.if(isLinux)( }) /** - * Runs `lines` in a process of its own whose temp dir is TMP. An `env` - * entry that is undefined is one that process does not have. + * Runs `lines` in a process of its own whose temp dir is TMP, and which + * has no `XDG_RUNTIME_DIR`. An `env` entry that is undefined is one that + * process does not have. */ function inAChild( name: string, @@ -1793,6 +2247,7 @@ describe.if(isLinux)( writeFileSync(file, lines.join('\n')) const merged: Record = { ...process.env, + XDG_RUNTIME_DIR: undefined, TMPDIR: TMP, ...env, } @@ -1809,15 +2264,19 @@ describe.if(isLinux)( return { status: r.status, stdout: r.stdout } } - /** What a process with that environment wraps `true` as. */ + /** + * What a process with that environment wraps `true` as, with `places` for + * where it looks. + */ function wrapped( options: Record, env: Record = {}, + places: Partial = {}, ): string { const child = inAChild( 'wrap.ts', [ - `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + `import { wrapCommandWithSandboxLinux } from ${libraryWith(places)}`, `console.log(await wrapCommandWithSandboxLinux({`, ` command: 'true',`, ` needsNetworkRestriction: false,`, @@ -1848,10 +2307,9 @@ describe.if(isLinux)( } it('are pinned above the manifest directory a process with no runtime directory keeps', () => { - const command = wrapped( - { writeConfig: { allowOnly: [HOME], denyWithinAllow: [] } }, - { XDG_RUNTIME_DIR: undefined }, - ) + const command = wrapped({ + writeConfig: { allowOnly: [HOME], denyWithinAllow: [] }, + }) expect(command).toContain(`--ro-bind-try ${TMP}/srt-mount-points-${UID}`) expectPinned(command, TMP) // The write root is a mount point already, and nothing above it is the @@ -1866,21 +2324,79 @@ describe.if(isLinux)( chmodSync(RUN, 0o700) const command = wrapped( { writeConfig: { allowOnly: [HOME], denyWithinAllow: [GUARD] } }, - { XDG_RUNTIME_DIR: RUN }, + {}, + { runtimeDir: RUN }, ) expect(command).toContain(`--lock-file ${RUN}/srt-mount-points/`) expectPinned(command, RUN) expectPinned(command, join(HOME, 'run')) }) + it('are pinned above every manifest directory, whichever of them the process writes to', () => { + // Four names for four directories, each strictly inside the write + // root: the runtime directory and /tmp, and what the environment has + // for the two. The process writes to the first, and a process of the + // user that writes to one of the others believes what it finds there, + // so the command is kept out of all of them: each is made, bound + // read-only where it is, and what lies above it pinned. + const at = (name: string): string => { + const dir = join(HOME, name, 'deeper') + mkdirSync(dir, { recursive: true, mode: 0o700 }) + chmodSync(dir, 0o700) + return dir + } + const runtimeDir = at('run-user') + const tempDir = at('tmp-root') + const XDG_RUNTIME_DIR = at('xdg') + const TMPDIR = at('tmpdir') + const every = [ + join(runtimeDir, 'srt-mount-points'), + join(tempDir, `srt-mount-points-${UID}`), + join(XDG_RUNTIME_DIR, 'srt-mount-points'), + join(TMPDIR, `srt-mount-points-${UID}`), + ] + + const recording = wrapped( + { writeConfig: { allowOnly: [HOME], denyWithinAllow: [GUARD] } }, + { XDG_RUNTIME_DIR, TMPDIR }, + { runtimeDir, tempDir }, + ) + expect(recording).toContain(`--lock-file ${every[0]}/`) + expect(countMounts(recording, '--ro-bind', every[0]!, every[0]!)).toBe(1) + for (const dir of every.slice(1)) { + expect(recording).toContain(`--ro-bind-try ${dir} ${dir} `) + } + for (const dir of every) { + expect(lstatSync(dir).isDirectory()).toBe(true) + expect(lstatSync(dir).mode & 0o777).toBe(0o700) + expectPinned(recording, dirname(dir)) + expectPinned(recording, dirname(dirname(dir))) + } + // Nothing was written to any but the first. + for (const dir of every.slice(1)) { + expect(readdirSync(dir)).toEqual([]) + } + + // A wrap with nothing to record writes nowhere, and binds all four. + const other = wrapped( + { writeConfig: { allowOnly: [HOME], denyWithinAllow: [] } }, + { XDG_RUNTIME_DIR, TMPDIR }, + { runtimeDir, tempDir }, + ) + expect(other).not.toContain('--lock-file') + for (const dir of every) { + expect(other).toContain(`--ro-bind-try ${dir} ${dir} `) + expectPinned(other, dirname(dir)) + } + }) + it('are pinned above the directory of its own a process settles on as it records', () => { // Neither shared name will do, so the wrap makes a directory only this // process knows, and makes it as it publishes: after the pins. takeTheSharedName() - const command = wrapped( - { writeConfig: { allowOnly: [HOME], denyWithinAllow: [GUARD] } }, - { XDG_RUNTIME_DIR: undefined }, - ) + const command = wrapped({ + writeConfig: { allowOnly: [HOME], denyWithinAllow: [GUARD] }, + }) expect(command).toMatch( new RegExp(`--lock-file ${TMP}/srt-mount-points-[A-Za-z0-9]{6}/`), ) @@ -1891,12 +2407,17 @@ describe.if(isLinux)( // The manifests are out of the way, under a runtime directory outside // the write root: the temp dir is pinned for the source alone. takeTheSharedName() - const command = wrapped({ - writeConfig: { - allowOnly: [HOME], - denyWithinAllow: [join(HOME, 'absent', 'leaf')], + const command = wrapped( + { + writeConfig: { + allowOnly: [HOME], + denyWithinAllow: [join(HOME, 'absent', 'leaf')], + }, }, - }) + {}, + { runtimeDir: runtime.places().runtimeDir }, + ) + expect(command).toContain(`--lock-file ${runtime.manifestDir()}/`) expect(command).toMatch( new RegExp(`--ro-bind ${TMP}/claude-empty-\\S+ ${HOME}/absent `), ) @@ -1937,26 +2458,25 @@ describe.if(isLinux)( symlinkSync('real-tmp', through) const command = wrapped( { writeConfig: { allowOnly: [HOME], denyWithinAllow: [] } }, - { XDG_RUNTIME_DIR: undefined, TMPDIR: through }, + { TMPDIR: through }, + { tempDir: through }, ) expectPinned(command, real) expect(countMounts(command, '--ro-bind', through, through)).toBe(0) }) it('are not pinned where the temp dir is itself the write root, or outside every one', () => { - const inside = wrapped( - { writeConfig: { allowOnly: [TMP], denyWithinAllow: [] } }, - { XDG_RUNTIME_DIR: undefined }, - ) + const inside = wrapped({ + writeConfig: { allowOnly: [TMP], denyWithinAllow: [] }, + }) expect(countMounts(inside, '--bind', TMP, TMP)).toBe(1) expect(countMounts(inside, '--ro-bind', TMP, TMP)).toBe(0) const AREA = join(BASE, 'area') mkdirSync(AREA) - const outside = wrapped( - { writeConfig: { allowOnly: [AREA], denyWithinAllow: [] } }, - { XDG_RUNTIME_DIR: undefined }, - ) + const outside = wrapped({ + writeConfig: { allowOnly: [AREA], denyWithinAllow: [] }, + }) expect(countMounts(outside, '--ro-bind', TMP, TMP)).toBe(0) expect(countMounts(outside, '--ro-bind', HOME, HOME)).toBe(0) }) @@ -2020,14 +2540,10 @@ describe.if(isLinux)( } expect(lstatSync(GUARD).size).toBe(0) - const collector = inAChild( - 'collector.ts', - [ - `import { cleanupBwrapMountPoints } from ${LIBRARY}`, - `cleanupBwrapMountPoints()`, - ], - { XDG_RUNTIME_DIR: undefined }, - ) + const collector = inAChild('collector.ts', [ + `import { cleanupBwrapMountPoints } from ${LIBRARY}`, + `cleanupBwrapMountPoints()`, + ]) expect(collector.status).toBe(0) expect(existsSync(GUARD)).toBe(true) } finally { @@ -2060,10 +2576,11 @@ describe.if(isLinux)( describe.if(isLinux)( 'The manifest directory under a temp dir whose name is a link', () => { - usePrivateManifestDirectory() + const runtime = usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() - const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + const LIBRARY_PATH = join( + import.meta.dir, + '../../src/sandbox/linux-sandbox-utils.ts', ) const UID = isLinux ? process.getuid!() : 0 let BASE: string @@ -2083,9 +2600,9 @@ describe.if(isLinux)( }) /** - * What a process whose temp dir is `through`, and which has no runtime - * directory, wraps `command` as with that deny list, and what the command - * said when run. + * What a process whose temp dir is `through`, as `/tmp` is, and which has + * no runtime directory, wraps `command` as with that deny list, and what + * the command said when run. */ function wrappedAndRun( through: string, @@ -2094,11 +2611,17 @@ describe.if(isLinux)( store?: string, ): { wrapped: string; status: number | null; said: string } { const file = join(BASE, 'wrap-and-run.ts') + const library = JSON.stringify( + runtime.isolated(LIBRARY_PATH, { + runtimeDir: join(BASE, 'no-runtime-directory'), + tempDir: through, + }), + ) writeFileSync( file, [ `import { spawnSync } from 'node:child_process'`, - `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + `import { wrapCommandWithSandboxLinux } from ${library}`, `const wrapped = await wrapCommandWithSandboxLinux({`, ` command: ${JSON.stringify(command)},`, ` needsNetworkRestriction: false,`, @@ -2265,7 +2788,7 @@ describe.if(isLinux)( describe.if(isLinux)( 'A mount point a running sandbox relies on, seen from another PID namespace', () => { - usePrivateManifestDirectory() + const runtime = usePrivateManifestDirectory() // A second PID namespace with a /proc of its own, uid unchanged, no root. const IN_NEW_PID_NAMESPACE = [ 'unshare', @@ -2281,9 +2804,13 @@ describe.if(isLinux)( ...IN_NEW_PID_NAMESPACE.slice(1), 'true', ]).status === 0 - const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + const LIBRARY_PATH = join( + import.meta.dir, + '../../src/sandbox/linux-sandbox-utils.ts', ) + // For a child process: the library with this describe's stand-ins in + // place. + const LIBRARY = JSON.stringify(runtime.isolated(LIBRARY_PATH)) let BASE: string let AREA: string let LOCK: string diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index 58fa38c8a..c7591ade0 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' -import { spawnSync } from 'node:child_process' +import { spawn, spawnSync } from 'node:child_process' import * as fs from 'node:fs' import { accessSync, @@ -13,6 +13,7 @@ import { readdirSync, readlinkSync, realpathSync, + renameSync, rmSync, statSync, statfsSync, @@ -21,12 +22,15 @@ import { writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { + type ManifestDirectoryPlaces, collectMountPoints, liveMountPoints, + mountPointManifestDirectories, namedMountPoints, publishMountPointManifest, + setMountPointManifestPlacesForTesting, } from '../../src/sandbox/bwrap-mount-manifests.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { isLinux } from '../helpers/platform.js' @@ -40,8 +44,9 @@ import { usePrivateManifestDirectory } from '../helpers/private-manifest-directo */ describe.if(isLinux)('The mount point manifests', () => { const runtime = usePrivateManifestDirectory() - const MODULE = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/bwrap-mount-manifests.ts'), + const MODULE = join( + import.meta.dir, + '../../src/sandbox/bwrap-mount-manifests.ts', ) const LIBRARY = JSON.stringify( join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), @@ -55,6 +60,7 @@ describe.if(isLinux)('The mount point manifests', () => { let LOCK: string // the directory's lock let X: string // a mount point an earlier sandbox left let TMP: string // the temp dir of every child process + let THEIRS: string // the directory under /tmp, which this process only reads beforeEach(() => { BASE = realpathSync(mkdtempSync(join(tmpdir(), 'mount-point-manifests-'))) @@ -65,9 +71,19 @@ describe.if(isLinux)('The mount point manifests', () => { chmodSync(DIR, 0o700) LOCK = join(DIR, 'directory.lock') X = join(BASE, 'config.lock') + THEIRS = join( + runtime.places().tempDir, + `srt-mount-points-${process.getuid!()}`, + ) }) afterEach(() => { + try { + chmodSync(THEIRS, 0o700) + } catch { + // Not made by this test. + } + rmSync(THEIRS, { recursive: true, force: true }) for (const name of readdirSync(DIR)) { const file = join(DIR, name) try { @@ -102,9 +118,10 @@ describe.if(isLinux)('The mount point manifests', () => { function manifestOfADeadProcess( paths: string[], fields: Record = {}, + dir = DIR, ): string { const pid = deadPid() - const file = join(DIR, `${pid}-${Math.random().toString(16).slice(2)}.json`) + const file = join(dir, `${pid}-${Math.random().toString(16).slice(2)}.json`) const body: Record = { version: 1, pid, @@ -128,6 +145,12 @@ describe.if(isLinux)('The mount point manifests', () => { * the thread it happens on, so a test in this process could not time it out. * `first` runs before the module is loaded, and an `env` entry that is * undefined is one the child does not have. + * + * The child looks where this process does for the runtime directory, and + * has a temp dir of its own, which is also what stands in for `/tmp`: it is + * where the manifests go when the runtime directory will not do, and the + * real one is every other process's. `places` puts something else in the + * place of either. It has no `XDG_RUNTIME_DIR` unless `env` gives it one. */ function inAChild( source: string, @@ -135,32 +158,60 @@ describe.if(isLinux)('The mount point manifests', () => { launcher?: string[] env?: Record first?: string + places?: Partial } = {}, ): { status: number | null; ms: number; stdout: string } { - const script = join(BASE, `child-${Math.random().toString(16).slice(2)}.ts`) + const script = writeChild( + `child-${Math.random().toString(16).slice(2)}.ts`, + source, + options.places, + options.first, + ) + const argv = [...(options.launcher ?? []), process.execPath, script] + const began = Date.now() + const run = spawnSync(argv[0]!, argv.slice(1), { + env: childEnv(options.env), + encoding: 'utf8', + timeout: 6000, + killSignal: 'SIGKILL', + }) + return { status: run.status, ms: Date.now() - began, stdout: run.stdout } + } + + /** The script {@link inAChild} runs, for a child that is started otherwise. */ + function writeChild( + name: string, + source: string, + places: Partial = {}, + first = '', + ): string { + const script = join(BASE, name) + const module = runtime.isolated(MODULE, { + runtimeDir: runtime.places().runtimeDir, + tempDir: TMP, + ...places, + }) writeFileSync( script, - `${options.first ?? ''}\nconst m = await import(${MODULE})\n${source}\n`, + `${first}\nconst m = await import(${JSON.stringify(module)})\n${source}\n`, ) - const argv = [...(options.launcher ?? []), process.execPath, script] - // A temp dir of its own: it is where the manifests go when the runtime - // directory will not do, and the real one is every other process's. + return script + } + + /** The environment {@link inAChild} gives its child. */ + function childEnv( + given: Record = {}, + ): Record { const env: Record = { ...process.env, + XDG_RUNTIME_DIR: undefined, TMPDIR: TMP, - ...options.env, + ...given, } for (const [name, value] of Object.entries(env)) { if (value === undefined) delete env[name] } - const began = Date.now() - const run = spawnSync(argv[0]!, argv.slice(1), { - env: env as Record, - encoding: 'utf8', - timeout: 6000, - killSignal: 'SIGKILL', - }) - return { status: run.status, ms: Date.now() - began, stdout: run.stdout } + return env as Record } const COLLECT = `m.collectMountPoints()` @@ -865,9 +916,13 @@ describe.if(isLinux)('The mount point manifests', () => { const shm = mkdtempSync(join(SHM, 'mount-point-manifests-')) chmodSync(shm, 0o700) try { + // Whichever place is under /dev is passed over, the first as much as + // the last. const elsewhere = TMP + const underDev = { runtimeDir: shm, tempDir: shm } const fellBack = inAChild(PUBLISH, { env: { XDG_RUNTIME_DIR: shm, TMPDIR: elsewhere }, + places: underDev, }) expect(fellBack.status).toBe(0) const manifest = JSON.parse(fellBack.stdout) as { file: string } @@ -876,14 +931,9 @@ describe.if(isLinux)('The mount point manifests', () => { // With nowhere else to go it records nothing, rather than somewhere // bubblewrap cannot reach. - const env: Record = { ...process.env, TMPDIR: shm } - delete env.XDG_RUNTIME_DIR - const script = join(BASE, 'nowhere.ts') - writeFileSync(script, `const m = await import(${MODULE})\n${PUBLISH}\n`) - const nowhere = spawnSync(process.execPath, [script], { - env, - encoding: 'utf8', - timeout: 6000, + const nowhere = inAChild(PUBLISH, { + env: { XDG_RUNTIME_DIR: shm, TMPDIR: shm }, + places: underDev, }) expect(nowhere.status).toBe(0) expect(JSON.parse(nowhere.stdout)).toBe(null) @@ -903,7 +953,9 @@ describe.if(isLinux)('The mount point manifests', () => { mkdirSync(target) chmodSync(target, 0o755) symlinkSync(target, join(TMP, `srt-mount-points-${process.getuid!()}`)) - const published = inAChild(PUBLISH, { env: { XDG_RUNTIME_DIR: undefined } }) + const published = inAChild(PUBLISH, { + places: { runtimeDir: join(BASE, 'no-runtime-directory') }, + }) expect(published.status).toBe(0) expect(statSync(target).mode & 0o777).toBe(0o755) expect(readdirSync(target)).toEqual([]) @@ -942,6 +994,618 @@ describe.if(isLinux)('The mount point manifests', () => { 15000, ) + // ---- which directory is written to, and which are read ----------------- + // + // Where the manifests went used to be the environment's to say: under + // $XDG_RUNTIME_DIR, else under $TMPDIR. Two processes of one user that + // differed in either kept their manifests apart, and neither knew of the + // sandboxes of the other. The user id and the file system say it first + // now, the same for every process, and what they name is read by every + // process. + + /** + * Four places of their own under `base`, none of them with a manifest + * directory yet. + */ + function fourPlaces(base = BASE): { + places: { runtimeDir: string; tempDir: string } + env: { XDG_RUNTIME_DIR: string; TMPDIR: string } + /** The four manifest directories, in the order they are tried. */ + dirs: [string, string, string, string] + } { + const at = (name: string, mode: number): string => { + const dir = join(base, name) + mkdirSync(dir, { recursive: true }) + chmodSync(dir, mode) + return dir + } + const places = { + runtimeDir: at('run-user', 0o700), + tempDir: at('tmp-root', 0o1777), + } + const env = { + XDG_RUNTIME_DIR: at('xdg', 0o700), + TMPDIR: at('tmpdir', 0o1777), + } + const perUser = `srt-mount-points-${process.getuid!()}` + return { + places, + env, + dirs: [ + join(places.runtimeDir, 'srt-mount-points'), + join(places.tempDir, perUser), + join(env.XDG_RUNTIME_DIR, 'srt-mount-points'), + join(env.TMPDIR, perUser), + ], + } + } + + /** A manifest directory as the library makes one. */ + function manifestDirectory(dir: string): string { + mkdirSync(dir, { mode: 0o700 }) + chmodSync(dir, 0o700) + return dir + } + + /** What makes a manifest live for as long as this test runs: its writer. */ + function ofThisProcess(): Record { + return { + pid: process.pid, + start: readFileSync('/proc/self/stat', 'utf8') + .split(') ') + .pop()! + .split(' ')[19], + } + } + + const LOOK = `console.log(JSON.stringify({ named: [...m.namedMountPoints().named].sort(), live: [...m.namedMountPoints().live].sort(), spared: [...m.liveMountPoints()].sort(), collected: m.collectMountPoints().sort() }))` + type Looked = { + named: string[] + live: string[] + spared: string[] + collected: string[] + } + + it('are written to the first directory that can be used: under the runtime directory, under /tmp, and only then where the environment says', () => { + // Every combination of the four being usable or not. One that is not is + // somebody's file at the name. + for (let usable = 0; usable < 16; usable++) { + const four = fourPlaces(join(BASE, `order-${usable}`)) + four.dirs.forEach((dir, index) => { + if ((usable & (1 << index)) === 0) writeFileSync(dir, '') + }) + const published = inAChild(PUBLISH, { + env: four.env, + places: four.places, + }) + expect(published.status).toBe(0) + const written = dirname( + (JSON.parse(published.stdout) as { file: string }).file, + ) + const first = four.dirs.find((_, index) => (usable & (1 << index)) !== 0) + if (first !== undefined) { + expect({ usable, written }).toEqual({ usable, written: first }) + } else { + // A directory of the process's own, under its temp dir. + expect(written).toMatch( + new RegExp(`^${four.env.TMPDIR}/srt-mount-points-[A-Za-z0-9]{6}$`), + ) + } + } + }, 60000) + + it('are one directory where two names lead to the same one', () => { + // What the environment says is usually the runtime directory and /tmp + // again, by those names or by others. + const four = fourPlaces() + const runtimeDir = join(BASE, 'runtime-by-another-name') + const tempDir = join(BASE, 'tmp-by-another-name') + symlinkSync(four.places.runtimeDir, runtimeDir) + symlinkSync(four.places.tempDir, tempDir) + const looked = inAChild( + `console.log(JSON.stringify(m.mountPointManifestDirectories()))`, + { + env: { XDG_RUNTIME_DIR: runtimeDir, TMPDIR: tempDir }, + places: four.places, + }, + ) + expect(looked.status).toBe(0) + expect(JSON.parse(looked.stdout)).toEqual(four.dirs.slice(0, 2)) + }) + + const notARuntimeDirectory: [string, (dir: string) => void][] = [ + ['is not there', dir => rmSync(dir, { recursive: true })], + [ + 'is a link to one', + dir => { + renameSync(dir, join(BASE, 'linked-to')) + symlinkSync(join(BASE, 'linked-to'), dir) + }, + ], + ['can be looked into by the group', dir => chmodSync(dir, 0o750)], + ['can be looked into by others', dir => chmodSync(dir, 0o705)], + ] + for (const [what, spoil] of notARuntimeDirectory) { + it(`are neither written nor read, nor is anything made, under a runtime directory that ${what}`, () => { + const four = fourPlaces() + // What is there for this user to find, with a manifest in it that would + // keep X. + const planted = join(four.places.runtimeDir, 'srt-mount-points') + manifestDirectory(planted) + leftover(X) + manifestOfADeadProcess([X], ofThisProcess(), planted) + spoil(four.places.runtimeDir) + const before = existsSync(planted) ? readdirSync(planted) : undefined + + const published = inAChild(PUBLISH, { + env: { TMPDIR: four.env.TMPDIR }, + places: four.places, + }) + expect(published.status).toBe(0) + expect( + dirname((JSON.parse(published.stdout) as { file: string }).file), + ).toBe(four.dirs[1]) + const looked = inAChild( + [ + `const directories = m.mountPointManifestDirectories()`, + `const named = [...m.namedMountPoints().named]`, + `console.log(JSON.stringify({ directories, named: named.filter(p => p === ${JSON.stringify(X)}), spared: [...m.liveMountPoints()] }))`, + ].join('\n'), + { env: { TMPDIR: four.env.TMPDIR }, places: four.places }, + ) + expect(looked.status).toBe(0) + expect(JSON.parse(looked.stdout)).toEqual({ + directories: [four.dirs[1], four.dirs[3]], + named: [], + spared: [], + }) + expect(existsSync(planted) ? readdirSync(planted) : undefined).toEqual( + before, + ) + }, 15000) + } + + /** + * Has `dir` look like another user's to whoever asks: nothing here can make + * a directory that is. + */ + function asSomebodyElses(dir: string): { restore(): void } { + const lstat = fs.lstatSync + const spy = spyOn(fs, 'lstatSync').mockImplementation((( + file: unknown, + options: unknown, + ) => { + const stat = (lstat as (f: unknown, o: unknown) => fs.Stats)( + file, + options, + ) + if (file !== dir) return stat + const other = Object.create( + Object.getPrototypeOf(stat) as object, + ) as fs.Stats + return Object.assign(other, stat, { uid: stat.uid + 1 }) + }) as never) + return { restore: () => spy.mockRestore() } + } + + it("are neither written nor read under a runtime directory that is somebody else's", () => { + const four = fourPlaces() + const planted = manifestDirectory( + join(four.places.runtimeDir, 'srt-mount-points'), + ) + leftover(X) + manifestOfADeadProcess([X], ofThisProcess(), planted) + const theirs = asSomebodyElses(four.places.runtimeDir) + setMountPointManifestPlacesForTesting({ + ...four.places, + environmentRuntimeDir: undefined, + environmentTempDir: four.env.TMPDIR, + }) + try { + expect(mountPointManifestDirectories()).toEqual([ + four.dirs[1], + four.dirs[3], + ]) + expect( + dirname(publishMountPointManifest([join(BASE, 'absent')], [])!.file), + ).toBe(four.dirs[1]) + expect([...namedMountPoints().named]).toEqual([join(BASE, 'absent')]) + expect([...liveMountPoints()]).toEqual([]) + expect(readdirSync(planted).length).toBe(1) + } finally { + theirs.restore() + collectMountPoints() + setMountPointManifestPlacesForTesting(runtime.places()) + } + }) + + for (const differing of ['XDG_RUNTIME_DIR', 'TMPDIR'] as const) { + it(`are read by a process whose ${differing} is not the writer's`, async () => { + // A login shell and what a service or an IDE starts, two shells with a + // temp dir each: the second has to find what the first wrote. + const four = fourPlaces() + const environment = (n: number): Record => { + const own = join(BASE, `${differing.toLowerCase()}-${n}`) + mkdirSync(own, { recursive: true }) + chmodSync(own, 0o700) + return differing === 'XDG_RUNTIME_DIR' + ? { XDG_RUNTIME_DIR: own, TMPDIR: four.env.TMPDIR } + : { XDG_RUNTIME_DIR: undefined, TMPDIR: own } + } + leftover(X) + const go = join(BASE, 'go') + const up = join(BASE, 'up') + // The first process wraps, and its sandbox runs for as long as it is + // not told to go: its manifest is live for being its own. + const writer = spawn( + process.execPath, + [ + writeChild( + 'writer.ts', + [ + `import { existsSync, writeFileSync } from 'node:fs'`, + `m.publishMountPointManifest([${JSON.stringify(X)}], [])`, + `writeFileSync(${JSON.stringify(up)}, '')`, + `while (!existsSync(${JSON.stringify(go)})) await new Promise(resolve => setTimeout(resolve, 20))`, + ].join('\n'), + four.places, + ), + ], + { env: childEnv(environment(1)), stdio: 'ignore' }, + ) + const writerExited = new Promise(resolve => writer.on('exit', resolve)) + try { + const deadline = Date.now() + 10000 + while (!existsSync(up)) { + if (Date.now() > deadline) throw new Error('the writer never wrote') + await new Promise(resolve => setTimeout(resolve, 20)) + } + const second = inAChild(LOOK, { + env: environment(2), + places: four.places, + }) + expect(second.status).toBe(0) + expect(JSON.parse(second.stdout) as Looked).toEqual({ + named: [X], + live: [X], + spared: [X], + collected: [], + }) + expect(existsSync(X)).toBe(true) + } finally { + writeFileSync(go, '') + await writerExited + } + + // And once the first is gone, and past its grace, the second is the one + // that takes away what it left. + await new Promise(resolve => setTimeout(resolve, 600)) + const after = inAChild(LOOK, { + env: environment(2), + places: four.places, + }) + expect(JSON.parse(after.stdout) as Looked).toEqual({ + named: [X], + live: [], + spared: [], + collected: [X], + }) + expect(existsSync(X)).toBe(false) + for (const dir of four.dirs) { + if (existsSync(dir)) { + expect(readdirSync(dir).filter(n => n.endsWith('.json'))).toEqual([]) + } + } + }, 30000) + } + + it('are believed in a directory this process does not write to', () => { + // This process writes under the runtime directory. What a process + // without one wrote under /tmp counts all the same: for a wrap that finds + // something at a path, and for a clean-up that is about to remove it. + const theirs = manifestDirectory(THEIRS) + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + const mine = manifestOfADeadProcess([X, Y]) + const live = manifestOfADeadProcess([X], ofThisProcess(), theirs) + const finished = manifestOfADeadProcess([Y], {}, theirs) + + const named = namedMountPoints() + expect([...named.named].sort()).toEqual([X, Y].sort()) + expect([...named.live]).toEqual([X]) + expect([...liveMountPoints()]).toEqual([X]) + + // What only finished manifests name goes, and they go with it, wherever + // they are; what a live one names stays, wherever that one is. + expect(collectMountPoints()).toEqual([Y]) + expect(existsSync(X)).toBe(true) + expect(existsSync(live)).toBe(true) + expect(existsSync(finished)).toBe(false) + expect(existsSync(mine)).toBe(false) + expect(readdirSync(theirs)).toEqual([basename(live)]) + + rmSync(live) + manifestOfADeadProcess([X], {}, theirs) + expect(collectMountPoints()).toEqual([X]) + expect(readdirSync(theirs)).toEqual([]) + // And all the while this process wrote under the runtime directory. + expect( + dirname(publishMountPointManifest([join(BASE, 'absent')], [])!.file), + ).toBe(DIR) + }) + + it('keeps a mount point named by a manifest that is published in another directory while the pass is under way', () => { + const theirs = manifestDirectory(THEIRS) + leftover(X) + manifestOfADeadProcess([X]) + let arrived: string | undefined + const pass = duringTheNextPass(() => { + arrived = manifestOfADeadProcess([X], { created: Date.now() }, theirs) + }) + try { + expect(collectMountPoints()).toEqual([]) + } finally { + pass.restore() + } + expect(arrived).toBeDefined() + expect(existsSync(X)).toBe(true) + }) + + it('keeps what a finished manifest names when the lock on a manifest in another directory shows on the look before the removals', () => { + const theirs = manifestDirectory(THEIRS) + leftover(X) + manifestOfADeadProcess([X]) + const locked = manifestOfADeadProcess([X], {}, theirs) + const line = `1: POSIX ADVISORY READ 4242 ${deviceOf(locked)}:${statSync(locked).ino} 0 EOF\n` + const readFile = fs.readFileSync + let reads = 0 + const spy = spyOn(fs, 'readFileSync').mockImplementation((( + file: unknown, + options: unknown, + ) => { + if (file === '/proc/locks') { + reads++ + return reads === 1 ? '' : line + } + return (readFile as (f: unknown, o: unknown) => unknown)(file, options) + }) as never) + try { + expect(collectMountPoints()).toEqual([]) + } finally { + spy.mockRestore() + } + expect(existsSync(X)).toBe(true) + expect(existsSync(locked)).toBe(true) + }) + + const notOurs: [string, (dir: string) => { restore(): void } | void][] = [ + [ + 'is a link to a directory', + dir => { + const target = join(BASE, 'linked-to') + renameSync(dir, target) + symlinkSync(target, dir) + }, + ], + ['can be written by others', dir => chmodSync(dir, 0o777)], + ['can be looked into by the group', dir => chmodSync(dir, 0o750)], + ["is somebody else's", asSomebodyElses], + ] + for (const [what, spoil] of notOurs) { + it(`are not believed in a directory that ${what}, which is left as it is`, () => { + const theirs = manifestDirectory(THEIRS) + const Y = join(BASE, 'second.lock') + const Z = join(BASE, 'third.lock') + leftover(X) + leftover(Y) + leftover(Z) + // One that would keep X, and one that would have Y removed. + manifestOfADeadProcess([X], ofThisProcess(), theirs) + manifestOfADeadProcess([Y], {}, theirs) + manifestOfADeadProcess([X, Z]) + const spoilt = spoil(theirs) + try { + const before = readdirSync(theirs).sort() + const mode = statSync(theirs).mode & 0o777 + + const named = namedMountPoints() + expect([...named.named].sort()).toEqual([X, Z].sort()) + expect([...named.live]).toEqual([]) + expect([...liveMountPoints()]).toEqual([]) + expect(collectMountPoints().sort()).toEqual([X, Z].sort()) + expect(existsSync(Y)).toBe(true) + + expect(readdirSync(theirs).sort()).toEqual(before) + expect(statSync(theirs).mode & 0o777).toBe(mode) + } finally { + spoilt?.restore() + } + }) + } + + it('are read in both directories that come from the user id, in none the environment names beside them, and none is made for it', () => { + const four = fourPlaces() + const paths = four.dirs.map((dir, index) => { + const held = join(BASE, `held-${index}.lock`) + leftover(held) + // The last is not there, and is not made. + if (index < 3) { + manifestOfADeadProcess([held], ofThisProcess(), manifestDirectory(dir)) + } + return held + }) + const looked = inAChild( + `console.log(JSON.stringify([...m.liveMountPoints()].sort()))`, + { env: four.env, places: four.places }, + ) + expect(looked.status).toBe(0) + expect(JSON.parse(looked.stdout)).toEqual(paths.slice(0, 2).sort()) + expect(existsSync(four.dirs[3])).toBe(false) + for (const dir of four.dirs.slice(0, 3)) { + expect(readdirSync(dir).length).toBe(1) + } + }) + + // What the environment names is kept out of a sandboxed command's reach by + // the processes whose environment names it, and by no other: a process with + // another temp dir has its sandboxes write there, if its caller lets them. + // So a manifest found there is somebody's word only to a process that has + // nowhere else to keep its own. + + /** + * In each directory the environment names: the manifest of a sandbox that + * is running, which would keep X, and one a dead process left, which would + * have Y removed. + */ + function whereTheEnvironmentSays( + four: ReturnType, + Y: string, + ): string[] { + return four.dirs.slice(2).flatMap(dir => { + manifestDirectory(dir) + return [ + manifestOfADeadProcess([X], ofThisProcess(), dir), + manifestOfADeadProcess([Y], {}, dir), + ] + }) + } + + for (const usable of ['the runtime directory', '/tmp'] as const) { + it(`are not believed where the environment says, by a process that can use the directory under ${usable}`, () => { + const four = fourPlaces() + if (usable === '/tmp') rmSync(four.places.runtimeDir, { recursive: true }) + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + const planted = whereTheEnvironmentSays(four, Y) + // What a process of the user left, where every process looks. + const finished = manifestOfADeadProcess( + [X], + {}, + manifestDirectory(four.dirs[1]), + ) + + const published = inAChild(PUBLISH, { + env: four.env, + places: four.places, + }) + expect( + dirname((JSON.parse(published.stdout) as { file: string }).file), + ).toBe(usable === '/tmp' ? four.dirs[1] : four.dirs[0]) + rmSync((JSON.parse(published.stdout) as { file: string }).file) + + const looked = inAChild(LOOK, { env: four.env, places: four.places }) + expect(looked.status).toBe(0) + // X is kept by nothing that is believed, and goes on the word of the + // one manifest that is; Y is named by nothing that is believed, and + // stays. + expect(JSON.parse(looked.stdout) as Looked).toEqual({ + named: [X], + live: [], + spared: [], + collected: [X], + }) + expect(existsSync(X)).toBe(false) + expect(existsSync(Y)).toBe(true) + expect(existsSync(finished)).toBe(false) + for (const manifest of planted) { + expect(existsSync(manifest)).toBe(true) + } + }, 15000) + } + + it('are believed where the environment says, and collected there, by a process that can use no directory that comes from the user id', () => { + const four = fourPlaces() + rmSync(four.places.runtimeDir, { recursive: true }) + writeFileSync(four.dirs[1], '') + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + const planted = whereTheEnvironmentSays(four, Y) + + const looked = inAChild(LOOK, { env: four.env, places: four.places }) + expect(looked.status).toBe(0) + expect(JSON.parse(looked.stdout) as Looked).toEqual({ + named: [X, Y].sort(), + live: [X], + spared: [X], + collected: [Y], + }) + expect(existsSync(X)).toBe(true) + expect(existsSync(Y)).toBe(false) + // The ones that named X for a running sandbox stay, in both; the ones a + // dead process left are gone, from both. + expect(planted.map(manifest => existsSync(manifest))).toEqual([ + true, + false, + true, + false, + ]) + + // And it is where this process writes: to the first of the two. + const published = inAChild(PUBLISH, { + env: four.env, + places: four.places, + }) + expect( + dirname((JSON.parse(published.stdout) as { file: string }).file), + ).toBe(four.dirs[2]) + }, 15000) + + it('are believed and collected in the directory a process settled on where the environment says, after one that comes from the user id has become usable', () => { + // Nothing that comes from the user id will do when the process first + // records, so it settles where its environment says, and stays there: + // its manifests are there, and the sandboxes that hold their locks. + const four = fourPlaces() + rmSync(four.places.runtimeDir, { recursive: true }) + writeFileSync(four.dirs[1], '') + const [settled, other] = [four.dirs[2], four.dirs[3]] + const Y = join(BASE, 'second.lock') + const Z = join(BASE, 'third.lock') + leftover(X) + leftover(Y) + leftover(Z) + manifestOfADeadProcess([Y], {}, manifestDirectory(settled)) + const notBelieved = manifestOfADeadProcess( + [Z], + {}, + manifestDirectory(other), + ) + + const ran = inAChild( + [ + `import { rmSync } from 'node:fs'`, + `import { dirname } from 'node:path'`, + `const first = m.publishMountPointManifest([${JSON.stringify(X)}], [])`, + // Somebody's file at the name under /tmp goes, and the next wrap + // makes the directory. + `rmSync(${JSON.stringify(four.dirs[1])})`, + `const directories = m.mountPointManifestDirectories()`, + `const named = m.namedMountPoints()`, + `const second = m.publishMountPointManifest([${JSON.stringify(join(BASE, 'absent'))}], [])`, + `const collected = m.collectMountPoints().sort()`, + `console.log(JSON.stringify({ first: dirname(first.file), second: dirname(second.file), directories, named: [...named.named].sort(), live: [...named.live], collected }))`, + ].join('\n'), + { env: four.env, places: four.places }, + ) + expect(ran.status).toBe(0) + expect(JSON.parse(ran.stdout)).toEqual({ + first: settled, + second: settled, + directories: [four.dirs[1], settled, other], + named: [X, Y].sort(), + live: [X], + collected: [X, Y].sort(), + }) + expect(existsSync(X)).toBe(false) + expect(existsSync(Y)).toBe(false) + expect(readdirSync(settled).filter(n => n.endsWith('.json'))).toEqual([]) + // What the environment names beside it is nobody's word any more. + expect(existsSync(Z)).toBe(true) + expect(existsSync(notBelieved)).toBe(true) + }, 15000) + // ---- what is read as a manifest ---------------------------------------- // // Whatever ends in `.json` in the directory used to be opened as it stood: @@ -1266,20 +1930,34 @@ describe.if(isLinux)('The mount point manifests', () => { expect(readdirSync(DIR).sort()).toEqual(before) }) + /** A runtime directory with nothing in it yet. */ + function emptyRuntimeDirectory(name: string): string { + const dir = join(BASE, name) + mkdirSync(dir, { mode: 0o700 }) + chmodSync(dir, 0o700) + return dir + } + it('makes no manifest directory where there is none, and holds nothing then', () => { - const run = join(BASE, 'run') - mkdirSync(run, { mode: 0o700 }) + const run = emptyRuntimeDirectory('run') + const xdg = emptyRuntimeDirectory('xdg') + const tmp = join(BASE, 'tmp-root') + mkdirSync(tmp) const looked = inAChild( `console.log(JSON.stringify([...m.liveMountPoints()]))`, - { env: { XDG_RUNTIME_DIR: run } }, + { + env: { XDG_RUNTIME_DIR: xdg }, + places: { runtimeDir: run, tempDir: tmp }, + }, ) expect(looked.status).toBe(0) expect(JSON.parse(looked.stdout)).toEqual([]) - expect(readdirSync(run)).toEqual([]) - expect(readdirSync(TMP)).toEqual([]) + for (const place of [run, xdg, tmp, TMP]) { + expect(readdirSync(place)).toEqual([]) + } }) - it('reads the directory a process without a runtime directory keeps, where that is the one there is', () => { + it('reads the directory under the temp dir, where that is the one there is', () => { const theirs = join(TMP, `srt-mount-points-${process.getuid!()}`) mkdirSync(theirs, { mode: 0o700 }) chmodSync(theirs, 0o700) @@ -1297,11 +1975,10 @@ describe.if(isLinux)('The mount point manifests', () => { { mode: 0o600 }, ) rmSync(manifest) - const run = join(BASE, 'run') - mkdirSync(run, { mode: 0o700 }) + const run = emptyRuntimeDirectory('run') const looked = inAChild( `console.log(JSON.stringify([...m.liveMountPoints()]))`, - { env: { XDG_RUNTIME_DIR: run } }, + { places: { runtimeDir: run } }, ) expect(looked.status).toBe(0) expect(JSON.parse(looked.stdout)).toEqual([X]) diff --git a/test/sandbox/path-canonicalization.test.ts b/test/sandbox/path-canonicalization.test.ts index 20e676cc0..9ff5d1c49 100644 --- a/test/sandbox/path-canonicalization.test.ts +++ b/test/sandbox/path-canonicalization.test.ts @@ -3,9 +3,11 @@ import { spawnSync } from 'node:child_process' import { mkdtempSync, realpathSync, rmSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' +import { fileURLToPath, pathToFileURL } from 'node:url' import { wrapCommandWithSandboxMacOS } from '../../src/sandbox/macos-sandbox-utils.js' import { normalizePathForSandbox } from '../../src/sandbox/sandbox-utils.js' import { isWindows } from '../helpers/platform.js' +import { isolatedModule } from '../helpers/private-manifest-directory.js' /** * Interior non-canonical spellings. @@ -47,9 +49,14 @@ function normalizeWithHome( ): { homedir: string; results: string[]; tildeBindsRoot: boolean } { const module = new URL('../../src/sandbox/sandbox-utils.ts', import.meta.url) .href - const linuxModule = new URL( - '../../src/sandbox/linux-sandbox-utils.ts', - import.meta.url, + // The wrap makes and binds the directories the mount point manifests are + // kept in, which are not this test's to reach. + const linuxModule = pathToFileURL( + isolatedModule( + fileURLToPath( + new URL('../../src/sandbox/linux-sandbox-utils.ts', import.meta.url), + ), + ), ).href const child = spawnSync( process.execPath, diff --git a/test/sandbox/stale-mount-point.test.ts b/test/sandbox/stale-mount-point.test.ts index 7f3d3e091..ec65c6f8a 100644 --- a/test/sandbox/stale-mount-point.test.ts +++ b/test/sandbox/stale-mount-point.test.ts @@ -488,7 +488,9 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { [ `import { spawnSync } from 'node:child_process'`, `import { wrapCommandWithSandboxLinux } from ${JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + runtime.isolated( + join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + ), )}`, `const command = await wrapCommandWithSandboxLinux({`, ` command: 'true',`, @@ -542,7 +544,7 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { describe.if(isLinux)( 'Mount points for deny paths that do not exist yet', () => { - usePrivateManifestDirectory() + const runtime = usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() let BASE: string let AREA: string // allowed write area @@ -761,7 +763,9 @@ describe.if(isLinux)( script, [ `import { wrapCommandWithSandboxLinux } from ${JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + runtime.isolated( + join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + ), )}`, `process.umask(0o200)`, `for (let i = 0; i < 2; i++) {`, From 99e1b2c39d9f6eddd7bd04c794aa8dfbbdb2b189 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Sun, 27 Sep 2026 16:05:56 +0000 Subject: [PATCH 3/5] README: where the mount point manifests are kept, and whose are believed --- README.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 3dcc8d56c..224e2e878 100644 --- a/README.md +++ b/README.md @@ -736,18 +736,19 @@ Filesystem restrictions are enforced at the OS level: - A `denyRead` entry naming a FILE is lifted only by an `allowRead` entry naming that same file. An `allowRead` entry that is a symlink to it names the link, so it does not cancel the deny of its target. - A `denyRead` entry that cannot be inspected (a parent made unsearchable, a dead network mount) hides the deepest directory above it that can be — never `/`, so when `/` is the only one left the entry mounts nothing and that deny is not enforced (the wrap logs which entry, and why, under `SRT_DEBUG`). Such a stand-in hides more than was written: nothing beneath it is readable, carve-outs named there included, and a carve-out elsewhere that resolves beneath it is not restored either. -**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed once no running sandbox relies on it. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). Each wrap records the mount points it relies on in a manifest, a small JSON file in a per-user directory, and passes it to bubblewrap as `--lock-file`: the sandbox's init process holds a kernel lock on the manifest for exactly the sandbox's lifetime, however the sandbox ends, and `/proc/locks` shows that lock to any process in the same PID namespace. So any srt process there can tell whether a mount point is still in use, whoever made it. `cleanupAfterCommand()` removes the ones no running sandbox relies on, this process's and other processes' alike, and calling it early, twice or not at all takes nothing away from a sandbox that is running. A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its own mount points; the next cleanup in any process does, once the manifest is more than half a second old. A mount point that was left behind cannot be told from somebody's own empty file or directory by looking at it, so only a manifest says that a path is one. An empty regular file with no write bits, or an empty directory, found at a `denyWrite` path under a writable directory is taken for a mount point only when a manifest in the manifest directory names it, of a sandbox that may still be running or of one that has ended. A wrap that denies such a path covers the file with `/dev/null` like an absent path and names the file or directory in its own manifest, so that it stays for as long as that sandbox runs, and a cleanup removes it once no running sandbox relies on it. One that no manifest names is the user's own (an empty `.mcp.json` of mode `0444`, say): it is bound read-only where it is, like any existing `denyWrite` path, and is never removed, and neither is one that has been written to, or had something put into it, since. The price is that a mount point left by a killed process of a release that wrote no manifest (0.0.77 and earlier) stays on the host until someone deletes it, as it did before 0.0.77. +**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed once no running sandbox relies on it. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). Each wrap records the mount points it relies on in a manifest, a small JSON file in a per-user directory, and passes it to bubblewrap as `--lock-file`: the sandbox's init process holds a kernel lock on the manifest for exactly the sandbox's lifetime, however the sandbox ends, and `/proc/locks` shows that lock to any process in the same PID namespace. So any srt process there can tell whether a mount point is still in use, whoever made it. `cleanupAfterCommand()` removes the ones no running sandbox relies on, this process's and other processes' alike, and calling it early, twice or not at all takes nothing away from a sandbox that is running. A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its own mount points; the next cleanup in any process does, once the manifest is more than half a second old. A mount point that was left behind cannot be told from somebody's own empty file or directory by looking at it, so only a manifest says that a path is one. An empty regular file with no write bits, or an empty directory, found at a `denyWrite` path under a writable directory is taken for a mount point only when a manifest in one of the manifest directories names it, of a sandbox that may still be running or of one that has ended. A wrap that denies such a path covers the file with `/dev/null` like an absent path and names the file or directory in its own manifest, so that it stays for as long as that sandbox runs, and a cleanup removes it once no running sandbox relies on it. One that no manifest names is the user's own (an empty `.mcp.json` of mode `0444`, say): it is bound read-only where it is, like any existing `denyWrite` path, and is never removed, and neither is one that has been written to, or had something put into it, since. The price is that a mount point left by a killed process of a release that wrote no manifest (0.0.77 and earlier) stays on the host until someone deletes it, as it did before 0.0.77. -- **The manifest directory** is `$XDG_RUNTIME_DIR/srt-mount-points` when `XDG_RUNTIME_DIR` is set, else `/srt-mount-points-`. It has to be a real directory (a symlink at the name is refused), owned by the user, mode `0700` (it is made so), and writable; a candidate under `/dev`, `/proc` or `/sys` is passed over (the sandbox mounts a fresh `/dev` and `/proc` over whatever was bound there, and bubblewrap could then not open the manifest). When neither name will do (both are read-only from where the process runs, as inside another sandbox), a process that has mount points to record makes a directory of its own under the temp dir, and removes it again at `reset()` or on exit once it is empty: its own sandboxes are covered as before, but no other process sees its manifests or collects what it leaves behind. With no directory at all, mount points are not recorded and this process leaves them on the host. Every sandbox that restricts writes has both of those names bound read-only, as the wrapping process sees them and whichever of them it uses itself (it makes the one that is not there), and its own directory if that is neither, whether or not its wrap has mount points. The directories between each of them and the write root it lies in, if it lies in one, are pinned like those above a protected path (see "Pinned directories" below), so that none of them can be renamed aside and the manifest directory made again under the old name. So a sandboxed command can neither delete a manifest nor forge one, nor make the directory first, for a process that was started with `XDG_RUNTIME_DIR` and for one that was started without; the two cases this does not reach are listed under the known limits. The `directory.lock` file in it keeps srt processes out of each other's way while they collect; nothing depends on it, and a pass never waits more than two seconds for it. -- **Commands in parallel.** Call `cleanupAfterCommand()` once for each wrapped command, when that command is over. A call cannot tell which command it is for, so with no argument nothing this process made is released until it has been called once for every wrap it handed out: a command that is wrapped and not yet started still needs its manifest (bubblewrap refuses to start without it, "Unable to open lock file") and its `--args` profile. One long-running command therefore holds back the cleanup of the ones that finish beside it. To avoid that, pass the `commandId` the wrap was given, `cleanupAfterCommand({ commandId })`: that one command's mount points go at once, whatever else is running. A call too many takes nothing from a running sandbox, but it is counted for some other wrap of this process, and a command that has not started by the time the count is back at zero then refuses to start. What other srt processes have finished with is collected by every call. `reset()` and the exit handler release everything this process made; a sandbox of this process that is still running then keeps its mount points, through its lock, and they go at the next cleanup in any process that uses the same manifest directory. -- **Removing a path yourself.** `cleanupAfterCommand()` keeps this promise for what the library removes, not for what the embedding program removes of its own accord after a command. Before it removes paths that a `denyWrite` entry of any process may name, it can call `liveMountPoints()`, exported from the package root, and leave out every path in the `ReadonlySet` it returns: removing one of those would lift the deny inside a sandbox that is running. A path is in the set only when both hold. A manifest names it that belongs to a sandbox that may still be running, or whose liveness cannot be told (`/proc/locks` cannot be read, or a manifest that cannot be read is locked or new: every path that any readable manifest names then counts, and what the unreadable one names cannot be listed at all). And what is at the path now still has the shape of a mount point: an empty regular file with no write bits and a single link, or an empty directory, owned by the user. So what a caller spares on the word of the set is never more than an empty placeholder, and a manifest that is out of date or forged cannot make it spare a file with content. Paths are as the wrap recorded them: absolute, with the symbolic links in the directories above the last component resolved and the last component as it stands. The set is a snapshot, taken without a lock from one listing of the manifest directory and one reading of `/proc/locks`, and may be out of date by the time the caller acts (a sandbox may start on a path a moment later): its only safe use is to skip a removal, never to decide that a path is free to write. The call is synchronous, needs no wrap in flight, and makes nothing on the host, the manifest directory included; it reads the manifest directory the calling process uses (before that process has recorded anything, the first of the two names above that exists). The set is empty where there is no manifest directory, and always off Linux. +- **The manifest directories.** Two processes of one user keep each other's mount points only when each reads what the other writes, so where the manifests go is worked out from the user id and the file system first and from the environment last. The environment is not the same for every process of a user: a login shell and what a service, an IDE, cron or `docker exec` starts differ in `XDG_RUNTIME_DIR`, and two shells can differ in `TMPDIR`. Two processes that each wrote where their own environment said did not see each other's manifests: the second took the first's mount point for the user's own file, and the first's cleanup removed it from under the second sandbox. The candidates, in the order they are tried for writing: (1) `/run/user//srt-mount-points`, where `/run/user/` is a real directory (not a symbolic link), owned by the user, with no permission bits for group or others; (2) `/tmp/srt-mount-points-`, in `/tmp` whatever `TMPDIR` says; (3) `$XDG_RUNTIME_DIR/srt-mount-points`, when `XDG_RUNTIME_DIR` is set; (4) `/srt-mount-points-`. Two names for one directory count once (they are compared by where they really are), and a candidate under `/dev`, `/proc` or `/sys` is passed over (the sandbox mounts a fresh `/dev` and `/proc` over whatever was bound there, and bubblewrap could then not open the manifest). A process writes its manifests to the first candidate that is usable: a real directory (a symlink at the name is refused), owned by the user, mode `0700`, and writable. One that is not there is made, with that mode, when its parent is there; one that is the user's is given that mode; one that is not the user's is left as it is. Every wrap, every cleanup and `liveMountPoints()` go by the manifests of the same directories, whichever of them the process writes to: candidates (1) and (2), each when it is there and passes the same checks; candidates (3) and (4) only when neither (1) nor (2) passes, which is when the process writes to one of them; and the directory the process has settled on, whatever it is. What the environment names is kept out of a sandboxed command's reach only by the processes whose environment names it, so a process that can use a directory every process of the user looks in does not take the word of a manifest found under its own `XDG_RUNTIME_DIR` or `TMPDIR`. A path is relied on when a live manifest in any of the directories the process goes by names it, and is taken for a mount point when a manifest in any of them names it. A candidate that fails a check is ignored: what is in it is not believed, and nothing fails because of it. A cleanup makes one pass over each of those directories, under that directory's lock, and takes away what the finished manifests in it name, and those manifests, when no live manifest in any directory names the same path. When no candidate will do (all are read-only from where the process runs, as inside another sandbox), a process that has mount points to record makes a directory of its own under the temp dir, and removes it again at `reset()` or on exit once it is empty: its own sandboxes are covered as before, but no other process sees its manifests or collects what it leaves behind. With no directory at all, mount points are not recorded and this process leaves them on the host. Every sandbox that restricts writes has every candidate bound read-only, as the wrapping process sees them and whichever of them it writes to, and its own directory if that is none of them, whether or not its wrap has mount points. The wrap makes the ones that are not there first: a sandboxed command runs as the same user and may be allowed to write `/tmp`, so a directory it made there itself would pass every check of owner and mode, which keep other users out and not the command, and every process would then believe the manifests it put in it. The directories between each candidate and the write root it lies in, if it lies in one, are pinned like those above a protected path (see "Pinned directories" below), so that none of them can be renamed aside and the manifest directory made again under the old name. So a sandboxed command can neither delete a manifest nor forge one, nor make a manifest directory first, whichever of them the wrapping process writes to; the cases this does not reach are listed under the known limits. The `directory.lock` file in each directory keeps srt processes out of each other's way while they collect there; nothing depends on it, and a pass never waits more than two seconds for it. +- **Commands in parallel.** Call `cleanupAfterCommand()` once for each wrapped command, when that command is over. A call cannot tell which command it is for, so with no argument nothing this process made is released until it has been called once for every wrap it handed out: a command that is wrapped and not yet started still needs its manifest (bubblewrap refuses to start without it, "Unable to open lock file") and its `--args` profile. One long-running command therefore holds back the cleanup of the ones that finish beside it. To avoid that, pass the `commandId` the wrap was given, `cleanupAfterCommand({ commandId })`: that one command's mount points go at once, whatever else is running. A call too many takes nothing from a running sandbox, but it is counted for some other wrap of this process, and a command that has not started by the time the count is back at zero then refuses to start. What other srt processes have finished with is collected by every call. `reset()` and the exit handler release everything this process made; a sandbox of this process that is still running then keeps its mount points, through its lock, and they go at the next cleanup in any process that reads the manifest directory they are recorded in. +- **Removing a path yourself.** `cleanupAfterCommand()` keeps this promise for what the library removes, not for what the embedding program removes of its own accord after a command. Before it removes paths that a `denyWrite` entry of any process may name, it can call `liveMountPoints()`, exported from the package root, and leave out every path in the `ReadonlySet` it returns: removing one of those would lift the deny inside a sandbox that is running. A path is in the set only when both hold. A manifest names it that belongs to a sandbox that may still be running, or whose liveness cannot be told (`/proc/locks` cannot be read, or a manifest that cannot be read is locked or new: every path that any readable manifest names then counts, and what the unreadable one names cannot be listed at all). And what is at the path now still has the shape of a mount point: an empty regular file with no write bits and a single link, or an empty directory, owned by the user. So what a caller spares on the word of the set is never more than an empty placeholder, and a manifest that is out of date or forged cannot make it spare a file with content. Paths are as the wrap recorded them: absolute, with the symbolic links in the directories above the last component resolved and the last component as it stands. The set is a snapshot, taken without a lock from one listing of each manifest directory and one reading of `/proc/locks`, and may be out of date by the time the caller acts (a sandbox may start on a path a moment later): its only safe use is to skip a removal, never to decide that a path is free to write. The call is synchronous, needs no wrap in flight, and makes nothing on the host, a manifest directory included; it reads the manifest directories that a wrap and a cleanup of the calling process go by (see above). The set is empty where there is no manifest directory, and always off Linux. - **Known limits.** - - _Another PID namespace._ `/proc/locks` lists a lock only when its holder has a pid in the PID namespace of the `/proc` being read, and `/proc/` is local to it too. A manifest therefore records the PID namespace it was written in, and a process in any other one (a container that shares `/tmp` or `XDG_RUNTIME_DIR` with the host, one srt inside another's sandbox) takes it for live: it never removes what that manifest names, and an empty file, or a directory, found at a path it names counts as a mount point for that process's own wraps. Only a process in the namespace that wrote a manifest collects it, so what a namespace that has since gone left behind (empty files, empty directories, the manifests) stays until it is removed by hand. - - _A sandbox this release did not start, or a directory it does not know of._ The read-only binds of the manifest directories are in the sandboxes this release starts, for the two names as the wrapping process sees them. A sandbox started by an older release, or by another tool, that can write the temp dir while a manifest directory is under it (no `XDG_RUNTIME_DIR`) can delete and forge manifests, and so can a sandbox of this release when another process of the same user keeps its manifests somewhere the wrapping process does not look (a different `TMPDIR`, a different `XDG_RUNTIME_DIR`) and the sandbox may write there. A cleanup on the host believes what it finds in its directory. It still removes nothing but an empty, unwritable, singly linked file of the user's, or an empty directory of the user's. - - _A manifest directory whose name a sandbox can still take._ Pinning holds a directory in place; it cannot hold a name that is not a directory, or not there. Where `TMPDIR` or `XDG_RUNTIME_DIR` is reached through a symbolic link that lies inside a write root, a sandboxed command can remove the link, make a directory in its place and a manifest directory in that, and the next cleanup by a process that uses that name believes what it finds there. Where `XDG_RUNTIME_DIR` names a directory that does not exist, below a write root, the wrap cannot make the manifest directory under it and a sandboxed command can, runtime directory and all: a process started with that environment afterwards believes it. What a cleanup removes on such a manifest's word is bounded as above. + - _Another PID namespace._ `/proc/locks` lists a lock only when its holder has a pid in the PID namespace of the `/proc` being read, and `/proc/` is local to it too. A manifest therefore records the PID namespace it was written in, and a process in any other one (a container that shares `/tmp` or `/run/user/` with the host, one srt inside another's sandbox) takes it for live: it never removes what that manifest names, and an empty file, or a directory, found at a path it names counts as a mount point for that process's own wraps. Only a process in the namespace that wrote a manifest collects it, so what a namespace that has since gone left behind (empty files, empty directories, the manifests) stays until it is removed by hand. + - _A sandbox this release did not start, or a directory it does not know of._ The read-only binds of the manifest directories are in the sandboxes this release starts, for the candidates as the wrapping process sees them. A sandbox started by an older release, or by another tool, that can write `/tmp` can delete and forge manifests in the directory under it. A process that has to write where its environment says, because it can use neither `/run/user/` nor `/tmp`, keeps its manifests in a directory that the sandbox of a process with another environment does not have bound, and that sandbox can delete and forge them when it may write there; a process that can use one of the first two believes nothing under the names its environment gives. A cleanup on the host believes what it finds in the directories it goes by. It still removes nothing but an empty, unwritable, singly linked file of the user's, or an empty directory of the user's. + - _A manifest directory whose name a sandbox can still take._ Pinning holds a directory in place; it cannot hold a name that is not a directory, or not there. Where a candidate's name, or a directory above it (`/tmp`, `TMPDIR`, `XDG_RUNTIME_DIR`), is a symbolic link that lies inside a write root, a sandboxed command can remove the link, make a directory in its place and a manifest directory in that, and the next wrap or cleanup by a process that reads that name believes what it finds there. Where a candidate's parent does not exist, below a write root (a `/run/user/` that has not been made yet where the command may write `/run/user`, an `XDG_RUNTIME_DIR` or a `TMPDIR` that names a directory that is not there), the wrap cannot make the manifest directory under it and a sandboxed command can, parent and all: every process that looks there afterwards believes it. What a cleanup removes on such a manifest's word is bounded as above. - _Between looking and removing._ The directory lock does not exclude: a holder can be held up past the minute after which its lock is broken, and a wrap that cannot have the lock within two seconds records its manifest without it. A cleanup pass therefore reads `/proc/locks` and lists the directory again immediately before it removes anything, and then again whenever its listing is a quarter of a millisecond old, and what it read from `/proc/locks` two milliseconds old or 64 removals ago. It removes a manifest last, after everything it names, and keeps one whose mount point it could not remove. A pass that is held up for longer than a sandbox takes to start (a few milliseconds) between such a look and a removal can still take away the mount point of a sandbox that started on the same path in that time. - - _Mount points recorded where another process does not look, or not at all._ Two processes see each other's manifests only when they settle on the same manifest directory. One that cannot write the first choice (it runs inside another sandbox, where that directory is read-only) records in the next one, in a private one, or nowhere, as a release that wrote no manifest does, and then nothing the other process reads says a sandbox relies on those mount points. A wrap in that other process that denies the same path takes the file for the user's own: it binds it read-only where it is and does not name it, so the file goes when the process that recorded it cleans up, from under the second sandbox if that is still running. The ones that could not be recorded at all are never removed, by the process that made them or by any other. - - _Exit under a running sandbox._ A process that exits, or calls `reset()`, while a sandbox it started is still running leaves that sandbox's mount points and manifest in place, as it must. They go at that process's own next cleanup if it lives on after `reset()`, and otherwise at the next cleanup by another srt process that uses the same manifest directory; with a private directory, no other process does. + - _A `/tmp/srt-mount-points-` that another user made._ Another local user can make `/tmp/srt-mount-points-` before the user's first wrap. It fails the owner check and is passed over, never repaired, and on a host with no `/run/user/` the user's processes then fall back to the names their environments give, where two processes with different environments do not see each other. + - _Mount points recorded where another process does not look, or not at all._ Two processes see each other's manifests only when one writes where the other reads. Every process reads the directories under `/run/user/` and `/tmp` when they pass the checks, so what is left is: a process that can use neither, which records where its environment says, where only a process in the same position with the same environment looks; a process that can write no candidate at all (it runs inside another sandbox, where they are read-only), which records in a directory of its own or nowhere; two processes that share a project but neither `/tmp` nor `/run/user/` (a container beside the host with a `/tmp` of its own, a service started with a private `/tmp`), or not `/proc` (see "Another PID namespace"); and a sandbox of a release that wrote no manifest. Nothing the other process reads then says a sandbox relies on those mount points. A wrap in that other process that denies the same path takes the file for the user's own: it binds it read-only where it is and does not name it, so the file goes when the process that recorded it cleans up, from under the second sandbox if that is still running. The ones that could not be recorded at all are never removed, by the process that made them or by any other. + - _Exit under a running sandbox._ A process that exits, or calls `reset()`, while a sandbox it started is still running leaves that sandbox's mount points and manifest in place, as it must. They go at that process's own next cleanup if it lives on after `reset()`, and otherwise at the next cleanup by another srt process that reads the manifest directory they are recorded in; with a private directory, no other process does. **Note (Linux, large profiles):** The wrapped string runs as one argument of `sh -c`, which Linux caps at 32 pages (128 KiB with 4 KiB pages). A profile that would not fit, with 4 KiB to spare for a prefix of the caller's own, has its mounts written to an unnamed file (`O_TMPFILE`) that the wrapping process holds open and bubblewrap reads through `--args`. The string then reads `/bin/sh -c '…' srt-args /proc//fd/ bwrap … --args 9 …`: still a simple command, which opens the profile on fd 9 and runs bubblewrap. The environment and the command stay on the command line; the file holds mount paths only. @@ -788,7 +789,7 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' **Pinned directories (Linux):** Every existing ancestor of a protected path (a write-denied path, a read-denied file or directory, a masked credential file, or a directory the library keeps on the host and binds read-only: the mount point manifest directories, the store of fake credential files, the empty directory that directory mount points bind from) up to the allowed write root covering it is made a mountpoint — "pinned" — and cannot be renamed or removed from inside the sandbox: `mv` or `rmdir` of such a directory (for example a nested repository's parent) fails with `EBUSY` ("Device or resource busy"), and `rm -rf` of a nested repository leaves the pinned directories and the protected files behind (as with `.git/hooks`). A pin is buried under the mounts above it, so it never appears on a lookup path: reads, writes, creation, renames and hard links inside or across a pinned directory are unaffected. -With `allowWrite: ["/"]` the pins reach every ancestor, including any other allowed write root that is one (`mv /work /work.bak` fails with `EBUSY` given `allowWrite: ["/", "/work"]` and a protected path inside `/work`), and the ancestors of the temp dir and of `XDG_RUNTIME_DIR` whatever the deny list, since the library's own directories are under them. They stop below the top-level directory, which is bound writable over them, and that directory is the one new filesystem boundary: `mv` or `ln` between two top-level directories — say `/tmp` and `/home` — fails with `EXDEV` ("Invalid cross-device link"), as it does on any host where they are separate filesystems. `mv` falls back to a copy; `ln` and a raw `rename(2)` do not. +With `allowWrite: ["/"]` the pins reach every ancestor, including any other allowed write root that is one (`mv /work /work.bak` fails with `EBUSY` given `allowWrite: ["/", "/work"]` and a protected path inside `/work`), and the ancestors of `/run/user/`, of `/tmp`, of the temp dir and of `XDG_RUNTIME_DIR` whatever the deny list, since the library's own directories are under them. They stop below the top-level directory, which is bound writable over them, and that directory is the one new filesystem boundary: `mv` or `ln` between two top-level directories — say `/tmp` and `/home` — fails with `EXDEV` ("Invalid cross-device link"), as it does on any host where they are separate filesystems. `mv` falls back to a copy; `ln` and a raw `rename(2)` do not. A wrap that carries no write restrictions at all — `filesystem.disabled` with credential masks still in force, or a library caller passing no write config while a `denyRead` entry or a mask still seeds a pin — is the same shape: the whole tree is bound writable, so it gets the same pins and the same top-level covers, and the same `EXDEV` boundary applies there too. From 0795878637e4a80bf54b3c9bd20357db3960199b Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 28 Sep 2026 11:37:44 +0000 Subject: [PATCH 4/5] Shorten the comments around the manifest directories Comments only: the JavaScript emitted with comments removed is byte-identical for every file, and the test names are unchanged. The same standard as on the branch this one is stacked on, applied to the comments this branch rewrites and adds. --- src/sandbox/bwrap-mount-manifests.ts | 754 ++++++++------------- src/sandbox/linux-sandbox-utils.ts | 180 ++--- src/sandbox/sandbox-manager.ts | 14 +- test/cli.test.ts | 5 +- test/control-fd.test.ts | 5 +- test/helpers/private-manifest-directory.ts | 109 ++- test/preload.ts | 7 +- test/sandbox/linux-bwrap-args-file.test.ts | 3 +- test/sandbox/mount-point-liveness.test.ts | 335 ++++----- test/sandbox/mount-point-manifests.test.ts | 176 ++--- test/sandbox/stale-mount-point.test.ts | 56 +- 11 files changed, 625 insertions(+), 1019 deletions(-) diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index 36f94d2ca..398fedda6 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -2,62 +2,41 @@ * Which mount points on the host a running sandbox still relies on (Linux). * * A deny on a path that does not exist needs a mount point there: bwrap makes - * an empty file (or, for an intermediate component, an empty directory) on the - * host and binds /dev/null or an empty directory over it. Unlinking that file - * while a sandbox is still bound over it detaches the mount inside that - * sandbox — the denied path can then be created, and what is written lands on - * the host. A mount point may therefore only be removed when no running - * sandbox relies on it, and nothing a process counts for itself can decide - * that: a second srt process, a caller that crashes, one that cleans up early, - * twice or never, each gets a different answer. + * an empty file or directory on the host and binds over it. Unlinking it while + * a sandbox is bound over it detaches the mount inside that sandbox, and the + * denied path can then be created on the host. So a mount point may be removed + * only when no running sandbox relies on it, and no process can decide that by + * counting for itself. * - * The kernel is asked instead. Each wrap writes a manifest naming the mount - * points it relies on into a per-user runtime directory and passes - * `--lock-file ` to bwrap. bubblewrap's sandbox init process opens - * that path and holds an fcntl read lock (F_RDLCK through F_SETLK) on it for - * exactly the sandbox's lifetime; the kernel drops the lock however the - * sandbox ends, SIGKILL included. /proc/locks then answers, for any process - * and at any moment, whether a sandbox that named a mount point is still - * running, so cleanup becomes a garbage collect any process may run at any - * time and any number of times: a mount point goes only when no live manifest - * names it. + * The kernel is asked instead. Each wrap writes a manifest naming its mount + * points into a per-user directory and passes `--lock-file ` to + * bwrap, whose sandbox init process holds a read lock on it for the sandbox's + * lifetime; the kernel drops it however the sandbox ends. /proc/locks then + * tells any process whether a sandbox that named a mount point is running, so + * cleanup is a garbage collect any process may run at any time. * - * The lock belongs to the sandbox's init process, not to the command: bwrap - * opens the manifest with O_CLOEXEC, so the descriptor does not survive the - * exec into the command, and a POSIX lock is only dropped by the process that - * took it. - * - * Two processes of one user keep each other's mount points only when each - * reads what the other writes, so where the manifests go is worked out from - * the user id and the file system before the environment, which differs - * between a login shell and what a service, an IDE or cron starts: see - * {@link manifestDirectoryCandidates}. A process writes to the first of those - * directories it can use, and believes what it finds in the ones that come - * from the user id, or, where it can use none of those, in the ones its - * environment names (see {@link believedManifestDirectories}). + * Two processes of one user keep each other's mount points only when each reads + * what the other writes, so where the manifests go is worked out from the user + * id and the file system before the environment, which differs between a login + * shell and what a service, an IDE or cron starts (see {@link + * manifestDirectoryCandidates}). A process writes to the first of those + * directories it can use and believes what it finds in the ones that come from + * the user id, or, where it can use none of those, in the ones its environment + * names (see {@link believedManifestDirectories}). * * Every one of them is bound read-only into every sandbox that restricts - * writes at all, whether or not its own invocation names a manifest, and - * whichever of them the wrapping process writes to (see - * {@link mountPointManifestDirectories}), so a sandboxed command can neither - * delete nor rewrite a manifest, its own or another sandbox's; one that is - * not there is made first, so that the command cannot make it itself, with - * manifests of its own in it; and the directories between each of them and - * the write root it lies in are pinned there, so that the command cannot - * rename one aside and make the directory again under the old name. - * What that leaves out is a sandbox this library did not start, or an older - * release of it did, with a directory writable, the sandbox of a process with - * another environment where a process has to keep its manifests under a name - * its own environment gives, and a directory whose name, or a name above it, - * is a link inside a write root, or whose parent is not there yet below one: - * neither can be pinned. + * writes, and is made first if it is missing, and the directories above it + * inside a write root are pinned, so a sandboxed command can neither rewrite a + * manifest nor make or swap the directory (see {@link + * mountPointManifestDirectories}). Not covered: a sandbox this library did not + * start, the sandbox of a process with another environment where a process has + * to keep its manifests under a name its own environment gives, and a directory + * whose name, or a name above it, is a link inside a write root or whose parent + * is not there yet. * - * Both of the kernel's answers are relative to a PID namespace: /proc/locks - * lists a lock only when its holder has a pid in the namespace of the /proc - * being read, and /proc/PID is local to it. A manifest therefore says which - * PID namespace wrote it, and a process in another one does not judge it at - * all: to that process it is live, and only a process in the namespace that - * wrote it, which can see both answers, ever collects what it names. + * /proc/locks and /proc/PID are relative to a PID namespace, so a manifest + * records the namespace that wrote it and only a process in that namespace + * judges it; to any other it is live. */ import { randomBytes } from 'node:crypto' @@ -73,14 +52,9 @@ const MANIFEST_VERSION = 1 const MANIFEST_SUFFIX = '.json' /** - * A manifest written this recently counts as live even with no lock on it and - * its writer gone. bubblewrap takes the lock in the sandbox's init process, - * which it forks alongside the command, so a wrapping process killed between - * the exec and that fcntl would otherwise leave a starting sandbox's mount - * point unclaimed. The window to cover is bubblewrap's own startup, a few - * milliseconds; this is two orders of magnitude more, and short enough that a - * mount point a killed process left behind is collectable while the command - * after it is still being wrapped. + * A manifest this young counts as live with no lock on it. bubblewrap takes the + * lock a few milliseconds after the wrap returns; a wrapping process killed in + * between must not leave a starting sandbox's mount point unclaimed. */ const MANIFEST_GRACE_MS = 500 @@ -91,62 +65,43 @@ const DIRECTORY_LOCK_WAIT_MS = 2_000 const DIRECTORY_LOCK_RETRY_MS = 25 /** - * A directory lock this old is broken whoever holds it, a holder that is - * still running included: far past the milliseconds a collect takes. It is - * the only way out for a holder that cannot be asked after, one in another - * PID namespace or one whose lock says nothing this process can read. A - * holder in this namespace is asked after directly, and the lock of one that - * is gone is broken at once, whatever its age. + * A directory lock this old is broken whoever holds it: the only way out for a + * holder that cannot be asked after (another PID namespace, unreadable + * content). A holder that is gone is broken at once. */ const DIRECTORY_LOCK_STALE_MS = 60_000 /** - * How many mount points a pass removes on one look at /proc/locks, and for how - * long one look is good. Each look reads all of /proc/locks, whose size is the - * host's and not this library's, so one per removal made a pass over many - * mount points on a busy host outlast the wait above. A removal takes some ten - * microseconds, and a sandbox takes several milliseconds, about four on a fast - * machine, to get from its manifest being published to its binds being in - * place (the wrap returns, the caller spawns a shell, bubblewrap sets up its - * namespaces), so a look shared by this many removals, under a millisecond's - * worth, is as good as one each. The time limit is for a pass that is held up - * between two removals, which then looks again before the next, and is kept - * under that start-up time. + * How many mount points a pass removes on one read of /proc/locks, and for how + * long one read is good. A sandbox needs several milliseconds from its manifest + * being published to its binds being in place, so both stay under that; one + * read per removal would make a pass on a busy host outlast the directory lock + * wait. */ const REMOVALS_PER_LOOK = 64 const LOCKS_GOOD_FOR_MS = 2 /** - * For how long one listing of the manifest directory is good. A manifest - * published for a sandbox that is about to start is the usual way for a pass - * to be out of date, and listing a directory of a few manifests costs less - * than one removal, so this is kept an order of magnitude under the time that - * sandbox needs to get to its binds, and not at the two milliseconds above. It - * is a time and not "before every removal" so that a directory of thousands of - * manifests, which takes milliseconds to list, is not listed thousands of + * For how long one listing of the manifest directory is good: well under the + * time a starting sandbox needs to reach its binds, and a time rather than + * "before every removal" so a directory of thousands is not listed thousands of * times. */ const LISTING_GOOD_FOR_MS = 0.25 /** - * A manifest whose contents cannot be read is dropped once it is this old and - * no sandbox holds its lock — long past anything that could still be using - * what it names, and long enough that another version of this library writing - * to the same directory keeps its manifests for as long as it needs them. + * An unreadable manifest with no lock on it is dropped once it is this old: + * long enough for another version of this library to keep its own format. */ const UNREADABLE_MANIFEST_MAX_AGE_MS = 60 * 60 * 1000 -/** - * The largest file that is read as a manifest. One names a wrap's mount - * points, some dozens of paths, and is a few kilobytes; whatever is larger - * than this is not one, and is not read into memory to find that out. - */ +/** The largest file read as a manifest; a real one is a few kilobytes. */ const MANIFEST_MAX_BYTES = 1024 * 1024 /** - * What a manifest, and the directory lock, is written as before it is moved - * into place. A process killed between the two leaves the file behind, and a - * pass removes one that is as old as an unreadable manifest has to be. + * Suffix of a manifest or directory lock before it is moved into place. One + * left by a killed process is removed at {@link + * UNREADABLE_MANIFEST_MAX_AGE_MS}. */ const TEMPORARY_SUFFIX = '.tmp' @@ -156,10 +111,8 @@ const ManifestSchema = z.object({ pid: z.number().int().nonnegative(), start: z.string(), /** - * The PID namespace the wrapping process was in, as `readlink - * /proc/self/ns/pid` names it. `pid` and the lock bubblewrap takes can only - * be asked after from there. Absent from a manifest written before the field - * existed, which is then from a namespace nobody can name. + * The writer's PID namespace (`readlink /proc/self/ns/pid`): `pid` and the + * lock can only be asked after from there. Absent in older manifests. */ ns: z.string().optional(), /** When the manifest was written, for {@link MANIFEST_GRACE_MS}. */ @@ -167,11 +120,9 @@ const ManifestSchema = z.object({ /** The mount points bwrap makes on the host for that wrap's deny paths. */ paths: z.array(z.string()), /** - * The empty directories those mount points bind FROM, which this library - * makes itself. A live bind's source must stay, so they go the same way the - * mount points do, and are held to what {@link removeMountSource} asks: a - * source that is no longer our own private empty directory is not ours to - * remove. + * The empty directories those mount points bind from. A live bind's source + * must stay, so they are collected the same way (see {@link + * removeMountSource}). */ sources: z.array(z.string()), }) @@ -182,23 +133,16 @@ type Manifest = z.infer & { inode: string device: string /** - * Whether this process has given the manifest up: it wrote it, the caller - * says the command is done, and nothing held the lock when the pass began. - * Its writer is running and it may be young, and neither keeps it: only a - * lock can still make it live. The file stays where it is until the end of - * the pass, like any other finished manifest's. + * This process wrote it, the caller says the command is over, and nothing + * held the lock when the pass began: only a lock can still make it live. */ released: boolean } /** - * Manifests this process wrote and that are still on disk as far as it knows, - * each with the key of the command it was wrapped for where the caller gave - * the wrap one, and whether the caller has said that command is over. That is - * remembered, not acted on at once: a pass can turn back half way, a removal - * can be refused, a sandbox can still hold the lock, and in each case the - * manifest has to stay on disk, since it is all that names the mount points, - * and go at a later pass whatever that pass is told to release. + * Manifests this process wrote that are still on disk, with the command key the + * caller gave and whether that command is over. Remembered rather than acted on + * at once: a manifest must stay on disk until a pass removes what it names. */ const ownManifests = new Map< string, @@ -206,18 +150,17 @@ const ownManifests = new Map< >() /** - * Which of this process's own manifests a collect may release. Releasing one - * says "the command this was wrapped for is over", which only the caller - * knows, so it is the caller that says which: + * Which of this process's own manifests a collect may release. Only the caller + * knows that a command is over: + * * - `all`: no wrap of this process is outstanding, or the process is ending; - * - `none`: some are, and nothing says which of them is over, so only what - * OTHER processes have finished with is taken away; - * - one command: that command is over, whatever else is still running. - * Releasing the manifest of a wrap whose command has not started yet is what - * must never happen: bubblewrap opens the manifest to lock it, and a command - * whose manifest is gone refuses to start, or, if it got its lock in the - * moment between the pass's last look at /proc/locks and the unlink, runs on - * with nothing on disk naming its mount points. + * - `none`: some are, and nothing says which is over, so only what other + * processes have finished with is collected; + * - one command: that command is over, whatever else is running. + * + * Never release the manifest of a wrap whose command has not started: + * bubblewrap opens the manifest to lock it, and the command would refuse to + * start or run with nothing on disk naming its mount points. */ export type OwnManifestRelease = 'all' | 'none' | { commandKey: string } @@ -233,9 +176,8 @@ let manifestDirectoryUnavailable = false const onLinux = (): boolean => process.platform === 'linux' /** - * Field 22 of /proc/PID/stat, the process's start time in clock ticks. The - * comm field can hold spaces and parentheses, so the fields are counted from - * the last ')' rather than from the start of the line. + * Field 22 of /proc/PID/stat, the start time in clock ticks, counted from the + * last ')' because the comm field can hold spaces and parentheses. */ function startTimeFromProcStat(stat: string): string | undefined { return stat @@ -253,9 +195,8 @@ function processStartTime(pid: number): string | undefined { } /** - * Whether the process a manifest names is still running. Only "the process is - * gone" answers no: a /proc that cannot be read must not make a live sandbox's - * manifest collectable. + * Whether the process a manifest names is still running. An unreadable /proc + * answers yes: it must not make a live sandbox's manifest collectable. */ function writerIsRunning(pid: number, start: string): boolean { try { @@ -270,10 +211,7 @@ function writerIsRunning(pid: number, start: string): boolean { let pidNamespace: string | undefined | null = null -/** - * The PID namespace this process is in, as the kernel names it - * (`pid:[4026531836]`), or `undefined` where it cannot be told. - */ +/** This process's PID namespace as the kernel names it, or `undefined`. */ function ownPidNamespace(): string | undefined { if (pidNamespace === null) { try { @@ -285,10 +223,7 @@ function ownPidNamespace(): string | undefined { return pidNamespace } -/** - * The device half of what /proc/locks prints for a file, from the number stat - * gives for it: `major:minor`, in decimal. - */ +/** `major:minor` in decimal, as /proc/locks prints a device. */ function deviceOf(dev: bigint): string { const major = ((dev >> 8n) & 0xfffn) | ((dev >> 32n) & ~0xfffn) const minor = (dev & 0xffn) | ((dev >> 12n) & ~0xffn) @@ -296,10 +231,8 @@ function deviceOf(dev: bigint): string { } /** - * The filesystems on which the device stat reports for a file is the one - * /proc/locks prints for a lock on it: both are the superblock's. It is not so - * everywhere - a btrfs subvolume and some overlay arrangements give stat a - * device of their own - and there only the inode number can be compared. + * Filesystems on which stat reports the device /proc/locks prints. Elsewhere (a + * btrfs subvolume, some overlays) only the inode number can be compared. */ const FILESYSTEMS_WITH_ONE_DEVICE = new Set([ 0x01021994, // tmpfs, which is what /run/user/UID is @@ -326,18 +259,11 @@ type Locks = { } /** - * Whether /proc/locks lists a lock on this file. Any lock on a manifest is the - * one bubblewrap's sandbox init holds: nothing else opens one to lock it. - * - * The device is compared where the manifests' filesystem reports the one - * /proc/locks prints, and only there. Inode numbers are per filesystem, and on - * a tmpfs they are small and handed out in order, so a manifest in a young - * runtime directory can share its number with some unrelated locked file on - * another filesystem; it then read as live for as long as that lock was held, - * and every path it named as a mount point for every later wrap. - * Where the two devices cannot be compared the inode number alone decides, - * which errs towards "locked": the other way round would hide a live - * sandbox's lock. + * Whether /proc/locks lists a lock on this file; nothing but bubblewrap's + * sandbox init locks a manifest. The device is compared where it can be: inode + * numbers are per filesystem, and an unrelated locked file with the same number + * would otherwise keep a manifest live. Where it cannot, the inode alone + * decides, which errs towards "locked". */ function holdsLock( locks: Locks, @@ -349,12 +275,9 @@ function holdsLock( } /** - * What /proc/locks lists, for the manifests in `dirs`. Only the locks whose - * holder has a pid in the PID namespace of the /proc being read are listed, - * which is why a manifest from another namespace is never judged by this. - * - * `undefined` when the list could not be read, which every caller reads as - * "every manifest is locked". + * What /proc/locks lists. Only locks whose holder has a pid in this PID + * namespace appear. `undefined` when it cannot be read, which every caller + * treats as "every manifest is locked". */ function readLocks(dirs: readonly string[]): Locks | undefined { let text: string @@ -412,13 +335,10 @@ function isOurPrivateDirectory(dir: string): boolean { } /** - * Make `dir` a directory of ours alone, or say it cannot be. - * - * The name is predictable and sits where sandboxed commands commonly write, - * so what is found there is looked at before it is touched: through a - * descriptor opened without following a link, so that a symlink planted at - * the name is refused rather than having its target's mode changed, and the - * mode is set on that descriptor, never on the path. + * Make `dir` a directory of ours alone, or say it cannot be. The name is + * predictable and sandboxed commands can often write beside it, so it is opened + * without following a link and its mode is set on the descriptor: a symlink + * planted at the name is refused, not followed. */ function makeOurPrivateDirectory(dir: string): boolean { try { @@ -452,9 +372,8 @@ function makeOurPrivateDirectory(dir: string): boolean { } /** - * Whether a manifest kept under `dir` would be out of bubblewrap's reach: the - * wrap mounts a fresh /dev and /proc after every bind, which buries whatever - * was bound beneath them, and bubblewrap opens the manifest after its mounts. + * Whether bubblewrap could not reach a manifest under `dir`: the wrap mounts a + * fresh /dev and /proc over whatever was bound beneath them. */ function isBuriedBySandboxMounts(dir: string): boolean { return ['/dev', '/proc', '/sys'].some( @@ -463,9 +382,9 @@ function isBuriedBySandboxMounts(dir: string): boolean { } /** - * The places the manifest directories are looked for in. The first two are - * the same for every process of a user, whatever started it; the last two are - * what this process's environment says. + * The places the manifest directories are looked for in. The first two are the + * same for every process of a user; the last two are what this process's + * environment says. */ export type ManifestDirectoryPlaces = { /** The user's runtime directory where the system keeps one: /run/user/UID. */ @@ -492,9 +411,8 @@ function manifestDirectoryPlaces(): ManifestDirectoryPlaces { } /** - * Whether `dir` is a runtime directory of this user's as the system makes - * one: a real directory, not a link to one, the user's, and nobody else's to - * look into. + * Whether `dir` is a runtime directory of this user's as the system makes one: + * a real directory, not a link, the user's, and closed to everyone else. */ function isTheUsersRuntimeDirectory(dir: string): boolean { try { @@ -510,9 +428,8 @@ function isTheUsersRuntimeDirectory(dir: string): boolean { } /** - * Where the directory `name` names really is, as far as that can be told - * before it is made: under its parent as the links above it resolve, by its - * own last component. + * Where the directory `name` names really is, as far as can be told before it + * is made: its parent with links resolved, plus its own last component. */ function whereItReallyIs(name: string): string { try { @@ -523,8 +440,9 @@ function whereItReallyIs(name: string): string { } /** - * The names under which the processes of this user keep their manifests where - * several can find them, in the order they are tried for writing: + * The names under which processes of this user keep manifests that others can + * find, in the order tried for writing: + * * - `/run/user/UID/srt-mount-points`, where `/run/user/UID` is a runtime * directory of the user's (see {@link isTheUsersRuntimeDirectory}); * - `/tmp/srt-mount-points-UID`; @@ -532,17 +450,12 @@ function whereItReallyIs(name: string): string { * - `srt-mount-points-UID` under the system temp dir, which is `$TMPDIR`'s. * * The user id and the file system come first because they are the same for - * every process of the user. The environment is not: a login shell and what a - * service, an IDE, cron or `docker exec` starts differ in `XDG_RUNTIME_DIR`, - * two shells can differ in `TMPDIR`, and two processes that each wrote where - * their own environment said never read each other's manifests. The second - * wrap then took the first's mount point for the user's own file, and the - * first's clean-up removed it from under the second sandbox. The names from - * the environment stay, last, for a process that can use neither of the first - * two. - * - * Two names for one directory are one, the earlier, by where they really are. - * One that a sandbox's own mounts would bury is left out. + * every process of the user. The environment is not: two processes that each + * wrote where their own environment said would never read each other's + * manifests, and one would remove the other's mount point from under its + * sandbox. The names from the environment stay, last, for a process that can + * use neither of the first two. Two names for one directory count once, and one + * that a sandbox's own mounts would bury is left out. */ function manifestDirectoryCandidates(): { name: string @@ -591,16 +504,11 @@ function sharedManifestDirectoryNames(): string[] { } /** - * The directory this process writes its manifests to: the first of the shared - * names (see {@link sharedManifestDirectoryNames}) that is, or can be made, a - * directory of ours alone that we can write, else - when none can, as in a - * sandbox that binds them read-only - a private directory only this process - * knows, which keeps the guarantee within this process and loses only what - * other processes would have read. - * - * Revalidated on every use: the temp dir is somewhere sandboxed commands - * commonly write, and a directory swapped for a symlink between two wraps - * would put manifests somewhere else entirely. + * The directory this process writes its manifests to: the first shared name + * (see {@link sharedManifestDirectoryNames}) that can be made ours alone, else + * a private directory only this process knows, which keeps the guarantee within + * this process. Revalidated on every use, because a directory swapped for a + * symlink between two wraps would send manifests elsewhere. */ function ensureManifestDirectory(): string | undefined { if ( @@ -666,28 +574,21 @@ function isOurDirectory(dir: string): boolean { } /** - * Every directory whose manifests this process believes, for a wrap, a - * clean-up and {@link liveMountPoints} to read: - * - each of the names that come from the user id that is there and is a - * directory of ours alone that we can write, whichever of them this process - * writes to; - * - the names the environment gives, held to the same, only where none of - * the first kind will do, which is where this process writes to one of - * them; - * - the one this process has settled on, whatever it is. + * Every directory whose manifests this process believes, for a wrap, a clean-up + * and {@link liveMountPoints} to read: * - * What the environment names is kept out of a sandboxed command's reach by - * the processes whose environment names it, and by no other: a process with - * another temp dir does not know of it, and its command may write there. A - * manifest planted that way would keep a path, or have one removed, on the - * word of a process that never relied on the directory, so it is believed - * only by a process that has nowhere else to keep its own. Nothing is lost by - * that: every process that can use a name from the user id meets every other - * one there. + * - each name that comes from the user id that is there and is a directory of + * ours alone that we can write; + * - the names the environment gives, held to the same, only where none of the + * first kind will do; + * - the one this process has settled on. * - * One that is a link, somebody else's, open to others or not to be written is - * passed over: what is in it is not believed, and nothing fails for it. - * Nothing is made. + * What the environment names is kept out of a sandboxed command's reach only by + * processes whose environment names it; a command of a process with another + * temp dir may write there. A manifest planted that way must not keep or remove + * a path, so such a directory is believed only by a process that has nowhere + * else to keep its own. One that is a link, somebody else's, open to others or + * not writable is passed over. Nothing is made. */ function believedManifestDirectories(): string[] { const usable = manifestDirectoryCandidates().filter(candidate => @@ -710,27 +611,18 @@ function believedManifestDirectories(): string[] { } /** - * Every directory a sandbox must not be able to write, because some process - * of this user keeps manifests in it and a collect on the host believes what - * it finds there: every shared name, as this process sees them, and the one - * this process has settled on where that is none of them. For a wrap to bind - * read-only, whether or not it has a manifest of its own. + * Every directory a wrap must bind read-only because some process of this user + * keeps manifests in it and a collect believes what it finds there: every + * shared name, plus the one this process settled on. * - * Every name, and not only the one this process writes to: every process of - * the user believes what is under the ones that come from the user id, and - * one with this environment that can use none of those what is under the - * ones the environment gives, so a sandbox with the temp dir writable could - * otherwise delete and forge manifests in the one under it while its own - * process writes under the runtime directory. Each is made if it can be, so - * that a sandboxed command cannot make it first and fill it before a process - * that will believe it comes along: the command runs as the user, so what it - * makes passes every check of owner and mode, which keep other users out and - * not the command. One that is there and the user's is listed even when it - * cannot be written from here. The last resort is not made for this: there is - * nothing to keep out of reach in a directory nobody has made. A wrap that is - * about to record says so with `recording`, and the directory its manifest - * will go to is then settled first, the last resort included: the wrap has to - * know every one of these before it publishes, to pin what lies above them. + * Every name, not only the one this process writes to: a sandbox with the temp + * dir writable could otherwise forge manifests in the directory under it while + * its own process writes under the runtime directory. Each is made if it can + * be, so a sandboxed command cannot make and fill it first: the command runs as + * the user, so what it makes passes every check of owner and mode. One that is + * there and the user's is listed even when it cannot be written from here. With + * `recording`, the directory this wrap's manifest will go to is settled first, + * so the wrap can pin what lies above it before it publishes. */ export function mountPointManifestDirectories(recording = false): string[] { if (!onLinux()) { @@ -753,9 +645,8 @@ export function mountPointManifestDirectories(recording = false): string[] { } /** - * Take away the directory only this process knows, once nothing is left in - * it, when the process or its session is ending: nobody else would. With the - * manifest of a sandbox that is still running in it, it stays. + * Remove the directory only this process knows, once it is empty, when the + * process or its session ends. It stays while a manifest is in it. */ export function removePrivateManifestDirectory(): void { if (manifestDirectory === undefined || !manifestDirectoryIsPrivate) { @@ -776,25 +667,16 @@ function sleep(ms: number): void { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) } -/** - * What a process leaves in the directory lock: who it is, and the PID - * namespace in which that can be asked after. - */ +/** What the directory lock holds: who took it, and in which PID namespace. */ function directoryLockContent(): string { return `${process.pid} ${processStartTime(process.pid) ?? '?'} ${ownPidNamespace() ?? '?'}\n` } /** - * Take the directory lock, and say what it now holds, or why it was not taken: - * `held` when something is at its name already, `unavailable` when this - * process cannot make one there at all. - * - * The lock comes into being whole. Made with O_EXCL and written to afterwards - * it stood empty for a moment, and a waiter that read it then found no holder - * in it and removed it at once, so two processes were inside together. It is - * written under another name and linked into place instead: link fails when - * the name is taken, exactly as O_EXCL does, and what it puts there is never - * seen half made. A filesystem without hard links gets no lock, and no pass. + * Take the directory lock: returns what it now holds, `held` when the name is + * taken, or `unavailable` when this process cannot make one there. The content + * is written under another name and hard-linked into place, so a waiter never + * reads a lock that is still empty and takes it for abandoned. */ function takeDirectoryLock( dir: string, @@ -827,9 +709,8 @@ function takeDirectoryLock( } /** - * Give the directory lock up, if it is still this process's. One broken for - * its age while this process was held up belongs to whoever took it next, and - * removing that one by name let a third process in beside it. + * Give the directory lock up if it is still this process's. One broken for its + * age meanwhile belongs to whoever took it next. */ function releaseDirectoryLock(lockFile: string, content: string): void { try { @@ -842,25 +723,22 @@ function releaseDirectoryLock(lockFile: string, content: string): void { } /** - * What stands at the directory lock's name, once taking it has failed: - * - `free`: nothing any more, or the lock of a holder that is gone, removed; + * What stands at the directory lock's name once taking it has failed: + * + * - `free`: nothing, or the lock of a holder that is gone, now removed; * - `held`: somebody's lock, to be waited for; - * - `stuck`: something this process can neither read as a lock nor remove, - * which no amount of waiting changes. + * - `stuck`: something this process can neither read as a lock nor remove. * - * Breaking a lock is three steps - read who holds it, ask after them, unlink - - * and not one, so two waiters can both find the same holder gone and the - * slower one's unlink can land on the lock a third process has taken since. - * Nothing here prevents that. It is why nothing rests on this lock: see - * {@link withDirectoryLock}. + * Breaking a lock is read, ask, unlink, not one step, so a slow waiter's unlink + * can land on a lock a third process has taken since. Nothing prevents that, + * which is why nothing rests on this lock (see {@link withDirectoryLock}). */ function examineDirectoryLock(lockFile: string): 'free' | 'held' | 'stuck' { let holder: string let age: number try { - // Looked at before it is opened, and opened without blocking or following - // a link: opening a FIFO to read waits for a writer, and nothing but a - // regular file is a lock this library made. + // Opened without blocking or following a link: opening a FIFO waits for a + // writer, and only a regular file is a lock this library made. if (!fs.lstatSync(lockFile).isFile()) { return 'stuck' } @@ -879,14 +757,11 @@ function examineDirectoryLock(lockFile: string): 'free' | 'held' | 'stuck' { fs.closeSync(fd) } } catch (e) { - // Gone between the failed attempt and this look: the next attempt takes - // it. Anything else is a file that is there and cannot be read. + // Gone meanwhile: the next attempt takes it. Anything else cannot be read. return (e as NodeJS.ErrnoException).code === 'ENOENT' ? 'free' : 'stuck' } - // A holder can be asked after only in the PID namespace it named, when that - // is this one: elsewhere its pid means nothing, or means some other - // process. One that cannot be asked after is believed until the lock is - // older than any pass, as is one that did not say who it is. + // A holder can be asked after only in its own PID namespace. One that cannot + // be, or that did not say who it is, is believed until the lock is stale. const who = /^(\d+) (\d+) (\S+)\n$/.exec(holder) const held = age < DIRECTORY_LOCK_STALE_MS && @@ -899,31 +774,25 @@ function examineDirectoryLock(lockFile: string): 'free' | 'held' | 'stuck' { try { fs.unlinkSync(lockFile) } catch (e) { - // Another process broke it first; or it cannot be removed from here, in - // which case it is still there and the next attempt would find it so. + // Another process broke it first, or it cannot be removed from here. return (e as NodeJS.ErrnoException).code === 'ENOENT' ? 'free' : 'stuck' } return 'free' } /** - * Run `body` while holding the manifest directory's lock, and report whether - * it ran, and if not, whether that was for somebody `held` it all the while. - * It gives up, without running `body`, after {@link DIRECTORY_LOCK_WAIT_MS} of - * finding the lock held, and at once when what is at the lock's name is - * nothing it could ever take: the wait is this thread's, in the wrap, in the - * clean-up after every command and in the exit handler, and nothing about a - * lock is worth more of it than that. + * Run `body` while holding the manifest directory's lock, and report whether it + * ran and, if not, whether the lock was `held` throughout. Gives up after + * {@link DIRECTORY_LOCK_WAIT_MS}, and at once when what is at the lock's name + * can never be taken: the wait blocks the wrap, the clean-up after every + * command and the exit handler. * - * The lock keeps srt processes out of each other's way most of the time; it - * does not exclude, and nothing may rest on it. Its holder can be held up past - * the age at which it is broken, the break itself is not atomic (see - * {@link examineDirectoryLock}), and a publish that cannot have the lock goes - * ahead without it. What makes a mount point safe to remove is looked at - * again immediately before the removals and at short intervals during them: - * the directory, for a manifest that was not there when the pass began, and - * the kernel's answer about every manifest the pass knows of (see - * {@link collectUnderLock}). + * The lock keeps srt processes out of each other's way; it does not exclude, + * and nothing may rest on it. Its holder can outlive the age at which it is + * broken, the break is not atomic, and a publish that cannot have the lock goes + * ahead without it. What makes a mount point safe to remove is looked at again + * immediately before the removals and during them (see {@link + * collectUnderLock}). */ function withDirectoryLock( dir: string, @@ -950,9 +819,8 @@ function withDirectoryLock( ) return { ran: false, held: false } } - // On every round, whatever the look above found: a lock that is broken - // and taken again by others for as long as this process keeps asking is - // as good as held. + // On every round: a lock that others keep breaking and retaking is as good + // as held. if (Date.now() >= deadline) { return { ran: false, held: true } } @@ -962,12 +830,9 @@ function withDirectoryLock( /** * What is at a manifest's name, read as one, or `undefined` when it is not a - * manifest this version can read. Only a regular file of the user's, no - * larger than {@link MANIFEST_MAX_BYTES}, is read at all, and it is looked at - * through the descriptor it is then read from, opened without following a - * link and without blocking: opening a FIFO to read waits for a writer, a - * link leads wherever whoever planted it likes, and nothing but such a file - * is a manifest this library wrote. + * manifest this version can read. Only a regular file of the user's, no larger + * than {@link MANIFEST_MAX_BYTES}, is read, through a descriptor opened without + * following a link and without blocking. */ function readManifest(file: string): Manifest | undefined { let inode: string @@ -1010,16 +875,11 @@ function readManifest(file: string): Manifest | undefined { /** * Whether a sandbox that named this manifest may still be running, as far as - * THIS process can tell. - * - * It can tell only about a manifest written in its own PID namespace: the - * writer's pid and the sandbox's lock are both invisible from any other. So a - * manifest from another namespace is live here whatever /proc says, for good: - * what it names is collected by a process in the namespace that wrote it, and - * by nobody if that namespace has gone, which leaves empty files behind and - * opens nothing. One that does not say where it is from is live while a - * process with its writer's pid and start time can be seen, and otherwise - * until it is as old as an unreadable manifest has to be to be dropped. + * this process can tell. It can tell only for a manifest written in its own PID + * namespace; one from another namespace is always live here and is collected by + * a process in the namespace that wrote it. One that names no namespace is live + * while its writer can be seen, and otherwise until {@link + * UNREADABLE_MANIFEST_MAX_AGE_MS}. */ function isLive(manifest: Manifest, locks: Locks): boolean { if (holdsLock(locks, manifest)) { @@ -1045,27 +905,23 @@ function isLive(manifest: Manifest, locks: Locks): boolean { } /** - * What became of a path a pass set out to remove: `left` when there is - * nothing more to do about it - it is gone, or is no longer what bubblewrap or - * this library made - and `failed` when it is still that and could not be - * removed from here, which a later pass, or another process, may yet manage. + * What became of a path a pass set out to remove: `left` when it is gone or no + * longer what bubblewrap or this library made, `failed` when it is still that + * and could not be removed from here. */ type Removal = 'removed' | 'left' | 'failed' /** Whether a failed removal means the path is no longer ours to remove. */ function meansNothingToRemove(e: unknown): boolean { const code = (e as NodeJS.ErrnoException | undefined)?.code - // Not empty after all: something was written into it between the look and - // the rmdir. + // Not empty after all: something was written into it meanwhile. return isAbsenceErrno(e) || code === 'ENOTEMPTY' || code === 'EEXIST' } /** * Remove an empty directory a placeholder bound from, if it is still our own. - * It sits under the system temp dir, which sandboxed commands commonly can - * write, so anything that is not a private empty directory of ours — a - * symlink, a directory whose mode has been widened, one with something in it — - * is somebody else's and is left exactly as found. + * It sits where sandboxed commands can often write, so anything but a private + * empty directory of ours is left exactly as found. */ function removeMountSource(source: string): Removal { try { @@ -1099,10 +955,9 @@ function removeMountSource(source: string): Removal { /** * Whether `p` still has the shape of a file bubblewrap made to bind onto: a - * regular file (not a link to one), empty, with no write bit, under one name, - * and ours. A manifest is only a claim about a path, and the directory it - * sits in is only as private as its host, so nothing is removed on a - * manifest's word that does not also look like what bubblewrap leaves. + * regular file (not a link), empty, with no write bit, under one name, and + * ours. A manifest is only a claim about a path, so nothing is removed on its + * word that does not also look like what bubblewrap leaves. */ export function isBwrapFileMountPoint(p: string): boolean { try { @@ -1120,10 +975,8 @@ export function isBwrapFileMountPoint(p: string): boolean { } /** - * What kind of mount point the path a live manifest names still is, by what - * is there now: an empty regular file, a directory, or neither any more. A - * manifest says a path was a mount point when it was written; a file that has - * been written to since, or a link put in its place, is somebody's own. + * What kind of mount point the path a live manifest names still is, by what is + * there now: an empty regular file, a directory, or neither any more. */ export function kindOfMountPoint(p: string): 'file' | 'directory' | undefined { try { @@ -1138,9 +991,8 @@ export function kindOfMountPoint(p: string): 'file' | 'directory' | undefined { } /** - * Remove a mount point, if it is still the empty file or directory bwrap made. - * Anything else - a file with content or a write bit, a link, a directory - * something has written into or that is not ours - is left where it is. + * Remove a mount point if it is still the empty file or directory bwrap made; + * anything else is left where it is. */ function removeMountPoint(mountPoint: string): Removal { try { @@ -1192,15 +1044,12 @@ export type MountPointManifest = { /** * Record the mount points this wrap relies on, and the empty directories they - * bind from, before the sandbox that relies on them can start, and say where - * to point bubblewrap's --lock-file. + * bind from, before the sandbox can start, and say where to point bubblewrap's + * --lock-file. * - * `undefined` when there is nothing to record, or when no manifest could be - * written, in which case the caller must not track those mount points at all: - * what this process cannot record it does not remove. That is as far as it - * goes. Nothing on disk then says a sandbox relies on them, so a wrap in - * another process that denies the same path takes the file for the caller's - * own: it binds it onto itself and names it nowhere, and nobody removes it. + * `undefined` when there is nothing to record or no manifest could be written. + * The caller must then not track those mount points: what this process cannot + * record it does not remove, and nobody else will. */ export function publishMountPointManifest( mountPoints: readonly string[], @@ -1228,14 +1077,10 @@ export function publishMountPointManifest( paths: [...new Set(mountPoints)], sources: [...new Set(sources)], } - // Written beside the manifest and renamed onto it, so a collect in another - // process reads it whole or not at all. The rename is made under the - // directory lock where that can be had, which keeps it out of the middle of - // most passes, and without it where it cannot: a wrap is not refused for - // want of a lock. Either way a pass may already have listed the directory, - // so what keeps this wrap's mount points is that every pass lists it again - // before it removes anything (see collectUnderLock), and that the sandbox - // this manifest covers has not started yet. + // Written beside the manifest and renamed onto it, so a collect reads it + // whole or not at all. Under the directory lock where that can be had; a wrap + // is not refused for want of one, because every pass lists the directory + // again before it removes anything (see collectUnderLock). try { fs.writeFileSync(temporary, JSON.stringify(body), { mode: 0o600 }) const publish = (): void => fs.renameSync(temporary, file) @@ -1272,8 +1117,8 @@ export function discardMountPointManifest(file: string): void { } /** - * The path of every file in `dirs`, for a reading on which nothing is - * removed: a directory that cannot be listed adds nothing. + * The path of every file in `dirs`. A directory that cannot be listed adds + * nothing. */ function filesIn(dirs: readonly string[]): string[] { return dirs.flatMap(dir => { @@ -1287,16 +1132,14 @@ function filesIn(dirs: readonly string[]): string[] { /** * The mount points the manifests name, on one reading of every directory this - * process believes (see {@link believedManifestDirectories}): `live`, by a - * manifest whose sandbox may still be running, and `named`, by any manifest at - * all. What only a finished manifest names is what a sandbox that has ended - * left behind and no pass has collected yet, and the next pass in any process - * removes it on that manifest's word. + * process believes (see {@link believedManifestDirectories}): `live` by a + * manifest whose sandbox may still be running, `named` by any manifest. What + * only a finished manifest names is a leftover the next pass removes. * - * `live` is empty when /proc/locks cannot be read, and a directory that - * cannot be listed adds to neither. A wrap takes an existing path for a mount - * point on a manifest's word only, never by the look of it, so with none to - * be read it takes every existing path for the caller's own. + * `live` is empty when /proc/locks cannot be read, and a directory that cannot + * be listed adds to neither: a wrap takes a path for a mount point on a + * manifest's word only, so with none to read it takes every existing path for + * the caller's own. */ export function namedMountPoints(): { live: Set; named: Set } { const live = new Set() @@ -1323,9 +1166,8 @@ export function namedMountPoints(): { live: Set; named: Set } { } /** - * Whether what is at `p` now is still something a clean-up would take away on - * a manifest's word: the empty file bubblewrap made (see - * {@link isBwrapFileMountPoint}), or an empty directory of the user's. + * Whether `p` is still something a clean-up would remove on a manifest's word: + * the empty file bubblewrap made, or an empty directory of the user's. */ function isEmptyMountPoint(p: string): boolean { if (isBwrapFileMountPoint(p)) { @@ -1345,46 +1187,29 @@ function isEmptyMountPoint(p: string): boolean { /** * The mount points a sandbox that may still be running relies on, as the - * manifests say at the moment of the call: for a caller that removes paths of - * its own accord after a command, to leave out the ones in this set. Removing - * a mount point from under a running sandbox lifts the deny there. + * manifests say at the moment of the call. For a caller that removes paths of + * its own accord after a command: removing a mount point from under a running + * sandbox lifts the deny there. * - * It is a snapshot, taken without a lock, from one listing of each manifest - * directory and one reading of /proc/locks, and it may be out of date by the - * time the caller acts on it: a sandbox can start on a path the moment after. - * Its only safe use is to SKIP a removal. That a path is not in it never - * means the path is free to be written, or that nothing will rely on it. + * A snapshot, taken without a lock, that may be out of date by the time the + * caller acts. Its only safe use is to SKIP a removal; a path not being in it + * never means the path is free to write. * - * A path is in the set only when both of these hold: - * - a manifest names it, and that manifest is live by the rule the clean-up - * goes by, or its liveness cannot be told. It cannot be told when - * /proc/locks cannot be read, or while a manifest that cannot be read is - * locked or new; every path that any readable manifest names then counts. - * What a manifest that cannot be read names cannot be listed at all; - * - what is at the path now still has the shape of a mount point: an empty - * regular file with no write bit and one link, or an empty directory, and - * the user's. So whatever a caller spares on the word of this set is an - * empty placeholder, and a manifest that is out of date, or forged, never - * makes it spare a file with something in it. + * A path is in the set only when both hold: * - * The paths are as the wraps recorded them: absolute, with the links in the - * directories above the last component resolved and the last component as it - * stands. A caller compares its own paths in that form. + * - a manifest names it, and that manifest is live by the clean-up's rule or + * its liveness cannot be told (/proc/locks unreadable, or an unreadable + * manifest that is locked or new), in which case every named path counts; + * - what is at the path now is still an empty placeholder: an empty regular + * file with no write bit and one link, or an empty directory, and the user's. + * So a stale or forged manifest never makes a caller spare a file with + * content. * - * The manifests are believed as found. Their directories can be written by - * the user's own processes outside any sandbox; every sandbox that restricts - * writes has each of them bound read-only, with the directories between it - * and the write root it lies in pinned, which does not reach a directory - * whose name, or a name above it, is a link inside a write root, nor one - * whose parent is not there yet below one. It answers for every manifest - * directory this process believes (see {@link believedManifestDirectories}), - * whichever of them it writes to, so a sandbox of a process that keeps its - * manifests under a name its environment gives, or in a directory of its own, - * for want of a name from the user id that it can use, or of something that - * writes none, is not seen. - * - * Makes nothing on the host, a manifest directory included. Empty where - * there is no manifest directory, and anywhere but on Linux. + * Paths are as the wraps recorded them: absolute, links above the last + * component resolved. It answers for every manifest directory this process + * believes (see {@link believedManifestDirectories}), so a sandbox whose + * process keeps manifests elsewhere is not seen. Makes nothing on the host. + * Empty off Linux and where no manifest directory exists. */ export function liveMountPoints(): ReadonlySet { const spared = new Set() @@ -1440,9 +1265,8 @@ export function liveMountPoints(): ReadonlySet { * nothing anywhere but on Linux. * * One pass for each directory this process believes, under that directory's - * lock: a pass takes off the disk what the finished manifests of its own - * directory name, and those manifests, and goes by the manifests of every - * directory for what is still relied on. + * lock: a pass removes what the finished manifests of its own directory name, + * and goes by the manifests of every directory for what is still relied on. */ export function collectMountPoints( release: OwnManifestRelease = 'all', @@ -1450,10 +1274,8 @@ export function collectMountPoints( if (!onLinux()) { return [] } - // What the caller says about its own commands is taken down first and kept, - // whatever becomes of this pass: one that cannot have the lock, or turns - // back half way, leaves the manifests for the next, which has to know they - // are done with even when it is told to release nothing. + // Recorded first and kept whatever becomes of this pass, so a later pass + // knows these commands are over even when told to release nothing. for (const own of ownManifests.values()) { if ( release === 'all' || @@ -1481,39 +1303,25 @@ export function collectMountPoints( } /** - * One pass over `dir`, which the caller makes under that directory's lock - * where it can. `dirs` is every directory this process believes, `dir` among - * them. The pass reads the manifests of all of them, and a mount point stays - * while a live manifest in any of them names it; what it takes off the disk - * is what the finished manifests of `dir` name, and those manifests. What the - * finished manifests of another directory name goes at the pass over that - * one, under its lock. + * One pass over `dir`, made under that directory's lock where the caller could + * take it. `dirs` is every directory this process believes, `dir` among them: a + * mount point stays while a live manifest in any of them names it, and what is + * removed is what the finished manifests of `dir` name. * - * Nothing is taken off the disk until the end. A manifest is all that names - * its mount points, so it goes last, after them, and only when every one of - * them is gone or is no longer a mount point: a pass that turns back half way, - * or a removal that is refused, leaves the manifest for a later pass to go by. - * That holds for the manifests of this process as for anybody's. The ones the - * caller has said are over are judged as if their writer were gone, and that - * is all that sets them apart. + * A manifest is all that names its mount points, so it is removed last, and + * only when every path it names is gone or is no longer a mount point: a pass + * that turns back, or a removal that is refused, leaves it for a later pass. + * Manifests the caller has said are over are judged as if their writer were + * gone. * - * The lock does not exclude (see {@link withDirectoryLock}), so the pass does - * not rely on the directory staying as first listed. Immediately before it - * removes anything, and again whenever its last listing is more than - * {@link LISTING_GOOD_FOR_MS} old, it lists every directory again, where a - * manifest that was not there at first keeps everything it names whatever - * else is true of it: that is how a sandbox about to start on the same path - * shows. And before the first removal, and again every - * {@link REMOVALS_PER_LOOK} removals or {@link LOCKS_GOOD_FOR_MS}, it reads - * /proc/locks again, where a manifest that has been locked since keeps what it - * names. That one covers the actor no lock could: the bubblewrap a wrap has - * already handed to its caller, which takes its lock whenever the caller - * starts it. What is left is the time between a look and the removal made on - * it, a quarter of a millisecond for a newly published manifest and two - * milliseconds for one that was there all along, against the four or more a - * sandbox takes to get from its start to its binds. A pass that is held up for - * longer than that between looking and removing, with a sandbox starting on - * the same path in that time, still takes that sandbox's mount point away. + * The lock does not exclude (see {@link withDirectoryLock}), so before the + * first removal, and again at {@link LISTING_GOOD_FOR_MS}, the pass lists every + * directory again: a manifest that was not there at first keeps everything it + * names, which is how a sandbox about to start on the same path shows. And + * every {@link REMOVALS_PER_LOOK} removals or {@link LOCKS_GOOD_FOR_MS} it + * reads /proc/locks again: a manifest locked since keeps what it names. What + * remains is a pass held up between a look and the removal for longer than a + * sandbox takes to reach its binds; it then removes that sandbox's mount point. */ function collectUnderLock(dir: string, dirs: readonly string[]): string[] { // Every file in every directory, or nothing: a directory that is believed @@ -1548,23 +1356,18 @@ function collectUnderLock(dir: string, dirs: readonly string[]): string[] { if (locks === undefined) { return [] } - // What the pass knows of: the first listing, so that everything found - // later is a newcomer. + // The first listing: whatever is found later is a newcomer. const known = new Set(files) - // A manifest of this process that something else has taken off the disk is - // nothing to remember. + // Forget own manifests that something else has removed. for (const file of ownManifests.keys()) { if (isHere(file) && !known.has(file)) { ownManifests.delete(file) } } - // A manifest whose contents cannot be read — a file another version of this - // library wrote, a truncated one, one this process may not read — names - // mount points that cannot be honoured one by one. While the kernel says a - // sandbox is running under it, or it is new enough that one may be starting, - // nothing at all is removed; once it is neither, whatever it names is not in - // use and it stands in the way of nothing. + // An unreadable manifest names mount points that cannot be honoured one by + // one. While it is locked, or new enough that a sandbox may be starting, + // nothing at all is removed. let unreadableIsLive = false for (const file of unreadable) { let stat: fs.BigIntStats @@ -1615,9 +1418,7 @@ function collectUnderLock(dir: string, dirs: readonly string[]): string[] { } } - // The wraps of this process the caller has said are over, except that one - // whose manifest is locked has a sandbox running under it all the same, and - // is anybody's live manifest until the lock goes. + // Over, by the caller's word, unless a sandbox still holds the lock. for (const manifest of manifests) { manifest.released = ownManifests.get(manifest.file)?.over === true && @@ -1638,9 +1439,8 @@ function collectUnderLock(dir: string, dirs: readonly string[]): string[] { finished.push(manifest) } } - // Each path once, and as a mount point where a manifest names it as one. - // Only what the finished manifests of this directory name: the others are - // kept in `finished` to be asked after again, not to be acted on. + // Each path once, and as a mount point where a manifest names it as one. Only + // what the finished manifests of this directory name. const candidates = new Map() for (const manifest of finished) { if (!isHere(manifest.file)) continue @@ -1656,10 +1456,8 @@ function collectUnderLock(dir: string, dirs: readonly string[]): string[] { } } - // When the pass last made sure, and what it found then: `revived` are the - // manifests of the first listing that read as finished then and as locked on - // a later look. A finished manifest's writer is gone and its grace is over, - // and neither comes back, so the lock is all that is asked after again. + // `revived`: manifests that read as finished at first and as locked on a + // later look. Only the lock can change; a gone writer does not come back. const revived = new Set() let removalsOnThisLook = 0 let lookedAt: number | undefined @@ -1713,9 +1511,8 @@ function collectUnderLock(dir: string, dirs: readonly string[]): string[] { listedAt = performance.now() return true } - // The kernel's list is read first and the directory listed last. Reading - // the list takes as long as the host has locks, milliseconds on a busy one, - // after which a listing made before it is out of date as well. + // Locks first, listing last: reading /proc/locks can take milliseconds, and a + // listing made before it would be out of date. const mayStillRemove = (): boolean => (locksAreFresh() || readTheLocksAgain()) && (listingIsFresh() || listTheDirectoryAgain()) @@ -1749,9 +1546,8 @@ function collectUnderLock(dir: string, dirs: readonly string[]): string[] { if (revived.has(manifest)) { continue } - // A mount point that is still there, still what bubblewrap made, and could - // not be removed from here - a directory this process sees read-only, say - // - is still somebody's to remove, and the manifest is all that says so. + // A mount point that could not be removed from here is still somebody's to + // remove, and the manifest is all that says so. if ( [...manifest.paths, ...manifest.sources].some(named => notRemoved.has(named), @@ -1772,17 +1568,11 @@ function collectUnderLock(dir: string, dirs: readonly string[]): string[] { /** * Put directories of a test's own in the place of those the manifest - * directories are looked for in (see {@link ManifestDirectoryPlaces}), or, - * with nothing, put the real ones back. Test seam, and nothing else may call - * it: the real places are every other process's of the user, and a test that - * lists, attacks or collects there does so to the sandboxes that user is - * running. A place that is left out stays what it really is, so a test can - * stand in for `/run/user/UID` and `/tmp` and leave the environment to say the - * rest. - * - * Which directory the manifests are written to is forgotten with it, so that - * the next use works it out again: this module may have settled on one before - * the test that wants another was loaded. + * directories are looked for in (see {@link ManifestDirectoryPlaces}), or, with + * nothing, put the real ones back. Test seam, and nothing else may call it: the + * real places are shared with every other process of the user. A place that is + * left out stays what it really is. Which directory the manifests are written + * to is forgotten with it, so the next use works it out again. */ export function setMountPointManifestPlacesForTesting( places?: Partial, diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 72c274b8a..0c82093d1 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -854,12 +854,10 @@ function renderBwrapInvocation( let exitHandlerRegistered = false /** - * Wraps handed to the caller and not yet cleaned up after. The caller is told - * to clean up once per command, when that command is over, so while this is - * above zero some command of this process may not have started yet, and what - * it relies on - its manifest, its --args profile - must still be there when - * it does. Nothing of this process's own is given up until the count is back - * at zero, unless the caller says which command it is cleaning up after. + * Wraps handed to the caller and not yet cleaned up after. While above zero, + * some command of this process may not have started, and its manifest and + * --args profile must still be there when it does; so nothing of this process's + * own is released unless the caller names the command. */ let activeSandboxCount = 0 @@ -889,25 +887,21 @@ function registerExitCleanupHandler(): void { * ghost dotfiles (e.g. .bashrc, .gitconfig) from appearing in the working * directory. It is also called on process exit as a safety net. * - * What it may take away, of what THIS process made: - * - with no `commandId`, nothing until it has been called once for every wrap - * handed out: a command wrapped and not yet started still needs its - * manifest and its --args profile, and nothing here can tell which command - * the call is for. So one long-running command holds back the clean-up of - * the ones that finished beside it; - * - with the `commandId` its wrap was given, what that one command relied on, - * at once, whatever else is still running; - * - with `force`, everything: the process is ending, or the session is. That - * includes the manifest directory itself where it is one only this process - * knows, once nothing is left in it. + * What it releases of what THIS process made: * - * In every case a mount point goes only when no running sandbox relies on it, - * which the kernel answers and not this process (see bwrap-mount-manifests.ts), - * and what other srt processes have finished with goes too. A call too many - * takes nothing from a running sandbox; it does count for some other wrap of - * this process, and if that wrap's command has not started by the time the - * count is back at zero it then refuses to start. Does nothing except on - * Linux. + * - with no `commandId`: nothing until it has been called once for every wrap + * handed out, since nothing can tell which command the call is for. One + * long-running command therefore holds back the clean-up of those that + * finished beside it; + * - with the `commandId` its wrap was given: what that command relied on, at + * once; + * - with `force`: everything, because the process or session is ending. + * + * A mount point goes only when no running sandbox relies on it, which the + * kernel answers (see bwrap-mount-manifests.ts). A call too many takes nothing + * from a running sandbox, but counts for another wrap of this process, whose + * command then refuses to start if it has not started by the time the count + * reaches zero. Does nothing except on Linux. */ export function cleanupBwrapMountPoints(opts?: { force?: boolean @@ -1628,14 +1622,10 @@ async function generateFilesystemArgs( const args: string[] = [] // fs already imported - // The mount points on the host this wrap relies on: the placeholders bwrap - // makes for absent deny paths, and the ones another sandbox made that this - // wrap covers again. They are named in a manifest below, and nothing removes - // them while a sandbox that named them runs (see bwrap-mount-manifests.ts). + // The mount points on the host this wrap relies on: placeholders bwrap makes + // for absent deny paths, and another sandbox's that this wrap covers again. const mountPoints: string[] = [] - // The mount points the manifests name, read once per wrap. The ones a - // manifest says a running sandbox relies on are mount points whatever their - // mode on the host says. + // What the manifests name, read once per wrap. let namedMountPointsCache: ReturnType | undefined // Collect normalized allowed write paths. Populated in the writeConfig @@ -2358,43 +2348,23 @@ async function generateFilesystemArgs( } // A mount point another sandbox made is an absent deny path in all but - // name. Bound onto itself as an existing path it would never be named in - // this wrap's manifest, so it would go, with this sandbox still bound - // over it, as soon as the sandbox that made it was cleaned up after. - // Only a manifest says that a path is one. An empty read-only file - // looks like anyone's, and so does an empty directory: taken for a - // leftover by its shape alone, a file its owner keeps empty and - // read-only was covered, named, and removed after the command. What no - // manifest names is the caller's, bound onto itself further down like - // any existing path and never removed, which leaves on the host what a - // killed process of a release that wrote no manifest left there. Any - // manifest will do, a finished one as much as a live one: a finished - // manifest is what a killed process leaves, the next clean-up in any - // process removes what it names on its word unless a live manifest - // names it too, and this wrap's is then the only one that can. What is - // done with a path a manifest names depends on its kind: - // - a FILE that has the shape bubblewrap leaves (see - // isBwrapFileMountPoint), or, where the manifest is live, that is - // still an empty regular file. It is covered with /dev/null like the - // absent leaf below, and named. Under a read-only denied directory - // nothing needs covering (the file is already unwritable there), but - // it is still named: it is no more the caller's file for being there; - // - a DIRECTORY, which is the first missing component of some other - // sandbox's deny. /dev/null cannot be bound over a directory - // (bubblewrap refuses to start), so it is named and then treated as - // the existing directory it is, further down: bound onto itself - // read-only, which is also what the pre-pass above recorded it as; - // - anything else (a file that has been written to since, a link) is - // the caller's by now, and is neither. - // The shape is asked before the existence: another process's collect can - // take a mount point away at any moment, and a path found to exist and - // then found not to have the shape because it has just gone would be - // bound onto itself as a file that is not there. And once more after it, - // when the two disagree: another sandbox starting on the same path makes - // its mount point at any moment too. + // name: bound onto itself it would not be named in this wrap's manifest + // and would go, with this sandbox still bound over it, when its maker is + // cleaned up after. Only a manifest says that a path is one; what no + // manifest names is the caller's own and is never removed. For a path a + // manifest names: + // + // - a FILE with the shape bubblewrap leaves (or, under a live manifest, + // any empty regular file) is covered with /dev/null and named; + // - a DIRECTORY is named and then bound onto itself read-only further + // down, since /dev/null cannot be bound over a directory; + // - anything else is the caller's by now. + // + // The shape is asked before the existence, and once more when the two + // disagree: another process can remove or make a mount point at any + // moment. let hasFileShape = isBwrapFileMountPoint(normalizedPath) - // Whether anything is at the path, asked once for the branches below, - // and once more where the manifests say the file is nobody's. + // Asked once for the branches below. let pathExists = hasFileShape || fs.existsSync(normalizedPath) if (pathExists && !hasFileShape) { hasFileShape = isBwrapFileMountPoint(normalizedPath) @@ -2403,17 +2373,11 @@ async function generateFilesystemArgs( isWithinAnyAllowedWritePath(path.dirname(normalizedPath)) || isWithinAnyAllowedWritePath(normalizedPath) ) { - // The manifests are read once per wrap, and a reading that names the - // path is believed whenever it was taken. One that does not name a - // file of that shape is believed only if it was taken after the file - // was seen: a sandbox publishes its manifest before bubblewrap makes - // the file, so such a reading names every mount point that is there, - // and an older one misses that of a sandbox that has started since, - // which would be bound onto itself as the caller's own file, named by - // this wrap nowhere, and gone from under it at that sandbox's - // clean-up. A file of that shape which such a reading does not name - // is the caller's, or has gone since it was seen, its manifest with - // it: whether it is still there is asked again. + // A reading that names the path is believed whenever it was taken. One + // that does not name a file of that shape is believed only if taken + // after the file was seen: a sandbox publishes its manifest before + // bubblewrap makes the file, so an older reading would miss a sandbox + // that started since. let named = pathExists ? namedMountPointsCache : undefined if ( pathExists && @@ -2791,14 +2755,11 @@ async function generateFilesystemArgs( // the mount sits. Deny binds, tmpfs units and masks are emitted later and // land on top of both pins and covers. // - // The directories of this library's own that are bound read-only at the end - // of this function are seeds as well: the manifest directories, the store of - // fake files and the empty directory the placeholders bind from. What is - // found at each of those names is believed on the host, by the next - // clean-up or the next wrap, and the read-only bind keeps a command out of - // the directory only for as long as the name leads to it: with the temp dir - // strictly inside a write root, a command could rename the temp dir aside - // and make the name again, with a manifest directory of its own in it. + // This library's own read-only directories are seeds as well (the manifest + // directories, the fake-file store, the empty placeholder source): a + // read-only bind protects one only while its name still leads to it, and + // with the temp dir strictly inside a write root a command could otherwise + // rename the temp dir aside and make the name again. const manifestDirs = writeConfig !== undefined ? mountPointManifestDirectories( @@ -2950,37 +2911,23 @@ async function generateFilesystemArgs( } } - // Name the mount points this wrap relies on, before the sandbox that relies - // on them can start, and have bwrap hold a lock on the manifest for exactly - // as long as that sandbox runs: the lock is what tells every process, this - // one included, that these mount points are still in use. Mount points that - // could not be recorded are left on the host, since what this process cannot - // record, no process may remove. + // Name the mount points this wrap relies on before the sandbox can start, and + // have bwrap hold a lock on the manifest for as long as it runs. Mount points + // that could not be recorded are left on the host. // // INVARIANT: a manifest directory must never be writable from inside a - // sandbox — a command that could delete a manifest could make the mount - // points a running sandbox relies on look unused, and one that could write - // a manifest could name any path at all for a collect on the host to - // remove. That holds for a sandbox whose own wrap names no manifest as much - // as for one that does, and for the directory some other process of this - // user keeps its manifests in as much as for this one's: one of them sits - // under /tmp whether or not this process writes there, where a caller's - // allowWrite commonly reaches, and what is in it then belongs to OTHER - // sandboxes. So every wrap that restricts writes binds every one of - // them read-only (see mountPointManifestDirectories), with what lies above - // them pinned (they are seeds of the ancestor pins), and a wrap with mount - // points of its own adds the lock. Emitted after every allow and deny bind, - // for the same reason as the two stores below (they are all directories of - // their own, so their order among themselves does not matter). It is also - // what lets bwrap open the manifest: it does that inside the sandbox, after - // the mounts, so a profile with a tmpfs over the temp dir would otherwise - // leave nothing there to open. + // sandbox. A command that could delete a manifest could make a running + // sandbox's mount points look unused, and one that could write a manifest + // could name any path for a collect on the host to remove. This holds for + // every wrap that restricts writes, whether or not it names a manifest, and + // for every directory some process of this user keeps manifests in, so each + // is bound read-only (see mountPointManifestDirectories) with what lies above + // it pinned. Emitted after every allow and deny bind, like the two stores + // below. // - // Each is bound where it really is, like a pin, and not at the name it - // was worked out as: bubblewrap makes a bind's destination by name - // inside the new root before it mounts, and a link on the way whose target - // is absolute leads nowhere from there ("Can't mkdir"), so with the temp dir - // reached through such a link no command that restricts writes started. + // Each is bound where it really is, not at the name it was worked out as: + // bubblewrap makes a bind's destination by name inside the new root, and a + // link with an absolute target on the way leads nowhere from there. const manifest = publishMountPointManifest( mountPoints, emptySource === undefined ? [] : [emptySource], @@ -3441,9 +3388,8 @@ export async function wrapCommandWithSandboxLinux( return wrappedCommand } catch (error) { - // No command came of this wrap, so no sandbox can be running under its - // manifest and the mount points it named are no one's, and the caller has - // nothing to clean up after. + // No command came of this wrap, so no sandbox runs under its manifest and + // the mount points it named are no one's. if (manifest !== undefined) { discardMountPointManifest(manifest.file) } diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 3ac05bc34..438e3fe0c 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -2077,14 +2077,12 @@ function updateConfig(newConfig: SandboxRuntimeConfig): void { * when protecting non-existent deny paths (e.g. ~/.bashrc, ~/.gitconfig). * These persist after bwrap exits. This function removes them. * - * Call it once for each wrapped command, when that command is over. Two - * commands of one process may be in flight at once, and a call cannot tell - * which of them it is for, so with no argument nothing this process made is - * taken away until it has been called once for every wrap handed out: the - * command that has not started yet still needs what its wrap prepared. Pass - * the `commandId` the wrap was given (`WrapWithSandboxOptions.commandId`) to - * have that one command's mount points go at once, whatever else is running, - * which matters when some command runs for a long time. + * Call it once for each wrapped command, when that command is over. With no + * argument nothing this process made is removed until it has been called + * once for every wrap handed out, because a call cannot tell which command + * it is for. Pass the `commandId` the wrap was given + * (`WrapWithSandboxOptions.commandId`) to have that command's mount points + * removed at once, which matters when another command runs for a long time. * * Safe to call on any platform: it does nothing except on Linux. * Also called automatically by reset() and on process exit as safety nets. diff --git a/test/cli.test.ts b/test/cli.test.ts index c5be11f20..854c11282 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -14,9 +14,8 @@ import { isolatedProgram } from './helpers/private-manifest-directory.js' let cliPath: string | undefined /** - * Get the path to the CLI entry point: the tool itself, started with - * directories of the run's own in place of the ones it keeps its mount point - * manifests in, which are every other srt process's of the user. + * Get the path to the CLI entry point, started with directories of the run's + * own in place of the ones the tool keeps its mount point manifests in. */ function getCliPath(): string { return (cliPath ??= isolatedProgram(join(process.cwd(), 'src', 'cli.ts'))) diff --git a/test/control-fd.test.ts b/test/control-fd.test.ts index 2e7f0fd73..2a10cc34d 100644 --- a/test/control-fd.test.ts +++ b/test/control-fd.test.ts @@ -12,9 +12,8 @@ import { type Writable } from 'stream' import { isLinux, isWindows } from './helpers/platform.js' import { isolatedProgram } from './helpers/private-manifest-directory.js' -// Get the path to the built CLI: the tool itself, started with directories of -// the run's own in place of the ones it keeps its mount point manifests in, -// which are every other srt process's of the user. +// Get the path to the built CLI, started with directories of the run's own in +// place of the ones the tool keeps its mount point manifests in. const CLI_PATH = isolatedProgram(path.join(process.cwd(), 'dist', 'cli.js')) // srt is expected to exit on its own shortly after the wrapped command diff --git a/test/helpers/private-manifest-directory.ts b/test/helpers/private-manifest-directory.ts index 2180c3373..caef055b4 100644 --- a/test/helpers/private-manifest-directory.ts +++ b/test/helpers/private-manifest-directory.ts @@ -20,31 +20,25 @@ const REPOSITORY = join(import.meta.dir, '../..') const SEAM = join(REPOSITORY, 'src/sandbox/bwrap-mount-manifests.ts') /** - * The manifests of every srt process of a user live in a few directories that - * are worked out from the user id: `/run/user/UID/srt-mount-points` and - * `/tmp/srt-mount-points-UID`, and after those what the environment names. - * Every wrap makes and binds them, and every clean-up reads, judges and - * removes what is in them. A test that did so in the real ones would be doing - * it to whatever else that user is running on the machine, and be failed by - * it in turn, and nothing in the environment moves a process away from them. - * So no test looks there: + * The manifests of every srt process of a user live in a few directories worked + * out from the user id (`/run/user/UID/srt-mount-points`, + * `/tmp/srt-mount-points-UID`) and after those in what the environment names. + * Every wrap makes and binds them and every clean-up reads and removes what is + * in them, so a test that used the real ones would act on whatever else the + * user is running. No test looks there: * * - in the test process, directories of the test's own stand in for all four - * places, through the seam the module has for it - * ({@link isolateManifestDirectoriesForTheRun} for every test file of the - * run, {@link usePrivateManifestDirectory} for a `describe` that wants them - * to itself); - * - a child process that loads the library from a script is given a module to - * load that puts the same stand-ins in place first ({@link isolatedModule}), - * and one that runs the command line tool a program that does - * ({@link isolatedProgram}); - * - a child process that is to work the directories out for itself, from the - * real names, runs in a mount namespace of its own in which the stand-ins - * are bound over `/tmp` and `/run/user` ({@link inPrivateNamespace}). + * places ({@link isolateManifestDirectoriesForTheRun} for the run, {@link + * usePrivateManifestDirectory} for one `describe`); + * - a child process that loads the library is given a module that puts the same + * stand-ins in place first ({@link isolatedModule}), and one that runs the + * command line tool a program that does ({@link isolatedProgram}); + * - a child process that is to work the directories out for itself runs in a + * mount namespace in which the stand-ins are bound over `/tmp` and + * `/run/user` ({@link inPrivateNamespace}). * - * All of it is bubblewrap's, which is Linux's: on any other platform the - * library keeps no manifest anywhere, nothing is put in the place of - * anything, and a child process is given the module or the program itself. + * Off Linux the library keeps no manifest, nothing is replaced, and a child + * process is given the module or the program itself. */ type Places = ManifestDirectoryPlaces @@ -61,11 +55,10 @@ type PrivatePlaces = { const bases = new Set() /** - * Directories of the caller's own to stand in for the four places. The - * runtime directory is named after the user id under its parent, as the real - * one is, so that the parent can be bound over `/run/user`. Nothing stands in - * for `$XDG_RUNTIME_DIR`, which is as if it were not set, and the temp dir - * the environment names is the one that stands in for `/tmp`. + * Directories of the caller's own to stand in for the four places. The runtime + * directory is named after the user id under its parent, so the parent can be + * bound over `/run/user`. Nothing stands in for `$XDG_RUNTIME_DIR`, and the + * temp dir the environment names is the one that stands in for `/tmp`. */ function makePlaces(): PrivatePlaces { const base = realpathSync(mkdtempSync(join(tmpdir(), 'srt-test-places-'))) @@ -104,14 +97,11 @@ function sourceOf(places: Partial): string { let filesMade = 0 /** - * A file for a child process to load in place of `target`, which puts - * `places` in place and then is `target`: a module of the sources, which it - * gives again, or the command line tool, from the sources or as it was built, - * which it runs. The tool's seam is the one beside it. - * - * The stand-ins are in place before the child does anything with what it has - * loaded. A module's imports are evaluated before its own body, and the body - * is the call to the seam; a program starts work as it is loaded, so it is + * A file for a child process to load in place of `target`, which puts `places` + * in place and then is `target`: a module, which it gives again, or the command + * line tool, which it runs. The stand-ins are in place before the child uses + * what it loaded: a module's imports are evaluated before its own body, and the + * body is the call to the seam; a program starts work as it is loaded, so it is * loaded after the call. */ function isolatedFile( @@ -152,10 +142,9 @@ function isolatedFile( let placesOfTheRun: PrivatePlaces | undefined /** - * Puts stand-ins in place for the whole run: most suites wrap a command - * somewhere, and none of them is to make, bind or collect in the user's real - * directories for it. The test runner's preload does it before any test file - * is loaded, and takes the stand-ins away after the last one + * Puts stand-ins in place for the whole run, so no suite makes, binds or + * collects in the user's real directories. The test runner's preload calls it + * before any test file is loaded and removes the stand-ins after the last one * ({@link removeManifestDirectoriesOfTheRun}). */ export function isolateManifestDirectoriesForTheRun(): void { @@ -184,9 +173,8 @@ function ofTheRun(): PrivatePlaces { /** * The path of a module for a child process to load in place of `module`, with - * the stand-ins of the run in place, or with `places` where the child is to - * look somewhere else. A place that is left out of `places` is the real one - * for that child: what its environment says, for the last two. + * the stand-ins of the run in place, or `places` instead. A place left out of + * `places` is the real one for that child. */ export function isolatedModule( module: string, @@ -200,9 +188,9 @@ export function isolatedModule( } /** - * The path of a program for a child process to run in place of `program`, the - * command line tool as it is in the sources or as it was built, with the - * stand-ins of the run in place. + * The path of a program for a child process to run in place of `program` (the + * command line tool, from the sources or as built), with the stand-ins of the + * run in place. */ export function isolatedProgram(program: string): string { if (!onLinux) { @@ -213,16 +201,14 @@ export function isolatedProgram(program: string): string { } /** - * The start of a command line that runs what follows it in a mount namespace - * of its own, in which `places.tempDir` is bound over `/tmp` and the parent of - * `places.runtimeDir` over `/run/user`: for a child process that is to work - * the directories out for itself, from the real names, without reaching the - * real ones. Whatever is in `keep` is bound back where it is, for what the - * child needs that lies under the real `/tmp`, and so are this repository and - * the runtime where they lie there. No PID namespace, so `/proc/locks` and - * the pids in it are the test's own. - * - * Needs a bubblewrap that can make a namespace (see `bwrapCanNamespace`). + * The start of a command line that runs what follows it in a mount namespace of + * its own, in which `places.tempDir` is bound over `/tmp` and the parent of + * `places.runtimeDir` over `/run/user`: for a child process that is to work the + * directories out for itself without reaching the real ones. Whatever is in + * `keep` is bound back where it is, as are this repository and the runtime + * where they lie under the real `/tmp`. No PID namespace, so `/proc/locks` and + * its pids are the test's own. Needs a bubblewrap that can make a namespace + * (see `bwrapCanNamespace`). */ export function inPrivateNamespace( places: Pick, @@ -250,18 +236,15 @@ export function inPrivateNamespace( /** * Gives the enclosing `describe` directories of its own for the mount point - * manifests, for as long as its tests run: a test that lists the manifests, - * attacks them from inside a sandbox or asserts on what is left of them needs - * to be the only one that writes there. + * manifests, for as long as its tests run: a test that lists or attacks the + * manifests needs to be the only one that writes there. * * It also starts the `describe` with no wrap of this process outstanding, and - * leaves it so. The library counts the wraps it has handed out and releases - * nothing of this process's until each has been cleaned up after; the count is - * the module's, shared by every test file of one run, and other suites wrap - * without cleaning up. A test that expects one clean-up to take its mount point - * away would otherwise pass or fail by which file ran before it. + * leaves it so: the library's count of wraps is shared by every test file of a + * run, and other suites wrap without cleaning up. * * Call it inside the `describe` callback. Returns: + * * - `manifestDir()`, where this process's manifests go: under the runtime * directory, the first place looked in; * - `places()`, the stand-ins themselves; diff --git a/test/preload.ts b/test/preload.ts index a6c4ef0be..5349887f8 100644 --- a/test/preload.ts +++ b/test/preload.ts @@ -4,9 +4,8 @@ import { removeManifestDirectoriesOfTheRun, } from './helpers/private-manifest-directory.js' -// Before any test file is loaded, so that no wrap and no clean-up of the run -// reaches the directories the user's own sandboxes keep their manifests in. A -// hook here belongs to the run and not to a file: the stand-ins go after the -// last test file. +// Before any test file is loaded, so no wrap or clean-up of the run reaches the +// user's real manifest directories. The stand-ins are removed after the last +// test file. isolateManifestDirectoriesForTheRun() afterAll(removeManifestDirectoriesOfTheRun) diff --git a/test/sandbox/linux-bwrap-args-file.test.ts b/test/sandbox/linux-bwrap-args-file.test.ts index 851f519b0..59ff45c7f 100644 --- a/test/sandbox/linux-bwrap-args-file.test.ts +++ b/test/sandbox/linux-bwrap-args-file.test.ts @@ -396,8 +396,7 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { it('keeps a pending profile through the clean-up after another command, and gives it back with the last', () => { // A profile belongs to one wrap and nothing maps it back to a command, so - // none is closed while any wrap of this process is outstanding: the one - // that has not started yet reopens its profile through /proc when it does. + // none is closed while any wrap of this process is outstanding. const seen = isolated(` const openFds = () => fs.readdirSync('/proc/self/fd').length // The runtime opens event-loop fds of its own on the first wrap. diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index 2ce6447ac..cdeada3f0 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -36,18 +36,11 @@ import { } from '../helpers/private-manifest-directory.js' /** - * A denyWrite path that does not exist is blocked by binding /dev/null over - * it, and bwrap makes the mount point for that bind on the host. Unlinking one - * while a sandbox is still bound over it detaches the mount inside that - * sandbox: the denied path can be created there, and what is written lands on - * the host. Whether a sandbox is still running is therefore not something a - * process may decide for itself — a second srt process, a caller that crashes, - * one that cleans up early, twice or never, each had a different answer, and a - * count kept in one process's memory could not see any of them. - * - * Every wrap now names the mount points it relies on in a manifest and has - * bwrap hold a lock on it for the sandbox's lifetime, so the kernel answers - * instead. + * A denyWrite path that does not exist is blocked by binding over a mount point + * bwrap makes on the host. Unlinking one while a sandbox is bound over it + * detaches the mount inside that sandbox, and the denied path can then be + * created on the host. So whether a sandbox still runs is asked of the kernel: + * every wrap names its mount points in a manifest that bwrap holds a lock on. */ describe.if(isLinux)('A mount point a running sandbox relies on', () => { // These tests list the manifests, attack them from inside a sandbox and @@ -157,8 +150,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { /** * The manifest of a wrap that named `paths`, written a minute ago by - * `writer`: a process that is gone, which makes it a finished one, unless - * told otherwise. + * `writer`: by default a process that is gone, which makes it a finished one. */ function manifestNaming( paths: string[], @@ -211,7 +203,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { expect(lstatSync(LOCK).size).toBe(0) // A second process wraps the same deny path, runs a command and cleans - // up. On the counter this took the running sandbox's mount point away. + // up. const second = spawnSync( process.execPath, [ @@ -257,10 +249,9 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { await waitFor(SIGNAL) cleanupBwrapMountPoints() cleanupBwrapMountPoints() - // `force` is what the exit handler and reset() pass: everything this - // process made may go. A sandbox that is running under a manifest is - // not this process's to end, so that manifest and what it names stay, - // for a later cleanup here or in any other process. + // `force` is what the exit handler and reset() pass. A sandbox running + // under a manifest is not this process's to end, so that manifest and + // what it names stay. cleanupBwrapMountPoints({ force: true }) expect(lstatSync(LOCK).size).toBe(0) expect(existsSync(manifestOf(command))).toBe(true) @@ -325,9 +316,9 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { it.if(BWRAP_CAN_NAMESPACE)( 'stays while a second sandbox that denies it runs, after the one it was made for has ended and been cleaned up after', async () => { - // The second wrap finds the file there. It is a mount point because a - // manifest names it, so the wrap covers it and names it too, and its - // own manifest keeps it once the first sandbox's is gone. + // The second wrap finds the file there. A manifest names it, so the wrap + // covers and names it too, and its own manifest keeps it once the first + // is gone. const upSecond = join(AREA, 'up-second') const goSecond = join(AREA, 'go-second') const holder = spawn( @@ -384,13 +375,10 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { async () => { // Two processes as they really are, each working the directories out // for itself from the real names, in a mount namespace where this - // test's stand-ins are what is at those names. They differ in what - // the environment says, as a login shell and what a service or an IDE - // starts do, and each kept its manifests where its own environment - // said: the second wrap took the first's mount point for the user's - // own file, and it went from under the second sandbox when the first - // process cleaned up. The two that differ in their temp dir have no - // runtime directory, and meet under /tmp. + // test's stand-ins are at those names. They differ in what the + // environment says, as a login shell and what a service or an IDE + // starts do. The two that differ in their temp dir have no runtime + // directory, and meet under /tmp. const noRuntimeDirectory = join(BASE, 'run-user') mkdirSync(noRuntimeDirectory) const launcher = inPrivateNamespace( @@ -543,11 +531,10 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { }) it('is not taken for one on the word of a manifest where the environment says, by a process that can use a directory that comes from the user id', async () => { - // What the environment names is out of reach of the sandboxes of the - // processes with that environment, and of no others. While this process - // has a directory every process of the user looks in, what is found - // there is nobody's word: the file is the user's own, empty and - // read-only as it is. + // What the environment names is out of reach only of the sandboxes of + // processes with that environment. While this process has a directory every + // process of the user looks in, what is found there is not believed: the + // file is the user's own. const xdg = join(BASE, 'xdg') mkdirSync(xdg) chmodSync(xdg, 0o700) @@ -581,13 +568,11 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { 'is not removed on the word of a manifest that the sandbox of a process with another temp dir wrote under this one', () => { // Two processes as they really are, in a mount namespace where this - // test's stand-ins are at the real names. The first has a temp dir of - // its own, inside what the second lets its command write. The second - // does not know of that temp dir, so its sandbox has nothing bound - // there, and its command writes a manifest under it that names - // somebody's empty read-only file. The first has a directory under - // /tmp to keep its manifests in, and believes nothing under its temp - // dir. + // test's stand-ins are at the real names. The first has a temp dir of its + // own, inside what the second lets its command write, so the second's + // command can write a manifest there that names somebody's empty + // read-only file. The first keeps its manifests under /tmp and believes + // nothing under its temp dir. const uid = process.getuid!() const HOME = join(BASE, 'home') const theirTmp = join(HOME, 'tmp') @@ -705,11 +690,10 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { it.if(BWRAP_CAN_NAMESPACE && existsSync('/run/user'))( 'cannot be made, written or moved aside under /tmp by a command that may write /tmp, while its process writes under the runtime directory', () => { - // The directory under /tmp is where a process without a runtime - // directory writes, and every process reads it. A command that may write - // /tmp runs as the same user: a directory it made there itself would - // pass every check of owner and mode, and what it put in it would be - // believed. So the wrap makes it first, and binds it read-only. + // The directory under /tmp is where a process without a runtime directory + // writes, and every process reads it. A command that may write /tmp runs + // as the same user, so a directory it made there would pass every check + // of owner and mode. So the wrap makes it first, and binds it read-only. const uid = process.getuid!() const theirs = `/tmp/srt-mount-points-${uid}` const onTheHost = join(runtime.places().tempDir, basename(theirs)) @@ -851,11 +835,9 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { it.if(BWRAP_CAN_NAMESPACE)( 'are read-only in a sandbox that names no manifest of its own', async () => { - // A wrap with no absent deny path has no mount points and no manifest, - // and the manifests in the directory are then other sandboxes'. The - // collect that runs on the host believes them, so a command able to - // write one could have any empty file removed, and one able to delete - // one could make a running sandbox's mount points look unused. + // A wrap with no absent deny path has no manifest, and the manifests in + // the directory are then other sandboxes'. A collect on the host believes + // them, so the command must be able neither to write nor to delete one. const directory = dirname(manifestOf(await wrap('true'))) const before = readdirSync(directory).filter(n => n.endsWith('.json')) expect(before.length).toBeGreaterThan(0) @@ -894,13 +876,11 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { it.if(BWRAP_CAN_NAMESPACE)( 'are read-only, too, in the directory under the temp dir, for a process that writes under the runtime directory', async () => { - // A process with a runtime directory keeps its manifests under it, and - // one without (in a container, under another user's `su`) under the - // temp dir, and every process reads both. A sandbox of the first kind - // that may write the temp dir could delete and forge the manifests of - // the second, which a collect on the host then believed. So every wrap - // binds every name read-only, and makes the one that is not there so - // that the command cannot make it first. + // A process with a runtime directory keeps its manifests under it, one + // without (in a container, under another user's `su`) under the temp dir, + // and every process reads both. Every wrap binds every name read-only and + // makes the one that is missing, so a sandbox of the first kind cannot + // forge the manifests of the second. const TMP = join(BASE, 'tmp') mkdirSync(TMP) const theirs = join(TMP, `srt-mount-points-${process.getuid!()}`) @@ -984,8 +964,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { timeout: 60000, }) // bubblewrap sets the mounts up before it opens the lock file, so the - // mount point is back on the host; the command it was made for never - // runs. + // mount point is back on the host; the command never runs. expect(result.status).not.toBe(0) expect(`${result.stdout}${result.stderr}`).toContain( 'Unable to open lock file', @@ -995,13 +974,11 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { 90000, ) - // ---- two commands of one process in flight ----------------------------- + // ---- two commands of one process in flight ---- // - // The caller cleans up once per command, when that command is over, and a - // call cannot tell which command it is for. So while a wrap is outstanding - // nothing this process made may go: the command that has not started yet - // still needs its manifest (bubblewrap opens it to take the lock) and its - // mount point. + // A call to clean up cannot tell which command it is for, so while a wrap is + // outstanding nothing this process made may go: the command that has not + // started still needs its manifest and its mount point. it.if(BWRAP_CAN_NAMESPACE)( 'a command wrapped and not yet started survives the clean-up after the one that finished first', @@ -1037,11 +1014,9 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { it.if(BWRAP_CAN_NAMESPACE)( 'a sandbox that takes its lock while another command is being cleaned up after keeps its manifest and its deny', async () => { - // The interleaving that chance produces when one command ends a few - // milliseconds after another was spawned, made certain: the collect - // looks at /proc/locks, where the second command's manifest is not yet - // locked, and before it goes on that command's sandbox comes up and - // takes the lock. + // The interleaving made certain: the collect reads /proc/locks while the + // second command's manifest is not yet locked, and that command's sandbox + // takes its lock before the collect goes on. const first = await wrap('true') const second = await wrap(heldCommand()) const manifest = manifestOf(second) @@ -1122,9 +1097,8 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { expect(run(first).status).toBe(0) expect(existsSync(lockOfFirst)).toBe(true) // bubblewrap leaves it - // One call per command, as always. This one says which command it is - // for, so that command's mount point goes although the second is still - // outstanding, and the second's manifest is untouched. + // This call says which command it is for, so that command's mount point + // goes although the second is still outstanding. cleanupBwrapMountPoints({ commandId: 'first-0123456789abcdef' }) expect(existsSync(lockOfFirst)).toBe(false) expect(existsSync(manifestOf(second))).toBe(true) @@ -1141,9 +1115,8 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { it.if(BWRAP_CAN_NAMESPACE)( 'a wrap that produced no command is not waited for by the clean-up after the next', async () => { - // It gave its count back when it threw: the caller has no command to - // clean up after, and one clean-up after the command that did run is - // all that is owed. + // It gave its count back when it threw, so one clean-up after the command + // that did run is all that is owed. const refused = wrapCommandWithSandboxLinux({ command: 'true', binShell: 'srt-no-such-shell', @@ -1164,10 +1137,9 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { ) it("takes a path a live manifest names for the caller's own file once something has been written to it", async () => { - // Live: written by this process, which has not let go of it. The path - // was a mount point when that manifest was written; a file with content - // is nothing bubblewrap made, and /dev/null over it would hide it from - // the command it belongs to. + // Live: written by this process, which has not let go of it. A file with + // content is nothing bubblewrap made, and /dev/null over it would hide it + // from the command it belongs to. const first = await wrap('true') expect(first).toContain(`--ro-bind /dev/null ${LOCK}`) writeFileSync(LOCK, 'somebody wrote this\n') @@ -1179,11 +1151,8 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { }) it('takes for a mount point one that another sandbox makes while the wrap is looking at the path', async () => { - // Absent when the wrap asked what shape it has, there when it asked - // whether anything is: another sandbox started on the same path in - // between, its manifest published and its mount point made. Bound onto - // itself as the caller's own file it was named by this wrap nowhere, and - // went from under its sandbox at the other's clean-up. + // Absent when the wrap asked for its shape, there when it asked whether + // anything is: another sandbox started on the same path in between. const exists = fs.existsSync let made = false const spy = spyOn(fs, 'existsSync').mockImplementation(((file: unknown) => { @@ -1357,9 +1326,8 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { `} catch {`, ` writeFileSync(${JSON.stringify(OUT)}, 'threw')`, `}`, - // Stays alive: a manifest of a process that is still running - // counts as live, so only letting go of it lets the mount point be - // collected here. + // Stays alive: a manifest of a running process counts as live, so + // only letting go of it lets the mount point be collected here. `await new Promise(resolve => setTimeout(resolve, 30000))`, ].join('\n'), ), @@ -1369,9 +1337,8 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { try { await waitFor(OUT) expect(readFileSync(OUT, 'utf8')).toBe('threw') - // This process names the same mount point and asks for the cleanup. Had - // the wrap that threw held on to its manifest, the process behind it - // being alive would have kept the mount point here. + // This process names the same mount point and asks for the cleanup. A + // wrap that threw must not have held on to its manifest. await wrap('true') cleanupBwrapMountPoints() expect(existsSync(LOCK)).toBe(false) @@ -1383,18 +1350,13 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { /** * The one case where nothing but the kernel's lock keeps a mount point: the - * process that wrapped the command is GONE, and the sandbox it started is - * STILL RUNNING. The manifest's writer no longer vouches for it, the grace - * period has run out, and no count in anyone's memory knows about it; only - * bubblewrap's --lock-file, seen through /proc/locks, says the sandbox is - * there. Every test above has the wrapping process alive, which alone keeps + * process that wrapped the command is gone and the sandbox it started is still + * running. Every test above has the wrapping process alive, which alone keeps * the manifest live. * - * The wrap always asks for --die-with-parent, so a sandbox outlives the - * process that wrapped it only when something else is bubblewrap's parent: - * here a shell in a session of its own, as when a wrapped command is handed to - * a job runner, a terminal multiplexer or `nohup sh -c`, or when the wrapping - * process is killed and the `sh -c` it spawned the command through is not. + * The wrap always asks for --die-with-parent, so a sandbox outlives its wrapper + * only when something else is bubblewrap's parent: here a shell in a session of + * its own, as under a job runner, a terminal multiplexer or `nohup sh -c`. */ describe.if(isLinux)( 'A mount point whose sandbox outlives the process that wrapped it', @@ -1518,9 +1480,8 @@ describe.if(isLinux)( } /** - * Says it is up, waits to be told, tries the denied write, reports what - * that returned, and then STAYS: the sandbox is still running when it is - * killed further down. + * Says it is up, waits to be told, tries the denied write, reports the + * result, and then stays: the sandbox is still running when it is killed. */ function heldCommand(): string { return `echo up > ${SIGNAL}; while [ ! -e ${GO} ]; do sleep 0.05; done; echo pwned > ${LOCK}; echo rc=$? > ${RESULT}; sleep 600` @@ -1528,8 +1489,8 @@ describe.if(isLinux)( /** * A process that wraps the held command, starts it under a shell in a - * session of its own, says who is who, waits for the sandbox to be up and - * then goes: `leave` is how. + * session of its own, waits for the sandbox to be up and then goes: `leave` + * is how. */ function writerScript(leave: string): string { return [ @@ -1605,7 +1566,7 @@ describe.if(isLinux)( /** * Has a process wrap the held command, start it and go, and checks the - * premise: that process is gone, and its sandbox is up and running. + * premise: that process is gone and its sandbox is running. */ async function sandboxWhoseWriterHasGone( leave: string, @@ -1654,9 +1615,8 @@ describe.if(isLinux)( `process.kill(process.pid, 'SIGKILL')`, 'SIGKILL', ], - // Its exit handler runs the cleanup, with `force`, under its own - // sandbox: everything the process made may go, and a sandbox that is - // still running under a manifest is not the process's to end. + // Its exit handler runs the cleanup with `force`: a sandbox still running + // under a manifest is not the process's to end. ['exits, running its exit-time cleanup', `process.exit(0)`, null], ] @@ -1675,11 +1635,9 @@ describe.if(isLinux)( expect(written.pid).toBe(info.writer) expect(written.paths).toContain(LOCK) - // Whether the mount point bwrap made is on the host, and the - // manifest that names it where the wrap put it, after each thing - // that might have taken them. Looked at together further down, so - // that a failure says all of what happened and not only the first - // of it. + // Whether the mount point and the manifest that names it are still + // there after each thing that might have taken them. Checked together + // further down, so a failure says all of what happened. const BOTH = 'the mount point and its manifest' const stayed: Record = {} const look = (when: string): void => { @@ -1708,11 +1666,10 @@ describe.if(isLinux)( cleanupBwrapMountPoints({ force: true }) look('after this process collected, twice') - // The sandbox is still there. Let it try the denied write: it must - // fail inside, and nothing may land on the host. (A mount point - // that was removed and made again by the second wrap is on the host - // again, but is no longer what this sandbox is bound over, so only - // the write tells.) + // The sandbox is still there. Its denied write must fail inside, and + // nothing may land on the host. (A mount point removed and made again + // by the second wrap is on the host but is no longer what this + // sandbox is bound over, so only the write tells.) expect(runningTree(info.launcher).length).toBeGreaterThan(1) expect({ stayed, ...(await theDeniedWrite()) }).toEqual({ stayed: { @@ -1731,9 +1688,8 @@ describe.if(isLinux)( expect(runScript('collector.ts', collectorScript())).toBe(0) expect(existsSync(LOCK)).toBe(true) - // Kill the sandbox. However it ends the kernel drops the lock, and - // the next collect, by anyone, takes the mount point and the - // manifest away. + // Kill the sandbox. The kernel drops the lock, and the next collect + // by anyone removes the mount point and the manifest. await killTree(info.launcher) launcher = undefined expect(runScript('collector.ts', collectorScript())).toBe(0) @@ -1747,12 +1703,10 @@ describe.if(isLinux)( it.if(BWRAP_CAN_NAMESPACE)( 'holds its deny while another process collects without pause, with the process that wrapped it gone', async () => { - // A collector that never has a candidate proves nothing, and with the - // wrapping process alive it never has one: the writer vouches for the - // manifest whatever the kernel says. Here the writer is gone and the - // grace is over before the write is tried, so for most of each round + // A collector that never has a candidate proves nothing. Here the + // writer is gone and the grace is over before the write is tried, so // the lock is all that stands between the collector and the mount - // point, and the collector is asking the whole time. + // point. const loopFile = join(BASE, 'collect-loop.ts') writeFileSync( loopFile, @@ -1798,8 +1752,8 @@ describe.if(isLinux)( /** * The mount point for a deny whose first missing component is not the leaf is - * an empty DIRECTORY, bound from an empty read-only one. On the host it looks - * like anyone's empty directory, so only a manifest says what it is. + * an empty directory. On the host it looks like anyone's, so only a manifest + * says what it is. */ describe.if(isLinux)( 'A directory mount point a running sandbox relies on', @@ -1891,9 +1845,8 @@ describe.if(isLinux)( it.if(BWRAP_CAN_NAMESPACE)( 'can be denied by a second wrap whose deny path is exactly that directory', async () => { - // A live manifest names it, which used to get it /dev/null bound over - // it like a file mount point: bubblewrap cannot put a character device - // over a directory, and the command did not start. + // A live manifest names it. /dev/null cannot be bound over a directory: + // bubblewrap would refuse to start. const up = join(AREA, 'up-holder') const go = join(AREA, 'go-holder') const held = holder(up, go) @@ -1921,12 +1874,10 @@ describe.if(isLinux)( it.if(BWRAP_CAN_NAMESPACE)( 'can be denied again by the same list, with the earlier wrap of this process not yet cleaned up after', async () => { - // No second process and nothing running: a manifest is live for as - // long as the process that wrote it has not let go of it. A deny list - // that names a missing directory and something beneath it makes the - // directory the mount point, and the same list again then finds it - // named by a live manifest. It is what the mandatory denies under - // `/.claude` make of a `denyWrite` on `/.claude` itself. + // No second process: a manifest is live while the process that wrote it + // has not let go of it. A deny list naming a missing directory and + // something beneath it makes the directory the mount point, and the + // same list again finds it named by a live manifest. const first = run(await wrap('echo first-ran', [DIRECTORY, LEAF])) expect(first.stdout).toContain('first-ran') expect(lstatSync(DIRECTORY).isDirectory()).toBe(true) @@ -1951,10 +1902,9 @@ describe.if(isLinux)( it.if(BWRAP_CAN_NAMESPACE)( 'stays while that second sandbox runs, after the sandbox that made it has ended and been cleaned up after', async () => { - // Bound onto itself and not named, it went with the sandbox that made - // it: the bind came off inside the second sandbox, the denied - // directory could be made again there, and what was written under it - // landed on the host. + // Bound onto itself and not named, it would go with the sandbox that + // made it, and the denied directory could be made again inside the + // second sandbox. const upHolder = join(AREA, 'up-holder') const goHolder = join(AREA, 'go-holder') const upSecond = join(AREA, 'up-second') @@ -2001,11 +1951,10 @@ describe.if(isLinux)( 90000, ) - // ---- left by a process that was killed ------------------------------ + // ---- left by a process that was killed ---- // - // Its manifest is finished, not live: the writer is gone, nothing holds - // the lock, the grace is over. The next clean-up in any process removes - // the directory on that manifest's word, so a wrap that denies the + // Its manifest is finished, not live. The next clean-up in any process + // removes the directory on that manifest's word, so a wrap that denies the // directory in the meantime has to name it for itself. /** What a process killed a minute ago left in the manifest directory. */ @@ -2044,17 +1993,16 @@ describe.if(isLinux)( mkdirSync(DIRECTORY) const left = manifestOfAKilledProcess([DIRECTORY]) - // The list that does this: the directory itself, beside something - // beneath it, which needs no mount point of its own under a directory - // that is read-only. So the directory stays empty on the host. + // The directory itself, beside something beneath it, which needs no mount + // point of its own under a read-only directory. const command = await wrap('true', [DIRECTORY, LEAF]) expect(command).toContain(`--ro-bind ${DIRECTORY} ${DIRECTORY}`) expect(command).not.toContain(LEAF) expect(namedBy(command)).toEqual([DIRECTORY]) - // A second command of this process, and the clean-up after it: the - // first is still outstanding, so nothing of this process's is released, - // and what the killed process left is collected. + // A second command of this process and the clean-up after it: the first + // is still outstanding, so nothing of this process's is released, and + // what the killed process left is collected. await wrap('true', []) cleanupBwrapMountPoints() expect(existsSync(left)).toBe(false) @@ -2098,10 +2046,8 @@ describe.if(isLinux)( it.if(BWRAP_CAN_NAMESPACE)( 'stays while a sandbox that denies it runs, with the process that made it killed and another cleaning up', async () => { - // Bound onto itself and not named, it went at the next clean-up in - // any process: the bind came off inside the running sandbox, the - // denied directory could be made again there, and what was written - // under it landed on the host. + // Bound onto itself and not named, it would go at the next clean-up in + // any process, from under the running sandbox. const script = (name: string, lines: string[]): string => { const file = join(BASE, name) writeFileSync(file, lines.join('\n')) @@ -2181,13 +2127,11 @@ describe.if(isLinux)( ) /** - * The manifest directories, the store of fake files and the empty directory - * the placeholders bind from are each bound read-only in a sandbox, and what - * is found at their names is believed on the host. The bind keeps a command - * out of the directory; it does not keep the name leading to it. Where the - * temp dir (or the runtime directory) lies strictly inside a write root, the - * directories between the two are the command's to rename, bind and all, and - * to make again: so they are pinned, like those above a deny path. + * The manifest directories, the fake-file store and the empty placeholder + * source are bound read-only in a sandbox, and what is found at their names is + * believed on the host. The bind keeps a command out of the directory, not the + * name leading to it: where the temp dir lies strictly inside a write root the + * directories between the two are pinned. */ describe.if(isLinux)( 'The directories above the ones this library keeps on the host', @@ -2234,9 +2178,9 @@ describe.if(isLinux)( }) /** - * Runs `lines` in a process of its own whose temp dir is TMP, and which - * has no `XDG_RUNTIME_DIR`. An `env` entry that is undefined is one that - * process does not have. + * Runs `lines` in a process of its own whose temp dir is TMP and which has + * no `XDG_RUNTIME_DIR`. An `env` entry that is undefined is removed from + * its environment. */ function inAChild( name: string, @@ -2333,12 +2277,11 @@ describe.if(isLinux)( }) it('are pinned above every manifest directory, whichever of them the process writes to', () => { - // Four names for four directories, each strictly inside the write - // root: the runtime directory and /tmp, and what the environment has - // for the two. The process writes to the first, and a process of the - // user that writes to one of the others believes what it finds there, - // so the command is kept out of all of them: each is made, bound - // read-only where it is, and what lies above it pinned. + // Four names for four directories, each strictly inside the write root: + // the runtime directory and /tmp, and what the environment has for the + // two. A process of the user may believe what it finds in any of them, so + // the command is kept out of all: each is made, bound read-only where it + // is, and what lies above it pinned. const at = (name: string): string => { const dir = join(HOME, name, 'deeper') mkdirSync(dir, { recursive: true, mode: 0o700 }) @@ -2449,9 +2392,8 @@ describe.if(isLinux)( }) it('are pinned where the temp dir really is, when its name leads there through a link', () => { - // A pin is a mount, and a mount lands where its path resolves. On the - // name it would be a mount on a link, which bubblewrap refuses from - // 0.12 on. + // A pin is a mount, and a mount lands where its path resolves; on the + // name it would be a mount on a link, which bubblewrap refuses. const real = join(HOME, 'real-tmp') const through = join(HOME, 'tmp-link') mkdirSync(real) @@ -2484,10 +2426,9 @@ describe.if(isLinux)( it.if(BWRAP_CAN_NAMESPACE)( 'cannot be moved aside from inside a sandbox, to make the manifest directory again with a manifest of its own', async () => { - // What the command is after: the next clean-up on the host reads the - // directory at the name, finds no live manifest there that claims - // GUARD, and removes it from under the sandbox that relies on it, on - // the word of a manifest the command wrote. + // What the command is after: a clean-up on the host that reads the + // directory at the name, finds no live manifest claiming GUARD, and + // removes it from under the sandbox that relies on it. const theirs = join(TMP, `srt-mount-points-${UID}`) const up = join(HOME, 'up') const go = join(HOME, 'go') @@ -2565,13 +2506,10 @@ describe.if(isLinux)( ) /** - * A bind is made where its destination really is. bubblewrap makes the - * destination by name inside the new root before it mounts on it, and a link - * on the way whose target is absolute leads out of that root, to nothing: - * "Can't mkdir". The manifest directories were bound at the name the - * environment gave for the temp dir, so with a temp dir reached through such - * a link (a `/tmp` that is a link to `/var/tmp`, a `TMPDIR` that is one) no - * command that restricts writes started at all. + * A bind is made where its destination really is: bubblewrap makes the + * destination by name inside the new root, and a link with an absolute target + * on the way leads out of that root. So with a temp dir reached through such a + * link, every manifest directory is bound at its real path. */ describe.if(isLinux)( 'The manifest directory under a temp dir whose name is a link', @@ -2600,9 +2538,9 @@ describe.if(isLinux)( }) /** - * What a process whose temp dir is `through`, as `/tmp` is, and which has - * no runtime directory, wraps `command` as with that deny list, and what - * the command said when run. + * What a process whose temp dir is `through`, as `/tmp` is, with no runtime + * directory, wraps `command` as with that deny list, and what the command + * said when run. */ function wrappedAndRun( through: string, @@ -2778,12 +2716,10 @@ describe.if(isLinux)( ) /** - * Both of a manifest's witnesses are relative to a PID namespace: /proc/PID - * is, and /proc/locks leaves out every lock whose holder has no pid in the - * namespace of the /proc being read. A process that cleans up from another PID - * namespace while sharing the manifest directory - a container beside the - * host, one srt inside another's sandbox - sees a running sandbox's manifest - * exactly as it would see one a dead process left. + * Both of a manifest's witnesses are relative to a PID namespace: /proc/PID, + * and /proc/locks, which leaves out every lock whose holder has no pid there. A + * process cleaning up from another PID namespace that shares the manifest + * directory sees a running sandbox's manifest as it would see a dead process's. */ describe.if(isLinux)( 'A mount point a running sandbox relies on, seen from another PID namespace', @@ -2897,9 +2833,8 @@ describe.if(isLinux)( writeScript('collector.ts', [ `import { existsSync, readFileSync, statSync } from 'node:fs'`, `import { cleanupBwrapMountPoints } from ${LIBRARY}`, - // The premise, so that a pass means something: from here the - // kernel's list really does leave the sandbox's lock out, and - // the writer's pid is nobody's. + // The premise: from here the kernel's list really does leave + // the sandbox's lock out, and the writer's pid is nobody's. `const inode = statSync(${JSON.stringify(manifest)}).ino`, `if (readFileSync('/proc/locks', 'utf8').includes(':' + inode + ' ')) process.exit(7)`, `if (existsSync('/proc/${holder.pid}')) process.exit(8)`, diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index c7591ade0..25438edbd 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -39,8 +39,8 @@ import { usePrivateManifestDirectory } from '../helpers/private-manifest-directo /** * The manifests and the pass that collects on their word, below the level of a * sandbox: what a pass believes, what it looks at again before it removes - * anything, and that nothing about the directory's lock can hold a process up - * or be relied on. + * anything, and that the directory's lock neither holds a process up nor is + * relied on. */ describe.if(isLinux)('The mount point manifests', () => { const runtime = usePrivateManifestDirectory() @@ -141,16 +141,14 @@ describe.if(isLinux)('The mount point manifests', () => { /** * Runs `source` with the module as `m` in a process of its own, which is - * killed if it has not ended by itself: what these cases guard against blocks - * the thread it happens on, so a test in this process could not time it out. - * `first` runs before the module is loaded, and an `env` entry that is - * undefined is one the child does not have. + * killed if it does not end by itself: what these cases guard against blocks + * the thread. `first` runs before the module is loaded, and an `env` entry + * that is undefined is removed from the child's environment. * - * The child looks where this process does for the runtime directory, and - * has a temp dir of its own, which is also what stands in for `/tmp`: it is - * where the manifests go when the runtime directory will not do, and the - * real one is every other process's. `places` puts something else in the - * place of either. It has no `XDG_RUNTIME_DIR` unless `env` gives it one. + * The child looks where this process does for the runtime directory and has a + * temp dir of its own, which also stands in for `/tmp`. `places` puts + * something else in the place of either. It has no `XDG_RUNTIME_DIR` unless + * `env` gives it one. */ function inAChild( source: string, @@ -217,14 +215,12 @@ describe.if(isLinux)('The mount point manifests', () => { const COLLECT = `m.collectMountPoints()` const PUBLISH = `console.log(JSON.stringify(m.publishMountPointManifest(['/nonexistent/x'], []) ?? null))` - // ---- the directory's lock never holds a process up --------------------- + // ---- the directory's lock never holds a process up ---- // - // Whatever is at the lock's name, a collect and a publish both come back: - // by the deadline when it is somebody's lock, and at once when it is nothing - // this process could ever take. Each used to go round for ever, on the one - // thread, in the wrap, in the clean-up after a command and in the exit - // handler. "At once" is held to well under the two seconds somebody's lock - // is waited for: the wait is made four times over by one command. + // Whatever is at the lock's name, a collect and a publish both come back: by + // the deadline when it is somebody's lock, and at once when it is nothing + // this process could ever take. "At once" is held to well under the + // two-second wait, which one command makes four times over. const AT_ONCE_MS = 1500 const notALock: [string, () => void, boolean][] = [ @@ -278,10 +274,9 @@ describe.if(isLinux)('The mount point manifests', () => { it.if(BWRAP_CAN_NAMESPACE && NOT_ROOT)( 'comes back from a collect at once where the directory is read-only from here, and collects nothing on its word', () => { - // What a process inside a sandbox sees of the directory: bound - // read-only, with a lock in it that it could never remove. It keeps its - // own manifests elsewhere, and what is in this one is not for it to - // judge: it could remove the mount point and not the manifest. + // What a process inside a sandbox sees of the directory: bound read-only, + // with a lock in it that it could never remove. It keeps its own + // manifests elsewhere and does not judge what is in this one. const lock = `${deadPid()} 1 ${OWN_NAMESPACE}\n` writeFileSync(LOCK, lock) leftover(X) @@ -327,11 +322,9 @@ describe.if(isLinux)('The mount point manifests', () => { }, 15000) it('believes a lock that names nobody until it is old, and breaks it then', () => { - // An empty lock file is what a lock made in two steps looked like for a - // moment, and a waiter that read it then took it for nobody's and removed - // it at once: two processes inside. The lock is made whole now, and one - // that says nothing is believed like any other whose holder cannot be - // asked after. + // An empty lock file says nothing about its holder. It is believed like any + // other lock whose holder cannot be asked after, not taken for nobody's and + // removed. writeFileSync(LOCK, '') leftover(X) manifestOfADeadProcess([X]) @@ -402,11 +395,9 @@ describe.if(isLinux)('The mount point manifests', () => { }) it('keeps a mount point named by a manifest that is published while the pass is under way', () => { - // The lock does not exclude: its holder can be held up past the age at - // which it is broken, breaking one is not atomic, and a publish that - // cannot have it goes ahead without. So a manifest can appear between a - // pass listing the directory and its removals, naming a path the pass is - // about to remove, with a sandbox starting under it. + // The lock does not exclude, so a manifest can appear between a pass + // listing the directory and its removals, naming a path the pass is about + // to remove, with a sandbox starting under it. leftover(X) manifestOfADeadProcess([X]) let published: { status: number | null; stdout: string } | undefined @@ -431,8 +422,7 @@ describe.if(isLinux)('The mount point manifests', () => { it('gives up only a lock that is still its own', () => { // Held up for longer than a lock is believed, a pass finds on its way out - // that the lock at that name is somebody else's by now. Removing it by - // name let a third process in beside the second. + // that the lock at that name is somebody else's by now, and must leave it. const theirs = `${process.ppid} 1 pid:[1]\n` let mine = '' const pass = duringTheNextPass(() => { @@ -478,9 +468,8 @@ describe.if(isLinux)('The mount point manifests', () => { }) it('looks at /proc/locks a bounded number of times, not once for every mount point', () => { - // The list is the host's, and reading all of it for each removal made a - // pass over many mount points take seconds, under a lock whose waiters - // give up after two. + // The list is the host's: reading all of it for each removal would make a + // pass over many mount points outlast the lock's waiters. const many = 500 const paths: string[] = [] for (let i = 0; i < many; i++) { @@ -504,8 +493,7 @@ describe.if(isLinux)('The mount point manifests', () => { expect(removed.length).toBe(many) // One to begin with, one before the first removal, one for every 64 // removals after that, and one more for every two milliseconds the pass - // took, since a look goes out of date with time as well. No fewer than - // that either: one look is not good for ever. + // took. No fewer either: one look is not good for ever. expect(looks).toBeGreaterThanOrEqual(1 + Math.ceil(many / 64)) expect(looks).toBeLessThanOrEqual( 3 + Math.ceil(many / 64) + Math.ceil(tookMs / 2), @@ -518,7 +506,7 @@ describe.if(isLinux)('The mount point manifests', () => { () => { // Seen read-only from here, as from inside a sandbox that may not write // the directory it is in. The manifest is all that says the file is a - // mount point and whose; with it gone the file stayed for good. + // mount point, so it must stay while the file does. const readOnly = join(BASE, 'read-only-from-here') mkdirSync(readOnly) const inside = join(readOnly, 'config.lock') @@ -537,14 +525,11 @@ describe.if(isLinux)('The mount point manifests', () => { }, ) - // ---- a manifest goes last, this process's own like anybody's ----------- + // ---- a manifest goes last, this process's own like anybody's ---- // - // A manifest is all that names its mount points. One this process wrote - // used to be unlinked as soon as the caller said its command was over, - // before the pass knew whether it would get as far as the removals or - // whether they would work; after a pass that turned back, or a removal that - // was refused, nothing on disk named the mount points and no later pass - // removed them. + // A manifest is all that names its mount points. It stays until a pass has + // removed what it names: after a pass that turned back, or a removal that was + // refused, a later pass still has it to go by. it.if(NOT_ROOT)( 'keeps the manifest of a wrap of its own whose mount point it could not remove, for a pass that can', @@ -573,8 +558,8 @@ describe.if(isLinux)('The mount point manifests', () => { leftover(X) const manifest = publishMountPointManifest([X], [])!.file // Another version of this library publishes, in a layout this one cannot - // read, after the pass has listed the directory: what that names cannot be - // kept path by path, so the pass stops short of removing anything. + // read, after the pass has listed the directory: the pass stops short of + // removing anything. const stranger = join(DIR, '4242-0123456789abcdef.json') const pass = duringTheNextPass(() => writeFileSync(stranger, '{"version":2}'), @@ -637,8 +622,7 @@ describe.if(isLinux)('The mount point manifests', () => { it('keeps what a finished manifest names when its lock shows on the look before the removals', () => { // The bubblewrap a wrap has handed to its caller takes its lock whenever - // the caller starts it, which no lock of this library's can put off: the - // kernel is asked again before anything goes. + // the caller starts it, so the kernel is asked again before anything goes. leftover(X) const manifest = manifestOfADeadProcess([X]) const line = `1: POSIX ADVISORY READ 4242 ${deviceOf(manifest)}:${statSync(manifest).ino} 0 EOF\n` @@ -665,10 +649,9 @@ describe.if(isLinux)('The mount point manifests', () => { }) it('lists the directory again between removals, not only before the first', () => { - // A sandbox about to start on a path shows as a manifest that was not - // there when the pass began, and it can appear at any point of a pass over - // many mount points. One listing is good for a quarter of a millisecond, - // far less than a sandbox needs to get to its binds. + // A sandbox about to start on a path shows as a manifest that was not there + // when the pass began, at any point of a pass over many mount points. One + // listing is good for a quarter of a millisecond. const Y = join(BASE, 'second.lock') leftover(X) leftover(Y) @@ -679,8 +662,8 @@ describe.if(isLinux)('The mount point manifests', () => { unlink(file) if (file === X && arrived === undefined) { // Published the moment the first mount point has gone, with the pass - // held up for a millisecond before it goes on to the next: less than - // the two for which it trusts what it read from /proc/locks. + // held up for a millisecond before the next: less than the two for + // which it trusts what it read from /proc/locks. arrived = manifestOfADeadProcess([Y], { pid: process.pid, created: Date.now(), @@ -789,12 +772,11 @@ describe.if(isLinux)('The mount point manifests', () => { expect(existsSync(young)).toBe(true) }) - // ---- liveness is relative to a PID namespace --------------------------- + // ---- liveness is relative to a PID namespace ---- // - // /proc/locks lists a lock only when its holder has a pid in the namespace - // of the /proc being read, and /proc/PID is local to it. From another - // namespace a running sandbox's manifest looks exactly like one a dead - // process left. + // /proc/locks lists a lock only when its holder has a pid in the namespace of + // the /proc being read, and /proc/PID is local to it. From another namespace + // a running sandbox's manifest looks like one a dead process left. it.each([ ['written in another PID namespace', 'kept', { ns: 'pid:[1]' }], @@ -859,9 +841,8 @@ describe.if(isLinux)('The mount point manifests', () => { 'is not kept live by a lock on a file with the same inode number on another filesystem', () => { // Inode numbers are per filesystem, and on a young tmpfs they are small. - // A manifest whose number some unrelated locked file shared read as - // live for as long as that lock was held: never released, and every - // path it named a mount point to every later wrap. + // A manifest that shares its number with an unrelated locked file on + // another filesystem must not read as live. leftover(X) const manifest = publishMountPointManifest([X], [])!.file const inode = statSync(manifest).ino @@ -911,8 +892,8 @@ describe.if(isLinux)('The mount point manifests', () => { 'are never kept under /dev, which the sandbox mounts afresh over them', () => { // bubblewrap opens the manifest after its mounts, and the wrap mounts a - // new /dev after every bind: a manifest under /dev is not there to be - // opened, and every command with a mount point refused to start. + // new /dev after every bind: a manifest under /dev would not be there to + // be opened. const shm = mkdtempSync(join(SHM, 'mount-point-manifests-')) chmodSync(shm, 0o700) try { @@ -946,9 +927,8 @@ describe.if(isLinux)('The mount point manifests', () => { ) it("are not kept behind a link planted at the directory's name, whose target is left as it was", () => { - // The name is predictable and sits where sandboxed commands commonly - // write. A link there is refused, not followed: its target is somebody's - // directory, and would have had its mode changed. + // The name is predictable and sits where sandboxed commands commonly write. + // A link there is refused, not followed. const target = join(BASE, 'somebody-elses') mkdirSync(target) chmodSync(target, 0o755) @@ -970,8 +950,7 @@ describe.if(isLinux)('The mount point manifests', () => { 'go to a directory this process can write when the usual one is read-only from here', () => { // A process inside a sandbox sees the directory bound read-only. It is - // ours in every other respect, and used to be settled on, after which - // every wrap of that process failed to record its mount points. + // ours in every other respect, and must not be settled on. const elsewhere = TMP const published = inAChild(PUBLISH, { launcher: [ @@ -994,14 +973,12 @@ describe.if(isLinux)('The mount point manifests', () => { 15000, ) - // ---- which directory is written to, and which are read ----------------- + // ---- which directory is written to, and which are read ---- // - // Where the manifests went used to be the environment's to say: under - // $XDG_RUNTIME_DIR, else under $TMPDIR. Two processes of one user that - // differed in either kept their manifests apart, and neither knew of the - // sandboxes of the other. The user id and the file system say it first - // now, the same for every process, and what they name is read by every - // process. + // Two processes of one user that differ in $XDG_RUNTIME_DIR or $TMPDIR must + // still find each other's manifests. The user id and the file system say + // where they go first, the same for every process, and what they name is read + // by every process. /** * Four places of their own under `base`, none of them with a manifest @@ -1447,16 +1424,14 @@ describe.if(isLinux)('The mount point manifests', () => { } }) - // What the environment names is kept out of a sandboxed command's reach by - // the processes whose environment names it, and by no other: a process with - // another temp dir has its sandboxes write there, if its caller lets them. - // So a manifest found there is somebody's word only to a process that has - // nowhere else to keep its own. + // What the environment names is kept out of a sandboxed command's reach only + // by processes whose environment names it. So a manifest found there is + // believed only by a process that has nowhere else to keep its own. /** - * In each directory the environment names: the manifest of a sandbox that - * is running, which would keep X, and one a dead process left, which would - * have Y removed. + * In each directory the environment names: the manifest of a running sandbox, + * which would keep X, and one a dead process left, which would have Y + * removed. */ function whereTheEnvironmentSays( four: ReturnType, @@ -1606,12 +1581,10 @@ describe.if(isLinux)('The mount point manifests', () => { expect(existsSync(notBelieved)).toBe(true) }, 15000) - // ---- what is read as a manifest ---------------------------------------- + // ---- what is read as a manifest ---- // - // Whatever ends in `.json` in the directory used to be opened as it stood: - // a link was followed to wherever it led, and a FIFO held the thread for - // good, in the wrap, in the clean-up and in the exit handler. Only a - // regular file of the user's, of a size a manifest can have, is one. + // Only a regular file of the user's, of a size a manifest can have, is read: + // a link is not followed and a FIFO is not opened. /** Thirty seconds old: nothing can be starting under it any more. */ function aged(file: string): string { @@ -1725,12 +1698,11 @@ describe.if(isLinux)('The mount point manifests', () => { }) }, 15000) - // ---- which mount points a running sandbox relies on -------------------- + // ---- which mount points a running sandbox relies on ---- // - // A caller that removes paths of its own accord after a command removes by - // path, and removing a mount point from under a running sandbox lifts the - // deny there. So it can ask first which paths to leave out, and what it is - // told to leave out is never more than an empty placeholder. + // A caller that removes paths itself after a command can ask first which to + // leave out. What it is told to leave out is never more than an empty + // placeholder. /** The set, as a sorted list. */ function live(): string[] { @@ -1864,10 +1836,9 @@ describe.if(isLinux)('The mount point manifests', () => { }) it('holds what any manifest names while a manifest that cannot be read may have a sandbox under it', () => { - // What another version of this library wrote, say. What it names cannot - // be listed, and whether the sandboxes of the others are over cannot be - // told apart from it, for as long as the clean-up takes it to be live: - // while it is new, or a lock is on it. + // What another version of this library wrote, say. What it names cannot be + // listed, so while the clean-up takes it to be live every named path + // counts. const written = join(BASE, 'written-to') writeFileSync(written, 'mine') chmodSync(written, 0o444) @@ -2005,8 +1976,7 @@ describe.if(isLinux)('The mount point manifests', () => { it('does nothing at all where the platform is not Linux', () => { // Every clean-up after a command, on every platform, comes through here. - // Off Linux it made the directory and its lock each time, and on a - // platform with no uids a fresh temporary directory each time. + // Off Linux it must make nothing. leftover(X) manifestOfADeadProcess([X]) const before = readdirSync(DIR).sort() diff --git a/test/sandbox/stale-mount-point.test.ts b/test/sandbox/stale-mount-point.test.ts index ec65c6f8a..c9e74a933 100644 --- a/test/sandbox/stale-mount-point.test.ts +++ b/test/sandbox/stale-mount-point.test.ts @@ -47,18 +47,14 @@ function run( /** * A denyWrite path that does not exist gets `--ro-bind /dev/null `, and - * bwrap creates the mount point for it on the host: an empty file, mode 0444. - * A cleanup removes it once no sandbox relies on it. A process killed before - * it can clean up leaves the file behind, and it used to stay for good: every - * later wrap saw an existing file, bound it onto itself and never removed it. - * For a path whose existence is its meaning that is a lasting fault on the - * host: a leftover `.git/config.lock` makes every `git config` write outside - * the sandbox fail with "could not lock config file". + * bwrap creates the mount point for it on the host: an empty file, mode 0444. A + * process killed before it can clean up leaves the file behind. For a path + * whose existence is its meaning that is a lasting fault: a leftover + * `.git/config.lock` makes every `git config` write outside the sandbox fail. * * The killed process leaves its manifest as well, and that is what says the - * file is a leftover. The file alone does not: an empty read-only file is - * also what somebody keeps at such a path on purpose, and one taken for a - * leftover by its shape was removed after the first command. + * file is a leftover. The file alone does not: an empty read-only file is also + * what somebody keeps at such a path on purpose. */ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { const runtime = usePrivateManifestDirectory() @@ -106,8 +102,8 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { } /** - * The manifest of a wrap that named `paths`, written by `pid`: a process - * that is gone, a minute ago, unless told otherwise. Where it is written. + * The manifest of a wrap that named `paths`, written a minute ago by `pid`, + * by default a process that is gone. Returns where it is written. */ function manifestNaming( paths: string[], @@ -188,11 +184,9 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { await wrap([LOCK]) await wrap([LOCK]) - // Two wraps handed out, one cleaned up after: the other command may not - // have started yet, and a command that starts after its mount point or - // its manifest has gone either refuses to start or runs without its deny. - // A call cannot tell which command it is for, so this process gives up - // nothing of its own until it has been called for both. + // Two wraps handed out, one cleaned up after. A call cannot tell which + // command it is for, so this process gives up nothing of its own until it + // has been called for both. cleanupBwrapMountPoints() expect(existsSync(LOCK)).toBe(true) @@ -255,10 +249,9 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { }, ) - // ---- an empty read-only file that no manifest names -------------------- + // ---- an empty read-only file that no manifest names ---- // - // It is the caller's own, whatever it looks like: an empty `.mcp.json` of - // mode 0444, a lockfile somebody keeps in place. It is denied like any + // It is the caller's own, whatever it looks like. It is denied like any // existing path, by a read-only bind onto itself, and nothing ever removes // it. @@ -331,9 +324,8 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { } expect(namedBy(command)).not.toContain(mandatory) expect(namedBy(command)).not.toContain(LOCK) - // The wrap has mount points of its own, for the mandatory denies that - // are absent: the clean-up below has something to remove, and removes - // that and nothing else. + // The wrap has mount points of its own, for the mandatory denies that are + // absent: the clean-up below removes those and nothing else. expect(namedBy(command)).toContain(join(project, '.bashrc')) const session = run(command, project) @@ -356,11 +348,9 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { ) it('is asked after again, where the manifests were read before another sandbox named it and made it', async () => { - // A wrap reads the manifests once. What was read before the file was - // there cannot say whose it is: a sandbox that started in between - // published its manifest first and had bubblewrap make the file after. - // Taken for the caller's own it was bound onto itself and named by this - // wrap nowhere, and went from under its sandbox at the other's clean-up. + // A wrap reads the manifests once. What was read before the file was there + // cannot say whose it is: a sandbox that started in between published its + // manifest first and had bubblewrap make the file after. const earlier = join(GIT_DIR, 'earlier.lock') plantLeftover(earlier) const exists = fs.existsSync @@ -387,9 +377,8 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { it('is taken for absent where it has gone, with the manifest that named it, while the wrap was looking', async () => { // Seen with the shape of a mount point, and named by no manifest a moment - // later: the sandbox it was made for has ended and been cleaned up after - // in between. Bound onto itself it was a source that is not there, and - // the command did not start. + // later: the sandbox it was made for has ended and been cleaned up after in + // between. const left = plantLeftover(LOCK) const readdir = fs.readdirSync let collected = false @@ -524,9 +513,8 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { expect(after.stdout).toContain(BOOTED) expect(after.stdout).toMatch(/rc=[1-9]/) // A manifest whose writer is gone and that no sandbox has locked counts - // as live for half a second, in case the sandbox it covers is starting: - // the killed process left one, so the cleanup that takes its mount - // point away is the first one after that. + // as live for half a second, so the cleanup that takes its mount point + // away is the first one after that. await new Promise(resolve => setTimeout(resolve, 600)) cleanupBwrapMountPoints() expect(existsSync(LOCK)).toBe(false) From 2551cecd1cd8a6c672ea6a53fda7172e87f6bfbd Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Tue, 29 Sep 2026 09:57:47 +0000 Subject: [PATCH 5/5] linux: the manifests live under /var/tmp, by the user id, and have an end The manifest directory was taken from XDG_RUNTIME_DIR or TMPDIR, so two processes of one user with different environments did not see each other, and a logout, which empties /run/user/, left a killed process's mount points in the project for good. A process now writes in the first usable of /var/tmp/srt-mount-points-, /run/user//srt-mount-points and /tmp/srt-mount-points-, and reads all three; what the environment names counts only when none of them can be written. Every place is made, bound read-only and pinned by every wrap, so that the sandboxed command cannot bring one into being. Not the home directory: a network home caches listings, which the claim by rename relies on. Ends. A manifest carries the boot id: of another boot, it is finished. Of another PID namespace, it is live for seven days. What can be a manifest of no version (zero bytes, not JSON) is spent after the grace. The boot id counts only on kinds of file system that one kernel owns, by an allowlist; elsewhere a manifest is this kernel's only when both boot ids can be read and are equal, since two hosts' PID namespaces can be named alike, and any other is live for the seven days. The permission bits of /run/user/ count for writing under it, not for reading: where it is a write root the command can chmod it, and must not make other processes read less by that. --- README.md | 27 +- eslint.config.js | 1 + src/sandbox/bwrap-mount-manifests.ts | 447 +++++++++++---- src/sandbox/linux-sandbox-utils.ts | 2 +- test/cli.test.ts | 5 +- test/control-fd.test.ts | 4 +- test/helpers/isolated/cli.ts | 2 + test/helpers/isolated/dist-cli.mjs | 5 + test/helpers/isolated/library.ts | 2 + test/helpers/isolated/manager.ts | 2 + test/helpers/isolated/manifests.ts | 2 + test/helpers/isolated/places.ts | 9 + test/helpers/private-manifest-directory.ts | 61 +- test/preload.ts | 12 +- test/sandbox/default-write-paths.test.ts | 2 +- test/sandbox/linux-bwrap-args-file.test.ts | 5 +- test/sandbox/mount-point-liveness.test.ts | 225 +++++++- test/sandbox/mount-point-manifests.test.ts | 622 ++++++++++++++++++++- test/sandbox/path-canonicalization.test.ts | 9 +- test/sandbox/stale-mount-point.test.ts | 4 +- 20 files changed, 1264 insertions(+), 184 deletions(-) create mode 100644 test/helpers/isolated/cli.ts create mode 100644 test/helpers/isolated/dist-cli.mjs create mode 100644 test/helpers/isolated/library.ts create mode 100644 test/helpers/isolated/manager.ts create mode 100644 test/helpers/isolated/manifests.ts create mode 100644 test/helpers/isolated/places.ts diff --git a/README.md b/README.md index 6768a1729..8f95f261c 100644 --- a/README.md +++ b/README.md @@ -742,21 +742,22 @@ Filesystem restrictions are enforced at the OS level: - A `denyRead` entry naming a FILE is lifted only by an `allowRead` entry naming that same file. An `allowRead` entry that is a symlink to it names the link, so it does not cancel the deny of its target. - A `denyRead` entry that cannot be inspected (a parent made unsearchable, a dead network mount) hides the deepest directory above it that can be — never `/`, so when `/` is the only one left the entry mounts nothing and that deny is not enforced (the wrap logs which entry, and why, under `SRT_DEBUG`). Such a stand-in hides more than was written: nothing beneath it is readable, carve-outs named there included, and a carve-out elsewhere that resolves beneath it is not restored either. -**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty file (read-only; with write bits where bubblewrap is older than 0.5.0) or, for a missing intermediate component, an empty directory, that is visible on the host for as long as a sandbox is alive and is removed once no running sandbox relies on it. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). Each wrap records the mount points it relies on in a manifest, a small JSON file in a per-user directory: its own, and every mount point of another sandbox that its own mounts lie in or bind from (in a project with no `.claude/`, the second of two commands finds the `.claude` the first one's sandbox made, puts its own mount points for `.claude/commands` and `.claude/agents` inside it, and names all three). Once the manifest is published the wrap looks once more at everything on the host that its profile relies on, and plans again if any of it has gone or changed hands meanwhile; after three plans it gives up with `mount_points_changed` rather than hand out a command that relies on something gone. The command line it returns is a shell that first appends its own line of `/proc/self/stat` to a record beside the manifest and then replaces itself with bubblewrap, which always runs with `--die-with-parent`: the sandbox runs exactly as long as a process with a recorded pid and start time exists, which any srt process in the same PID namespace can ask. That shell runs on the host, before bubblewrap: it is `/bin/bash -p` (or `/usr/bin/bash -p`) where there is one, never one looked up on `PATH`, and with `-p` it neither imports functions from the environment nor reads the file `BASH_ENV` names; it hands the environment on unchanged, adding `PWD` and `SHLVL` where they are absent, with one exception: an exported `SHELLOPTS` is rewritten by `bash -p`, so `errexit` and `pipefail` set that way do not reach a bash that the command runs, and that bash starts in privileged mode. Where neither is there it is `/bin/sh` (with `-p` where that is bash, as on NixOS), and where that is dash, environment entries whose names are not shell identifiers (`a.b`, `INPUT_GITHUB-TOKEN`, an exported bash function) do not reach the command. When the record cannot be written the string fails with that shell's own message (not one beginning `bwrap:`) and status, and the command does not run. A record with no whole line on it (its shell was killed, or the disk was full) counts as no record: a manifest is then judged as one whose command has not started, by whether this process has released it and whether the process that wrote it still runs, never by its age alone. bubblewrap binds the manifest before it makes a mount point, and a cleanup claims a manifest, by renaming it, before it removes what that names, and reads the record after that: a command started once its manifest has been claimed fails to start (`bwrap: Can't find source path `, or `bwrap: Can't get type of source ` when the claim lands while bubblewrap is starting) and leaves nothing on the host but its line on the record, which the next cleanup drops, and one started just before keeps its mount points and has its manifest given back. A path goes only when every manifest that names it is claimed and has no process on its record. Whatever a cleanup cannot read or ask (a manifest, a record, `/proc//stat`) counts as a running sandbox: only a process that is gone, or one with that pid and another start time, ends one. (What sits at a manifest's name and cannot be read as one for what it is — it does not hold a manifest's fields, is no regular file, or may not be read by its owner — counts so for an hour, or for as long as a process on a readable record beside it runs, and is dropped then; a record whose manifest is gone counts only while a process on it runs; a manifest written by a later release is read by the fields this one knows.) `cleanupAfterCommand()` removes the ones no running sandbox relies on, this process's and other processes' alike, and calling it early, twice or not at all takes nothing away from a sandbox that is running. A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its own mount points; the next cleanup in any process does, once the sandbox has ended (for a command that was never started, once its manifest is half a second old). A mount point that was left behind cannot be told from somebody's own empty file or directory by looking at it, so only a manifest says that a path is one. An empty regular file with a single link, whatever its mode, or a directory, owned by the user and found at a path the profile denies or relies on, is taken for a mount point only when a manifest in the manifest directory names it, of a sandbox that may still be running or of one that has ended. A wrap that denies such a path covers the file with `/dev/null` like an absent path and names the file or directory in its own manifest, so that it stays for as long as that sandbox runs, and a cleanup removes it once no running sandbox relies on it. One that no manifest names is the user's own (an empty `.mcp.json` of mode `0444`, say): it is bound read-only where it is, like any existing `denyWrite` path, and is never removed, and neither is one that has been written to, or had something put into it, since. That holds for a path no manifest names: an empty file or directory the user makes at a path that a manifest names cannot be told from the mount point, and goes with it. The price is that a mount point whose manifest is not there stays on the host until someone deletes it, as it did before 0.0.77: see "Mount points with no manifest" under the known limits. +**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty file (read-only; with write bits where bubblewrap is older than 0.5.0) or, for a missing intermediate component, an empty directory, that is visible on the host for as long as a sandbox is alive and is removed once no running sandbox relies on it. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). Each wrap records the mount points it relies on in a manifest, a small JSON file in a per-user directory: its own, and every mount point of another sandbox that its own mounts lie in or bind from (in a project with no `.claude/`, the second of two commands finds the `.claude` the first one's sandbox made, puts its own mount points for `.claude/commands` and `.claude/agents` inside it, and names all three). Once the manifest is published the wrap looks once more at everything on the host that its profile relies on, and plans again if any of it has gone or changed hands meanwhile; after three plans it gives up with `mount_points_changed` rather than hand out a command that relies on something gone. The command line it returns is a shell that first appends its own line of `/proc/self/stat` to a record beside the manifest and then replaces itself with bubblewrap, which always runs with `--die-with-parent`: the sandbox runs exactly as long as a process with a recorded pid and start time exists, which any srt process in the same PID namespace can ask. That shell runs on the host, before bubblewrap: it is `/bin/bash -p` (or `/usr/bin/bash -p`) where there is one, never one looked up on `PATH`, and with `-p` it neither imports functions from the environment nor reads the file `BASH_ENV` names; it hands the environment on unchanged, adding `PWD` and `SHLVL` where they are absent, with one exception: an exported `SHELLOPTS` is rewritten by `bash -p`, so `errexit` and `pipefail` set that way do not reach a bash that the command runs, and that bash starts in privileged mode. Where neither is there it is `/bin/sh` (with `-p` where that is bash, as on NixOS), and where that is dash, environment entries whose names are not shell identifiers (`a.b`, `INPUT_GITHUB-TOKEN`, an exported bash function) do not reach the command. When the record cannot be written the string fails with that shell's own message (not one beginning `bwrap:`) and status, and the command does not run. A record with no whole line on it (its shell was killed, or the disk was full) counts as no record: a manifest is then judged as one whose command has not started, by whether this process has released it and whether the process that wrote it still runs, never by its age alone. bubblewrap binds the manifest before it makes a mount point, and a cleanup claims a manifest, by renaming it, before it removes what that names, and reads the record after that: a command started once its manifest has been claimed fails to start (`bwrap: Can't find source path `, or `bwrap: Can't get type of source ` when the claim lands while bubblewrap is starting) and leaves nothing on the host but its line on the record, which the next cleanup drops, and one started just before keeps its mount points and has its manifest given back. A path goes only when every manifest that names it is claimed and has no process on its record. Whatever a cleanup cannot read or ask (a manifest, a record, `/proc//stat`) counts as a running sandbox: only a process that is gone, or one with that pid and another start time, ends one. (What sits at a manifest's name and cannot be read as one for what it is — it does not hold a manifest's fields, is no regular file, or may not be read by its owner — counts so for an hour, or for as long as a process on a readable record beside it runs, and is dropped then; what is no JSON at all, an empty file included, can be a manifest of no release (a manifest is not synced before it is moved into place, so a crash can leave one) and counts for half a second in place of the hour; a record whose manifest is gone counts only while a process on it runs; a manifest written by a later release is read by the fields this one knows.) `cleanupAfterCommand()` removes the ones no running sandbox relies on, this process's and other processes' alike, and calling it early, twice or not at all takes nothing away from a sandbox that is running. A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its own mount points; the next cleanup in any process does, once the sandbox has ended (for a command that was never started, once its manifest is half a second old). A mount point that was left behind cannot be told from somebody's own empty file or directory by looking at it, so only a manifest says that a path is one. An empty regular file with a single link, whatever its mode, or a directory, owned by the user and found at a path the profile denies or relies on, is taken for a mount point only when a manifest in one of the manifest directories names it, of a sandbox that may still be running or of one that has ended. A wrap that denies such a path covers the file with `/dev/null` like an absent path and names the file or directory in its own manifest, so that it stays for as long as that sandbox runs, and a cleanup removes it once no running sandbox relies on it. One that no manifest names is the user's own (an empty `.mcp.json` of mode `0444`, say): it is bound read-only where it is, like any existing `denyWrite` path, and is never removed, and neither is one that has been written to, or had something put into it, since. That holds for a path no manifest names: an empty file or directory the user makes at a path that a manifest names cannot be told from the mount point, and goes with it. The price is that a mount point whose manifest is not there stays on the host until someone deletes it, as it did before 0.0.77: see "Mount points with no manifest" under the known limits. -- **The manifest directory** is `$XDG_RUNTIME_DIR/srt-mount-points` when `XDG_RUNTIME_DIR` is set, else `/srt-mount-points-`. It has to be a real directory (a symlink at the name is refused), owned by the user, mode `0700` (it is made so), and writable; a candidate under `/dev`, `/proc` or `/sys` is passed over (the sandbox mounts a fresh `/dev` and `/proc` over whatever was bound there). When neither name will do (both are read-only from where the process runs, as inside another sandbox), a process that has mount points to record makes a directory of its own under the temp dir, and removes it again at `reset()` or on exit once it is empty: its own sandboxes are covered as before, but no other process sees its manifests or collects what it leaves behind. With no directory at all, the process keeps its mount points in memory and removes them at its own cleanup, once none of its commands is outstanding; no other process knows of them. It does the same when a manifest cannot be written to a directory that is there (a full temp dir), and its cleanup then keeps every path that a manifest of another process names. Every sandbox that restricts writes has both of those names bound read-only, as the wrapping process sees them and whichever of them it uses itself (it makes the one that is not there), and its own directory if that is neither, whether or not its wrap has mount points. The directories between each of them and the write root it lies in, if it lies in one, are pinned like those above a protected path (see "Pinned directories" below), so that none of them can be renamed aside and the manifest directory made again under the old name. So a sandboxed command can neither delete a manifest nor forge one, nor make the directory first, for a process that was started with `XDG_RUNTIME_DIR` and for one that was started without; the two cases this does not reach are listed under the known limits. A cleanup takes no lock and waits for nothing: any number may run at once, in any processes. -- **Commands in parallel.** Call `cleanupAfterCommand()` once for each wrapped command, when that command is over. A command that has run is judged by its own record, so every cleanup, in any process, with or without an argument, collects what it relied on once it has ended, whatever else is still running. What a call cannot tell is which command it is for, so with no argument it lets go of nothing that only this process can vouch for until it has been called once for every wrap handed out: the manifest of a command that is wrapped and not yet started (bubblewrap refuses to start without it) and the `--args` profiles. Pass the `commandId` the wrap was given, `cleanupAfterCommand({ commandId })`, to let go of that one command's manifest at once: it matters for a command that was wrapped and will never be started, whose mount points otherwise stay named until the last outstanding command has been cleaned up after. The id has to be unique among the wraps in flight. A wrapped string is for one run: once that run has ended any cleanup, in any process, may collect its manifest, before this process has cleaned up after it too, and a second run (a server started again from a kept string) is refused. A call too many takes nothing from a running sandbox, but it is counted for some other wrap of this process, and a command that has not started by the time the count is back at zero then refuses to start. `reset()` and the exit handler release everything this process made; a sandbox of this process that is still running then keeps its mount points, through its record, and they go at the next cleanup in any process that uses the same manifest directory. -- **Removing a path yourself.** `cleanupAfterCommand()` keeps this promise for what the library removes, not for what the embedding program removes of its own accord after a command. Before it removes paths that a `denyWrite` entry of any process may name, it can call `liveMountPoints()`, exported from the package root, which returns a `ReadonlySet`, or `undefined` when it cannot tell. Leave out every path in the set: removing one of those would lift the deny inside a sandbox that is running. On `undefined` (the manifest directory, its listing or a manifest could not be read, or something that is no manifest sits among them) skip every removal of an empty file or an empty directory: a sandbox may be running on paths that could not be listed. It never throws. A path is in the set only when both hold. A manifest names it that belongs to a sandbox that may still be running (or this process could record it nowhere). And what is at the path now still has the shape of a mount point, or cannot be looked at: an empty regular file with a single link, of any mode, or an empty directory, owned by the user. So what a caller spares on the word of the set is never more than an empty placeholder, and a manifest that is out of date or forged cannot make it spare a file with content. Paths are as the wrap recorded them: absolute, with the symbolic links in the directories above the last component resolved and the last component as it stands. The set is a snapshot of the manifest directory, and may be out of date by the time the caller acts (a sandbox may start on a path a moment later): its only safe use is to skip a removal, never to decide that a path is free to write. The call is synchronous, needs no wrap in flight, and makes nothing on the host, the manifest directory included; it reads the manifest directory the calling process uses (before that process has recorded anything, the first of the two names above that exists). Off Linux the set is always empty. +- **The manifest directories.** Two processes protect each other's sandboxes only when each reads what the other writes, and a mount point whose manifest is lost stays for good. So the places follow from the user id, not from the environment (a login shell and what a service, an IDE, cron or `docker exec` starts differ in `XDG_RUNTIME_DIR`, two shells can differ in `TMPDIR`), and the first of them is local to the host and outlives a logout and a reboot. In the order they are tried for writing: (1) `/var/tmp/srt-mount-points-`; (2) `/run/user//srt-mount-points`, where `/run/user/` is a real directory of the user's, which to be written under also has to have no permission bits for group or others (they do not count for reading: a command that may write that directory can `chmod` it, and must not make other processes read less by that); (3) `/tmp/srt-mount-points-`, whatever `TMPDIR` says; (4) `$XDG_RUNTIME_DIR/srt-mount-points`; (5) `/srt-mount-points-`. The later ones are for where `/var/tmp` is missing or read-only: a minimal image, a `--read-only` container, inside another sandbox. Not the home directory: a network home caches directory listings, and a cleanup relies on the next listing in another process showing a rename; a home is often inside a write root; nothing ages what is left there; and in a home shared between hosts another boot id may be another running host's. A process writes in the first that is a real directory (a symlink at the name is refused), owned by the user, mode `0700` (it is made, and made so), and writable; a candidate under `/dev`, `/proc` or `/sys` is passed over (the sandbox mounts a fresh `/dev` and `/proc` over whatever was bound there). It believes, that is reads for every wrap, every cleanup and `liveMountPoints()`, and collects in: (1) to (3), each for as long as it passes those checks, writable or not; (4) and (5) only when it can write in none of the first three, because only processes with the same environment keep those two out of a sandbox's reach; and the one it has settled on. A manifest in any of them keeps what it names, what cannot be read in any of them puts everything in doubt, and all that is named in one that cannot be written from where the process runs is kept, since nothing can be claimed there. Each manifest records the boot (`/proc/sys/kernel/random/boot_id`), and one of another boot is finished whatever else it says: a reboot ends every record, and the next cleanup removes what was left. That counts only where the manifest's directory is on a kind of file system that this kernel alone reaches (tmpfs, ext2/3/4, xfs, btrfs, overlayfs, zfs, f2fs). On any other (NFS, 9p, virtiofs, FUSE) a manifest may be another running kernel's, and is taken for this kernel's only when its boot id and the reader's can both be read and are equal (two hosts each in their initial PID namespace name that namespace alike); any other counts as one that records no PID namespace, live for seven days, which is also how long what an earlier boot left waits on a local kind that is not on that list. overlayfs says nothing about what its upper layer sits on. systemd ages `/var/tmp` at 30 days (`/tmp` at 10) by access time, and every wrap and cleanup reads every manifest, so only a sandbox that runs that long with no other srt activity is exposed. Every release that writes manifests keeps them in these places, none was released that kept them elsewhere, and a later one has to go on reading them: two versions side by side protect each other on that. When none of them will do (all are read-only from where the process runs, as inside another sandbox), a process that has mount points to record makes a directory of its own under the temp dir, and removes it again at `reset()` or on exit once it is empty: its own sandboxes are covered as before, but no other process sees its manifests or collects what it leaves behind. With no directory at all, the process keeps its mount points in memory and removes them at its own cleanup, once none of its commands is outstanding; no other process knows of them. It does the same when a manifest cannot be written to a directory that is there (a full disk), and its cleanup then keeps every path that a manifest of another process names. Every sandbox that restricts writes has every one of those names bound read-only, as the wrapping process sees them and whichever of them it writes in, and its own directory if that is none of them, whether or not its wrap has mount points. The wrap makes the ones that are not there first: the command runs as the same user and may be allowed to write `/tmp`, so a directory it made there itself would pass every check of owner and mode, and be believed. One that can be neither made nor bound is not believed, and the command cannot bring it into being either: where its parent is missing, `/var`, `/run/user` and `/` are root's to write; where its parent is read-only, so is every mount made from it; where another user has the name, in a sticky directory, only that user or root can remove or rename it. The directories between each of them and the write root it lies in, if it lies in one, are pinned like those above a protected path (see "Pinned directories" below), so that none of them can be renamed aside and the manifest directory made again under the old name. So a sandboxed command can neither delete a manifest nor forge one, nor make a manifest directory first, whichever of them the wrapping process writes in; the cases this does not reach are listed under the known limits. A cleanup takes no lock and waits for nothing: any number may run at once, in any processes. +- **Commands in parallel.** Call `cleanupAfterCommand()` once for each wrapped command, when that command is over. A command that has run is judged by its own record, so every cleanup, in any process, with or without an argument, collects what it relied on once it has ended, whatever else is still running. What a call cannot tell is which command it is for, so with no argument it lets go of nothing that only this process can vouch for until it has been called once for every wrap handed out: the manifest of a command that is wrapped and not yet started (bubblewrap refuses to start without it) and the `--args` profiles. Pass the `commandId` the wrap was given, `cleanupAfterCommand({ commandId })`, to let go of that one command's manifest at once: it matters for a command that was wrapped and will never be started, whose mount points otherwise stay named until the last outstanding command has been cleaned up after. The id has to be unique among the wraps in flight. A wrapped string is for one run: once that run has ended any cleanup, in any process, may collect its manifest, before this process has cleaned up after it too, and a second run (a server started again from a kept string) is refused. A call too many takes nothing from a running sandbox, but it is counted for some other wrap of this process, and a command that has not started by the time the count is back at zero then refuses to start. `reset()` and the exit handler release everything this process made; a sandbox of this process that is still running then keeps its mount points, through its record, and they go at the next cleanup in any process that reads the manifest directory they are recorded in. +- **Removing a path yourself.** `cleanupAfterCommand()` keeps this promise for what the library removes, not for what the embedding program removes of its own accord after a command. Before it removes paths that a `denyWrite` entry of any process may name, it can call `liveMountPoints()`, exported from the package root, which returns a `ReadonlySet`, or `undefined` when it cannot tell. Leave out every path in the set: removing one of those would lift the deny inside a sandbox that is running. On `undefined` (a manifest directory, its listing or a manifest could not be read, or something that is no manifest sits among them) skip every removal of an empty file or an empty directory: a sandbox may be running on paths that could not be listed. It never throws. A path is in the set only when both hold. A manifest names it that belongs to a sandbox that may still be running (or this process could record it nowhere). And what is at the path now still has the shape of a mount point, or cannot be looked at: an empty regular file with a single link, of any mode, or an empty directory, owned by the user. So what a caller spares on the word of the set is never more than an empty placeholder, and a manifest that is out of date or forged cannot make it spare a file with content. Paths are as the wrap recorded them: absolute, with the symbolic links in the directories above the last component resolved and the last component as it stands. The set is a snapshot of the manifest directories, and may be out of date by the time the caller acts (a sandbox may start on a path a moment later): its only safe use is to skip a removal, never to decide that a path is free to write. The call is synchronous, needs no wrap in flight, and makes nothing on the host, a manifest directory included; it reads the manifest directories the calling process believes (see above). Off Linux the set is always empty. - **Known limits.** - - _Another PID namespace._ `/proc/` is local to a PID namespace. A manifest therefore records the PID namespace it was written in, and a process in any other one (a container that shares `/tmp` or `XDG_RUNTIME_DIR` with the host, one srt inside another's sandbox) takes it for live, as every process takes one that records none: it never removes what that manifest names, and an empty file, or a directory, found at a path it names counts as a mount point for that process's own wraps. Only a process in the namespace that wrote a manifest collects it, so what a namespace that has since gone left behind (empty files, empty directories, the manifests) stays until it is removed by hand. The command line has to be run in the PID namespace it was wrapped in: the pid it records means nothing in another. A time namespace is not recorded: from one with another boot-time offset every start time reads differently, so a cleanup there takes a running sandbox for ended. - - _A sandbox this release did not start, or a directory it does not know of._ The read-only binds of the manifest directories are in the sandboxes this release starts, for the two names as the wrapping process sees them. A sandbox started by an older release, or by another tool, that can write the temp dir while a manifest directory is under it (no `XDG_RUNTIME_DIR`) can delete and forge manifests, and so can a sandbox of this release when another process of the same user keeps its manifests somewhere the wrapping process does not look (a different `TMPDIR`, a different `XDG_RUNTIME_DIR`) and the sandbox may write there. A cleanup on the host believes what it finds in its directory. It still removes nothing but an empty, singly linked file of the user's, or an empty directory of the user's. - - _A manifest directory whose name a sandbox can still take._ Pinning holds a directory in place; it cannot hold a name that is not a directory, or not there. Where `TMPDIR` or `XDG_RUNTIME_DIR` is reached through a symbolic link that lies inside a write root, a sandboxed command can remove the link, make a directory in its place and a manifest directory in that, and the next cleanup by a process that uses that name believes what it finds there. Where `XDG_RUNTIME_DIR` names a directory that does not exist, below a write root, the wrap cannot make the manifest directory under it and a sandboxed command can, runtime directory and all: a process started with that environment afterwards believes it. What a cleanup removes on such a manifest's word is bounded as above. - - _A path that two wraps share._ After it has claimed, a cleanup reads every manifest in the directory, and it lists the directory again before every removal (in a cleanup of more than 64 paths, whenever that listing is a quarter of a millisecond old): a manifest that has come to its own name since (published, or given back by the cleanup that had claimed it) keeps what it names. A wrap looks at what it relies on once more after it has published, and nowhere after that. So a cleanup that listed just before the publish and is held up, in the microseconds between that listing and the removal after it, until the wrap has looked, removes a path that the new manifest names: a directory the profile relies on, and bubblewrap then refuses to start on it; or, held up for as long as a sandbox takes to start (a few milliseconds), a mount point from under the sandbox. A wrap held up twice in one plan, so that a directory it has decided to put a mount point in is removed before it pins that directory and made again before it reads the manifests, names the directory and does not pin it. - - _Mount points with no manifest._ Only a manifest makes a path a mount point, so one whose manifest is gone is the user's file from then on and is never removed. That is what a killed process of 0.0.77 or earlier leaves, and what any killed process leaves when the manifest directory is emptied and the project is not: a reboot or the last logout (`$XDG_RUNTIME_DIR` is a tmpfs that logind removes), a container restart with the project on a volume, a cleaner of the temp dir. What stays is, for every `denyWrite` and mandatory deny path that was absent under a writable directory (`.bashrc`, `.bash_profile`, `.zshrc`, `.zprofile`, `.profile`, `.gitconfig`, `.gitmodules`, `.ripgreprc`, `.mcp.json`, `.vscode`, `.idea` in the working directory, and `.claude` with nothing in it), an empty regular file of mode `0444` (`0666` less the umask from bubblewrap before 0.5.0), or an empty directory at a missing intermediate component, owned by the user. An embedding program that knows its own deny list can remove such an empty path itself, after a `liveMountPoints()` that returned a set without it. + - _Another PID namespace._ `/proc/` is local to a PID namespace. A manifest therefore records the PID namespace it was written in, and a process in any other one (a container that shares `/var/tmp` with the host, one srt inside another's sandbox) takes it for live, as every process takes one that records none, for seven days from the last change to the manifest or to its record, whichever is later, and for finished then. Until then it removes nothing that manifest names, and an empty file, or a directory, found at a path it names counts as a mount point for that process's own wraps. The seven days are for a namespace that has gone: after `docker restart` the container has its files, the host's boot id and a new PID namespace, and nothing else would ever end what the earlier one left. A sandbox that runs longer than that in another PID namespace, sharing both this directory and the project, is no longer protected from processes outside its namespace. The command line has to be run in the PID namespace it was wrapped in: the pid it records means nothing in another. A time namespace is not recorded: from one with another boot-time offset every start time reads differently, so a cleanup there takes a running sandbox for ended. + - _A sandbox this release did not start, or a directory it does not know of._ The read-only binds of the manifest directories are in the sandboxes this release starts, for the names as the wrapping process sees them. A sandbox started by an older release, or by another tool, that can write `/var/tmp` or `/tmp` can delete and forge manifests there. A process that can write in none of the first three places keeps its manifests where its environment says, which the sandbox of a process with another environment does not have bound, and that sandbox can delete and forge them when it may write there. A cleanup on the host believes what it finds in the directories it goes by. It still removes nothing but an empty, singly linked file of the user's, or an empty directory of the user's. + - _A manifest directory whose name a sandbox can still take._ Pinning holds a directory in place; it cannot hold a name that is not a directory, or not there. Where one of the names, or a directory above it, is a symbolic link of the user's that lies inside a write root, a sandboxed command can remove the link, make a directory in its place and a manifest directory in that, and the next wrap or cleanup by a process that reads that name believes what it finds there. Where the parent of a name does not exist, and the user may write the directory it is missing from, and that lies inside a write root (a process that runs as root and has no `/run/user/0`, with `/` or `/run` writable; an `XDG_RUNTIME_DIR` or `TMPDIR` that is not there), the wrap cannot make the manifest directory and a sandboxed command can, parent and all: every process that looks there afterwards believes it. Where `/run/user/` itself is a write root the command can open it to others; every process goes on reading what is under it, but another local user can then move that manifest directory aside. What a cleanup removes on such a manifest's word is bounded as above. + - _A `/var/tmp/srt-mount-points-` that another user made._ Another local user can make that name, or the one in `/tmp`, before the user's first wrap. It fails the owner check and is passed over, never repaired, and the user's processes write in the next place. + - _A path that two wraps share._ After it has claimed, a cleanup reads every manifest in every directory it believes, and it lists them again before every removal (in a cleanup of more than 64 paths, whenever that listing is a quarter of a millisecond old): a manifest that has come to its own name since (published, or given back by the cleanup that had claimed it) keeps what it names. A wrap looks at what it relies on once more after it has published, and nowhere after that. So a cleanup that listed just before the publish and is held up, in the microseconds between that listing and the removal after it, until the wrap has looked, removes a path that the new manifest names: a directory the profile relies on, and bubblewrap then refuses to start on it; or, held up for as long as a sandbox takes to start (a few milliseconds), a mount point from under the sandbox. A wrap held up twice in one plan, so that a directory it has decided to put a mount point in is removed before it pins that directory and made again before it reads the manifests, names the directory and does not pin it. + - _Mount points with no manifest._ Only a manifest makes a path a mount point, so one whose manifest is gone is the user's file from then on and is never removed. That is what a killed process of 0.0.77 or earlier leaves, and what any killed process leaves when the manifest directory is emptied and the project is not: a container made anew with the project on a volume, a cleaner of `/var/tmp`, and, for a process that had to write in a later place, the last logout (`/run/user/` is a tmpfs that logind removes) or a reboot. What stays is, for every `denyWrite` and mandatory deny path that was absent under a writable directory (`.bashrc`, `.bash_profile`, `.zshrc`, `.zprofile`, `.profile`, `.gitconfig`, `.gitmodules`, `.ripgreprc`, `.mcp.json`, `.vscode`, `.idea` in the working directory, and `.claude` with nothing in it), an empty regular file of mode `0444` (`0666` less the umask from bubblewrap before 0.5.0), or an empty directory at a missing intermediate component, owned by the user. An embedding program that knows its own deny list can remove such an empty path itself, after a `liveMountPoints()` that returned a set without it. - _An older release beside this one._ A process of 0.0.77 takes every empty `0444` file at a path it denies for a leftover, covers it, and removes it at its own cleanup, from under a sandbox of this release that is still running; one of an earlier release removes at its cleanup what it made itself, whoever relies on it by then. The protection holds between processes of this release and later. - _bubblewrap killed._ The process on record is bubblewrap's own, and `--die-with-parent` ends the sandbox with it: killing bubblewrap kills the command. Killed with `SIGKILL`, bubblewrap can be gone a moment before the last process of its sandbox is, and a cleanup in that moment already takes the sandbox for ended. - - _Mount points recorded where another process does not look, or not at all._ Two processes see each other's manifests only when they settle on the same manifest directory. One that cannot write the first choice (it runs inside another sandbox, where that directory is read-only) records in the next one, in a private one, or nowhere, as a release that wrote no manifest does, and then nothing the other process reads says a sandbox relies on those mount points. A wrap in that other process that denies the same path takes the file for the user's own: it binds it read-only where it is and does not name it, so the file goes when the process that recorded it cleans up, from under the second sandbox if that is still running. The ones that could not be recorded at all are removed by the process that made them, at its own cleanup, whatever other process relies on them, and from under its own running sandbox when that cleanup is `reset()`, the exit handler, or a call made early or once too often. A process that fails to look at its directory once as it records (an I/O error) also moves to the next one, and stays there. - - _Exit under a running sandbox._ A process that exits, or calls `reset()`, while a sandbox it started is still running leaves that sandbox's mount points and manifest in place, as it must. They go at that process's own next cleanup if it lives on after `reset()`, and otherwise at the next cleanup by another srt process that uses the same manifest directory; with a private directory, no other process does. So a program that exits without first ending its running commands leaves their mount points in the project until the next command of any session has been cleaned up after. + - _Mount points recorded where another process does not look, or not at all._ Two processes see each other's manifests only when one writes where the other reads. Every process reads the first three places, so what is left is: a process that can write in none of them, which records where its environment says, where only a process in the same position looks, in a private directory (it runs inside another sandbox, where all are read-only), or nowhere; two processes that share a project but none of the three (a container beside the host, a service with a private `/tmp` and `/var/tmp`); and a release that wrote no manifest. Nothing the other process reads then says a sandbox relies on those mount points. A wrap in that other process that denies the same path takes the file for the user's own: it binds it read-only where it is and does not name it, so the file goes when the process that recorded it cleans up, from under the second sandbox if that is still running. The ones that could not be recorded at all are removed by the process that made them, at its own cleanup, whatever other process relies on them, and from under its own running sandbox when that cleanup is `reset()`, the exit handler, or a call made early or once too often. A process that fails to look at its directory once as it records (an I/O error) also moves to the next one, and stays there. + - _Exit under a running sandbox._ A process that exits, or calls `reset()`, while a sandbox it started is still running leaves that sandbox's mount points and manifest in place, as it must. They go at that process's own next cleanup if it lives on after `reset()`, and otherwise at the next cleanup by another srt process that reads the manifest directory they are recorded in; with a private directory, no other process does. So a program that exits without first ending its running commands leaves their mount points in the project until the next command of any session has been cleaned up after. **Note (Linux, large profiles):** The wrapped string runs as one argument of `sh -c`, which Linux caps at 32 pages (128 KiB with 4 KiB pages). A profile that would not fit, with 4 KiB to spare for a prefix of the caller's own, has its mounts written to an unnamed file (`O_TMPFILE`) that the wrapping process holds open and bubblewrap reads through `--args`. The string then reads ` -c '…' srt-args /proc//fd/ bwrap … --args 9 …` (with the manifest's record ahead of the profile, and the binds of the root and of the manifest kept on the line ahead of `--args`, for a wrap that named mount points): still a simple command, which opens the profile on fd 9 and runs bubblewrap. The environment and the command stay on the command line; the file holds mount paths only. @@ -765,7 +766,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 and the wrap's mount point manifest are released: with several commands in flight, when the last of them has been cleaned up after (the manifest alone goes earlier when the command's own `commandId` is passed). A string of a wrap that named mount points, run after that, refuses to start rather than running without them, and makes nothing on the host: its manifest's bind is on the command line, whatever the descriptor number of its profile holds by then. This holds for every such wrap, not only an over-long one. The string is for one run: once that run has ended, any cleanup, in any process, may collect its manifest, and a second run is then refused. - 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 (`too_many_arguments`, `nul_in_path`, `args_file_unavailable`, `command_too_long`, `too_many_mount_points`, `mount_points_unreadable`, `mount_points_changed`); 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 the two that are about other sandboxes. `mount_points_unreadable`: the manifests could not be read, so the wrap cannot tell a file of the user's from a mount point that a running sandbox relies on, and does not guess. It refuses every wrap of that user that restricts writes, and always ends by itself: this process is out of descriptors or memory, until it is not; something that cannot be read as a manifest sits at a manifest's name, for an hour from its last change or for as long as a process on the record beside it runs; a process runs on a record whose manifest is gone, until it ends. The message, and `.cause`, name the file and the reason. `mount_points_changed`: what the profile relies on on the host changed under each of three plans, because other sandboxes' mount points were being made and removed there, or their manifests kept moving while the wrap read them; wrap again, once. `too_many_mount_points`: the mount points do not fit one manifest (a mebibyte). A wrap that threw has already let go of what it held and is not counted among the commands to clean up after: `cleanupAfterCommand()` need not be called for it, and with other commands in flight should not be, since the call would be taken for one of theirs. +- 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 (`too_many_arguments`, `nul_in_path`, `args_file_unavailable`, `command_too_long`, `too_many_mount_points`, `mount_points_unreadable`, `mount_points_changed`); 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 the two that are about other sandboxes. `mount_points_unreadable`: the manifests could not be read, so the wrap cannot tell a file of the user's from a mount point that a running sandbox relies on, and does not guess. It refuses every wrap of that user that restricts writes, and always ends by itself: this process is out of descriptors or memory, until it is not; something that cannot be read as a manifest sits at a manifest's name, for an hour from its last change (half a second where it is no JSON at all) or for as long as a process on the record beside it runs; a process runs on a record whose manifest is gone, until it ends. The message, and `.cause`, name the file and the reason. `mount_points_changed`: what the profile relies on on the host changed under each of three plans, because other sandboxes' mount points were being made and removed there, or their manifests kept moving while the wrap read them; wrap again, once. `too_many_mount_points`: the mount points do not fit one manifest (a mebibyte). A wrap that threw has already let go of what it held and is not counted among the commands to clean up after: `cleanupAfterCommand()` need not be called for it, and with other commands in flight should not be, since the call would be taken for one of theirs. ### Mandatory Deny Paths (Auto-Protected Files) @@ -797,7 +798,7 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' **Pinned directories (Linux):** Every existing ancestor of a protected path (a write-denied path, a read-denied file or directory, a masked credential file, or a directory the library keeps on the host and binds read-only: the mount point manifest directories, the store of fake credential files, the empty directory that directory mount points bind from) up to the allowed write root covering it is made a mountpoint — "pinned" — and cannot be renamed or removed from inside the sandbox: `mv` or `rmdir` of such a directory (for example a nested repository's parent) fails with `EBUSY` ("Device or resource busy"), and `rm -rf` of a nested repository leaves the pinned directories and the protected files behind (as with `.git/hooks`). A pin is buried under the mounts above it, so it never appears on a lookup path: reads, writes, creation, renames and hard links inside or across a pinned directory are unaffected. -With `allowWrite: ["/"]` the pins reach every ancestor, including any other allowed write root that is one (`mv /work /work.bak` fails with `EBUSY` given `allowWrite: ["/", "/work"]` and a protected path inside `/work`), and the ancestors of the temp dir and of `XDG_RUNTIME_DIR` whatever the deny list, since the library's own directories are under them. They stop below the top-level directory, which is bound writable over them, and that directory is the one new filesystem boundary: `mv` or `ln` between two top-level directories — say `/tmp` and `/home` — fails with `EXDEV` ("Invalid cross-device link"), as it does on any host where they are separate filesystems. `mv` falls back to a copy; `ln` and a raw `rename(2)` do not. +With `allowWrite: ["/"]` the pins reach every ancestor, including any other allowed write root that is one (`mv /work /work.bak` fails with `EBUSY` given `allowWrite: ["/", "/work"]` and a protected path inside `/work`), and the ancestors of `/var/tmp`, of `/run/user/`, of `/tmp`, of the temp dir and of `XDG_RUNTIME_DIR` whatever the deny list, since the library's own directories are under them. They stop below the top-level directory, which is bound writable over them, and that directory is the one new filesystem boundary: `mv` or `ln` between two top-level directories — say `/tmp` and `/home` — fails with `EXDEV` ("Invalid cross-device link"), as it does on any host where they are separate filesystems. `mv` falls back to a copy; `ln` and a raw `rename(2)` do not. A wrap that carries no write restrictions at all — `filesystem.disabled` with credential masks still in force, or a library caller passing no write config while a `denyRead` entry or a mask still seeds a pin — is the same shape: the whole tree is bound writable, so it gets the same pins and the same top-level covers, and the same `EXDEV` boundary applies there too. diff --git a/eslint.config.js b/eslint.config.js index b7783d1c9..bebf03e6f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,6 +22,7 @@ export default [ allowDefaultProject: [ 'eslint.config.js', 'test/utils/which-node-test.mjs', + 'test/helpers/isolated/dist-cli.mjs', 'vendor/build-common.ts', 'vendor/seccomp/build.ts', 'vendor/srt-win/build.ts', diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts index 46c5f30c1..73120345e 100644 --- a/src/sandbox/bwrap-mount-manifests.ts +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -34,17 +34,25 @@ * No sandboxed command can write the directory (see below), so what is there * and should not be comes from an accident on the host. * - * The manifest directories are bound read-only into every sandbox that - * restricts writes, and the directories above them inside a write root are - * pinned, so a sandboxed command can neither rewrite a manifest nor swap the - * directory (see {@link mountPointManifestDirectories}). Not covered: a sandbox - * this library did not start, a process that looks for its runtime or temp - * directory elsewhere, and a runtime or temp directory that is a link inside a - * write root or not there yet. + * Two processes protect each other only when each reads what the other writes, + * and a mount point whose manifest is lost stays for good. So the directories + * follow from the user id, not the environment, and the first survives a logout + * and a reboot (see {@link sharedManifestDirectoryNames}). + * + * INVARIANT: all of this rests on the sandboxed command, which has the same + * uid, being unable to write OR CREATE a directory a pass believes: one it made + * itself would pass every check of owner and mode. So every wrap that restricts + * writes makes every one of them, binds it read-only and pins what lies above + * it inside a write root, before it hands the command out (see {@link + * mountPointManifestDirectories}). Not covered: a sandbox this library did not + * start; a name, or one above it, that is a link inside a write root; a parent + * that is not there, where the command may make it. * * /proc/PID is relative to a PID namespace, so a manifest records the namespace * that wrote it and only a process in that namespace judges it; to any other it - * is live. + * is live, for {@link OTHER_NAMESPACE_MAX_AGE_MS}. It records the boot too: no + * process of an earlier one runs, where only this kernel reaches the directory. + * NO LASTING STATE KEEPS WHAT IS LEFT FOR EVER. */ import { randomBytes } from 'node:crypto' @@ -72,7 +80,7 @@ const CLAIMED_SUFFIX = '.claimed' const MANIFEST_GRACE_MS = 500 /** - * A pass with no more candidates than this lists the manifest directory again + * A pass with no more candidates than this lists the manifest directories again * before every removal, so each is judged on a listing a few microseconds old. * An ordinary pass is a handful of paths. */ @@ -98,6 +106,13 @@ const LISTING_ATTEMPTS = 8 */ const UNREADABLE_MANIFEST_MAX_AGE_MS = 60 * 60 * 1000 +/** + * A manifest of another PID namespace, or that records none, counts as live + * until it and its record are this old. Nothing else ends one whose namespace + * is gone: a restarted container keeps its files and the host's boot id. + */ +const OTHER_NAMESPACE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 + /** The largest file read as a manifest; a real one is a few kilobytes. */ const MANIFEST_MAX_BYTES = 1024 * 1024 @@ -128,6 +143,12 @@ const ManifestSchema = z.object({ * be asked after from there. Absent where it could not be read. */ ns: z.string().optional(), + /** + * The boot it was written in. Absent where that could not be read. As read, + * both are absent where nothing follows from them (see {@link + * ONE_KERNEL_FILE_SYSTEMS}). + */ + boot: z.string().optional(), /** When the manifest was written, for {@link MANIFEST_GRACE_MS}. */ created: z.number(), /** The mount points bwrap makes on the host for that wrap's deny paths. */ @@ -268,24 +289,89 @@ function ownPidNamespace(): string | undefined { return pidNamespace } +let bootId: string | undefined + +/** This boot as the kernel names it, or `undefined`. */ +function ownBootId(): string | undefined { + try { + return (bootId ??= + fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim() || + undefined) + } catch { + return undefined + } +} + +/** + * The kinds of file system that only processes of this kernel reach, by + * `statfs`'s type. On any other (NFS, 9p, virtiofs, FUSE) a manifest may be + * another running kernel's, and only the boot id tells: two hosts each in their + * initial PID namespace name that alike. So there a manifest is this kernel's + * only when its boot id and ours can both be read and are equal. Of any other, + * neither its boot nor its namespace says anything: it records none, and is + * live for {@link OTHER_NAMESPACE_MAX_AGE_MS}. + * + * INVARIANT: a list of what is let in, not of what is kept out. On a local kind + * that is missing, what an earlier boot left waits those seven days instead of + * going at once. A kind wrongly let in, as the next network file system would be + * by a list of the known ones, has a live sandbox judged finished and its mount + * points removed. + */ +const ONE_KERNEL_FILE_SYSTEMS = new Set([ + 0x01021994, // tmpfs + 0xef53, // ext2, ext3, ext4 + 0x58465342, // xfs + 0x9123683e, // btrfs + 0x794c7630, // overlayfs + 0x2fc12fc1, // zfs + 0xf2f52010, // f2fs +]) + +/** Whether `dir` is on one of those. Not where that cannot be asked. */ +function isReachedByOneKernel(dir: string): boolean { + try { + return ONE_KERNEL_FILE_SYSTEMS.has(Number(fs.statfsSync(dir).type)) + } catch { + return false + } +} + +/** + * Whether `file` was last written over `ms` ago. One that cannot be looked at + * was not; one that is not there counts as `absent` says. + */ +function olderThan(file: string, ms: number, absent = false): boolean { + try { + return Date.now() - fs.lstatSync(file).mtimeMs > ms + } catch (e) { + return absent && isAbsenceErrno(e) + } +} + /** - * Whether `dir` is a directory of ours that nobody else can write, and we can. - * With `orThrow`, an error that says neither "not there" nor "not to be written - * from here" is thrown, for a caller that must not take it for a no. + * Whether `dir` is a directory of ours, with `alone`, that nobody else can + * write, and, with `toWrite`, that we can. With `orThrow`, an error that says + * neither "not there" nor "not to be written from here" is thrown, for a caller + * that must not take it for a no. */ -function isOurPrivateDirectory(dir: string, orThrow = false): boolean { +function isOurPrivateDirectory( + dir: string, + orThrow = false, + toWrite = true, + alone = true, +): boolean { try { const stat = fs.lstatSync(dir) if ( !stat.isDirectory() || stat.uid !== process.getuid?.() || - (stat.mode & 0o077) !== 0 + (alone && (stat.mode & 0o077) !== 0) ) { return false } - // Ours, and no use if it cannot be written: a directory an outer sandbox - // binds read-only reads as ours in every other respect. - fs.accessSync(dir, fs.constants.W_OK | fs.constants.X_OK) + // A directory an outer sandbox binds read-only reads as ours in every + // other respect. + if (toWrite) fs.accessSync(dir, fs.constants.W_OK | fs.constants.X_OK) return true } catch (e) { const code = (e as NodeJS.ErrnoException).code @@ -348,20 +434,82 @@ function isBuriedBySandboxMounts(dir: string): boolean { ) } +// Test seam: what stands in for `/` in the names that follow from the user id. +let root = '/' + +/** + * The names every process of this user works out alike, in the order tried: + * under /var/tmp, which is local to the host and outlives a logout and a + * reboot; under /run/user/UID, where that is a directory of the user's; under + * /tmp. INVARIANT for every later version: it goes on reading these. + * + * To be written under (`toWrite`), /run/user/UID has to be closed to everyone + * else as well. Not to be read under: where it is a write root the command can + * chmod it, and must not make other processes read less by that. Reading more + * does no harm, a manifest being only a claim about a path. + * + * Not the home directory. A network home caches listings, and the claim by + * rename, the reading after it and the listing before a removal all need the + * next readdir in another process to show a rename; a home is often inside a + * write root; nothing ages what is left there; and in a shared one another boot + * id may be another live host's. + */ +function fixedManifestDirectoryNames( + orThrow = false, + toWrite = false, +): string[] { + const uid = process.getuid?.() ?? 0 + const runtimeDir = path.join(root, 'run/user', String(uid)) + return [ + path.join(root, 'var/tmp', `srt-mount-points-${uid}`), + ...(isOurPrivateDirectory(runtimeDir, orThrow, false, toWrite) + ? [path.join(runtimeDir, 'srt-mount-points')] + : []), + path.join(root, 'tmp', `srt-mount-points-${uid}`), + ] +} + /** * Where processes of this user keep manifests that others can find, in the - * order tried: under $XDG_RUNTIME_DIR, then under the system temp dir. + * order tried: {@link fixedManifestDirectoryNames}, then, for a process that + * can use none of those (a minimal or read-only image, another sandbox), under + * $XDG_RUNTIME_DIR and under the system temp dir. */ -function sharedManifestDirectoryNames(): string[] { +function sharedManifestDirectoryNames( + orThrow = false, + toWrite = false, +): string[] { + const shared = fixedManifestDirectoryNames(orThrow, toWrite) const runtimeDir = process.env['XDG_RUNTIME_DIR'] - const shared = - runtimeDir !== undefined && path.isAbsolute(runtimeDir) - ? [path.join(runtimeDir, 'srt-mount-points')] - : [] + if (runtimeDir !== undefined && path.isAbsolute(runtimeDir)) { + shared.push(path.join(runtimeDir, 'srt-mount-points')) + } shared.push( path.join(tmpdir(), `srt-mount-points-${process.getuid?.() ?? 0}`), ) - return shared.filter(candidate => !isBuriedBySandboxMounts(candidate)) + return [...new Set(shared)].filter( + candidate => !isBuriedBySandboxMounts(candidate), + ) +} + +/** + * The directories whose manifests this process believes: it reads them all, and + * claims and collects in those it can write. The names that follow from the + * user id; the ones the environment gives only where none of those can be + * written, because only processes with that environment keep them out of a + * sandbox's reach; and the one it has settled on. Each only while it is a + * directory of ours alone. Makes nothing. Throws where it cannot tell. + */ +function believedManifestDirectories(): string[] { + const names = fixedManifestDirectoryNames(true, true).some(dir => + isOurPrivateDirectory(dir, true), + ) + ? fixedManifestDirectoryNames(true) + : sharedManifestDirectoryNames(true) + if (manifestDirectory !== undefined) names.push(manifestDirectory) + return [...new Set(names)].filter(dir => + isOurPrivateDirectory(dir, true, false), + ) } /** @@ -378,7 +526,7 @@ function ensureManifestDirectory(): string | undefined { return manifestDirectory } manifestDirectoryIsPrivate = false - for (const candidate of sharedManifestDirectoryNames()) { + for (const candidate of sharedManifestDirectoryNames(false, true)) { if (makeOurPrivateDirectory(candidate)) { manifestDirectory = candidate return candidate @@ -432,15 +580,21 @@ function isOurDirectory(dir: string): boolean { /** * Every directory a wrap must bind read-only because some process of this user - * keeps manifests in it and a collect believes what it finds there: both shared - * names, plus the one this process settled on. + * keeps manifests in it and a collect believes what it finds there: every + * shared name, whichever this process writes to, plus the one it settled on. + * + * Each is made if it can be, so a sandboxed command cannot make and fill it + * first. One that can be neither made nor bound is not believed, and the + * command does not bring it into being either: * - * Both names, because a process started without $XDG_RUNTIME_DIR (cron, plain - * ssh, a service) uses the second, and a sandbox of the first kind with the - * temp dir writable could otherwise forge its manifests. Each is made if it can - * be, so a sandboxed command cannot make and fill it first. With `recording`, - * the directory this wrap's manifest will go to is settled first, so the wrap - * can pin what lies above it before it publishes. + * - its parent is not there: it is missing from /var, /run/user or /, which + * are root's to write (what the environment names may be missing anywhere); + * - its parent is read-only: so is every mount made from it; + * - another user has the name, in a sticky directory: only that user or root + * can remove or rename it. + * + * With `recording`, the directory this wrap's manifest will go to is settled + * first, so the wrap can pin what lies above it before it publishes. */ export function mountPointManifestDirectories(recording = false): string[] { if (!onLinux()) { @@ -483,6 +637,12 @@ export function removePrivateManifestDirectory(): void { /** What is at a name and is not what this library keeps there. */ class NotOurs extends Error {} +/** + * What can be a manifest of no version. A manifest is not synced before it is + * moved into place, so a crash can leave one with nothing in it. + */ +class NotJson extends NotOurs {} + /** * Whether `file` is what an open does not refuse for what it is: a regular file * of the user's that its owner may read. What cannot be looked at may be. @@ -548,7 +708,7 @@ function readManifest(file: string): Manifest | undefined { try { json = JSON.parse(text) } catch { - throw new NotOurs(`${file} is not JSON`) + throw new NotJson(`${file} is not JSON`) } const parsed = ManifestSchema.safeParse(json) if (!parsed.success) { @@ -605,16 +765,29 @@ function recordVouches(record: string, orphan = false): boolean | undefined { /** * Whether a sandbox that named this manifest may be running, or may yet start. - * Only a process in the PID namespace that wrote it can tell; to any other it - * is live, and is collected by a process in its own. With a started record, the - * record alone says. With none, no sandbox has got past a claim, and one the - * caller has not released is live while it is young or its writer runs. + * None of another boot is, whatever else the manifest says. Only a process in + * the PID namespace that wrote it can tell; to any other it is live until {@link + * OTHER_NAMESPACE_MAX_AGE_MS}. With a started record, the record alone says. + * With none, no sandbox has got past a claim, and one the caller has not + * released is live while it is young or its writer runs. */ function isLive(manifest: Manifest): boolean { + if ( + manifest.boot !== undefined && + manifest.boot !== (ownBootId() ?? manifest.boot) + ) { + return false + } const here = ownPidNamespace() - if (here === undefined || manifest.ns !== here) { + if (here === undefined) { return true } + if (manifest.ns !== here) { + return !( + olderThan(manifest.file, OTHER_NAMESPACE_MAX_AGE_MS) && + olderThan(manifest.record, OTHER_NAMESPACE_MAX_AGE_MS, true) + ) + } const vouched = recordVouches(manifest.record) if (vouched !== undefined) { return vouched @@ -783,6 +956,7 @@ export function publishMountPointManifest( version: MANIFEST_VERSION, ...ownProcess(), ns: ownPidNamespace(), + boot: ownBootId(), created: Date.now(), paths: [...new Set(mountPoints)], sources: [...new Set(sources)], @@ -837,10 +1011,14 @@ function drop(file: string): boolean { } } -/** One reading of a manifest directory. */ +/** Every file in `dirs`. Throws where one of them cannot be listed. */ +const filesIn = (dirs: readonly string[]): string[] => + dirs.flatMap(dir => fs.readdirSync(dir).map(name => path.join(dir, name))) + +/** One reading of the manifest directories. */ type Reading = { - /** Every name in it. */ - names: string[] + /** Every file in them. */ + files: string[] /** The manifests that could be read, claimed ones among them. */ manifests: Manifest[] /** @@ -855,7 +1033,8 @@ type Reading = { } /** - * Reads the manifests in `dir`, every one that the listing showed. + * Reads the manifests in `dirs`, every one that the listings showed. Doubt in + * one directory is doubt. * * A pass renames a manifest to claim it and to give it back, which can hide it * from a listing under way and from the open that follows one, and one published @@ -873,16 +1052,11 @@ type Reading = { * too: always where that is an error of the moment (a sandboxed command can use * up what this user may open), and where it is what is there that cannot be * read as a manifest, while a process on its record runs or it is younger than - * {@link UNREADABLE_MANIFEST_MAX_AGE_MS}. + * {@link UNREADABLE_MANIFEST_MAX_AGE_MS} ({@link MANIFEST_GRACE_MS} for a {@link + * NotJson}). */ -function readManifests(dir: string): Reading { - const olderThan = (file: string, ms: number): boolean => { - try { - return Date.now() - fs.lstatSync(file).mtimeMs > ms - } catch { - return false - } - } +function readManifests(dirs: readonly string[]): Reading { + const oneKernel = dirs.filter(isReachedByOneKernel) // What cannot be asked after counts as there. const isThere = (file: string): boolean => { try { @@ -893,30 +1067,44 @@ function readManifests(dir: string): Reading { } } for (let attempt = 1; ; attempt++) { - let names: string[] + let files: string[] try { - names = fs.readdirSync(dir) + files = filesIn(dirs) } catch (e) { - return { names: [], manifests: [], spent: [], inDoubt: String(e) } + return { files: [], manifests: [], spent: [], inDoubt: String(e) } } - const reading: Reading = { names, manifests: [], spent: [] } + const reading: Reading = { files, manifests: [], spent: [] } const listed = new Set() let moved = false - for (const name of names) { - if (!name.endsWith(MANIFEST_SUFFIX) && !name.endsWith(CLAIMED_SUFFIX)) { + for (const file of files) { + if (!file.endsWith(MANIFEST_SUFFIX) && !file.endsWith(CLAIMED_SUFFIX)) { continue } - const file = path.join(dir, name) listed.add(idOf(file)) try { const manifest = readManifest(file) - if (manifest !== undefined) reading.manifests.push(manifest) - else moved = true + if (manifest === undefined) { + moved = true + continue + } + if ( + !oneKernel.includes(path.dirname(file)) && + (manifest.boot === undefined || manifest.boot !== ownBootId()) + ) { + delete manifest.boot + delete manifest.ns + } + reading.manifests.push(manifest) } catch (e) { if ( e instanceof NotOurs && recordVouches(`${idOf(file)}${STARTED_SUFFIX}`, true) !== true && - olderThan(file, UNREADABLE_MANIFEST_MAX_AGE_MS) + olderThan( + file, + e instanceof NotJson + ? MANIFEST_GRACE_MS + : UNREADABLE_MANIFEST_MAX_AGE_MS, + ) ) { reading.spent.push(file) } else { @@ -925,14 +1113,13 @@ function readManifests(dir: string): Reading { } } } - for (const name of names) { - const file = path.join(dir, name) - if (name.endsWith(TEMPORARY_SUFFIX)) { + for (const file of files) { + if (file.endsWith(TEMPORARY_SUFFIX)) { // Left by a process killed between writing and moving into place. if (olderThan(file, UNREADABLE_MANIFEST_MAX_AGE_MS)) { reading.spent.push(file) } - } else if (name.endsWith(STARTED_SUFFIX) && !listed.has(idOf(file))) { + } else if (file.endsWith(STARTED_SUFFIX) && !listed.has(idOf(file))) { // Asked for at its own name first, and the record read last. Only a // claim given back can then pass between the two names unseen, and a // pass gives back only once it has found a process on the record, @@ -957,7 +1144,7 @@ function readManifests(dir: string): Reading { } if (moved) { reading.keptChanging = reading.inDoubt === undefined - reading.inDoubt ??= `${dir} kept changing` + reading.inDoubt ??= `${dirs.join(', ')} kept changing` } return reading } @@ -978,10 +1165,10 @@ export type NamedMountPoints = { * doubt: a path it does not name may then be a running sandbox's mount point * all the same, and a wrap that took it for the caller's own would not name it * and would lose it under its own sandbox. With `keptChanging`, only because - * other processes are busy in the directory: the next reading may do. + * other processes are busy in a directory: the next reading may do. * * `earlier` is a reading this call may answer with. A manifest is never - * rewritten, so it still holds when the directory lists nothing it did not, + * rewritten, so it still holds when the directories list nothing it did not, * `own` (a manifest the caller has published since) apart. */ export function namedMountPoints( @@ -989,19 +1176,14 @@ export function namedMountPoints( own?: string, ): NamedMountPoints | { inDoubt: string; keptChanging?: boolean } { try { - const dir = onLinux() ? existingManifestDirectory() : undefined - if (dir === undefined) { - return { paths: new Set(unrecorded.paths), listed: new Set() } - } + const dirs = onLinux() ? believedManifestDirectories() : [] if ( earlier !== undefined && - fs - .readdirSync(dir) - .every(n => earlier.listed.has(n) || path.join(dir, n) === own) + filesIn(dirs).every(file => earlier.listed.has(file) || file === own) ) { return earlier } - const reading = readManifests(dir) + const reading = readManifests(dirs) if (reading.inDoubt !== undefined) { return { inDoubt: reading.inDoubt, keptChanging: reading.keptChanging } } @@ -1010,25 +1192,14 @@ export function namedMountPoints( ...unrecorded.paths, ...reading.manifests.flatMap(manifest => manifest.paths), ]), - listed: new Set(reading.names), + listed: new Set(reading.files), } } catch (e) { - // The directory cannot be looked at, or listed. + // A directory cannot be looked at, or listed. return { inDoubt: String(e) } } } -/** - * The directory this process keeps its manifests in, if it exists already. - * Makes and settles nothing: for a look that must leave the host as found. - * Throws where it cannot tell. - */ -function existingManifestDirectory(): string | undefined { - return [manifestDirectory, ...sharedManifestDirectoryNames()].find( - dir => dir !== undefined && isOurPrivateDirectory(dir, true), - ) -} - /** * Whether `p` is still something a clean-up would remove on a manifest's word: * an empty file or an empty directory. One that cannot be looked at may be. @@ -1050,7 +1221,7 @@ function isEmptyMountPoint(p: string): boolean { * its own accord after a command: removing a mount point from under a running * sandbox lifts the deny there. * - * `undefined` when it cannot tell: the manifest directory, its listing, a + * `undefined` when it cannot tell: a manifest directory, its listing, a * manifest or a record without a manifest cannot be read or accounted for, so a * sandbox may be running on paths that cannot be listed. The caller must then * skip every removal of an empty file or an empty directory. It does not throw. @@ -1069,10 +1240,9 @@ function isEmptyMountPoint(p: string): boolean { * file with content. * * Paths are as the wraps recorded them: absolute, links above the last - * component resolved. It answers for one manifest directory, the one this - * process uses or, before its first wrap, the first shared name that exists, so - * a sandbox whose process keeps manifests elsewhere is not seen. Makes nothing - * on the host. Empty off Linux. + * component resolved. It answers for the directories this process believes + * (see {@link believedManifestDirectories}), so a sandbox whose process keeps + * manifests elsewhere is not seen. Makes nothing on the host. Empty off Linux. */ export function liveMountPoints(): ReadonlySet | undefined { const spared = new Set() @@ -1081,12 +1251,11 @@ export function liveMountPoints(): ReadonlySet | undefined { } const named = new Set(unrecorded.paths) try { - const dir = existingManifestDirectory() - const reading = dir === undefined ? undefined : readManifests(dir) - if (reading?.inDoubt !== undefined) { + const reading = readManifests(believedManifestDirectories()) + if (reading.inDoubt !== undefined) { throw new Error(reading.inDoubt) } - for (const manifest of reading?.manifests ?? []) { + for (const manifest of reading.manifests) { if (isLive(manifest)) { for (const mountPoint of manifest.paths) named.add(mountPoint) } @@ -1128,9 +1297,21 @@ export function collectMountPoints( own.over = true } } - const dir = ensureManifestDirectory() - if (dir !== undefined) { - return collect(dir, release === 'all') + // Made first: with one of the first places to write in, what the environment + // names is not believed. + ensureManifestDirectory() + let dirs: string[] + try { + dirs = believedManifestDirectories() + } catch (e) { + logForDebugging( + `[Sandbox Linux] Where the mount point manifests are cannot be told - leaving every mount point where it is (${String(e)})`, + { level: 'warn' }, + ) + return [] + } + if (dirs.length > 0) { + return collect(dirs, release === 'all') } const removed: string[] = [] if (release === 'all') { @@ -1151,11 +1332,13 @@ const childrenFirst = (paths: Iterable): string[] => [...paths].sort().reverse() /** - * One pass over a manifest directory. It takes no lock and waits for nothing; - * any number may run at once, over the same claims. + * One pass over the manifest directories. It takes no lock and waits for + * nothing; any number may run at once, over the same claims. * - * INVARIANT: a path is removed only when every manifest that names it is - * claimed and has no process on its record, by a reading made after the claims. + * INVARIANT: a path is removed only when every manifest that names it, in any + * directory, is claimed and has no process on its record, by a reading of every + * directory made after the claims in every directory. A directory that cannot + * be written from here cannot be claimed in, so all it names is kept. * bubblewrap binds the manifest before it makes a mount point, so a start under * a claimed manifest is refused, and a sandbox that got past the claim wrote * its record first: every reading after the claim finds it. A manifest at its @@ -1166,8 +1349,8 @@ const childrenFirst = (paths: Iterable): string[] => * A pass gives back the claims it made itself, and only when that reading finds * a process on the record. So what remains is a manifest that comes to its own * name after the reading, published or given back, whose sandbox reaches its - * bind on a path a claimed manifest names too. The directory is listed again - * before every removal (in a pass of over {@link + * bind on a path a claimed manifest names too. The directories, worked out + * afresh, are listed again before every removal (in a pass of over {@link * LISTS_BEFORE_EACH_REMOVAL_UP_TO} candidates, whenever the listing is {@link * LISTING_GOOD_FOR_MS} old), and what has come to its name keeps what it names, * so the pass would have to be held up between a listing and the removal that @@ -1180,16 +1363,16 @@ const childrenFirst = (paths: Iterable): string[] => * or a directory holds what another manifest names. Only content that no * manifest names makes a directory nobody's. */ -function collect(dir: string, unrecordedToo: boolean): string[] { - const before = readManifests(dir) +function collect(dirs: readonly string[], unrecordedToo: boolean): string[] { + const before = readManifests(dirs) before.spent.forEach(drop) const own = new Set() let claims = false if (before.inDoubt === undefined) { // Forget own manifests that something else has removed. - const there = new Set(before.names.map(name => idOf(path.join(dir, name)))) + const there = new Set(before.files.map(idOf)) for (const file of ownManifests.keys()) { - if (path.dirname(file) === dir && !there.has(idOf(file))) { + if (dirs.includes(path.dirname(file)) && !there.has(idOf(file))) { ownManifests.delete(file) } } @@ -1200,13 +1383,13 @@ function collect(dir: string, unrecordedToo: boolean): string[] { fs.renameSync(manifest.file, claim) own.add(claim) } catch { - // Another pass has it. + // Another pass has it, or the directory cannot be written. } } claims ||= manifest.claimed || own.size > 0 } } - const reading = claims ? readManifests(dir) : before + const reading = claims ? readManifests(dirs) : before if (reading.inDoubt !== undefined) { logForDebugging( `[Sandbox Linux] A sandbox may be running on mount points that cannot be listed - leaving every mount point where it is (${reading.inDoubt})`, @@ -1222,8 +1405,13 @@ function collect(dir: string, unrecordedToo: boolean): string[] { } } const finished: Manifest[] = [] + const writable = dirs.filter(dir => isOurPrivateDirectory(dir)) for (const manifest of reading.manifests) { - if (manifest.claimed && !isLive(manifest)) { + if ( + manifest.claimed && + !isLive(manifest) && + writable.includes(path.dirname(manifest.file)) + ) { finished.push(manifest) continue } @@ -1249,20 +1437,22 @@ function collect(dir: string, unrecordedToo: boolean): string[] { } } } - const known = new Set(reading.names) + const known = new Set(reading.files) const goodFor = candidates.size <= LISTS_BEFORE_EACH_REMOVAL_UP_TO ? 0 : LISTING_GOOD_FOR_MS let listedAt = -Infinity const newcomersKeepWhatTheyName = (): boolean => { try { - for (const name of fs.readdirSync(dir)) { - if (known.has(name) || !name.endsWith(MANIFEST_SUFFIX)) continue + // The first place last: most is published there, so that listing is the + // one to be a few microseconds old. + for (const file of filesIn(believedManifestDirectories().reverse())) { + if (known.has(file) || !file.endsWith(MANIFEST_SUFFIX)) continue // Published, or given back, since the reading. One that is gone again // is looked for the next time: it may be given back once more. - const arrived = readManifest(path.join(dir, name)) + const arrived = readManifest(file) if (arrived === undefined) continue keep(arrived) - known.add(name) + known.add(file) } } catch (e) { logForDebugging( @@ -1322,14 +1512,25 @@ function collect(dir: string, unrecordedToo: boolean): string[] { return removed } +/** + * Look for the places that follow from the user id under `under` instead of + * `/`. Test seam, and nothing else may call it: no environment keeps a test out + * of the ones the user's own processes keep. + */ +export function setMountPointManifestPlacesForTesting(under: string): void { + root = under + forgetMountPointManifestDirectory() +} + /** * Forget which directory the manifests are kept in, and which PID namespace - * this is, so the next use works them out again. Test seam: a test gives itself - * a runtime directory of its own after this module may have settled on the real - * one. + * and boot this is, so the next use works them out again. Test seam: a test + * gives itself a runtime directory of its own after this module may have + * settled on the real one. */ export function forgetMountPointManifestDirectory(): void { pidNamespace = null + bootId = undefined manifestDirectory = undefined manifestDirectoryIsPrivate = false manifestDirectoryUnavailable = false diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 7497388bf..605dec40c 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -3095,7 +3095,7 @@ async function generateFilesystemArgs( // it pinned. Emitted after every allow and deny bind, like the two stores // below. // - // Each is bound where it really is, not at the name the environment gave: + // Each is bound where it really is, not at the name it was worked out as: // bubblewrap makes a bind's destination by name inside the new root, and a // link with an absolute target on the way leads nowhere from there. const names = [...mountPoints, ...mountPointsReliedOn()] diff --git a/test/cli.test.ts b/test/cli.test.ts index 654490796..0d86ce840 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -11,10 +11,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' /** - * Get the path to the CLI entry point + * Get the path to the CLI entry point, kept out of the user's own mount point + * manifests */ function getCliPath(): string { - return join(process.cwd(), 'src', 'cli.ts') + return join(process.cwd(), 'test', 'helpers', 'isolated', 'cli.ts') } /** diff --git a/test/control-fd.test.ts b/test/control-fd.test.ts index 770d6c2e0..e14b19421 100644 --- a/test/control-fd.test.ts +++ b/test/control-fd.test.ts @@ -11,8 +11,8 @@ import * as path from 'path' import { type Writable } from 'stream' import { isLinux, isWindows } from './helpers/platform.js' -// Get the path to the built CLI -const CLI_PATH = path.join(process.cwd(), 'dist', 'cli.js') +// Get the path to the built CLI, kept out of the user's own mount point manifests +const CLI_PATH = path.join(process.cwd(), 'test/helpers/isolated/dist-cli.mjs') // srt is expected to exit on its own shortly after the wrapped command // (which runs for well under a second) finishes; a hang is a failure, not diff --git a/test/helpers/isolated/cli.ts b/test/helpers/isolated/cli.ts new file mode 100644 index 000000000..0acc64dd6 --- /dev/null +++ b/test/helpers/isolated/cli.ts @@ -0,0 +1,2 @@ +import './places.js' +import '../../../src/cli.js' diff --git a/test/helpers/isolated/dist-cli.mjs b/test/helpers/isolated/dist-cli.mjs new file mode 100644 index 000000000..1eaf8644b --- /dev/null +++ b/test/helpers/isolated/dist-cli.mjs @@ -0,0 +1,5 @@ +// places.ts, for the built command line tool. +import { setMountPointManifestPlacesForTesting } from '../../../dist/sandbox/bwrap-mount-manifests.js' + +setMountPointManifestPlacesForTesting('/nonexistent') +await import('../../../dist/cli.js') diff --git a/test/helpers/isolated/library.ts b/test/helpers/isolated/library.ts new file mode 100644 index 000000000..f55b59c6f --- /dev/null +++ b/test/helpers/isolated/library.ts @@ -0,0 +1,2 @@ +import './places.js' +export * from '../../../src/sandbox/linux-sandbox-utils.js' diff --git a/test/helpers/isolated/manager.ts b/test/helpers/isolated/manager.ts new file mode 100644 index 000000000..a0752db06 --- /dev/null +++ b/test/helpers/isolated/manager.ts @@ -0,0 +1,2 @@ +import './places.js' +export * from '../../../src/sandbox/sandbox-manager.js' diff --git a/test/helpers/isolated/manifests.ts b/test/helpers/isolated/manifests.ts new file mode 100644 index 000000000..d5e6b3b02 --- /dev/null +++ b/test/helpers/isolated/manifests.ts @@ -0,0 +1,2 @@ +import './places.js' +export * from '../../../src/sandbox/bwrap-mount-manifests.js' diff --git a/test/helpers/isolated/places.ts b/test/helpers/isolated/places.ts new file mode 100644 index 000000000..8f3738784 --- /dev/null +++ b/test/helpers/isolated/places.ts @@ -0,0 +1,9 @@ +import { setMountPointManifestPlacesForTesting } from '../../../src/sandbox/bwrap-mount-manifests.js' + +// The first places the mount point manifests are kept in follow from the user +// id, so no environment keeps a test out of the ones the user's own processes +// keep. Whatever loads this looks for them under a root that is not there, finds +// none, and goes by $XDG_RUNTIME_DIR and the temp dir, which a test can give it. +// The preload loads it for the test process. A child process loads the library +// through one of the modules beside this one. +setMountPointManifestPlacesForTesting('/nonexistent') diff --git a/test/helpers/private-manifest-directory.ts b/test/helpers/private-manifest-directory.ts index 895061f92..c0c1dccca 100644 --- a/test/helpers/private-manifest-directory.ts +++ b/test/helpers/private-manifest-directory.ts @@ -1,5 +1,7 @@ import { afterAll, afterEach, beforeAll } from 'bun:test' import { + chmodSync, + existsSync, lstatSync, mkdirSync, mkdtempSync, @@ -9,7 +11,7 @@ import { rmSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { forgetMountPointManifestDirectory } from '../../src/sandbox/bwrap-mount-manifests.js' import { cleanupBwrapMountPoints } from '../../src/sandbox/linux-sandbox-utils.js' @@ -58,6 +60,54 @@ function endSandboxesOnRecord(dir: string): void { } } +/** + * Makes `/var/tmp`, `/run/user/UID` and `/tmp` under `root`, as a system has + * them. Returns the places the manifests are kept in there, in the order they + * are tried, none of them made: for `setMountPointManifestPlacesForTesting`, or + * {@link inPrivateNamespace}. + */ +export function makePlaces(root: string): [string, string, string] { + const uid = process.getuid?.() ?? 0 + const places = [ + join(root, 'var/tmp', `srt-mount-points-${uid}`), + join(root, 'run/user', String(uid), 'srt-mount-points'), + join(root, 'tmp', `srt-mount-points-${uid}`), + ] as const + places.forEach((place, i) => { + mkdirSync(dirname(place), { recursive: true }) + chmodSync(dirname(place), i === 1 ? 0o700 : 0o1777) + }) + return [...places] +} + +/** + * The start of a command line that runs what follows it in a mount namespace of + * its own, where what {@link makePlaces} made under `root` stands at its real + * name: for a child process that is to work the places out for itself, with + * nothing put in its way, and not reach the user's own. `keep`, this repository + * and the runtime are bound back where they are: they may lie under one of the + * three. No PID namespace. Needs `bwrapCanNamespace`. + */ +export function inPrivateNamespace(root: string, keep: string[]): string[] { + return [ + 'bwrap', + '--die-with-parent', + '--dev-bind', + '/', + '/', + // Where one of them is not there, there is nothing under it to reach. + ...['/var/tmp', '/run/user', '/tmp'] + .filter(dir => existsSync(dir)) + .flatMap(dir => ['--bind', join(root, dir), dir]), + ...[ + join(import.meta.dir, '../..'), + dirname(process.execPath), + ...keep, + ].flatMap(dir => ['--bind', dir, dir]), + '--', + ] +} + /** * Gives the enclosing `describe` a runtime directory and a temp dir of its own, * for as long as its tests run. @@ -65,10 +115,11 @@ function endSandboxesOnRecord(dir: string): void { * The manifests of every srt process of a user live in one directory, and a * collect reads, judges and removes all of them, so a test that lists or * attacks that directory would otherwise act on whatever else the user is - * running. The library looks under `$XDG_RUNTIME_DIR` first, and every wrap - * that restricts writes makes the directory under the temp dir as well, so both - * are replaced, before anything is cleaned up: nothing is made or removed in - * the directories the user's own processes keep. + * running. With the places that follow from the user id out of reach (see + * isolated/places.ts) the library looks under `$XDG_RUNTIME_DIR` first, and + * every wrap that restricts writes makes the directory under the temp dir as + * well, so both are replaced, before anything is cleaned up: nothing is made or + * removed in the directories the user's own processes keep. * * It also starts the `describe` with no wrap of this process outstanding, and * leaves it so: the library's count of wraps is shared by every test file of a diff --git a/test/preload.ts b/test/preload.ts index de92d48b8..69c0bd2c3 100644 --- a/test/preload.ts +++ b/test/preload.ts @@ -2,13 +2,15 @@ import { afterAll } from 'bun:test' import { mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import './helpers/isolated/places.js' // Every wrap that restricts writes makes, binds and collects in the per-user -// directories the mount point manifests are kept in, under $XDG_RUNTIME_DIR and -// the temp dir. The run is given both of its own before any test file is -// loaded, so that no suite acts on what else the user is running. A `describe` -// that lists or attacks the manifests takes its own on top of these (see -// helpers/private-manifest-directory.ts). +// directories the mount point manifests are kept in. The ones that follow from +// the user id are put out of reach (see helpers/isolated/places.ts), which +// leaves those under $XDG_RUNTIME_DIR and the temp dir. The run is given both of +// its own before any test file is loaded, so that no suite acts on what else +// the user is running. A `describe` that lists or attacks the manifests takes +// its own on top of these (see helpers/private-manifest-directory.ts). if (process.platform === 'linux') { // Short names: suites keep Unix sockets beneath these, and a socket's path is // limited to 108 bytes. diff --git a/test/sandbox/default-write-paths.test.ts b/test/sandbox/default-write-paths.test.ts index c6fd91384..134be0f5f 100644 --- a/test/sandbox/default-write-paths.test.ts +++ b/test/sandbox/default-write-paths.test.ts @@ -193,7 +193,7 @@ describe.if(isLinux || isMacOS)( mkdirSync(join(fakeHome, NPM_LOGS), { recursive: true }) const { allowOnly, wrapped, reopened } = runWithHome( fakeHome, - `const { SandboxManager } = await import('./src/sandbox/sandbox-manager.ts') + `const { SandboxManager } = await import('./test/helpers/isolated/manager.ts') const filesystem = { denyRead: ['~/.npm'], allowWrite: [], denyWrite: [] } await SandboxManager.initialize({ network: { allowedDomains: [], deniedDomains: [] }, diff --git a/test/sandbox/linux-bwrap-args-file.test.ts b/test/sandbox/linux-bwrap-args-file.test.ts index b4a326b99..dcf4c50d1 100644 --- a/test/sandbox/linux-bwrap-args-file.test.ts +++ b/test/sandbox/linux-bwrap-args-file.test.ts @@ -69,10 +69,7 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { `^${literally(STEP_SHELL)} '(?:${literally(RECORD_STEP)} && )?exec 9<"\\$1" && shift && exec "\\$@"' srt-args (?:\\S+\\.started )?(\\S+) bwrap (.*?) ?--args 9 (.*)$`, 's', ) - const MODULE = join( - import.meta.dir, - '../../src/sandbox/linux-sandbox-utils.ts', - ) + const MODULE = join(import.meta.dir, '../helpers/isolated/library.ts') let BASE: string const savedCwd = process.cwd() diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts index b3cbe5b4c..f095b2f99 100644 --- a/test/sandbox/mount-point-liveness.test.ts +++ b/test/sandbox/mount-point-liveness.test.ts @@ -34,7 +34,11 @@ import { } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { isLinux } from '../helpers/platform.js' -import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' +import { + inPrivateNamespace, + makePlaces, + usePrivateManifestDirectory, +} from '../helpers/private-manifest-directory.js' /** * A denyWrite path that does not exist is blocked by binding over a mount point @@ -51,7 +55,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { const runtime = usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + join(import.meta.dir, '../helpers/isolated/library.ts'), ) let BASE: string let AREA: string // the allowed write area @@ -528,7 +532,11 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { // What a process without a runtime directory has recorded there. const manifest = join(theirs, '4242-0123456789abcdef.json') - writeFileSync(manifest, '{"version":1,"paths":[]}', { mode: 0o600 }) + writeFileSync( + manifest, + '{"version":1,"pid":4242,"start":"1","created":0,"paths":[],"sources":[]}', + { mode: 0o600 }, + ) const late = withARuntimeDirectory( attacker( 'late.ts', @@ -754,7 +762,7 @@ describe.if(isLinux)('A mount point a running sandbox relies on', () => { try { // The shell is found once, so in a copy of the library of its own. const fresh = (await import( - `${JSON.parse(LIBRARY)}?${basename(sh)}` + `../../src/sandbox/linux-sandbox-utils.ts?${basename(sh)}` )) as { wrapCommandWithSandboxLinux: typeof wrapCommandWithSandboxLinux } @@ -1481,7 +1489,7 @@ describe.if(isLinux)( usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + join(import.meta.dir, '../helpers/isolated/library.ts'), ) let BASE: string let AREA: string // the allowed write area @@ -1879,7 +1887,7 @@ describe.if(isLinux)( const runtime = usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + join(import.meta.dir, '../helpers/isolated/library.ts'), ) let BASE: string let AREA: string @@ -2409,7 +2417,7 @@ describe.if(isLinux)( ...rest: unknown[] ) => { const names = (readdir as (...args: unknown[]) => unknown)(dir, ...rest) - if (dir === shared[0]) { + if (dir === shared[1]) { for (const name of shared) { fs.rmdirSync(name) writeFileSync(name, '') @@ -2676,7 +2684,7 @@ describe.if(isLinux)( usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + join(import.meta.dir, '../helpers/isolated/library.ts'), ) const UID = isLinux ? process.getuid!() : 0 let BASE: string @@ -2975,7 +2983,7 @@ describe.if(isLinux)( usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + join(import.meta.dir, '../helpers/isolated/library.ts'), ) const UID = isLinux ? process.getuid!() : 0 let BASE: string @@ -3192,7 +3200,7 @@ describe.if(isLinux)( 'true', ]).status === 0 const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + join(import.meta.dir, '../helpers/isolated/library.ts'), ) let BASE: string let AREA: string @@ -3313,3 +3321,200 @@ describe.if(isLinux)( ) }, ) + +/** + * Where the manifests go follows from the user id, whatever the environment + * says. The child processes here load the library itself and work the places + * out with nothing put in their way, in a mount namespace in which directories + * of the test's own stand at /var/tmp, /run/user and /tmp. + */ +describe.if(isLinux)('The manifest directories at their real names', () => { + usePrivateManifestDirectory() + const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() + const UID = isLinux ? process.getuid!() : 0 + const PLACES = [ + ['/var/tmp', `srt-mount-points-${UID}`], + ['/run/user', `${UID}/srt-mount-points`], + ['/tmp', `srt-mount-points-${UID}`], + ] + .filter(([system]) => existsSync(system!)) + .map(([system, place]) => join(system!, place!)) + let BASE: string + let ROOT: string // what stands at those names is under here + let AREA: string + let LOCK: string // a denyWrite path, absent to begin with + + beforeEach(() => { + BASE = realpathSync(mkdtempSync(join(tmpdir(), 'mount-point-places-'))) + ROOT = join(BASE, 'root') + makePlaces(ROOT) + AREA = join(BASE, 'area') + mkdirSync(AREA) + LOCK = join(AREA, 'guard.lock') + }) + + afterEach(() => rmSync(BASE, { recursive: true, force: true })) + + /** + * Starts a process with a runtime directory and a temp dir of its own, named + * after `name`, that wraps `command`, runs it, cleans up after it and prints + * what it said. + */ + function running( + name: string, + command: string, + allowOnly: string[], + ): { said: Promise } { + const file = join(BASE, `${name}.ts`) + writeFileSync( + file, + [ + `import { spawnSync } from 'node:child_process'`, + `import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints } from ${JSON.stringify(join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'))}`, + `const wrapped = await wrapCommandWithSandboxLinux({`, + ` command: ${JSON.stringify(command)},`, + ` needsNetworkRestriction: false,`, + ` readConfig: { denyOnly: [] },`, + ` writeConfig: { allowOnly: ${JSON.stringify(allowOnly)}, denyWithinAllow: [${JSON.stringify(LOCK)}] },`, + `})`, + `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, + `cleanupBwrapMountPoints()`, + `console.log(String(r.stdout ?? '') + String(r.stderr ?? ''))`, + ].join('\n'), + ) + const env = { ...process.env } + for (const variable of ['XDG_RUNTIME_DIR', 'TMPDIR']) { + env[variable] = join(BASE, `${name}-${variable}`) + mkdirSync(env[variable], { mode: 0o700 }) + } + const argv = [...inPrivateNamespace(ROOT, [BASE]), process.execPath, file] + const child = spawn(argv[0]!, argv.slice(1), { + env, + stdio: ['ignore', 'pipe', 'inherit'], + // Outside every allowed write path, so no mount points for cwd. + cwd: import.meta.dir, + }) + let out = '' + child.stdout.on('data', (data: Buffer) => (out += data.toString())) + return { + said: new Promise(resolve => child.on('close', () => resolve(out))), + } + } + + async function until(file: string): Promise { + const deadline = Date.now() + 30000 + while (!existsSync(file)) { + if (Date.now() > deadline) throw new Error(`${file} never came`) + await new Promise(resolve => setTimeout(resolve, 25)) + } + } + + it.if(BWRAP_CAN_NAMESPACE)( + 'can be neither made, written, moved aside, removed nor replaced from inside a sandbox that may write all around them', + async () => { + // None is there when the wrap begins. A command that could make one, as + // the same user and with the right mode, would be believed. + const attempts = PLACES.flatMap(place => [ + `touch ${dirname(place)}/beside; echo beside=$?`, + `mkdir -m 700 ${place}; echo make=$?`, + `touch ${place}/forged.json; echo write=$?`, + `mv ${place} ${place}.aside; echo aside=$?`, + `rmdir ${place}; echo remove=$?`, + `mkdir -m 700 ${place}.mine; mv -T ${place}.mine ${place}; echo replace=$?`, + ]) + const said = await running( + 'attempts', + `exec 2>/dev/null; ${attempts.join('; ')}`, + [AREA, ...PLACES.map(place => dirname(place))], + ).said + expect(said.trim().split('\n')).toEqual( + PLACES.flatMap(() => [ + 'beside=0', + ...['make', 'write', 'aside', 'remove', 'replace'].map(what => + expect.stringMatching(new RegExp(`^${what}=[1-9]`)), + ), + ]), + ) + for (const place of PLACES) { + expect(lstatSync(join(ROOT, place)).mode & 0o40777).toBe(0o40700) + expect(readdirSync(join(ROOT, place))).toEqual([]) + expect(existsSync(join(ROOT, `${place}.aside`))).toBe(false) + } + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE && existsSync('/run/user'))( + 'are read under a /run/user/UID that a sandboxed command has opened to others', + async () => { + // The name in /var/tmp is taken, so the manifest goes under + // /run/user/UID, which the command may write. What a killed process left + // in /tmp names the same path: a clean-up that no longer read the first + // would take the mount point from under the sandbox. + writeFileSync(join(ROOT, `/var/tmp/srt-mount-points-${UID}`), '') + const first = running( + 'first', + [ + `chmod 0777 /run/user/${UID}; echo chmod=$?`, + `echo up > ${AREA}/up; while [ ! -e ${AREA}/end ]; do sleep 0.05; done`, + `echo pwned > ${LOCK}; echo rc=$?`, + ].join('; '), + [AREA, `/run/user/${UID}`], + ) + await until(join(AREA, 'up')) + expect( + readdirSync(join(ROOT, `/run/user/${UID}/srt-mount-points`)), + ).toHaveLength(2) + const left = join(ROOT, `/tmp/srt-mount-points-${UID}`) + writeFileSync( + join(left, '4194000-00.json'), + JSON.stringify({ + version: 1, + pid: 4194000, + start: '1', + ns: readlinkSync('/proc/self/ns/pid'), + created: 1, + paths: [LOCK], + sources: [], + }), + { mode: 0o600 }, + ) + await running('second', 'true', [AREA]).said + expect(readdirSync(left)).toEqual([]) + expect(lstatSync(LOCK).size).toBe(0) + writeFileSync(join(AREA, 'end'), '') + const said = await first.said + expect(said).toContain('chmod=0') + expect(said).toMatch(/rc=[1-9]/) + expect(existsSync(LOCK)).toBe(false) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'keep a mount point for the sandbox of a process with another runtime directory and temp dir', + async () => { + // The second wrap finds the mount point of the first. Unless it reads + // that one's manifest it takes it for the user's own file and names it + // nowhere, and the first process's clean-up takes it from under it. + const waitFor = (name: string): string => + `echo up > ${AREA}/up-${name}; while [ ! -e ${AREA}/end-${name} ]; do sleep 0.05; done` + const first = running('first', waitFor('first'), [AREA]) + await until(join(AREA, 'up-first')) + const second = running( + 'second', + `${waitFor('second')}; echo pwned > ${LOCK}; echo rc=$?`, + [AREA], + ) + await until(join(AREA, 'up-second')) + writeFileSync(join(AREA, 'end-first'), '') + await first.said + expect(lstatSync(LOCK).size).toBe(0) + writeFileSync(join(AREA, 'end-second'), '') + expect(await second.said).toMatch(/rc=[1-9]/) + expect(existsSync(LOCK)).toBe(false) + expect(readdirSync(join(ROOT, PLACES[0]!))).toEqual([]) + }, + 90000, + ) +}) diff --git a/test/sandbox/mount-point-manifests.test.ts b/test/sandbox/mount-point-manifests.test.ts index 549ca4ed7..f9eb36e21 100644 --- a/test/sandbox/mount-point-manifests.test.ts +++ b/test/sandbox/mount-point-manifests.test.ts @@ -26,13 +26,18 @@ import { discardMountPointManifest, forgetMountPointManifestDirectory, liveMountPoints, + mountPointManifestDirectories, namedMountPoints, type NamedMountPoints, publishMountPointManifest, + setMountPointManifestPlacesForTesting, } from '../../src/sandbox/bwrap-mount-manifests.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { isLinux } from '../helpers/platform.js' -import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' +import { + makePlaces, + usePrivateManifestDirectory, +} from '../helpers/private-manifest-directory.js' /** * The manifests and the pass that collects on their word, below the level of a @@ -42,10 +47,10 @@ import { usePrivateManifestDirectory } from '../helpers/private-manifest-directo describe.if(isLinux)('The mount point manifests', () => { const runtime = usePrivateManifestDirectory() const MODULE = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/bwrap-mount-manifests.ts'), + join(import.meta.dir, '../helpers/isolated/manifests.ts'), ) const LIBRARY = JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + join(import.meta.dir, '../helpers/isolated/library.ts'), ) const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() const NOT_ROOT = process.getuid?.() !== 0 @@ -539,7 +544,11 @@ describe.if(isLinux)('The mount point manifests', () => { const written = JSON.parse(readFileSync(manifest, 'utf8')) as { created: number } - expect(written).toMatchObject({ pid: outer, start: startOf(stat) }) + expect(written).toMatchObject({ + pid: outer, + start: startOf(stat), + boot: readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim(), + }) expect(written.created).toBeGreaterThanOrEqual(before) expect(written.created).toBeLessThanOrEqual(Date.now()) }) @@ -1266,9 +1275,12 @@ describe.if(isLinux)('The mount point manifests', () => { }) it('takes every manifest for live when it cannot tell which PID namespace it is in', () => { - // With no /proc every process asked after reads as gone. + // With no /proc every process asked after reads as gone. However old: that + // is a condition of this process, which the age of a file does not end. leftover(X) - started(manifestOfADeadProcess([X]), { pid: deadPid(), start: '1' }) + const manifest = manifestOfADeadProcess([X]) + aged(started(manifest, { pid: deadPid(), start: '1' }), 30 * 24 * 60) + aged(recordOf(manifest), 30 * 24 * 60) forgetMountPointManifestDirectory() const spy = spyOn(fs, 'readlinkSync').mockImplementation((() => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) @@ -1529,6 +1541,202 @@ describe.if(isLinux)('The mount point manifests', () => { expect(existsSync(manifest)).toBe(true) }) + it('is live for seven days when written in another PID namespace, or in none it records, and finished then', () => { + // Nothing else ends one whose namespace is gone. Counted from the later of + // the manifest and its record, which is when a sandbox last started. + const DAYS = 24 * 60 + for (const ns of ['pid:[1]', undefined]) { + leftover(X) + const manifest = started(manifestOfADeadProcess([X], { ns }), { + pid: deadPid(), + start: '1', + }) + for (const [written, recorded] of [ + [7 * DAYS - 1, 7 * DAYS + 1], + [7 * DAYS + 1, 7 * DAYS - 1], + ] as const) { + aged(manifest, written) + aged(recordOf(manifest), recorded) + expect(live()).toEqual([X]) + expect(collectMountPoints()).toEqual([]) + } + aged(recordOf(manifest), 7 * DAYS + 1) + expect(live()).toEqual([]) + expect(collectMountPoints()).toEqual([X]) + expect(readdirSync(DIR)).toEqual([]) + + // With no record, from the manifest alone. + leftover(X) + aged(manifestOfADeadProcess([X], { ns }), 7 * DAYS + 1) + expect(collectMountPoints()).toEqual([X]) + } + }) + + /** Has every directory be on a file system of that kind, or not say. */ + function onAFileSystemOf(type: number | bigint | Error): { + restore(): void + } { + const spy = spyOn(fs, 'statfsSync').mockImplementation((() => { + if (type instanceof Error) throw type + return { type } + }) as never) + return { restore: () => spy.mockRestore() } + } + + it('is finished when written in another boot, whatever runs on its record and whichever PID namespace wrote it', () => { + // The directory outlives a reboot, and no process does. On each kind of + // file system that only this kernel reaches: tmpfs, ext4, xfs, btrfs, + // overlayfs, zfs, f2fs. The kind may come as a bigint. + for (const type of [ + ...[0x01021994, 0xef53, 0x58465342, 0x9123683e], + ...[0x794c7630, 0x2fc12fc1, 0xf2f52010, BigInt(0xef53)], + ]) { + const kind = onAFileSystemOf(type) + try { + for (const ns of [OWN_NAMESPACE, 'pid:[1]', undefined]) { + leftover(X) + started( + manifestOfADeadProcess([X], { ns, boot: 'another' }), + thisProcess(), + ) + expect(live()).toEqual([]) + expect(collectMountPoints()).toEqual([X]) + expect(readdirSync(DIR)).toEqual([]) + } + } finally { + kind.restore() + } + } + }) + + it('is not finished for its boot where another kernel may reach the directory, or that cannot be asked', () => { + // There another boot id may be that of a host that is up, with the sandbox + // running on it. NFS, 9p, FUSE and virtiofs, and a kind nobody has heard of. + leftover(X) + for (const type of [ + ...[0x6969, 0x01021997, 0x65735546, 0x12345678], + Object.assign(new Error('ENOSYS'), { code: 'ENOSYS' }), + ]) { + const manifest = started( + manifestOfADeadProcess([X], { boot: 'another' }), + thisProcess(), + ) + const kind = onAFileSystemOf(type) + try { + expect(live()).toEqual([X]) + expect(collectMountPoints()).toEqual([]) + expect(existsSync(X)).toBe(true) + } finally { + kind.restore() + } + rmSync(manifest) + rmSync(recordOf(manifest)) + } + }) + + // Two hosts each in their initial PID namespace name that alike, so where + // another kernel may reach the directory only an equal boot id says that a + // manifest is this kernel's, and a pid on its record one to ask after here. + const BOOT_ID = '/proc/sys/kernel/random/boot_id' + const OWN_BOOT = isLinux ? readFileSync(BOOT_ID, 'utf8').trim() : '' + + for (const [what, fields, ownIsUnreadable] of [ + ['is of another boot', { boot: 'another' }, false], + ['records no boot', {}, false], + [ + 'is of a boot this process cannot hold against its own', + { boot: OWN_BOOT }, + true, + ], + ] as const) { + it(`is live for seven days on a file system another kernel may reach when it ${what}, though of this PID namespace by name and with nobody on its record here`, () => { + const left = (): string => { + leftover(X) + return started(manifestOfADeadProcess([X], fields), { + pid: deadPid(), + start: '1', + }) + } + const bothAged = (manifest: string, minutes: number): void => { + aged(manifest, minutes) + aged(recordOf(manifest), minutes) + } + forgetMountPointManifestDirectory() + const spies = [onAFileSystemOf(0x6969)] + if (ownIsUnreadable) { + spies.push(failingCall('readFileSync', BOOT_ID, 'EACCES')) + } + try { + const manifest = left() + expect(live()).toEqual([X]) + expect(collectMountPoints()).toEqual([]) + bothAged(manifest, 6 * 24 * 60) + expect(collectMountPoints()).toEqual([]) + bothAged(manifest, 7 * 24 * 60 + 1) + expect(collectMountPoints()).toEqual([X]) + + // Where only this kernel reaches: at once, as ever. + spies.shift()!.restore() + spies.push(onAFileSystemOf(0xef53)) + left() + expect(collectMountPoints()).toEqual([X]) + } finally { + spies.forEach(spy => spy.restore()) + forgetMountPointManifestDirectory() + } + }) + } + + it('is judged as ever on a file system another kernel may reach when it is of this boot: the same kernel', () => { + for (const type of [0x6969, 0xef53]) { + leftover(X) + const manifest = started( + manifestOfADeadProcess([X], { boot: OWN_BOOT }), + thisProcess(), + ) + const kind = onAFileSystemOf(type) + try { + expect(live()).toEqual([X]) + expect(collectMountPoints()).toEqual([]) + started(manifest, { pid: deadPid(), start: '1' }) + expect(collectMountPoints()).toEqual([X]) + } finally { + kind.restore() + } + } + }) + + it('asks which kind of file system a directory is on once for a reading, however many manifests are in it', () => { + for (let i = 0; i < 3; i++) { + manifestOfADeadProcess([X], { boot: 'another' }) + } + const statfs = spyOn(fs, 'statfsSync') + try { + expect(live()).toEqual([]) + expect(statfs.mock.calls.filter(([dir]) => dir === DIR)).toHaveLength(1) + } finally { + statfs.mockRestore() + } + }) + + it('concludes nothing from the boot where it cannot tell its own', () => { + leftover(X) + started(manifestOfADeadProcess([X], { boot: 'another' }), thisProcess()) + forgetMountPointManifestDirectory() + const failing = failingCall( + 'readFileSync', + '/proc/sys/kernel/random/boot_id', + 'EACCES', + ) + try { + expect(live()).toEqual([X]) + expect(collectMountPoints()).toEqual([]) + expect(readFileSync(publish([X]), 'utf8')).not.toContain('"boot"') + } finally { + failing.restore() + } + }) + // ---- where the manifests are kept -------------------------------------- it.if(BWRAP_CAN_NAMESPACE)( @@ -1698,6 +1906,388 @@ describe.if(isLinux)('The mount point manifests', () => { } }) + // ---- several directories ---- + // + // Where the manifests go follows from the user id first, so that processes + // with different environments read each other's, and a process believes + // more directories than the one it writes in. + + describe('in several directories', () => { + let PLACES: string[] // under /var/tmp, /run/user/UID and /tmp, none made + let NAMED: string[] // what the environment names + let Y: string + + beforeEach(() => { + PLACES = makePlaces(join(BASE, 'root')) + NAMED = [DIR, join(tmpdir(), `srt-mount-points-${process.getuid!()}`)] + Y = join(BASE, 'second.lock') + setMountPointManifestPlacesForTesting(join(BASE, 'root')) + }) + + afterEach(() => { + setMountPointManifestPlacesForTesting('/nonexistent') + rmSync(NAMED[1]!, { recursive: true, force: true }) + }) + + /** What `make` makes, in `place`, which is made, instead of in DIR. */ + function at(place: string, make: () => T): T { + mkdirSync(place, { recursive: true, mode: 0o700 }) + chmodSync(place, 0o700) + DIR = place + try { + return make() + } finally { + DIR = runtime.manifestDir() + } + } + + /** Has something else be at each of those names. */ + function taken(places: string[]): void { + for (const place of places) { + rmSync(place, { recursive: true, force: true }) + writeFileSync(place, '') + } + } + + it("are written in the first that can be made the user's alone: /var/tmp, /run/user/UID, /tmp, then as the environment says", () => { + const order = [...PLACES, ...NAMED] + try { + for (const place of order) { + expect(recordedIn()).toBe(place) + expect(lstatSync(place).mode & 0o777).toBe(0o700) + collectMountPoints() + taken([place]) + } + expect(order).not.toContain(recordedIn()) + } finally { + collectMountPoints() + order.forEach(place => rmSync(place)) + mkdirSync(DIR, { mode: 0o700 }) + } + }) + + it('are believed, collected in and bound, but not written, under a /run/user/UID that is open to others', () => { + // Where that is a write root a sandboxed command can open it up, and + // must not have other processes read less by that. + leftover(X) + const relied = at(PLACES[1]!, () => + started(manifestOfADeadProcess([X]), thisProcess()), + ) + at(PLACES[2]!, () => manifestOfADeadProcess([X])) + taken([PLACES[0]!]) + chmodSync(dirname(PLACES[1]!), 0o777) + expect(dirname(publish([Y]))).toBe(PLACES[2]!) + expect(named()).toEqual([X, Y].sort()) + expect(live()).toEqual([X]) + expect(collectMountPoints()).toEqual([]) + expect(existsSync(X)).toBe(true) + started(relied, { pid: deadPid(), start: '1' }) + expect(collectMountPoints()).toEqual([X]) + expect(readdirSync(PLACES[1]!)).toEqual([]) + + // Made, too, so that the command cannot. + rmSync(PLACES[1]!, { recursive: true }) + expect(mountPointManifestDirectories()).toContain(PLACES[1]) + expect(lstatSync(PLACES[1]!).mode & 0o40777).toBe(0o40700) + + // It is none to write in: with no other of the three, what the + // environment names is believed. + taken([PLACES[2]!]) + forgetMountPointManifestDirectory() + at(NAMED[1]!, () => manifestOfADeadProcess([Y])) + expect(named()).toEqual([Y]) + }) + + for (const [what, spoil] of [ + [ + 'a link', + (dir: string) => { + fs.renameSync(dir, `${dir}-moved`) + symlinkSync(`${dir}-moved`, dir) + }, + ], + ["somebody else's", (dir: string) => asSomebodyElses(dir)], + ] as const) { + it(`are neither written nor believed under a /run/user/UID that is ${what}`, () => { + leftover(X) + at(PLACES[1]!, () => manifestOfADeadProcess([X])) + taken([PLACES[0]!]) + const spoiled = spoil(dirname(PLACES[1]!)) + try { + expect(dirname(publish([Y]))).toBe(PLACES[2]!) + expect(mountPointManifestDirectories()).not.toContain(PLACES[1]) + expect(named()).toEqual([Y]) + expect(collectMountPoints()).toEqual([]) + } finally { + spoiled?.restore() + } + }) + } + + for (const [what, squat] of [ + [ + 'a link', + (place: string) => { + fs.renameSync(place, `${place}-moved`) + symlinkSync(`${place}-moved`, place) + }, + ], + ["somebody else's", (place: string) => asSomebodyElses(place)], + ] as const) { + for (const [system, i, next] of [ + ['/var/tmp', 0, 1], + ['/tmp', 2, 0], + ] as const) { + it(`are neither written, believed nor bound at the name in ${system} when that is ${what}`, () => { + // The name is predictable, in a directory anyone may write. + leftover(X) + at(PLACES[i]!, () => manifestOfADeadProcess([X])) + const squatted = squat(PLACES[i]!) + try { + expect(dirname(publish([Y]))).toBe(PLACES[next]!) + expect(mountPointManifestDirectories()).not.toContain(PLACES[i]) + expect(named()).toEqual([Y]) + expect(collectMountPoints()).toEqual([]) + } finally { + squatted?.restore() + } + }) + } + } + + it('are made, every one, for a wrap to bind, and none by a mere look', () => { + rmSync(DIR, { recursive: true }) + expect(named()).toEqual([]) + expect(live()).toEqual([]) + expect([...PLACES, ...NAMED].filter(existsSync)).toEqual([]) + expect(mountPointManifestDirectories()).toEqual([...PLACES, ...NAMED]) + for (const place of [...PLACES, ...NAMED]) { + expect(lstatSync(place).mode & 0o40777).toBe(0o40700) + } + }) + + it('are believed in each of the three, whichever this process writes in', () => { + for (const place of PLACES) { + leftover(X) + const relied = at(place, () => + started(manifestOfADeadProcess([X]), thisProcess()), + ) + PLACES.forEach(each => at(each, () => manifestOfADeadProcess([X]))) + expect(named()).toEqual([X]) + expect(live()).toEqual([X]) + expect(collectMountPoints()).toEqual([]) + expect(existsSync(X)).toBe(true) + started(relied, { pid: deadPid(), start: '1' }) + expect(collectMountPoints()).toEqual([X]) + expect(PLACES.flatMap(each => readdirSync(each))).toEqual([]) + } + }) + + it('are believed where the environment says only by a process that can write in none of the three, or has settled there', () => { + // Only processes with that environment keep those out of a sandbox's + // reach. + leftover(X) + const planted = NAMED.map(place => + at(place, () => manifestOfADeadProcess([X])), + ) + const junk = join(NAMED[1]!, '4242-00.json') + writeFileSync(junk, '{}') + // A clean-up makes the first of the three before it looks. + expect(collectMountPoints()).toEqual([]) + expect(named()).toEqual([]) + expect(live()).toEqual([]) + expect(planted.filter(existsSync)).toEqual(planted) + + // Both of them, with none of the three to write in. + taken(PLACES) + forgetMountPointManifestDirectory() + expect(named()).toBeUndefined() + rmSync(junk) + rmSync(planted[0]!) + expect(named()).toEqual([X]) + expect(dirname(publish([Y]))).toBe(NAMED[0]!) + + // Only the one it writes in, once one of the three will do again. + PLACES.forEach(place => rmSync(place)) + mkdirSync(PLACES[2]!, { mode: 0o700 }) + expect(named()).toEqual([Y]) + }) + + it('puts everything in doubt by what cannot be read in any of them', () => { + for (const place of PLACES) { + leftover(X) + PLACES.forEach(each => at(each, () => manifestOfADeadProcess([X]))) + const junk = join(place, '4242-00.json') + writeFileSync(junk, '{}') + expect(named()).toBeUndefined() + expect(live()).toBeUndefined() + expect(collectMountPoints()).toEqual([]) + expect(PLACES.flatMap(each => at(each, claims))).toEqual([]) + rmSync(junk) + expect(collectMountPoints()).toEqual([X]) + } + }) + + it("goes by the file system a manifest's own directory is on for what its boot says", () => { + leftover(X) + leftover(Y) + for (const [place, mountPoint] of [ + [PLACES[0]!, X], + [PLACES[2]!, Y], + ] as const) { + at(place, () => + started( + manifestOfADeadProcess([mountPoint], { boot: 'another' }), + thisProcess(), + ), + ) + } + const spy = spyOn(fs, 'statfsSync').mockImplementation((( + dir: string, + ) => ({ + type: dir === PLACES[2] ? 0x6969 : 0xef53, + })) as never) + try { + expect(live()).toEqual([Y]) + expect(collectMountPoints()).toEqual([X]) + } finally { + spy.mockRestore() + } + }) + + it('removes nothing when one of them cannot be looked at', () => { + // Not even what this process could not record and would remove on its + // own word. + leftover(X) + leftover(Y) + at(PLACES[0]!, () => manifestOfADeadProcess([X])) + unrecorded([Y]) + mkdirSync(PLACES[2]!, { mode: 0o700 }) + const look = failingCall('lstatSync', PLACES[2]!, 'EIO') + try { + expect(named()).toBeUndefined() + expect(live()).toBeUndefined() + expect(collectMountPoints()).toEqual([]) + } finally { + look.restore() + } + expect(collectMountPoints().sort()).toEqual([X, Y].sort()) + }) + + it('reads every directory again once it has claimed, not only where it claimed', () => { + // Published in another directory since the first reading, and claimed + // there by another pass that its sandbox got past. + leftover(X) + at(PLACES[0]!, () => manifestOfADeadProcess([X])) + mkdirSync(PLACES[2]!, { mode: 0o700 }) + let claim: string | undefined + const pass = duringTheNextPass(() => { + claim = at(PLACES[2]!, () => + claimed(started(manifestOfADeadProcess([X]), thisProcess())), + ) + }) + try { + expect(collectMountPoints()).toEqual([]) + } finally { + pass.restore() + } + expect(existsSync(X)).toBe(true) + expect(existsSync(claim!)).toBe(true) + }) + + it('lists every directory again before every removal, one made since the pass began too', () => { + leftover(X) + leftover(Y) + at(PLACES[0]!, () => manifestOfADeadProcess([X, Y])) + const removal = atTheRemovalOf(Y, () => + at(PLACES[2]!, () => + manifestOfADeadProcess([X], { + pid: process.pid, + created: Date.now(), + }), + ), + ) + try { + expect(collectMountPoints()).toEqual([Y]) + } finally { + removal.restore() + } + expect(existsSync(X)).toBe(true) + }) + + it('lists the first of them last before a removal, where most is published', () => { + leftover(X) + leftover(Y) + at(PLACES[0]!, () => manifestOfADeadProcess([X, Y])) + mkdirSync(PLACES[2]!, { mode: 0o700 }) + const readdir = fs.readdirSync + let listings = 0 + const spy = spyOn(fs, 'readdirSync').mockImplementation((( + ...args: Parameters + ) => { + // Two readings, then the listing before the first removal. + if (args[0] === PLACES[2] && ++listings === 3) { + at(PLACES[0]!, () => + manifestOfADeadProcess([X, Y], { pid: process.pid }), + ) + } + return readdir(...args) + }) as never) + try { + expect(collectMountPoints()).toEqual([]) + } finally { + spy.mockRestore() + } + }) + + it('answers a wrap with an earlier reading only while none of them lists anything new', () => { + at(PLACES[0]!, () => manifestOfADeadProcess([X])) + const first = reads(namedMountPoints()) + expect(namedMountPoints(first)).toBe(first) + at(PLACES[2]!, () => manifestOfADeadProcess([Y])) + expect([...reads(namedMountPoints(first)).paths].sort()).toEqual( + [X, Y].sort(), + ) + }) + + it.if(BWRAP_CAN_NAMESPACE && NOT_ROOT)( + 'keeps all that is named in a directory that is read-only from here, and writes in the next', + () => { + // As /var/tmp is in a read-only container, and every place is to a + // process inside another sandbox: nothing can be claimed there. + const Z = join(BASE, 'third.lock') + ;[X, Y, Z].forEach(leftover) + const [manifest, claim] = at(PLACES[0]!, () => [ + manifestOfADeadProcess([X]), + claimed(manifestOfADeadProcess([Y])), + ]) + at(PLACES[2]!, () => manifestOfADeadProcess([X, Y, Z])) + const looked = inAChild( + [ + `m.setMountPointManifestPlacesForTesting(${JSON.stringify(join(BASE, 'root'))})`, + `const named = [...m.namedMountPoints().paths].sort()`, + `const removed = m.collectMountPoints()`, + `console.log(JSON.stringify({ named, removed, file: m.publishMountPointManifest(['/nonexistent/x'], []).file }))`, + ].join('\n'), + { + launcher: [ + ...['bwrap', '--dev-bind', '/', '/'], + ...['--ro-bind', PLACES[0]!, PLACES[0]!, '--'], + ], + }, + ) + expect(looked.status).toBe(0) + expect(JSON.parse(looked.stdout)).toEqual({ + named: [X, Y, Z].sort(), + removed: [Z], + file: expect.stringContaining(`${PLACES[1]}/`), + }) + expect([X, Y, manifest!, claim!].filter(existsSync)).toHaveLength(4) + }, + 15000, + ) + }) + // ---- with nowhere to record ---- // // No other process can know of these mount points, so the process that made @@ -1802,7 +2392,13 @@ describe.if(isLinux)('The mount point manifests', () => { return text } - const notAManifest: [string, (name: string, paths: string[]) => void][] = [ + // The last says what can be a manifest of no version, as a crash leaves one + // that was not on disk yet: it counts for the grace, not for the hour. + const notAManifest: [ + string, + (name: string, paths: string[]) => void, + 'of no version'?, + ][] = [ [ 'a link to a manifest kept somewhere else', (name, paths) => { @@ -1824,6 +2420,7 @@ describe.if(isLinux)('The mount point manifests', () => { 'a manifest cut short', (name, paths) => writeFileSync(name, finishedManifestText(paths).slice(0, -1)), + 'of no version', ], [ 'a manifest of a later version without a field this one goes by', @@ -1835,7 +2432,7 @@ describe.if(isLinux)('The mount point manifests', () => { .replace('"sources":[]', '"origins":[]'), ), ], - ['an empty file', name => writeFileSync(name, '')], + ['an empty file', name => writeFileSync(name, ''), 'of no version'], ['a directory', name => mkdirSync(name)], [ 'a FIFO nobody writes to', @@ -1849,8 +2446,8 @@ describe.if(isLinux)('The mount point manifests', () => { writeFileSync(name, finishedManifestText(paths), { mode: 0 }), ]) } - for (const [what, plant] of notAManifest) { - it(`removes nothing beside ${what} until it is an hour old, and passes over it then`, () => { + for (const [what, plant, ofNoVersion] of notAManifest) { + it(`removes nothing beside ${what} until it is ${ofNoVersion ? 'half a second' : 'an hour'} old, and passes over it then`, () => { const Y = join(BASE, 'second.lock') leftover(X) leftover(Y) @@ -1872,6 +2469,8 @@ describe.if(isLinux)('The mount point manifests', () => { } // It names nothing that can be read, and may name anything. const inDoubt = { named: 'cannot tell', held: 'cannot tell', removed: [] } + // Not yet as old as the child takes to look, nor just short of the hour. + aged(junk, ofNoVersion ? -1 : 59) expect(look()).toEqual(inDoubt) // Nor while a process is on its record, however old it is. @@ -1880,6 +2479,7 @@ describe.if(isLinux)('The mount point manifests', () => { expect(look()).toEqual(inDoubt) // A record that names no process to ask after puts nothing off. + aged(junk, ofNoVersion ? 1 : 61) writeFileSync(recordOf(junk), 'ended\n') expect(look()).toEqual({ named: [Y], held: [], removed: [Y] }) expect(existsSync(X)).toBe(true) @@ -2024,7 +2624,7 @@ describe.if(isLinux)('The mount point manifests', () => { /** Has `call` on `target` fail with `code`. */ function failingCall( - call: 'readdirSync' | 'lstatSync', + call: 'readdirSync' | 'lstatSync' | 'readFileSync', target: string, code: string, ): { restore(): void } { diff --git a/test/sandbox/path-canonicalization.test.ts b/test/sandbox/path-canonicalization.test.ts index 20e676cc0..9bb577347 100644 --- a/test/sandbox/path-canonicalization.test.ts +++ b/test/sandbox/path-canonicalization.test.ts @@ -47,10 +47,8 @@ function normalizeWithHome( ): { homedir: string; results: string[]; tildeBindsRoot: boolean } { const module = new URL('../../src/sandbox/sandbox-utils.ts', import.meta.url) .href - const linuxModule = new URL( - '../../src/sandbox/linux-sandbox-utils.ts', - import.meta.url, - ).href + const linuxModule = new URL('../helpers/isolated/library.ts', import.meta.url) + .href const child = spawnSync( process.execPath, [ @@ -69,7 +67,8 @@ function normalizeWithHome( { encoding: 'utf8', timeout: 20000, - env: { PATH: process.env.PATH ?? '', HOME: home }, + // The temp dir is where the wrap keeps its mount point manifests. + env: { PATH: process.env.PATH ?? '', HOME: home, TMPDIR: tmpdir() }, // Its own directory: the Linux wrap scans the working directory and may // leave mount-point stubs there, which this process would not clean up. cwd: scratch, diff --git a/test/sandbox/stale-mount-point.test.ts b/test/sandbox/stale-mount-point.test.ts index 2e139ef99..61bdc5f4e 100644 --- a/test/sandbox/stale-mount-point.test.ts +++ b/test/sandbox/stale-mount-point.test.ts @@ -481,7 +481,7 @@ describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { [ `import { spawnSync } from 'node:child_process'`, `import { wrapCommandWithSandboxLinux } from ${JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + join(import.meta.dir, '../helpers/isolated/library.ts'), )}`, `const command = await wrapCommandWithSandboxLinux({`, ` command: 'true',`, @@ -751,7 +751,7 @@ describe.if(isLinux)( script, [ `import { wrapCommandWithSandboxLinux } from ${JSON.stringify( - join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'), + join(import.meta.dir, '../helpers/isolated/library.ts'), )}`, `process.umask(0o200)`, `for (let i = 0; i < 2; i++) {`,