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++) {`,